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>
2181 lines
74 KiB
Markdown
2181 lines
74 KiB
Markdown
# 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`:
|
|
|
|
```python
|
|
"""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:`:
|
|
```yaml
|
|
front_camera:
|
|
base_url: http://192.168.123.99:8878
|
|
timeout_s: 5.0
|
|
```
|
|
|
|
Add a new top-level block:
|
|
```yaml
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```python
|
|
"""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`:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```python
|
|
"""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:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```python
|
|
"""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`**
|
|
|
|
```python
|
|
"""Logica della tab Braccio DX: math puro + endpoint."""
|
|
```
|
|
|
|
- [ ] **Step 4: Create `src/presa_mano/right_arm/move.py`**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```python
|
|
"""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`**
|
|
|
|
```python
|
|
"""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`:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
@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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
```python
|
|
from typing import Any, Optional
|
|
import logging
|
|
|
|
log = logging.getLogger(__name__)
|
|
```
|
|
|
|
Add Pydantic model and endpoint:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
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`:
|
|
```python
|
|
import numpy as np
|
|
from ..perception.sam21_bridge import Sam21Fallback, segment_with_sam21
|
|
from .perception_routes import _bbox_to_roi
|
|
```
|
|
|
|
Add model and endpoint:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
```python
|
|
from ..right_arm.move import DeltaOversize, apply_z_floor, compute_delta_wrist
|
|
```
|
|
|
|
Add Pydantic model and endpoint:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
```python
|
|
from ..geometry import rpy_deg_to_quaternion_xyzw
|
|
```
|
|
|
|
Append:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
```html
|
|
<button class="tab" data-tab="ee">Movimento EE</button>
|
|
```
|
|
|
|
Insert BEFORE it:
|
|
```html
|
|
<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:
|
|
|
|
```html
|
|
<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:
|
|
```html
|
|
<script type="module" src="/static/work_plane.js"></script>
|
|
```
|
|
|
|
Add immediately after:
|
|
```html
|
|
<script type="module" src="/static/right_arm.js"></script>
|
|
```
|
|
|
|
- [ ] **Step 4: Aggiungi gli stili CSS minimi**
|
|
|
|
Append to `web/style.css`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```javascript
|
|
// 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:
|
|
```python
|
|
for name in ("app.js", "work_plane.js", "style.css"):
|
|
```
|
|
|
|
Change to:
|
|
```python
|
|
for name in ("app.js", "work_plane.js", "right_arm.js", "style.css"):
|
|
```
|
|
|
|
- [ ] **Step 3: Smoke test (manual)**
|
|
|
|
```bash
|
|
# 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**
|
|
|
|
```bash
|
|
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.
|