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:
commit
ac573505b7
121 files changed
+67199
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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)}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user