refactor: remove deprecated robot API and implement dynamic routing for endpoints

This commit is contained in:
Даниил Грабарь
2026-05-26 17:23:46 +10:00
parent ec5fb3ccdb
commit 30d7785d8c
6 changed files with 450 additions and 173 deletions
+132
View File
@@ -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.011.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.011.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.011.0]"
response_fields: [success, message]
enabled: true
+117
View File
@@ -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
-26
View File
@@ -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
+199
View File
@@ -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 -2
View File
@@ -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():
-145
View File
@@ -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}