refactor: remove deprecated robot API and implement dynamic routing for endpoints
This commit is contained in:
@@ -0,0 +1,132 @@
|
|||||||
|
endpoints:
|
||||||
|
|
||||||
|
- path: /robot/joint_states
|
||||||
|
method: GET
|
||||||
|
type: topic
|
||||||
|
ros_name: /joint_states
|
||||||
|
msg_type: sensor_msgs/msg/JointState
|
||||||
|
summary: "Текущее состояние суставов"
|
||||||
|
description: "Возвращает имена, позиции, скорости и усилия всех суставов."
|
||||||
|
tags: [robot]
|
||||||
|
fields: [name, position, velocity, effort]
|
||||||
|
timeout: 2.0
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- path: /robot/stop
|
||||||
|
method: POST
|
||||||
|
type: service
|
||||||
|
ros_name: cobot/stop
|
||||||
|
msg_type: std_srvs/srv/Trigger
|
||||||
|
summary: "[Service] Немедленно остановить движение"
|
||||||
|
description: "Вызывает сервис экстренной остановки — движение прерывается немедленно."
|
||||||
|
tags: [robot]
|
||||||
|
response_fields: [success, message]
|
||||||
|
timeout: 5.0
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- path: /robot/move/named
|
||||||
|
method: POST
|
||||||
|
type: service
|
||||||
|
ros_name: cobot/move_to_named
|
||||||
|
msg_type: iiwa_msgs/srv/MoveToNamedPose
|
||||||
|
summary: "[Action] Переместить в именованную позу из SRDF"
|
||||||
|
description: "Перемещает робота в позу, определённую по имени в SRDF-файле (например, home, work)."
|
||||||
|
tags: [motion]
|
||||||
|
timeout: 30.0
|
||||||
|
request_fields:
|
||||||
|
- name: name
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Имя позиции из SRDF"
|
||||||
|
- name: speed
|
||||||
|
type: float
|
||||||
|
default: 0.1
|
||||||
|
min: 0.01
|
||||||
|
max: 1.0
|
||||||
|
description: "Скорость [0.01–1.0]"
|
||||||
|
- name: accel_scale
|
||||||
|
type: float
|
||||||
|
default: 0.0
|
||||||
|
min: 0.0
|
||||||
|
max: 1.0
|
||||||
|
description: "Масштаб ускорения (0 = равно speed)"
|
||||||
|
response_fields: [success, message]
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- path: /robot/move/pose
|
||||||
|
method: POST
|
||||||
|
type: action
|
||||||
|
ros_name: cobot/move_to_pose
|
||||||
|
msg_type: iiwa_msgs/action/MoveToPose
|
||||||
|
summary: "[Action] Переместить в декартову позу"
|
||||||
|
description: "Перемещает TCP робота в заданную декартову позицию и ориентацию."
|
||||||
|
tags: [motion]
|
||||||
|
timeout: 30.0
|
||||||
|
request_fields:
|
||||||
|
- name: x
|
||||||
|
type: float
|
||||||
|
required: true
|
||||||
|
description: "Позиция X в метрах"
|
||||||
|
- name: y
|
||||||
|
type: float
|
||||||
|
required: true
|
||||||
|
description: "Позиция Y в метрах"
|
||||||
|
- name: z
|
||||||
|
type: float
|
||||||
|
required: true
|
||||||
|
description: "Позиция Z в метрах"
|
||||||
|
- name: a
|
||||||
|
type: float
|
||||||
|
default: 0.0
|
||||||
|
description: "Угол A (ZYX Эйлер, KUKA ABC) в радианах"
|
||||||
|
- name: b
|
||||||
|
type: float
|
||||||
|
default: 0.0
|
||||||
|
description: "Угол B в радианах"
|
||||||
|
- name: c
|
||||||
|
type: float
|
||||||
|
default: 0.0
|
||||||
|
description: "Угол C в радианах"
|
||||||
|
- name: speed
|
||||||
|
type: float
|
||||||
|
default: 0.1
|
||||||
|
min: 0.01
|
||||||
|
max: 1.0
|
||||||
|
description: "Скорость [0.01–1.0]"
|
||||||
|
- name: planner
|
||||||
|
type: string
|
||||||
|
default: "ptp"
|
||||||
|
choices: [ompl, ptp, lin, circ, chomp]
|
||||||
|
normalize: lower
|
||||||
|
description: "Планировщик движения: ompl, ptp, lin, circ, chomp"
|
||||||
|
- name: frame_id
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
description: "Целевой фрейм (пусто = default_frame)"
|
||||||
|
response_fields: [success, message]
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- path: /robot/move/joints
|
||||||
|
method: POST
|
||||||
|
type: action
|
||||||
|
ros_name: cobot/move_to_joints
|
||||||
|
msg_type: iiwa_msgs/action/MoveToJoints
|
||||||
|
summary: "[Action] Переместить в позиции суставов"
|
||||||
|
description: "Перемещает все суставы робота в заданные угловые позиции (радианы). Лимиты читаются из joint_limits.yaml."
|
||||||
|
tags: [motion]
|
||||||
|
timeout: 30.0
|
||||||
|
request_fields:
|
||||||
|
- name: joints
|
||||||
|
type: float_array
|
||||||
|
required: true
|
||||||
|
length: 7
|
||||||
|
joint_limits: true
|
||||||
|
description: "Позиции суставов в радианах [j1..j7]"
|
||||||
|
- name: speed
|
||||||
|
type: float
|
||||||
|
default: 0.1
|
||||||
|
min: 0.01
|
||||||
|
max: 1.0
|
||||||
|
description: "Скорость [0.01–1.0]"
|
||||||
|
response_fields: [success, message]
|
||||||
|
enabled: true
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FieldDef:
|
||||||
|
name: str
|
||||||
|
type: str # string | float | int | bool | float_array
|
||||||
|
required: bool = False
|
||||||
|
default: Any = None
|
||||||
|
description: str = ""
|
||||||
|
min: Optional[float] = None
|
||||||
|
max: Optional[float] = None
|
||||||
|
length: Optional[int] = None
|
||||||
|
choices: Optional[list] = None
|
||||||
|
normalize: Optional[str] = None # "lower" | "upper"
|
||||||
|
joint_limits: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EndpointDef:
|
||||||
|
path: str
|
||||||
|
method: str # GET | POST
|
||||||
|
type: str # topic | service | action
|
||||||
|
ros_name: str
|
||||||
|
msg_type: str
|
||||||
|
summary: str = ""
|
||||||
|
description: str = ""
|
||||||
|
tags: list = field(default_factory=list)
|
||||||
|
fields: list = field(default_factory=list) # topic: поля ответа
|
||||||
|
response_fields: list = field(default_factory=list) # service/action: поля ответа
|
||||||
|
request_fields: list[FieldDef] = field(default_factory=list)
|
||||||
|
timeout: float = 5.0
|
||||||
|
deprecated: bool = False
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(package: str, relative: str) -> Path:
|
||||||
|
"""Ищет файл сначала через ament_index, затем по пути относительно src/."""
|
||||||
|
try:
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
return Path(get_package_share_directory(package)) / relative
|
||||||
|
except Exception:
|
||||||
|
src = Path(__file__).parents[3] # .../src/iiwa_web/iiwa_web/ -> .../src/
|
||||||
|
return src / package / relative
|
||||||
|
|
||||||
|
|
||||||
|
def load_joint_limits(
|
||||||
|
package: str = "iiwa_config",
|
||||||
|
relative: str = "config/moveit/joint_limits.yaml",
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
"""Возвращает список (min, max) для каждого сустава по порядку joint1..jointN."""
|
||||||
|
path = _resolve_path(package, relative)
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
joints = data["joint_limits"]
|
||||||
|
limits: list[tuple[float, float]] = []
|
||||||
|
i = 1
|
||||||
|
while f"joint{i}" in joints:
|
||||||
|
j = joints[f"joint{i}"]
|
||||||
|
limits.append((j["min_position"], j["max_position"]))
|
||||||
|
i += 1
|
||||||
|
return limits
|
||||||
|
|
||||||
|
|
||||||
|
def load_api_config(
|
||||||
|
package: str = "iiwa_config",
|
||||||
|
relative: str = "config/api_endpoints.yaml",
|
||||||
|
) -> list[EndpointDef]:
|
||||||
|
"""Загружает описания эндпоинтов из YAML и возвращает список EndpointDef."""
|
||||||
|
path = _resolve_path(package, relative)
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
endpoints: list[EndpointDef] = []
|
||||||
|
for ep in data.get("endpoints", []):
|
||||||
|
if not ep.get("enabled", True):
|
||||||
|
continue
|
||||||
|
|
||||||
|
request_fields = [
|
||||||
|
FieldDef(
|
||||||
|
name=rf["name"],
|
||||||
|
type=rf["type"],
|
||||||
|
required=rf.get("required", False),
|
||||||
|
default=rf.get("default"),
|
||||||
|
description=rf.get("description", ""),
|
||||||
|
min=rf.get("min"),
|
||||||
|
max=rf.get("max"),
|
||||||
|
length=rf.get("length"),
|
||||||
|
choices=rf.get("choices"),
|
||||||
|
normalize=rf.get("normalize"),
|
||||||
|
joint_limits=rf.get("joint_limits", False),
|
||||||
|
)
|
||||||
|
for rf in ep.get("request_fields", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
endpoints.append(EndpointDef(
|
||||||
|
path=ep["path"],
|
||||||
|
method=ep["method"].upper(),
|
||||||
|
type=ep["type"],
|
||||||
|
ros_name=ep["ros_name"],
|
||||||
|
msg_type=ep["msg_type"],
|
||||||
|
summary=ep.get("summary", ""),
|
||||||
|
description=ep.get("description", ""),
|
||||||
|
tags=ep.get("tags", []),
|
||||||
|
fields=ep.get("fields", []),
|
||||||
|
response_fields=ep.get("response_fields", []),
|
||||||
|
request_fields=request_fields,
|
||||||
|
timeout=ep.get("timeout", 5.0),
|
||||||
|
deprecated=ep.get("deprecated", False),
|
||||||
|
))
|
||||||
|
|
||||||
|
return endpoints
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import time
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from .ros_node import get_bridge
|
|
||||||
|
|
||||||
_TIMEOUT = 2.0
|
|
||||||
_POLL_INTERVAL = 0.05
|
|
||||||
|
|
||||||
def ros_topic(topic_name: str, msg_type):
|
|
||||||
|
|
||||||
def dependency():
|
|
||||||
bridge = get_bridge()
|
|
||||||
bridge.subscribe(topic_name, msg_type)
|
|
||||||
|
|
||||||
deadline = time.monotonic() + _TIMEOUT
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
msg = bridge.get_latest(topic_name)
|
|
||||||
if msg is not None:
|
|
||||||
return msg
|
|
||||||
time.sleep(_POLL_INTERVAL)
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail=f"Timeout waiting for message on topic '{topic_name}'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return dependency
|
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import importlib
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import Field, create_model
|
||||||
|
|
||||||
|
from .config_loader import EndpointDef, FieldDef, load_api_config, load_joint_limits
|
||||||
|
from .ros_node import get_bridge
|
||||||
|
|
||||||
|
_POLL_INTERVAL = 0.05
|
||||||
|
|
||||||
|
_TYPE_MAP: dict[str, type] = {
|
||||||
|
"string": str,
|
||||||
|
"float": float,
|
||||||
|
"int": int,
|
||||||
|
"bool": bool,
|
||||||
|
"float_array": list[float],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _import_ros_type(type_str: str):
|
||||||
|
"""'sensor_msgs/msg/JointState' → класс JointState."""
|
||||||
|
parts = type_str.split("/")
|
||||||
|
module = importlib.import_module(".".join(parts[:-1]))
|
||||||
|
return getattr(module, parts[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_python(val: Any) -> Any:
|
||||||
|
"""Конвертирует ROS-значение в JSON-сериализуемый Python-тип."""
|
||||||
|
if isinstance(val, float):
|
||||||
|
return None if math.isnan(val) else val
|
||||||
|
if hasattr(val, "__iter__") and not isinstance(val, (str, bytes)):
|
||||||
|
return [None if (isinstance(v, float) and math.isnan(v)) else v for v in val]
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _extract(msg, fields: list[str]) -> dict:
|
||||||
|
return {f: _to_python(getattr(msg, f)) for f in fields}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_model(name: str, field_defs: list[FieldDef]) -> type:
|
||||||
|
"""Динамически создаёт Pydantic-модель из списка FieldDef."""
|
||||||
|
definitions: dict[str, tuple] = {}
|
||||||
|
for fd in field_defs:
|
||||||
|
py_type = _TYPE_MAP[fd.type]
|
||||||
|
kwargs: dict[str, Any] = {"description": fd.description}
|
||||||
|
if fd.min is not None:
|
||||||
|
kwargs["ge"] = fd.min
|
||||||
|
if fd.max is not None:
|
||||||
|
kwargs["le"] = fd.max
|
||||||
|
if fd.length is not None:
|
||||||
|
kwargs["min_length"] = fd.length
|
||||||
|
kwargs["max_length"] = fd.length
|
||||||
|
kwargs["default"] = ... if fd.required else fd.default
|
||||||
|
definitions[fd.name] = (py_type, Field(**kwargs))
|
||||||
|
return create_model(name, **definitions)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_topic_handler(ep: EndpointDef):
|
||||||
|
ros_type = _import_ros_type(ep.msg_type)
|
||||||
|
ros_name = ep.ros_name
|
||||||
|
fields = ep.fields
|
||||||
|
timeout = ep.timeout
|
||||||
|
|
||||||
|
def handler():
|
||||||
|
bridge = get_bridge()
|
||||||
|
bridge.subscribe(ros_name, ros_type)
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
msg = bridge.get_latest(ros_name)
|
||||||
|
if msg is not None:
|
||||||
|
return JSONResponse(_extract(msg, fields))
|
||||||
|
time.sleep(_POLL_INTERVAL)
|
||||||
|
raise HTTPException(503, f"Timeout waiting for topic '{ros_name}'")
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _make_service_handler(ep: EndpointDef, Body: type | None):
|
||||||
|
ros_type = _import_ros_type(ep.msg_type)
|
||||||
|
ros_name = ep.ros_name
|
||||||
|
response_fields = ep.response_fields
|
||||||
|
timeout = ep.timeout
|
||||||
|
field_defs = ep.request_fields
|
||||||
|
|
||||||
|
if Body is None:
|
||||||
|
def handler():
|
||||||
|
try:
|
||||||
|
resp = get_bridge().call_service(ros_type, ros_name, ros_type.Request(), timeout)
|
||||||
|
except (RuntimeError, TimeoutError) as e:
|
||||||
|
raise HTTPException(503, str(e))
|
||||||
|
return _extract(resp, response_fields) if response_fields else {"success": resp.success}
|
||||||
|
else:
|
||||||
|
def handler(body: Body): # type: ignore[valid-type]
|
||||||
|
req = ros_type.Request()
|
||||||
|
for fd in field_defs:
|
||||||
|
setattr(req, fd.name, getattr(body, fd.name))
|
||||||
|
try:
|
||||||
|
resp = get_bridge().call_service(ros_type, ros_name, req, timeout)
|
||||||
|
except (RuntimeError, TimeoutError) as e:
|
||||||
|
raise HTTPException(503, str(e))
|
||||||
|
return _extract(resp, response_fields) if response_fields else {"success": resp.success}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _make_action_handler(ep: EndpointDef, Body: type, joint_limits: list[tuple[float, float]]):
|
||||||
|
ros_type = _import_ros_type(ep.msg_type)
|
||||||
|
ros_name = ep.ros_name
|
||||||
|
response_fields = ep.response_fields
|
||||||
|
timeout = ep.timeout
|
||||||
|
field_defs = ep.request_fields
|
||||||
|
|
||||||
|
choice_fields = [(fd.name, fd.choices, fd.normalize) for fd in field_defs if fd.choices]
|
||||||
|
jl_fields = [fd.name for fd in field_defs if fd.joint_limits]
|
||||||
|
|
||||||
|
def handler(body: Body): # type: ignore[valid-type]
|
||||||
|
values: dict[str, Any] = {fd.name: getattr(body, fd.name) for fd in field_defs}
|
||||||
|
|
||||||
|
# Нормализация и валидация choices
|
||||||
|
for fname, choices, normalize in choice_fields:
|
||||||
|
val = values[fname]
|
||||||
|
if normalize == "lower" and isinstance(val, str):
|
||||||
|
val = val.lower()
|
||||||
|
elif normalize == "upper" and isinstance(val, str):
|
||||||
|
val = val.upper()
|
||||||
|
if val not in choices:
|
||||||
|
raise HTTPException(422, f"Поле '{fname}' должно быть одним из {choices}, получено '{val}'")
|
||||||
|
values[fname] = val
|
||||||
|
|
||||||
|
# Валидация лимитов суставов из joint_limits.yaml
|
||||||
|
for fname in jl_fields:
|
||||||
|
joints = values[fname]
|
||||||
|
if len(joints) != len(joint_limits):
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
f"Ожидалось {len(joint_limits)} суставов, получено {len(joints)}",
|
||||||
|
)
|
||||||
|
for i, (pos, (lo, hi)) in enumerate(zip(joints, joint_limits)):
|
||||||
|
if not (lo <= pos <= hi):
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
f"Сустав {i + 1}: {pos:.4f} рад вне диапазона [{lo:.3f}, {hi:.3f}]",
|
||||||
|
)
|
||||||
|
|
||||||
|
goal = ros_type.Goal()
|
||||||
|
for fd in field_defs:
|
||||||
|
setattr(goal, fd.name, values[fd.name])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = get_bridge().send_action(ros_type, ros_name, goal, timeout)
|
||||||
|
except (RuntimeError, TimeoutError) as e:
|
||||||
|
raise HTTPException(503, str(e))
|
||||||
|
|
||||||
|
return _extract(result.result, response_fields) if response_fields else {}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def build_dynamic_router() -> APIRouter:
|
||||||
|
"""Читает api_endpoints.yaml и joint_limits.yaml, возвращает готовый APIRouter."""
|
||||||
|
endpoints = load_api_config()
|
||||||
|
joint_limits = load_joint_limits()
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
for ep in endpoints:
|
||||||
|
# Имя модели — CamelCase из пути (/robot/move/joints → RobotMoveJoints)
|
||||||
|
model_name = "".join(p.title() for p in ep.path.strip("/").split("/"))
|
||||||
|
|
||||||
|
Body: type | None = None
|
||||||
|
if ep.request_fields:
|
||||||
|
Body = _build_model(f"{model_name}Request", ep.request_fields)
|
||||||
|
|
||||||
|
if ep.type == "topic":
|
||||||
|
handler = _make_topic_handler(ep)
|
||||||
|
elif ep.type == "service":
|
||||||
|
handler = _make_service_handler(ep, Body)
|
||||||
|
elif ep.type == "action":
|
||||||
|
if Body is None:
|
||||||
|
raise ValueError(f"Action-эндпоинт '{ep.path}' не имеет request_fields")
|
||||||
|
handler = _make_action_handler(ep, Body, joint_limits)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Неизвестный тип эндпоинта: '{ep.type}'")
|
||||||
|
|
||||||
|
router.add_api_route(
|
||||||
|
ep.path,
|
||||||
|
handler,
|
||||||
|
methods=[ep.method],
|
||||||
|
summary=ep.summary or ep.description,
|
||||||
|
description=ep.description,
|
||||||
|
tags=ep.tags,
|
||||||
|
deprecated=ep.deprecated,
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -2,7 +2,7 @@ import uvicorn
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from .ros_node import init_ros_node
|
from .ros_node import init_ros_node
|
||||||
from . import robot
|
from .dynamic_router import build_dynamic_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -12,7 +12,7 @@ async def lifespan(_: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
app.include_router(robot.router)
|
app.include_router(build_dynamic_router())
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import math
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
from sensor_msgs.msg import JointState
|
|
||||||
from std_srvs.srv import Trigger
|
|
||||||
from iiwa_msgs.action import MoveToPose, MoveToJoints
|
|
||||||
from iiwa_msgs.srv import MoveToNamedPose
|
|
||||||
|
|
||||||
from .deps import ros_topic
|
|
||||||
from .ros_node import get_bridge
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/robot", tags=["robot"])
|
|
||||||
|
|
||||||
JOINT_LIMITS = [
|
|
||||||
(-2.967, 2.967),
|
|
||||||
(-2.094, 2.094),
|
|
||||||
(-2.967, 2.967),
|
|
||||||
(-2.094, 2.094),
|
|
||||||
(-2.967, 2.967),
|
|
||||||
(-2.094, 2.094),
|
|
||||||
(-3.054, 3.054),
|
|
||||||
]
|
|
||||||
|
|
||||||
PLANNERS = ["ompl", "ptp", "lin", "circ", "chomp"]
|
|
||||||
|
|
||||||
|
|
||||||
def _clean(values):
|
|
||||||
return [None if math.isnan(v) else v for v in values]
|
|
||||||
|
|
||||||
|
|
||||||
def _action_error(e: Exception):
|
|
||||||
return HTTPException(status_code=503, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class MoveToPoseRequest(BaseModel):
|
|
||||||
x: float = Field(..., description="Позиция X в метрах")
|
|
||||||
y: float = Field(..., description="Позиция Y в метрах")
|
|
||||||
z: float = Field(..., description="Позиция Z в метрах")
|
|
||||||
a: float = Field(0.0, description="Угол A (ZYX Эйлер, KUKA ABC) в радианах")
|
|
||||||
b: float = Field(0.0, description="Угол B в радианах")
|
|
||||||
c: float = Field(0.0, description="Угол C в радианах")
|
|
||||||
speed: float = Field(0.1, ge=0.01, le=1.0, description="Скорость [0.01 – 1.0]")
|
|
||||||
planner: str = Field("ompl", description=f"Планировщик: {', '.join(PLANNERS)}")
|
|
||||||
frame_id: str = Field("", description="Целевой фрейм (пусто = default_frame)")
|
|
||||||
|
|
||||||
@field_validator("planner")
|
|
||||||
@classmethod
|
|
||||||
def check_planner(cls, v: str) -> str:
|
|
||||||
if v.lower() not in PLANNERS:
|
|
||||||
raise ValueError(f"Неизвестный планировщик '{v}'. Доступные: {', '.join(PLANNERS)}")
|
|
||||||
return v.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class MoveToJointsRequest(BaseModel):
|
|
||||||
joints: list[float] = Field(
|
|
||||||
..., min_length=7, max_length=7,
|
|
||||||
description="Позиции суставов в радианах [j1..j7]",
|
|
||||||
)
|
|
||||||
speed: float = Field(0.1, ge=0.01, le=1.0, description="Скорость [0.01 – 1.0]")
|
|
||||||
|
|
||||||
@field_validator("joints")
|
|
||||||
@classmethod
|
|
||||||
def check_limits(cls, joints: list[float]) -> list[float]:
|
|
||||||
for i, (pos, (lo, hi)) in enumerate(zip(joints, JOINT_LIMITS)):
|
|
||||||
if not (lo <= pos <= hi):
|
|
||||||
raise ValueError(
|
|
||||||
f"Сустав {i + 1}: {pos:.4f} рад вне диапазона [{lo:.3f}, {hi:.3f}]"
|
|
||||||
)
|
|
||||||
return joints
|
|
||||||
|
|
||||||
|
|
||||||
class MoveToNamedRequest(BaseModel):
|
|
||||||
name: str = Field(..., description="Имя позиции из SRDF")
|
|
||||||
speed: float = Field(0.1, ge=0.01, le=1.0, description="Скорость [0.01 – 1.0]")
|
|
||||||
accel_scale: float = Field(0.0, ge=0.0, le=1.0, description="Ускорение (0 = равно speed)")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/joint_states", summary="Текущее состояние суставов")
|
|
||||||
def get_joint_states(msg: JointState = Depends(ros_topic("/joint_states", JointState))):
|
|
||||||
return JSONResponse({
|
|
||||||
"name": list(msg.name),
|
|
||||||
"position": _clean(msg.position),
|
|
||||||
"velocity": _clean(msg.velocity),
|
|
||||||
"effort": _clean(msg.effort),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/move/pose", summary="[Action] Переместить в декартову позу")
|
|
||||||
def move_to_pose(req: MoveToPoseRequest):
|
|
||||||
goal = MoveToPose.Goal()
|
|
||||||
goal.x, goal.y, goal.z = req.x, req.y, req.z
|
|
||||||
goal.a, goal.b, goal.c = req.a, req.b, req.c
|
|
||||||
goal.speed = req.speed
|
|
||||||
goal.planner = req.planner
|
|
||||||
goal.frame_id = req.frame_id
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = get_bridge().send_action(MoveToPose, "cobot/move_to_pose", goal)
|
|
||||||
except (RuntimeError, TimeoutError) as e:
|
|
||||||
raise _action_error(e)
|
|
||||||
|
|
||||||
return {"success": result.result.success, "message": result.result.message}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/move/joints", summary="[Action] Переместить в позиции суставов")
|
|
||||||
def move_to_joints(req: MoveToJointsRequest):
|
|
||||||
goal = MoveToJoints.Goal()
|
|
||||||
goal.joints = req.joints
|
|
||||||
goal.speed = req.speed
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = get_bridge().send_action(MoveToJoints, "cobot/move_to_joints", goal)
|
|
||||||
except (RuntimeError, TimeoutError) as e:
|
|
||||||
raise _action_error(e)
|
|
||||||
|
|
||||||
return {"success": result.result.success, "message": result.result.message}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/move/named", summary="[Service] Переместить в именованную позу из SRDF")
|
|
||||||
def move_to_named(req: MoveToNamedRequest):
|
|
||||||
request = MoveToNamedPose.Request()
|
|
||||||
request.name = req.name
|
|
||||||
request.speed = req.speed
|
|
||||||
request.accel_scale = req.accel_scale
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = get_bridge().call_service(MoveToNamedPose, "cobot/move_to_named", request)
|
|
||||||
except (RuntimeError, TimeoutError) as e:
|
|
||||||
raise HTTPException(status_code=503, detail=str(e))
|
|
||||||
|
|
||||||
return {"success": response.success, "message": response.message}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop", summary="[Service] Немедленно остановить движение")
|
|
||||||
def stop():
|
|
||||||
try:
|
|
||||||
response = get_bridge().call_service(Trigger, "cobot/stop", Trigger.Request())
|
|
||||||
except (RuntimeError, TimeoutError) as e:
|
|
||||||
raise HTTPException(status_code=503, detail=str(e))
|
|
||||||
|
|
||||||
return {"success": response.success, "message": response.message}
|
|
||||||
Reference in New Issue
Block a user