feat: initial commit — orchestratore presa-mano con AI assistant + dual-robot

Pipeline completa per calibrazione hand-eye e grasp planning su robot
Unitree G1/H1 con camera depth Nuwa-HP60C. Esposta come FastAPI + GUI vanilla
JS, con assistente AI (OpenRouter + tool calling curato) per pilotare il
sistema in linguaggio naturale.

Architettura:
- FastAPI orchestrator (porta da .env, default 8890) che coordina 4
  microservizi upstream: Nuwa-HP60C, marker-finder, YOLO, robot bridge.
- Dual-robot: services.g1 e services.h1 in config.yaml; switchabili a
  runtime via PUT /api/home/robot.
- Calibrazione hand-eye eye-to-hand split per-side (right/left).
- Marker pose: marker-finder riceve gli intrinseci RGB della Nuwa al boot,
  PnP server-side con quei dati. Nessun re-PnP locale richiesto.
- Depth-to-RGB: registrazione iterativa IR↔RGB per back-projection 3D
  corretta (Nuwa /api/measure usa coords IR; il nostro /api/perception/
  measure_rgb prende pixel RGB e ritorna 3D nel frame RGB OpenCV).
- Correzione affine sul depth bias (calibrabile via marker come
  ground-truth) — riduce errore Z da ~6% a ~1.5% nei test.
- AI assistant: 16 tool curati (move_wrist_*, hand_action, get_marker[_3d],
  detect_object, calibration_*, set_active_robot…), system prompt
  dinamico che riflette robot attivo + marker config + addendum
  customizzabile da GUI. Temperature di default 0.1 per evitare invenzioni.

GUI (7 tab):
- Home: stato comunicazione device + selettore robot attivo G1/H1
- Assistente AI: chat con OpenRouter, history persistita lato browser,
  task riutilizzabili (prompt salvati), trace tool calls
- Impostazioni AI: modello da combo curata, temperature, max iterations,
  prompt addendum, anteprima system prompt
- Calibrazione, Auto-Calibrazione, TrovaUtensile, Servo Visuale, Vista 3D,
  Movimento EE — invariate dalle versioni precedenti

Setup:
- Variabili in presa-mano/.env (template in .env.example)
- OPENROUTER_API_KEY richiesta solo per il tab Assistente AI
- PRESA_MANO_PORT configurabile

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal PastroandClaude Opus 4.7 committed 2026-05-06 13:28:23 +02:00
commit ac573505b7
121 files changed
+67199

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Load `_deps` first so it is registered as an attribute of this package
# before any of the route modules (which reach into the agent module which
# in turn imports `from ..api import _deps`) start executing.
from . import _deps # noqa: F401
from . import (
agent_routes,
calibration_routes,
config_routes,
grasp_routes,
home_routes,
perception_routes,
posture_routes,
robot_routes,
wrist_routes,
)
__all__ = [
"agent_routes",
"calibration_routes",
"config_routes",
"grasp_routes",
"home_routes",
"perception_routes",
"posture_routes",
"robot_routes",
"wrist_routes",
]
+96
View File
@@ -0,0 +1,96 @@
"""Build clients & helpers from current config — called per-request so runtime
updates (PUT /api/config) take effect without restart."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..clients import G1Client, MarkerClient, NuwaClient, YoloClient
from ..config import data_dir, get_config
def _svc(cfg: dict[str, Any], key: str) -> dict[str, Any]:
return cfg.get("services", {}).get(key, {})
def nuwa() -> NuwaClient:
c = _svc(get_config(), "nuwa")
return NuwaClient(c["base_url"], timeout_s=c.get("timeout_s", 5.0), api_key=c.get("api_key"))
def marker() -> MarkerClient:
c = _svc(get_config(), "marker_finder")
return MarkerClient(c["base_url"], timeout_s=c.get("timeout_s", 5.0))
def yolo() -> YoloClient:
c = _svc(get_config(), "yolo")
return YoloClient(c["base_url"], timeout_s=c.get("timeout_s", 10.0))
def active_robot_key() -> str:
"""Which robot the orchestrator is currently bound to ('g1' or 'h1').
Read from `robot.active` in config; defaults to 'g1' for backward compat."""
return str(get_config().get("robot", {}).get("active", "g1")).lower()
def g1(dry_run: bool | None = None) -> G1Client:
"""Returns the client for the currently-active robot service. Despite the
historical name, this picks `services.g1` or `services.h1` based on
`robot.active`. Both robots speak the same HTTP API (G1_API_Server)."""
cfg = get_config()
key = active_robot_key()
c = _svc(cfg, key)
if not c:
# Fallback to legacy g1 block if the active key is missing — keeps the
# orchestrator usable on configs that predate the dual-robot split.
c = _svc(cfg, "g1")
if dry_run is None:
dry_run = bool(cfg.get("robot", {}).get("dry_run", False))
return G1Client(
c["base_url"],
timeout_s=c.get("timeout_s", 5.0),
write_timeout_s=c.get("write_timeout_s"),
dry_run=dry_run,
)
def _resolve_data_path(rel: str) -> Path:
p = Path(rel)
if p.is_absolute():
return p
return data_dir() / Path(rel).name if rel.startswith("data/") else data_dir() / rel
def _per_side(p: Path, side: str) -> Path:
"""Return <stem>_<side><suffix> in the same directory as p."""
return p.with_name(f"{p.stem}_{side}{p.suffix}")
def default_side() -> str:
return str(get_config().get("robot", {}).get("wrist_side", "right")).lower()
def calibration_file_path(side: str | None = None) -> Path:
"""Calibration matrix file for the given side. The hand-eye matrix is
arm-specific (the camera is fixed but each arm has its own kinematic chain),
so we keep right/left in separate files (calibration_right.yaml / _left.yaml)."""
cfg = get_config()
rel = cfg.get("calibration", {}).get("file", "data/calibration.yaml")
base = _resolve_data_path(rel)
s = (side or default_side()).lower()
return _per_side(base, s)
def samples_file_path(side: str | None = None) -> Path:
cfg = get_config()
rel = cfg.get("calibration", {}).get("samples_file", "data/samples.json")
base = _resolve_data_path(rel)
s = (side or default_side()).lower()
return _per_side(base, s)
def depth_correction_path() -> Path:
"""Where the depth-bias correction state (params + samples) is persisted."""
return data_dir() / "depth_correction.json"
+203
View File
@@ -0,0 +1,203 @@
"""AI assistant routes.
- `POST /api/agent/chat` — send a user message, get back the assistant reply
and the trace of every tool the model invoked. Stateless on the server side:
the GUI keeps the rolling history and posts it back in `history`.
- `GET /api/agent/tools` — list of tools exposed to the model (debug aid).
- `GET/POST/DELETE /api/agent/tasks` — persistent registry of named prompt
templates so frequently-used tasks ("calibra braccio dx", "sposta a home
position", …) can be re-launched in one click without retyping the prompt.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import asdict
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
# Submodule imports (not `from ..agent import …`) to break a circular import:
# api/__init__.py is loaded first by main.py and pulls in agent_routes; if we
# imported via the agent package, agent/__init__.py would try to load
# agent.runner → agent.tools → ..api._deps while ..api is still mid-init.
from ..agent.runner import AgentRunner, OpenRouterError
from ..agent.tools import TOOL_DEFINITIONS
from ..config import data_dir, get_config
router = APIRouter()
# ---------- chat ----------
class ChatMessage(BaseModel):
role: str
content: Optional[str] = None
# Pass-through fields used by the OpenAI tool-calling protocol.
tool_calls: Optional[list[dict[str, Any]]] = None
tool_call_id: Optional[str] = None
name: Optional[str] = None
class ChatReq(BaseModel):
message: str = Field(..., min_length=1, max_length=4000)
history: list[ChatMessage] = Field(default_factory=list)
@router.get("/tools")
def list_tools() -> dict[str, Any]:
"""Tools exposed to the LLM. Useful for debugging the system prompt."""
return {"tools": TOOL_DEFINITIONS}
@router.get("/health")
def agent_health() -> dict[str, Any]:
"""Reports whether OpenRouter credentials are available and which model
is configured. The frontend uses this to grey-out the chat input when
the env var is missing."""
runner = AgentRunner()
return {
"configured": runner.has_credentials(),
"model": runner.model,
"api_key_env": runner.api_key_env,
"base_url": runner.base_url,
"temperature": runner.temperature,
"max_tool_iterations": runner.max_tool_iterations,
"timeout_s": runner.timeout_s,
}
@router.get("/system_prompt_preview")
def system_prompt_preview() -> dict[str, Any]:
"""Returns the exact system prompt the model will receive at the next
chat. Useful for the AI Settings tab so the operator can preview the
effect of editing chat.openrouter.user_prompt_addendum or any other
state that influences the prompt (active robot, marker config…)."""
from ..agent.prompt import build_system_prompt
return {"system_prompt": build_system_prompt()}
# Curated list shown in the AI Settings model selector. Plus a free-text
# field on the GUI side for anything not in the list. Keep the list short
# and focused on models that we've verified support OpenRouter's tool-use.
_KNOWN_MODELS = [
{"id": "anthropic/claude-sonnet-4.5", "label": "Claude Sonnet 4.5 (consigliato)"},
{"id": "anthropic/claude-opus-4.1", "label": "Claude Opus 4.1 (più capace, più caro)"},
{"id": "anthropic/claude-haiku-4.5", "label": "Claude Haiku 4.5 (veloce, economico)"},
{"id": "openai/gpt-4o", "label": "GPT-4o"},
{"id": "openai/gpt-4o-mini", "label": "GPT-4o mini"},
{"id": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
{"id": "google/gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
{"id": "meta-llama/llama-3.3-70b-instruct", "label": "Llama 3.3 70B"},
{"id": "mistralai/mistral-large-2411", "label": "Mistral Large 2411"},
]
@router.get("/known_models")
def known_models() -> dict[str, Any]:
"""Curated short-list of OpenRouter models that work with our tool-calling.
The GUI also offers a free-text input for anything not in this list."""
return {"models": _KNOWN_MODELS}
@router.post("/chat")
def chat(req: ChatReq) -> dict[str, Any]:
runner = AgentRunner()
if not runner.has_credentials():
raise HTTPException(status_code=412,
detail=f"OpenRouter API key non impostata (env var {runner.api_key_env}).")
history = [m.model_dump(exclude_none=True) for m in req.history]
try:
result, updated_history = runner.run(req.message, history=history)
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=str(e))
return {
"reply": result.reply,
"iterations": result.iterations,
"finish_reason": result.finish_reason,
"model": result.model,
"usage": result.usage,
"tool_calls": [asdict(t) for t in result.tool_calls],
"history": updated_history,
}
# ---------- saved task templates ----------
_NAME_RE = re.compile(r"^[a-zA-Z0-9 _\-àèéìòù]{1,80}$")
class TaskItem(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
prompt: str = Field(..., min_length=1, max_length=4000)
description: Optional[str] = Field(None, max_length=400)
def _tasks_path():
return data_dir() / "agent_tasks.json"
def _load_tasks() -> list[dict[str, Any]]:
p = _tasks_path()
if not p.exists():
return []
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
def _save_tasks(items: list[dict[str, Any]]) -> None:
p = _tasks_path()
p.parent.mkdir(parents=True, exist_ok=True)
with open(p, "w", encoding="utf-8") as f:
json.dump(items, f, ensure_ascii=False, indent=2)
@router.get("/tasks")
def list_tasks() -> dict[str, Any]:
return {"tasks": _load_tasks()}
@router.post("/tasks")
def save_task(req: TaskItem) -> dict[str, Any]:
if not _NAME_RE.match(req.name):
raise HTTPException(status_code=422, detail="nome non valido (max 80 char, alfanumerici/spazi/_-)")
items = _load_tasks()
now = time.time()
# Replace by name if already exists, otherwise append.
found = False
for it in items:
if it.get("name") == req.name:
it["prompt"] = req.prompt
it["description"] = req.description
it["updated_at"] = now
found = True
break
if not found:
items.append({
"name": req.name,
"prompt": req.prompt,
"description": req.description,
"created_at": now,
"updated_at": now,
})
_save_tasks(items)
return {"ok": True, "total": len(items), "name": req.name}
@router.delete("/tasks/{name}")
def delete_task(name: str) -> dict[str, Any]:
items = _load_tasks()
new_items = [it for it in items if it.get("name") != name]
if len(new_items) == len(items):
raise HTTPException(status_code=404, detail=f"task '{name}' non trovata")
_save_tasks(new_items)
return {"ok": True, "total": len(new_items)}
+474
View File
@@ -0,0 +1,474 @@
"""Calibration endpoints."""
from __future__ import annotations
import logging
import time
from dataclasses import asdict
from typing import Any, Optional
import numpy as np
from fastapi import APIRouter, HTTPException, Query, Response
from pydantic import BaseModel, Field
from ..calibration import CalibrationSampler, CalibrationStore, HandEyeSolver, SamplesStore
from ..calibration.auto_job import AutoRunner, build_grid, get_job as _auto_job
from ..clients import G1Unavailable
from ..config import get_config
from ..timing import Stopwatch
from . import _deps
log = logging.getLogger(__name__)
router = APIRouter()
class SampleReq(BaseModel):
marker_id: Optional[int] = None
size_mm: Optional[float] = None
side: Optional[str] = None
note: str = ""
class SolveReq(BaseModel):
method: Optional[str] = Field(None, description="TSAI | PARK | HORAUD | ANDREFF | DANIILIDIS")
side: Optional[str] = Field(None, description="right|left (default: robot.wrist_side)")
sanity_check: bool = Field(True, description="drop samples whose pair-angle or T_wrist_marker are outliers")
pair_angle_tol_deg: float = Field(8.0, ge=1.0, le=45.0)
wm_translation_mad_factor: float = Field(3.5, ge=1.0, le=10.0)
def _samples_store(side: str | None = None) -> SamplesStore:
return SamplesStore(_deps.samples_file_path(side))
def _calib_store(side: str | None = None) -> CalibrationStore:
return CalibrationStore(_deps.calibration_file_path(side))
@router.post("/sample")
def acquire_sample(req: SampleReq) -> dict[str, Any]:
sw = Stopwatch()
cfg = get_config()
marker_id = req.marker_id if req.marker_id is not None else cfg["aruco"]["wrist_marker"]["id"]
size_mm = req.size_mm if req.size_mm is not None else cfg["aruco"]["wrist_marker"]["size_mm"]
side = req.side or cfg["robot"]["wrist_side"]
sampler = CalibrationSampler(_deps.nuwa(), _deps.marker(), _deps.g1())
try:
sample = sampler.acquire(marker_id=marker_id, size_mm=size_mm, side=side, note=req.note)
except Exception as e:
raise HTTPException(status_code=422, detail=str(e))
with sw.step("persist"):
items = _samples_store(side).append(sample)
return {"sample": asdict(sample), "total": len(items), "side": side,
"timings_ms": {**(sample.timings_ms or {}), **sw.as_dict()}}
@router.get("/samples")
def list_samples(side: Optional[str] = Query(None, description="right|left (default: robot.wrist_side)")) -> dict[str, Any]:
side = side or _deps.default_side()
items = _samples_store(side).load()
return {"total": len(items), "side": side, "samples": items}
@router.delete("/samples")
def clear_samples(side: Optional[str] = Query(None)) -> dict[str, Any]:
side = side or _deps.default_side()
_samples_store(side).clear()
return {"ok": True, "total": 0, "side": side}
@router.post("/solve")
def solve(req: SolveReq) -> dict[str, Any]:
sw = Stopwatch()
cfg = get_config()
method = (req.method or cfg.get("calibration", {}).get("method") or "TSAI").upper()
side = (req.side or _deps.default_side()).lower()
min_samples = int(cfg.get("calibration", {}).get("min_samples", 5))
with sw.step("load_samples"):
samples = _samples_store(side).load()
if len(samples) < min_samples:
raise HTTPException(status_code=422,
detail=f"need at least {min_samples} samples for side='{side}', have {len(samples)}")
try:
with sw.step("hand_eye"):
result = HandEyeSolver.solve(
samples, method=method,
sanity_check=req.sanity_check,
pair_angle_tol_deg=req.pair_angle_tol_deg,
wm_translation_mad_factor=req.wm_translation_mad_factor,
)
except Exception as e:
raise HTTPException(status_code=422, detail=f"hand-eye solve failed: {e}")
with sw.step("persist"):
_calib_store(side).save(result)
result["side"] = side
result["timings_ms"] = sw.as_dict()
return result
@router.get("/result")
def current_calibration(side: Optional[str] = Query(None)) -> dict[str, Any]:
side = side or _deps.default_side()
doc = _calib_store(side).load()
if doc is None:
raise HTTPException(status_code=404, detail=f"no calibration saved yet for side='{side}'")
doc["side"] = side
return doc
# --- Live preview helpers (RGB proxy + marker overlay) --------------------
@router.get("/rgb.jpg")
def rgb_proxy() -> Response:
"""Proxy Nuwa RGB stream to avoid CORS issues in the GUI."""
nuwa = _deps.nuwa()
try:
jpeg = nuwa.rgb_jpeg()
except Exception as e:
raise HTTPException(status_code=502, detail=f"Nuwa RGB failed: {e}")
return Response(content=jpeg, media_type="image/jpeg",
headers={"Cache-Control": "no-store"})
class PreviewReq(BaseModel):
marker_id: Optional[int] = None
size_mm: Optional[float] = None
@router.post("/preview")
def preview_marker(req: PreviewReq) -> dict[str, Any]:
"""Detect the wrist marker on the current RGB frame and return its corners
(for drawing overlays in the GUI) + pose in camera frame.
Marker-finder runs the PnP server-side using the Nuwa intrinsics that
presa-mano pushes at startup, so we just relay its result."""
sw = Stopwatch()
cfg = get_config()
marker_id = req.marker_id if req.marker_id is not None else cfg["aruco"]["wrist_marker"]["id"]
size_mm = req.size_mm if req.size_mm is not None else cfg["aruco"]["wrist_marker"]["size_mm"]
try:
with sw.step("marker_detect"):
result = _deps.marker().detect_url(
markers=[{"id": int(marker_id), "size_mm": float(size_mm)}],
image_url=_deps.nuwa().rgb_url(),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"marker-finder failed: {e}")
found = result.get("found", [])
entry = next((m for m in found if int(m["id"]) == int(marker_id)), None)
if entry is None:
return {"found": False, "marker_id": marker_id,
"not_found": result.get("not_found", []),
"timings_ms": sw.as_dict()}
img_pts = np.asarray(entry["corners"], dtype=np.float64).reshape(-1, 2)
tvec = np.asarray(entry.get("position_m") or [0, 0, 0], dtype=np.float64)
return {
"found": True,
"marker_id": marker_id,
"corners": entry["corners"],
"center_px": img_pts.mean(axis=0).tolist(),
"pose_camera": {
"rvec": entry.get("rvec"),
"tvec": tvec.tolist(),
"distance_m": float(np.linalg.norm(tvec)),
"quaternion_xyzw": entry.get("quaternion_xyzw"),
"source": "marker_finder",
},
"timings_ms": sw.as_dict(),
}
# --- Future: scripted grid acquisition --------------------------------------
class AutoGridReq(BaseModel):
"""Automated calibration by visiting a 3D grid of wrist poses.
Default: 3x3 (X,Y) at 3 heights = 27 waypoints.
Z is clamped so it never drops below `min_z_m` to avoid hitting the table.
"""
start_position: list[float] = Field(..., min_length=3, max_length=3)
orientation_quaternion_xyzw: list[float] = Field(..., min_length=4, max_length=4)
step_x_m: float = 0.05
step_y_m: float = 0.05
step_z_m: float = 0.05
n_x: int = 3
n_y: int = 3
n_z: int = 3
min_z_m: float = Field(..., description="floor/table safety — z is clamped to >= this value")
settle_s: float = 1.0
side: Optional[str] = None
dry_run: bool = True
auto_solve: bool = True
@router.post("/auto_grid")
def auto_grid(req: AutoGridReq) -> dict[str, Any]:
"""Move the wrist through a grid of waypoints, acquiring a sample at each.
While the G1 DDS is unstable, keep dry_run=True to just preview the waypoints.
"""
cfg = get_config()
side = req.side or cfg["robot"]["wrist_side"]
g1 = _deps.g1(dry_run=req.dry_run)
sampler = CalibrationSampler(_deps.nuwa(), _deps.marker(), _deps.g1(dry_run=False))
store = _samples_store(side)
x0, y0, z0 = req.start_position
waypoints: list[list[float]] = []
for iz in range(req.n_z):
for iy in range(req.n_y):
for ix in range(req.n_x):
x = x0 + (ix - (req.n_x - 1) / 2.0) * req.step_x_m
y = y0 + (iy - (req.n_y - 1) / 2.0) * req.step_y_m
z = max(req.min_z_m, z0 + iz * req.step_z_m)
waypoints.append([x, y, z])
results: list[dict[str, Any]] = []
for i, pos in enumerate(waypoints):
try:
move = g1.wrist_absolute(side=side, position=pos,
quaternion=req.orientation_quaternion_xyzw,
wait=True, timeout_s=req.settle_s * 5)
except G1Unavailable as e:
results.append({"idx": i, "position": pos, "error": f"G1 move failed: {e}"})
continue
time.sleep(req.settle_s)
if req.dry_run:
results.append({"idx": i, "position": pos, "move": move, "sample": None, "dry_run": True})
continue
try:
sample = sampler.acquire(
marker_id=cfg["aruco"]["wrist_marker"]["id"],
size_mm=cfg["aruco"]["wrist_marker"]["size_mm"],
side=side,
note=f"auto_grid[{i}]",
)
store.append(sample)
results.append({"idx": i, "position": pos, "move": move, "sample": asdict(sample)})
except Exception as e:
log.warning("auto_grid sample %d failed: %s", i, e)
results.append({"idx": i, "position": pos, "move": move, "error": str(e)})
out: dict[str, Any] = {"waypoints": waypoints, "results": results, "dry_run": req.dry_run}
if req.auto_solve and not req.dry_run:
samples = store.load()
if len(samples) >= int(cfg.get("calibration", {}).get("min_samples", 5)):
try:
method = (cfg.get("calibration", {}).get("method") or "TSAI").upper()
solve_result = HandEyeSolver.solve(samples, method=method)
_calib_store(side).save(solve_result)
out["calibration"] = solve_result
except Exception as e:
out["solve_error"] = str(e)
out["side"] = side
return out
# --- Automated calibration procedure ---------------------------------------
# Grid + wrist rotation search, background job with polled status. Frontend
# (tab "Auto-Calibrazione") renders the waypoints on top of the 3D URDF.
class AutoStartReq(BaseModel):
# Starting wrist pose: taken from G1 live if omitted.
start_position: Optional[list[float]] = Field(None, min_length=3, max_length=3)
start_quaternion_xyzw: Optional[list[float]] = Field(None, min_length=4, max_length=4)
# Grid span (mm) and number of divisions per axis.
delta_x_mm: float = Field(..., ge=0.0, le=500.0)
delta_y_mm: float = Field(..., ge=0.0, le=500.0)
delta_z_mm: float = Field(..., ge=0.0, le=500.0)
# Offset (mm) applied to the live wrist pose before centring the grid.
# Lets the operator displace the grid centre from the current pose
# without physically moving the wrist first.
offset_x_mm: float = Field(0.0, ge=-500.0, le=500.0)
offset_y_mm: float = Field(0.0, ge=-500.0, le=500.0)
offset_z_mm: float = Field(0.0, ge=-500.0, le=500.0)
n_x: int = Field(3, ge=1, le=7)
n_y: int = Field(3, ge=1, le=7)
n_z: int = Field(3, ge=1, le=7)
# Wrist rotation search range (degrees) when marker not found at nominal pose.
wrist_rot_delta_deg: float = Field(10.0, ge=0.0, le=45.0)
min_z_m: float = 0.05
side: Optional[str] = None
settle_s: float = Field(1.0, ge=0.1, le=5.0)
move_timeout_s: float = Field(8.0, ge=1.0, le=30.0)
auto_solve: bool = True
dry_run: bool = False
order: str = Field("serpentine", pattern="^(nested|serpentine)$")
class AutoFeasibilityReq(AutoStartReq):
"""Same shape as start, used only to preview the grid."""
pass
class AutoVerifyReq(AutoStartReq):
"""Real reachability verification — same grid, robot moves to each point
but does NOT acquire samples. Faster than full procedure, definitive."""
pass
def _resolve_start_pose(req: AutoStartReq, side: str) -> tuple[list[float], list[float]]:
"""If caller did not supply a start pose, read the current wrist pose from G1."""
if req.start_position and req.start_quaternion_xyzw:
return list(req.start_position), list(req.start_quaternion_xyzw)
try:
w = _deps.g1().wrist()
except Exception as e:
raise HTTPException(status_code=502, detail=f"cannot read wrist pose: {e}")
body = w.get("data") if isinstance(w.get("data"), dict) else w
pose = body.get(f"{side}_wrist") or body.get(side)
if pose is None or "position" not in pose or "quaternion" not in pose:
raise HTTPException(status_code=422, detail=f"G1 /wrist missing '{side}' pose")
return list(pose["position"]), list(pose["quaternion"])
def _grid_from_req(req: AutoStartReq, side: str) -> tuple[list, list[float], list[float], list[float]]:
"""Returns (grid, home_position, home_quaternion, center_position).
home_pose is the actual live wrist pose — used for return-to-home on stop.
center_position is home_position + (offset_x, offset_y, offset_z), which
is what the grid is actually centred on."""
home_p, home_q = _resolve_start_pose(req, side)
center_p = [
home_p[0] + req.offset_x_mm / 1000.0,
home_p[1] + req.offset_y_mm / 1000.0,
home_p[2] + req.offset_z_mm / 1000.0,
]
grid = build_grid(
start_position=np.asarray(center_p),
start_quaternion=np.asarray(home_q),
delta_m=(req.delta_x_mm / 1000.0, req.delta_y_mm / 1000.0, req.delta_z_mm / 1000.0),
divisions=(req.n_x, req.n_y, req.n_z),
min_z_m=req.min_z_m,
order=req.order,
)
return grid, home_p, home_q, center_p
@router.post("/auto/feasibility")
def auto_feasibility(req: AutoFeasibilityReq) -> dict[str, Any]:
"""Build the grid and return per-point reachability — no motion."""
from dataclasses import asdict as _asdict
cfg = get_config()
side = req.side or cfg["robot"]["wrist_side"]
grid, home_p, home_q, center_p = _grid_from_req(req, side)
return {
"side": side,
"start_position": home_p,
"start_quaternion_xyzw": home_q,
"center_position": center_p,
"offset_mm": [req.offset_x_mm, req.offset_y_mm, req.offset_z_mm],
"wrist_rot_delta_deg": req.wrist_rot_delta_deg,
"waypoints": [_asdict(w) for w in grid],
"n_reachable": sum(1 for w in grid if w.reachable),
"n_unreachable": sum(1 for w in grid if not w.reachable),
}
def _make_runner(req: AutoStartReq, side: str, *, feasibility_only: bool):
cfg = get_config()
return AutoRunner(
side=side,
marker_id=int(cfg["aruco"]["wrist_marker"]["id"]),
size_mm=float(cfg["aruco"]["wrist_marker"]["size_mm"]),
wrist_rot_delta_rad=float(np.deg2rad(req.wrist_rot_delta_deg)),
settle_s=req.settle_s,
move_timeout_s=req.move_timeout_s,
g1_move=_deps.g1(dry_run=req.dry_run).wrist_absolute,
sampler=CalibrationSampler(_deps.nuwa(), _deps.marker(), _deps.g1(dry_run=False)),
store=_samples_store(side),
feasibility_only=feasibility_only,
)
@router.post("/auto/start")
def auto_start(req: AutoStartReq) -> dict[str, Any]:
job = _auto_job()
if job.is_running():
raise HTTPException(status_code=409, detail="another auto job is already running")
cfg = get_config()
side = req.side or cfg["robot"]["wrist_side"]
grid, home_p, home_q, center_p = _grid_from_req(req, side)
runner = _make_runner(req, side, feasibility_only=False)
def on_finish(state) -> None:
if not req.auto_solve:
return
samples = _samples_store(side).load()
min_n = int(cfg.get("calibration", {}).get("min_samples", 5))
if len(samples) < min_n:
return
try:
method = (cfg.get("calibration", {}).get("method") or "TSAI").upper()
result = HandEyeSolver.solve(samples, method=method)
_calib_store(side).save(result)
state.calibration = result
except Exception as e:
log.warning("auto_solve after auto-calibration failed: %s", e)
params = {
"side": side,
"start_position": home_p,
"start_quaternion_xyzw": home_q,
"center_position": center_p,
"offset_mm": [req.offset_x_mm, req.offset_y_mm, req.offset_z_mm],
"delta_mm": [req.delta_x_mm, req.delta_y_mm, req.delta_z_mm],
"divisions": [req.n_x, req.n_y, req.n_z],
"wrist_rot_delta_deg": req.wrist_rot_delta_deg,
"min_z_m": req.min_z_m,
"dry_run": req.dry_run,
"auto_solve": req.auto_solve,
"order": req.order,
"mode": "calibration",
}
job.start(waypoints=grid, runner=runner, on_finish=on_finish, params=params,
home_pose=(home_p, home_q))
return {"ok": True, "status": "running", "n_waypoints": len(grid), "params": params}
@router.post("/auto/verify_reachability")
def auto_verify_reachability(req: AutoVerifyReq) -> dict[str, Any]:
"""Run the robot through the grid WITHOUT acquiring samples. The reached_target
flag from each move is the ground truth for reachability — slow but definitive."""
job = _auto_job()
if job.is_running():
raise HTTPException(status_code=409, detail="another auto job is already running")
cfg = get_config()
side = req.side or cfg["robot"]["wrist_side"]
grid, home_p, home_q, center_p = _grid_from_req(req, side)
runner = _make_runner(req, side, feasibility_only=True)
params = {
"side": side,
"start_position": home_p,
"start_quaternion_xyzw": home_q,
"center_position": center_p,
"offset_mm": [req.offset_x_mm, req.offset_y_mm, req.offset_z_mm],
"delta_mm": [req.delta_x_mm, req.delta_y_mm, req.delta_z_mm],
"divisions": [req.n_x, req.n_y, req.n_z],
"order": req.order,
"dry_run": req.dry_run,
"mode": "verify_reachability",
}
job.start(waypoints=grid, runner=runner, params=params,
home_pose=(home_p, home_q))
return {"ok": True, "status": "running", "n_waypoints": len(grid), "params": params}
@router.get("/auto/status")
def auto_status() -> dict[str, Any]:
return _auto_job().snapshot()
@router.post("/auto/stop")
def auto_stop() -> dict[str, Any]:
"""Stop the current job. If the job was started with a home_pose, the
worker returns the wrist there before finishing."""
stopped = _auto_job().stop()
return {"ok": True, "was_running": stopped}
+21
View File
@@ -0,0 +1,21 @@
"""GET/PUT /api/config — read the merged config and apply runtime overrides."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body
from ..config import get_config, update_config
router = APIRouter()
@router.get("")
def read_config() -> dict[str, Any]:
return get_config()
@router.put("")
def write_config(patch: dict[str, Any] = Body(...)) -> dict[str, Any]:
return update_config(patch)
+319
View File
@@ -0,0 +1,319 @@
"""Grasp planning + execution endpoints."""
from __future__ import annotations
from typing import Any, Optional
import numpy as np
import time
from dataclasses import asdict
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..calibration import CalibrationStore
from ..clients import G1Unavailable
from ..config import get_config
from ..geometry import transform_points
from ..grasp import GraspPlanner, compute_axes
from ..grasp.visual_servo import (
ServoError,
WorkPlane,
fit_plane_svd,
get_work_plane,
plan as servo_plan,
set_work_plane,
)
from ..perception import ObjectDetector, filter_outliers, nuwa_to_cv_frame
from .perception_routes import _bbox_to_roi
from ..timing import Stopwatch
from . import _deps
router = APIRouter()
class PlanReq(BaseModel):
conf: Optional[float] = None
class_name: Optional[str] = None
class ExecuteReq(PlanReq):
dry_run: bool = True
def _object_points_in_base(conf: float | None, class_name: str | None, sw: Stopwatch
) -> tuple[np.ndarray, dict[str, Any]]:
cfg = get_config()
nuwa = _deps.nuwa()
yolo = _deps.yolo()
side = cfg.get("robot", {}).get("wrist_side", "right")
T_base_cam = CalibrationStore(_deps.calibration_file_path(side)).T_base_camera()
if T_base_cam is None:
raise HTTPException(status_code=412, detail=f"calibration missing for side='{side}': run /api/calibration/solve first")
detector = ObjectDetector(yolo, nuwa)
try:
with sw.step("yolo"):
dets = detector.detect(conf=conf)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"YOLO detect failed: {e}")
if class_name:
dets = [d for d in dets if (d.get("class") or "").lower() == class_name.lower()]
if not dets:
raise HTTPException(status_code=404, detail="no object detected")
dets.sort(key=lambda d: d["confidence"], reverse=True)
top = dets[0]
b = top["bbox"]
u_min, v_min, u_max, v_max = _bbox_to_roi(b)
try:
with sw.step("pointcloud_roi"):
pc, _meta = nuwa.pointcloud_roi(
u_min, v_min, u_max, v_max,
step=int(cfg.get("grasp", {}).get("pointcloud_step", 1)),
max_depth_mm=int(cfg.get("grasp", {}).get("pointcloud_max_depth_mm", 2000)),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Nuwa ROI pointcloud failed: {e}")
with sw.step("filter_outliers"):
cropped = filter_outliers(pc)
if len(cropped) < 10:
raise HTTPException(status_code=422, detail=f"too few valid depth points in bbox: {len(cropped)}")
with sw.step("to_base_frame"):
pts_cv = nuwa_to_cv_frame(cropped[:, :3])
pts_base = transform_points(T_base_cam, pts_cv)
return pts_base, {"detection": top, "T_base_camera": T_base_cam.tolist(), "n_points": len(pts_base)}
@router.post("/plan")
def plan(req: PlanReq) -> dict[str, Any]:
sw = Stopwatch()
cfg = get_config()
pts_base, meta = _object_points_in_base(req.conf, req.class_name, sw)
with sw.step("pca_axes"):
axes = compute_axes(pts_base)
planner = GraspPlanner(
approach_offset_m=float(cfg["grasp"]["approach_offset_m"]),
max_object_width_m=float(cfg["grasp"]["max_object_width_m"]),
)
with sw.step("plan"):
g = planner.plan(axes)
return {"plan": g.to_dict(), "timings_ms": sw.as_dict(), **meta}
@router.post("/execute")
def execute(req: ExecuteReq) -> dict[str, Any]:
sw = Stopwatch()
cfg = get_config()
side = cfg["robot"]["wrist_side"]
pts_base, meta = _object_points_in_base(req.conf, req.class_name, sw)
with sw.step("pca_axes"):
axes = compute_axes(pts_base)
planner = GraspPlanner(
approach_offset_m=float(cfg["grasp"]["approach_offset_m"]),
max_object_width_m=float(cfg["grasp"]["max_object_width_m"]),
)
with sw.step("plan"):
g = planner.plan(axes)
open_action = "RIGHT_OPEN" if side == "right" else "LEFT_OPEN"
close_action = "RIGHT_CLOSE" if side == "right" else "LEFT_CLOSE"
g1 = _deps.g1(dry_run=req.dry_run)
steps: list[dict[str, Any]] = []
try:
with sw.step("hand_open"):
steps.append({"step": "hand_open", "result": g1.hand_action(open_action)})
with sw.step("pre_grasp"):
steps.append({"step": "pre_grasp", "result": g1.wrist_absolute(
side=side,
position=g.pre_grasp_position.tolist(),
quaternion=g.grasp_quaternion.tolist(),
)})
with sw.step("grasp_approach"):
steps.append({"step": "grasp_approach", "result": g1.wrist_absolute(
side=side,
position=g.grasp_position.tolist(),
quaternion=g.grasp_quaternion.tolist(),
)})
with sw.step("hand_close"):
steps.append({"step": "hand_close", "result": g1.hand_action(close_action)})
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
return {"plan": g.to_dict(), "steps": steps, "timings_ms": sw.as_dict(), **meta}
# ---------------------------------------------------------------------------
# Visual servo (marker-relative, no static hand-eye calibration).
# ---------------------------------------------------------------------------
class VisualServoReq(BaseModel):
conf: Optional[float] = None
class_name: Optional[str] = None
side: Optional[str] = None
min_distance_m: Optional[float] = None # default from config (0.1 m)
step_fraction: Optional[float] = None # default from config (0.5)
# Override for grasp.visual_servo.wrist_marker_rvec (radians). Useful for
# live tuning from the UI without persisting to config on every try.
wrist_marker_rvec: Optional[list[float]] = None
class VisualServoStepReq(VisualServoReq):
dry_run: bool = True
timeout_s: float = 5.0
def _servo_plan_from_req(req: VisualServoReq, sw: Stopwatch):
cfg = get_config()
vs_cfg = (cfg.get("grasp", {}) or {}).get("visual_servo", {}) or {}
side = req.side or cfg["robot"]["wrist_side"]
min_dist = float(req.min_distance_m
if req.min_distance_m is not None
else vs_cfg.get("min_distance_m", 0.10))
step_frac = float(req.step_fraction
if req.step_fraction is not None
else vs_cfg.get("step_fraction", 0.5))
# Request-level rvec override: thread it into a shallow-copied cfg so the
# core plan() function keeps reading from a single place.
if req.wrist_marker_rvec is not None:
cfg = {**cfg, "grasp": {**(cfg.get("grasp") or {}),
"visual_servo": {**vs_cfg,
"wrist_marker_rvec": list(req.wrist_marker_rvec)}}}
marker_id = int(cfg["aruco"]["wrist_marker"]["id"])
size_mm = float(cfg["aruco"]["wrist_marker"]["size_mm"])
pc_step = int(cfg.get("grasp", {}).get("pointcloud_step", 1))
pc_max_depth = int(cfg.get("grasp", {}).get("pointcloud_max_depth_mm", 2000))
try:
with sw.step("servo_plan"):
result = servo_plan(
side=side, marker_id=marker_id, size_mm=size_mm,
conf=req.conf, class_name=req.class_name,
min_distance_m=min_dist, step_fraction=step_frac,
pointcloud_step=pc_step, pointcloud_max_depth_mm=pc_max_depth,
cfg=cfg,
nuwa=_deps.nuwa(), marker=_deps.marker(),
yolo=_deps.yolo(), g1=_deps.g1(dry_run=False),
)
except ServoError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception as e:
raise HTTPException(status_code=502, detail=f"servo plan failed: {e}")
return side, min_dist, step_frac, result
@router.post("/visual_servo/plan")
def visual_servo_plan(req: VisualServoReq) -> dict[str, Any]:
"""Perception + next-step computation, no motion. Useful for UI preview."""
sw = Stopwatch()
side, min_dist, step_frac, p = _servo_plan_from_req(req, sw)
return {
"side": side,
"min_distance_m": min_dist,
"step_fraction": step_frac,
"plan": p.__dict__,
"timings_ms": sw.as_dict(),
}
@router.post("/visual_servo/step")
def visual_servo_step(req: VisualServoStepReq) -> dict[str, Any]:
"""Plan + execute one half-step toward the object. Returns the move result
plus the plan (before the move) so the UI can visualise what just happened;
the next click re-plans from the new wrist pose."""
sw = Stopwatch()
side, min_dist, step_frac, p = _servo_plan_from_req(req, sw)
move_result: dict[str, Any] | None = None
if p.done or p.target_position_base is None:
return {
"side": side,
"min_distance_m": min_dist,
"step_fraction": step_frac,
"plan": p.__dict__,
"moved": False,
"reason": p.reason or "reached",
"timings_ms": sw.as_dict(),
}
g1 = _deps.g1(dry_run=req.dry_run)
try:
with sw.step("wrist_absolute"):
move_result = g1.wrist_absolute(
side=side,
position=list(p.target_position_base),
quaternion=list(p.target_quaternion_base or p.wrist_quaternion_base),
wait=True,
timeout_s=req.timeout_s,
)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
return {
"side": side,
"min_distance_m": min_dist,
"step_fraction": step_frac,
"plan": p.__dict__,
"moved": True,
"move_result": move_result,
"timings_ms": sw.as_dict(),
}
# ---------------------------------------------------------------------------
# Work plane (for the visual-servo tab): fit once from a user-selected ROI,
# stored in-memory (singleton). Subsequent visual_servo/plan calls enrich the
# response with plane projections of marker and object.
# ---------------------------------------------------------------------------
class PlaneFitReq(BaseModel):
u_min: int = Field(..., ge=0)
v_min: int = Field(..., ge=0)
u_max: int = Field(..., gt=0)
v_max: int = Field(..., gt=0)
max_depth_mm: Optional[int] = None
@router.post("/visual_servo/plane/fit")
def visual_servo_plane_fit(req: PlaneFitReq) -> dict[str, Any]:
cfg = get_config()
nuwa = _deps.nuwa()
pc_max = req.max_depth_mm or int(cfg.get("grasp", {}).get("pointcloud_max_depth_mm", 2000))
try:
pc, _meta = nuwa.pointcloud_roi(
req.u_min, req.v_min, req.u_max, req.v_max,
step=1, max_depth_mm=pc_max,
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Nuwa pointcloud_roi failed: {e}")
if len(pc) < 10:
raise HTTPException(status_code=422, detail=f"too few depth points in ROI: {len(pc)}")
pts_cv = nuwa_to_cv_frame(pc[:, :3])
# Drop gross outliers before SVD (stray far-depth pixels around the edges).
pts_cv = filter_outliers(pts_cv, n_std=2.0)
try:
normal, offset, centroid, rms_mm = fit_plane_svd(pts_cv)
except ServoError as e:
raise HTTPException(status_code=422, detail=str(e))
wp = WorkPlane(
normal=[float(x) for x in normal],
offset=float(offset),
centroid=[float(x) for x in centroid],
n_inliers=int(len(pts_cv)),
fit_rms_mm=float(rms_mm),
roi=[int(req.u_min), int(req.v_min), int(req.u_max), int(req.v_max)],
created_at=time.time(),
)
set_work_plane(wp)
return {"ok": True, "plane": asdict(wp)}
@router.get("/visual_servo/plane")
def visual_servo_plane_get() -> dict[str, Any]:
wp = get_work_plane()
return {"set": wp is not None, "plane": asdict(wp) if wp is not None else None}
@router.delete("/visual_servo/plane")
def visual_servo_plane_clear() -> dict[str, Any]:
set_work_plane(None)
return {"ok": True, "set": False}
+122
View File
@@ -0,0 +1,122 @@
"""Home dashboard endpoints.
Aggregates communication health for every upstream microservice
(Nuwa, marker-finder, YOLO, and the currently-active robot bridge — G1 or H1)
and exposes the active-robot selector used by the Home tab in the GUI.
The orchestrator has no hardware of its own, so "is the system healthy" reduces
to "are the four upstream services reachable". This endpoint pings each one
and reports status + round-trip latency in a single payload so the GUI can
render it without making four parallel calls itself.
"""
from __future__ import annotations
import time
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..config import get_config, update_config
from . import _deps
router = APIRouter()
def _ping(name: str, base_url: str, path: str, timeout_s: float) -> dict[str, Any]:
"""Best-effort GET ping. Always returns a record, never raises."""
rec: dict[str, Any] = {
"name": name,
"url": base_url.rstrip("/") + path,
"ok": False,
"status_code": None,
"latency_ms": None,
"error": None,
}
t0 = time.perf_counter()
try:
with httpx.Client(timeout=timeout_s) as c:
r = c.get(rec["url"])
rec["latency_ms"] = round((time.perf_counter() - t0) * 1000.0, 1)
rec["status_code"] = r.status_code
rec["ok"] = r.is_success
if not r.is_success:
rec["error"] = (r.text or "")[:200]
except (httpx.HTTPError, OSError) as e:
rec["latency_ms"] = round((time.perf_counter() - t0) * 1000.0, 1)
rec["error"] = str(e)[:200]
return rec
@router.get("/status")
def status() -> dict[str, Any]:
"""Aggregate health of every configured upstream microservice."""
cfg = get_config()
services = cfg.get("services", {})
active = _deps.active_robot_key()
devices: list[dict[str, Any]] = []
nuwa = services.get("nuwa") or {}
if nuwa.get("base_url"):
devices.append({
"key": "nuwa",
"label": "Nuwa-HP60C (camera)",
**_ping("nuwa", nuwa["base_url"], "/api/health", float(nuwa.get("timeout_s", 5.0))),
})
marker = services.get("marker_finder") or {}
if marker.get("base_url"):
# marker-finder has no /health; fall back to the root which returns a
# response (typically 404/405) — what matters for "is it reachable" is
# that we got *any* HTTP response, so we treat 4xx as reachable too.
rec = _ping("marker_finder", marker["base_url"], "/", float(marker.get("timeout_s", 5.0)))
if rec["status_code"] is not None:
rec["ok"] = True
rec["error"] = None
devices.append({"key": "marker_finder", "label": "marker-finder (ArUco)", **rec})
yolo = services.get("yolo") or {}
if yolo.get("base_url"):
rec = _ping("yolo", yolo["base_url"], "/classes", float(yolo.get("timeout_s", 10.0)))
devices.append({"key": "yolo", "label": "YOLO server", **rec})
for robot_key in ("g1", "h1"):
rcfg = services.get(robot_key) or {}
if not rcfg.get("base_url"):
continue
rec = _ping(robot_key, rcfg["base_url"], "/health", float(rcfg.get("timeout_s", 5.0)))
devices.append({
"key": robot_key,
"label": f"Robot {robot_key.upper()}" + (" (attivo)" if robot_key == active else ""),
"active": robot_key == active,
**rec,
})
overall = "ok" if all(d["ok"] for d in devices) else "degraded" if any(d["ok"] for d in devices) else "down"
return {"overall": overall, "active_robot": active, "devices": devices}
class ActiveRobotReq(BaseModel):
active: str = Field(..., pattern="^(g1|h1)$")
@router.get("/robot")
def get_active_robot() -> dict[str, Any]:
cfg = get_config()
services = cfg.get("services", {}) or {}
return {
"active": _deps.active_robot_key(),
"available": [k for k in ("g1", "h1") if (services.get(k) or {}).get("base_url")],
}
@router.put("/robot")
def set_active_robot(req: ActiveRobotReq) -> dict[str, Any]:
services = (get_config().get("services") or {})
if not (services.get(req.active) or {}).get("base_url"):
raise HTTPException(status_code=400, detail=f"services.{req.active}.base_url not configured")
update_config({"robot": {"active": req.active}})
return {"active": _deps.active_robot_key()}
+441
View File
@@ -0,0 +1,441 @@
"""Perception endpoints: YOLO detect + object point cloud in base frame."""
from __future__ import annotations
from typing import Any, Optional
import numpy as np
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from ..config import get_config
from ..geometry import transform_points
from ..perception import ObjectDetector, filter_outliers, nuwa_to_cv_frame
from ..timing import Stopwatch
from . import _deps
router = APIRouter()
class DetectReq(BaseModel):
conf: Optional[float] = None
class_name: Optional[str] = None
max_points: int = 20000
class ScanReq(BaseModel):
conf: Optional[float] = None
class_name: Optional[str] = None # if set, point cloud is computed for this class
max_points: int = 20000
include_points: bool = True # set false to skip the ROI pointcloud (faster)
@router.post("/detect")
def detect_objects(req: DetectReq) -> dict[str, Any]:
sw = Stopwatch()
yolo = _deps.yolo()
nuwa = _deps.nuwa()
detector = ObjectDetector(yolo, nuwa)
try:
with sw.step("yolo"):
dets = detector.detect(conf=req.conf)
except Exception as e:
raise HTTPException(status_code=502, detail=f"YOLO detect failed: {e}")
if req.class_name:
dets = [d for d in dets if (d.get("class") or "").lower() == req.class_name.lower()]
return {"detections": dets, "timings_ms": sw.as_dict()}
@router.get("/classes")
def list_classes() -> dict[str, Any]:
"""Class names exposed by the YOLO model. Empty list if yolo-server is older
and does not serve /classes yet — the UI should still work by deriving the
classes from /scan detections."""
try:
items = _deps.yolo().classes()
except Exception as e:
raise HTTPException(status_code=502, detail=f"YOLO classes failed: {e}")
return {"classes": items}
@router.post("/scan")
def scan(req: ScanReq) -> dict[str, Any]:
"""One YOLO call → all detections (sorted by confidence) + optional point
cloud for the selected class. If class_name is provided the point cloud is
computed for its highest-confidence detection; otherwise for the top overall
detection."""
sw = Stopwatch()
yolo = _deps.yolo()
nuwa = _deps.nuwa()
detector = ObjectDetector(yolo, nuwa)
try:
with sw.step("yolo"):
all_dets = detector.detect(conf=req.conf)
except Exception as e:
raise HTTPException(status_code=502, detail=f"YOLO detect failed: {e}")
detections = sorted(
(d for d in all_dets if (d.get("class") or "").strip()),
key=lambda d: d["confidence"],
reverse=True,
)
out: dict[str, Any] = {
"detections": detections,
"selected": None,
"n_points": 0,
"timings_ms": sw.as_dict(),
}
if not req.include_points or not detections:
out["timings_ms"] = sw.as_dict()
return out
target: dict[str, Any] | None = None
if req.class_name:
target = next(
(d for d in detections if (d.get("class") or "").lower() == req.class_name.lower()),
None,
)
if target is None and not req.class_name:
target = detections[0]
if target is None:
# requested class not detected in this frame
out["timings_ms"] = sw.as_dict()
return out
bbox = target["bbox"]
cfg = get_config()
step = int(cfg.get("grasp", {}).get("pointcloud_step", 1))
max_depth = int(cfg.get("grasp", {}).get("pointcloud_max_depth_mm", 2000))
u_min, v_min, u_max, v_max = _bbox_to_roi(bbox)
try:
with sw.step("pointcloud_roi"):
pc, _meta = nuwa.pointcloud_roi(u_min, v_min, u_max, v_max,
step=step, max_depth_mm=max_depth)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Nuwa pointcloud ROI failed: {e}")
with sw.step("filter_outliers"):
cropped = filter_outliers(pc)
if len(cropped) > req.max_points:
idx = np.random.choice(len(cropped), req.max_points, replace=False)
cropped = cropped[idx]
out["selected"] = target
out["n_points"] = int(len(cropped))
out["points_camera"] = cropped[:, :3].tolist() if len(cropped) else []
T_base_cam = _load_calibration()
if T_base_cam is not None and len(cropped) > 0:
with sw.step("to_base_frame"):
pts_cv = nuwa_to_cv_frame(cropped[:, :3])
pts_base = transform_points(T_base_cam, pts_cv)
out["points_base"] = pts_base.tolist()
out["T_base_camera"] = T_base_cam.tolist()
out["timings_ms"] = sw.as_dict()
return out
@router.post("/object_points")
def object_points(req: DetectReq) -> dict[str, Any]:
"""Runs YOLO, takes the top detection (or class-filtered), returns its point cloud
both in camera frame and in base frame (if calibration is available)."""
sw = Stopwatch()
yolo = _deps.yolo()
nuwa = _deps.nuwa()
detector = ObjectDetector(yolo, nuwa)
try:
with sw.step("yolo"):
dets = detector.detect(conf=req.conf)
except Exception as e:
raise HTTPException(status_code=502, detail=f"YOLO detect failed: {e}")
if req.class_name:
dets = [d for d in dets if (d.get("class") or "").lower() == req.class_name.lower()]
if not dets:
raise HTTPException(status_code=404, detail="no object detected")
dets.sort(key=lambda d: d["confidence"], reverse=True)
top = dets[0]
bbox = top["bbox"]
cfg = get_config()
step = int(cfg.get("grasp", {}).get("pointcloud_step", 1))
max_depth = int(cfg.get("grasp", {}).get("pointcloud_max_depth_mm", 2000))
u_min, v_min, u_max, v_max = _bbox_to_roi(bbox)
try:
with sw.step("pointcloud_roi"):
pc, _meta = nuwa.pointcloud_roi(u_min, v_min, u_max, v_max,
step=step, max_depth_mm=max_depth)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Nuwa pointcloud ROI failed: {e}")
with sw.step("filter_outliers"):
cropped = filter_outliers(pc)
if len(cropped) > req.max_points:
idx = np.random.choice(len(cropped), req.max_points, replace=False)
cropped = cropped[idx]
out: dict[str, Any] = {
"detection": top,
"n_points": int(len(cropped)),
"points_camera": cropped[:, :3].tolist(),
}
T_base_cam = _load_calibration()
if T_base_cam is not None and len(cropped) > 0:
with sw.step("to_base_frame"):
pts_cv = nuwa_to_cv_frame(cropped[:, :3])
pts_base = transform_points(T_base_cam, pts_cv)
out["points_base"] = pts_base.tolist()
out["T_base_camera"] = T_base_cam.tolist()
out["timings_ms"] = sw.as_dict()
return out
def _load_calibration() -> np.ndarray | None:
from ..calibration import CalibrationStore
return CalibrationStore(_deps.calibration_file_path(_deps.default_side())).T_base_camera()
@router.get("/measure")
def measure(u: int = Query(..., ge=0), v: int = Query(..., ge=0),
radius: int = Query(6, ge=0, le=50)) -> dict[str, Any]:
"""Back-project a pixel to 3D (camera frame, and base frame if calibrated)."""
sw = Stopwatch()
try:
with sw.step("nuwa_measure"):
res = _deps.nuwa().measure(u=u, v=v, radius=radius)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Nuwa measure failed: {e}")
out: dict[str, Any] = {"camera": res}
T_base_cam = _load_calibration()
pt = _extract_xyz(res)
if T_base_cam is not None and pt is not None:
pt_cv = nuwa_to_cv_frame(np.asarray([pt]))[0]
base_xyz = (T_base_cam[:3, :3] @ pt_cv + T_base_cam[:3, 3]).tolist()
out["base"] = {"x": base_xyz[0], "y": base_xyz[1], "z": base_xyz[2]}
out["timings_ms"] = sw.as_dict()
return out
# ---------- depth-bias correction ----------
# Sensor depth has a non-trivial offset (see /api/perception/measure_rgb vs
# PnP on a known marker). We model it as: depth_raw = scale * depth_true +
# offset_mm and let the user calibrate `scale, offset` from samples.
# State + samples persisted in data/depth_correction.json.
class DepthCorrectionParams(BaseModel):
enabled: Optional[bool] = None
scale: Optional[float] = None
offset_mm: Optional[float] = None
class DepthCorrectionSampleReq(BaseModel):
"""Add a (raw, true) sample. Either supply both directly, or set
`from_marker=True` to acquire one automatically from the current
marker (PnP Z taken as ground truth, raw read from depth sensor at
the marker centre)."""
from_marker: bool = False
marker_id: Optional[int] = None
size_mm: Optional[float] = None
depth_raw_mm: Optional[int] = None
depth_true_mm: Optional[int] = None
note: str = ""
def _load_dc():
from ..perception.depth_correction import load
return load(_deps.depth_correction_path())
def _save_dc(state):
from ..perception.depth_correction import save
save(_deps.depth_correction_path(), state)
@router.get("/depth_correction")
def get_depth_correction() -> dict[str, Any]:
return _load_dc()
@router.put("/depth_correction")
def set_depth_correction(req: DepthCorrectionParams) -> dict[str, Any]:
state = _load_dc()
if req.enabled is not None:
state["enabled"] = bool(req.enabled)
if req.scale is not None:
state["scale"] = float(req.scale)
if req.offset_mm is not None:
state["offset_mm"] = float(req.offset_mm)
_save_dc(state)
return state
@router.delete("/depth_correction/samples")
def clear_dc_samples() -> dict[str, Any]:
from ..perception.depth_correction import clear_samples
state = clear_samples(_load_dc())
_save_dc(state)
return state
@router.post("/depth_correction/sample")
def add_dc_sample(req: DepthCorrectionSampleReq) -> dict[str, Any]:
"""Add a sample to the depth-correction calibration set.
`from_marker=True`: detect the configured marker, take its PnP-derived
Z (in the camera frame) as ground truth, and read the raw sensor depth
at the marker centre via the same registration pipeline. The marker
must be in view.
"""
from ..perception.depth_correction import add_sample
cfg = get_config()
state = _load_dc()
if req.from_marker:
from ..perception.depth_to_rgb import (
CameraRegistration, back_project_rgb_pixel, fetch_depth_mm,
)
marker_id = int(req.marker_id or cfg["aruco"]["wrist_marker"]["id"])
size_mm = float(req.size_mm or cfg["aruco"]["wrist_marker"]["size_mm"])
try:
mres = _deps.marker().detect_url(
markers=[{"id": marker_id, "size_mm": size_mm}],
image_url=_deps.nuwa().rgb_url(),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"marker-finder failed: {e}")
entry = next((m for m in mres.get("found", []) if int(m["id"]) == marker_id), None)
if entry is None:
raise HTTPException(status_code=422, detail=f"marker id={marker_id} non in vista")
pnp_z_m = float(np.asarray(entry["position_m"], dtype=np.float64)[2])
# Sample the raw depth at the centre pixel via the registration pipeline
# WITHOUT correction (we want the uncorrected sensor reading).
center_uv = np.asarray(entry["corners"], dtype=np.float64).reshape(-1, 2).mean(axis=0)
nuwa = _deps.nuwa()
reg = CameraRegistration.from_nuwa(nuwa.intrinsics())
depth = fetch_depth_mm(nuwa)
r = back_project_rgb_pixel(center_uv[0], center_uv[1], depth, reg,
sample_radius=4, depth_correction=None)
if not r.get("ok"):
raise HTTPException(status_code=422,
detail=f"depth non disponibile al marker: {r.get('error')}")
depth_raw = int(r["depth_mm_raw"])
depth_true = int(round(pnp_z_m * 1000.0))
note = req.note or f"from_marker id={marker_id} size={size_mm}mm pnp_z={depth_true}mm"
else:
if req.depth_raw_mm is None or req.depth_true_mm is None:
raise HTTPException(status_code=422,
detail="senza from_marker servono depth_raw_mm e depth_true_mm")
depth_raw = int(req.depth_raw_mm)
depth_true = int(req.depth_true_mm)
note = req.note
state = add_sample(state, depth_raw_mm=depth_raw, depth_true_mm=depth_true, note=note)
_save_dc(state)
return {"ok": True, "n_samples": len(state["samples"]),
"added": {"depth_raw_mm": depth_raw, "depth_true_mm": depth_true,
"delta_mm": depth_raw - depth_true, "note": note}}
@router.post("/depth_correction/fit")
def fit_dc() -> dict[str, Any]:
"""Fit the affine model from collected samples and (optionally) enable it.
Updates `scale` and `offset_mm` in the persisted state. Does not change
`enabled` — call PUT /depth_correction with `enabled=true` to activate."""
from ..perception.depth_correction import fit_affine
state = _load_dc()
samples = state.get("samples") or []
try:
fit = fit_affine(samples)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
state["scale"] = fit["scale"]
state["offset_mm"] = fit["offset_mm"]
state["fit_meta"] = fit
_save_dc(state)
return state
@router.get("/measure_rgb")
def measure_rgb(u: int = Query(..., ge=0, description="pixel column in the RGB image"),
v: int = Query(..., ge=0, description="pixel row in the RGB image"),
radius: int = Query(4, ge=0, le=50)) -> dict[str, Any]:
"""Back-project an **RGB** pixel to 3D, properly handling IR↔RGB
registration.
`Nuwa /api/measure` interprets `(u,v)` as IR-image pixel coords and
back-projects with IR intrinsics; for an RGB pixel that yields a wrong
point. This endpoint:
1. samples depth at the corresponding IR pixel (computed iteratively
from the RGB ray + extrinsics);
2. back-projects with IR intrinsics;
3. transforms to the RGB camera frame.
Output is in the **OpenCV-convention RGB camera frame** (X-right, Y-down,
Z-forward), the same frame the hand-eye matrix `T_base_camera` expects.
"""
sw = Stopwatch()
nuwa = _deps.nuwa()
try:
from ..perception.depth_to_rgb import (
CameraRegistration, back_project_rgb_pixel, fetch_depth_mm,
)
with sw.step("intrinsics"):
reg = CameraRegistration.from_nuwa(nuwa.intrinsics())
with sw.step("depth_fetch"):
depth = fetch_depth_mm(nuwa)
with sw.step("back_project"):
r = back_project_rgb_pixel(u, v, depth, reg, sample_radius=radius,
depth_correction=_load_dc())
except Exception as e:
raise HTTPException(status_code=502, detail=f"measure_rgb failed: {e}")
if not r["ok"]:
return {"found": False, "rgb_pixel": [u, v], "error": r.get("error"),
"timings_ms": sw.as_dict()}
pt_rgb_m = r["point_rgb_m"]
out: dict[str, Any] = {
"found": True,
"rgb_pixel": r["rgb_pixel"],
"ir_pixel": r["ir_pixel"],
"depth_mm": r["depth_mm"],
"point_rgb_m": {"x": pt_rgb_m[0], "y": pt_rgb_m[1], "z": pt_rgb_m[2]},
"point_ir_m": {"x": r["point_ir_m"][0], "y": r["point_ir_m"][1], "z": r["point_ir_m"][2]},
"distance_m": float(np.linalg.norm(pt_rgb_m)),
}
T_base_cam = _load_calibration()
if T_base_cam is not None:
pt_arr = np.asarray(pt_rgb_m)
base_xyz = (T_base_cam[:3, :3] @ pt_arr + T_base_cam[:3, 3]).tolist()
out["point_base_m"] = {"x": base_xyz[0], "y": base_xyz[1], "z": base_xyz[2]}
out["timings_ms"] = sw.as_dict()
return out
def _bbox_to_roi(bbox: dict[str, Any]) -> tuple[int, int, int, int]:
"""Convert a YOLO bbox {x1,y1,x2,y2} to integer ROI (u_min,v_min,u_max,v_max)."""
u_min = max(0, int(np.floor(bbox["x1"])))
v_min = max(0, int(np.floor(bbox["y1"])))
u_max = int(np.ceil(bbox["x2"]))
v_max = int(np.ceil(bbox["y2"]))
if u_max <= u_min:
u_max = u_min + 1
if v_max <= v_min:
v_max = v_min + 1
return u_min, v_min, u_max, v_max
def _extract_xyz(res: Any) -> list[float] | None:
if not isinstance(res, dict):
return None
for key in ("point", "xyz", "camera_xyz"):
v = res.get(key)
if isinstance(v, (list, tuple)) and len(v) >= 3:
return [float(v[0]), float(v[1]), float(v[2])]
if all(k in res for k in ("x", "y", "z")):
return [float(res["x"]), float(res["y"]), float(res["z"])]
return None
+112
View File
@@ -0,0 +1,112 @@
"""Posture & state endpoints: full live state, hand control, server-side presets.
Thin proxy over G1 endpoints not covered by `wrist_routes.py`. Mutating calls
honor `dry_run` (handled inside `G1Client`).
"""
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..clients import G1Unavailable
from . import _deps
router = APIRouter()
# ---------- live state ----------
@router.get("/state")
def get_state() -> dict[str, Any]:
try:
return _deps.g1().state()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/hand")
def get_hand() -> dict[str, Any]:
try:
return _deps.g1().hand()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
# ---------- per-finger control ----------
class FingerValues(BaseModel):
pinky: Optional[float] = Field(None, ge=0.0, le=1.0)
ring: Optional[float] = Field(None, ge=0.0, le=1.0)
middle: Optional[float] = Field(None, ge=0.0, le=1.0)
index: Optional[float] = Field(None, ge=0.0, le=1.0)
thumb_bend: Optional[float] = Field(None, ge=0.0, le=1.0)
thumb_rotation: Optional[float] = Field(None, ge=0.0, le=1.0)
class HandControlReq(BaseModel):
side: str = Field(..., pattern="^(left|right)$")
fingers: FingerValues
dry_run: bool = False
@router.post("/hand_control")
def post_hand_control(req: HandControlReq) -> dict[str, Any]:
fingers = {k: v for k, v in req.fingers.model_dump().items() if v is not None}
try:
return _deps.g1(dry_run=req.dry_run).hand_control(side=req.side, fingers=fingers)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
# ---------- presets ----------
class PresetSaveReq(BaseModel):
name: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
dry_run: bool = False
class PresetExecuteReq(BaseModel):
dry_run: bool = False
@router.get("/presets")
def list_presets() -> dict[str, Any]:
try:
return _deps.g1().presets()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/presets/{name}")
def get_preset(name: str) -> dict[str, Any]:
try:
return _deps.g1().preset_get(name)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/presets/save")
def save_preset(req: PresetSaveReq) -> dict[str, Any]:
try:
return _deps.g1(dry_run=req.dry_run).preset_save(name=req.name)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/presets/{name}/execute")
def execute_preset(name: str, req: PresetExecuteReq) -> dict[str, Any]:
try:
return _deps.g1(dry_run=req.dry_run).preset_execute(name=name)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.delete("/presets/{name}")
def delete_preset(name: str, dry_run: bool = False) -> dict[str, Any]:
try:
return _deps.g1(dry_run=dry_run).preset_delete(name=name)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
+155
View File
@@ -0,0 +1,155 @@
"""Robot FSM, arm-mode and locomotion endpoints (proxied to G1).
Thin proxy over `/robot/*` and `/arms/*` on the G1 API server. Mutating calls
honor `dry_run` (handled inside `G1Client`).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from ..clients import G1StatusError, G1Unavailable
from . import _deps
router = APIRouter()
# ---------- FSM state ----------
class StateReq(BaseModel):
state: str = Field(..., min_length=1, max_length=32)
dry_run: bool = False
@router.get("/state")
def get_state() -> dict[str, Any]:
try:
return _deps.g1().robot_state_get()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/state")
def set_state(req: StateReq) -> dict[str, Any]:
try:
return _deps.g1(dry_run=req.dry_run).robot_state_set(req.state)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
# ---------- arm mode ----------
class ArmModeReq(BaseModel):
mode: str = Field(..., min_length=1, max_length=32)
dry_run: bool = False
@router.post("/arm_mode")
def set_arm_mode(req: ArmModeReq) -> dict[str, Any]:
try:
return _deps.g1(dry_run=req.dry_run).robot_arm_mode(req.mode)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
# ---------- stand / move ----------
class StandReq(BaseModel):
mode: str = Field("high", min_length=1, max_length=32)
dry_run: bool = False
class MoveReq(BaseModel):
vx: float = Field(..., ge=-2.0, le=2.0)
vy: float = Field(..., ge=-1.0, le=1.0)
vyaw: float = Field(..., ge=-2.0, le=2.0)
continuous: bool = False
dry_run: bool = False
@router.post("/stand")
def post_stand(req: StandReq) -> dict[str, Any]:
try:
return _deps.g1(dry_run=req.dry_run).robot_stand(req.mode)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/move")
def post_move(req: MoveReq) -> dict[str, Any]:
try:
return _deps.g1(dry_run=req.dry_run).robot_move(
vx=req.vx, vy=req.vy, vyaw=req.vyaw, continuous=req.continuous,
)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/stopmove")
def post_stopmove(dry_run: bool = Query(False)) -> dict[str, Any]:
try:
return _deps.g1(dry_run=dry_run).robot_stopmove()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
# ---------- arms (compliance + helpers) ----------
@router.get("/arms/compliance")
def get_compliance() -> dict[str, Any]:
try:
return _deps.g1().arms_compliance()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/arms/compliance/start")
def start_compliance(dry_run: bool = Query(False)) -> dict[str, Any]:
try:
return _deps.g1(dry_run=dry_run).arms_compliance_start()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/arms/compliance/stop")
def stop_compliance(dry_run: bool = Query(False)) -> dict[str, Any]:
try:
return _deps.g1(dry_run=dry_run).arms_compliance_stop()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/arms/go_neutral")
def go_neutral(dry_run: bool = Query(False)) -> dict[str, Any]:
try:
return _deps.g1(dry_run=dry_run).arms_go_neutral()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/arms/loco_reset")
def loco_reset(dry_run: bool = Query(False)) -> dict[str, Any]:
try:
return _deps.g1(dry_run=dry_run).arms_loco_reset()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/diagnostics")
def diagnostics() -> dict[str, Any]:
"""Proxy for G1 /diagnostics (motor temps, fault codes). Falls back to
{"available": false} when the upstream version doesn't expose it — the
frontend uses this to hide the thermal UI gracefully."""
try:
data = _deps.g1().diagnostics()
data = data if isinstance(data, dict) else {"data": data}
return {"available": True, **data}
except G1StatusError as e:
if e.status == 404:
return {"available": False, "reason": "G1 API does not expose /diagnostics yet"}
raise HTTPException(status_code=e.status, detail=str(e.detail))
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
+92
View File
@@ -0,0 +1,92 @@
"""Direct end-effector control endpoints (proxied to G1)."""
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from ..clients import G1Unavailable
from . import _deps
router = APIRouter()
class AbsoluteReq(BaseModel):
side: str = Field(..., pattern="^(left|right|both)$")
position: list[float] = Field(..., min_length=3, max_length=3)
quaternion: list[float] = Field(..., min_length=4, max_length=4)
wait: bool = True
timeout_s: float = 5.0
precise: bool = False
dry_run: bool = False
class RelativeReq(BaseModel):
side: str = Field(..., pattern="^(left|right|both)$")
delta_position: Optional[list[float]] = None
delta_quaternion: Optional[list[float]] = None
wait: bool = True
timeout_s: float = 5.0
dry_run: bool = False
class HandActionReq(BaseModel):
action: str = Field(..., pattern="^(LEFT_OPEN|LEFT_CLOSE|RIGHT_OPEN|RIGHT_CLOSE)$")
dry_run: bool = False
@router.get("")
def get_wrist() -> dict[str, Any]:
try:
return _deps.g1().wrist()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/absolute")
def post_absolute(req: AbsoluteReq) -> dict[str, Any]:
try:
return _deps.g1(dry_run=req.dry_run).wrist_absolute(
side=req.side, position=req.position, quaternion=req.quaternion,
wait=req.wait, timeout_s=req.timeout_s, precise=req.precise,
)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/relative")
def post_relative(req: RelativeReq) -> dict[str, Any]:
try:
return _deps.g1(dry_run=req.dry_run).wrist_relative(
side=req.side, delta_position=req.delta_position,
delta_quaternion=req.delta_quaternion,
wait=req.wait, timeout_s=req.timeout_s,
)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/hand")
def post_hand(req: HandActionReq) -> dict[str, Any]:
try:
return _deps.g1(dry_run=req.dry_run).hand_action(req.action)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/emergency_stop")
def post_estop(dry_run: bool = Query(False)) -> dict[str, Any]:
try:
return _deps.g1(dry_run=dry_run).emergency_stop()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/unlock")
def post_unlock(dry_run: bool = Query(False)) -> dict[str, Any]:
try:
return _deps.g1(dry_run=dry_run).unlock()
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=str(e))