From e9e3163ce603f7698bfd412f62aff289b27f3dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=B8=D0=BB=20=D0=93=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=D1=80=D1=8C?= Date: Wed, 3 Jun 2026 13:19:13 +1000 Subject: [PATCH] feat: add support for named positions in robot API, including new endpoints and metadata --- src/iiwa_config/config/api_endpoints.yaml | 2 +- src/iiwa_config/config/moveit/iiwa7.srdf | 9 +++ .../config/moveit/named_positions_meta.yaml | 9 +++ src/iiwa_web/iiwa_web/config_loader.py | 48 +++++++++++++ src/iiwa_web/iiwa_web/main.py | 6 +- src/iiwa_web/iiwa_web/positions.py | 68 +++++++++++++++++++ 6 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 src/iiwa_config/config/moveit/named_positions_meta.yaml create mode 100644 src/iiwa_web/iiwa_web/positions.py diff --git a/src/iiwa_config/config/api_endpoints.yaml b/src/iiwa_config/config/api_endpoints.yaml index 71245ca..c78f075 100644 --- a/src/iiwa_config/config/api_endpoints.yaml +++ b/src/iiwa_config/config/api_endpoints.yaml @@ -42,7 +42,7 @@ endpoints: ros_name: cobot/move_to_named msg_type: iiwa_msgs/srv/MoveToNamedPose summary: "Переместить в именованную позу из SRDF" - description: "Перемещает робота в позу, определённую по имени в SRDF-файле (например, home, work)." + description: "Перемещает робота в позу, определённую по имени в SRDF-файле. Список доступных имён и значения суставов для каждой позиции возвращает GET /robot/positions." tags: [motion] timeout: 30.0 request_fields: diff --git a/src/iiwa_config/config/moveit/iiwa7.srdf b/src/iiwa_config/config/moveit/iiwa7.srdf index 3879ee9..ee69f82 100644 --- a/src/iiwa_config/config/moveit/iiwa7.srdf +++ b/src/iiwa_config/config/moveit/iiwa7.srdf @@ -45,6 +45,15 @@ + + + + + + + + + diff --git a/src/iiwa_config/config/moveit/named_positions_meta.yaml b/src/iiwa_config/config/moveit/named_positions_meta.yaml new file mode 100644 index 0000000..996160b --- /dev/null +++ b/src/iiwa_config/config/moveit/named_positions_meta.yaml @@ -0,0 +1,9 @@ +# Метаданные для именованных позиций из iiwa7.srdf +# Ключ совпадает с атрибутом name тега +named_positions: + home: + description: "Нулевое положение всех суставов - домашняя позиция робота" + work: + description: "Рабочее положение для выполнения задач" + transport: + description: "Позиция для транспортировки робота" \ No newline at end of file diff --git a/src/iiwa_web/iiwa_web/config_loader.py b/src/iiwa_web/iiwa_web/config_loader.py index a496cf3..a8e5af0 100644 --- a/src/iiwa_web/iiwa_web/config_loader.py +++ b/src/iiwa_web/iiwa_web/config_loader.py @@ -1,5 +1,6 @@ from __future__ import annotations +import xml.etree.ElementTree as ET import yaml from dataclasses import dataclass, field from pathlib import Path @@ -141,3 +142,50 @@ def load_api_config( )) return endpoints + + +@dataclass +class NamedPosition: + name: str + group: str + joints: dict[str, float] + description: str = "" + + +def load_named_positions( + srdf_path: str | None = None, + meta_path: str | None = None, + package: str = "iiwa_config", + srdf_relative: str = "config/moveit/iiwa7.srdf", + meta_relative: str = "config/moveit/named_positions_meta.yaml", +) -> list[NamedPosition]: + """Загружает именованные позиции из SRDF и объединяет с описаниями из YAML.""" + resolved_srdf = Path(srdf_path) if srdf_path else _resolve_path(package, srdf_relative) + resolved_meta = Path(meta_path) if meta_path else _resolve_path(package, meta_relative) + + tree = ET.parse(resolved_srdf) + root = tree.getroot() + + descriptions: dict[str, str] = {} + if resolved_meta.exists(): + with open(resolved_meta) as f: + meta = yaml.safe_load(f) or {} + for name, attrs in meta.get("named_positions", {}).items(): + descriptions[name] = attrs.get("description", "") + + positions: list[NamedPosition] = [] + for gs in root.findall("group_state"): + name = gs.get("name", "") + group = gs.get("group", "") + joints = { + j.get("name"): float(j.get("value", 0)) + for j in gs.findall("joint") + } + positions.append(NamedPosition( + name=name, + group=group, + joints=joints, + description=descriptions.get(name, ""), + )) + + return positions diff --git a/src/iiwa_web/iiwa_web/main.py b/src/iiwa_web/iiwa_web/main.py index 158dfa3..5949962 100644 --- a/src/iiwa_web/iiwa_web/main.py +++ b/src/iiwa_web/iiwa_web/main.py @@ -9,7 +9,7 @@ from sensor_msgs.msg import JointState from .dynamic_router import build_dynamic_router from .ros_node import CobotWebNode, get_bridge, set_bridge -from . import runner, trajectory +from . import runner, trajectory, positions def main(): @@ -23,10 +23,13 @@ def main(): endpoints_path = node.get_parameter('endpoints_path').value or None joint_limits_path = node.get_parameter('joint_limits_path').value or None + positions.init() + _schema_app = FastAPI() _schema_app.include_router(build_dynamic_router(endpoints_path, joint_limits_path)) _schema_app.include_router(runner.router) _schema_app.include_router(trajectory.router) + _schema_app.include_router(positions.router) mcp = FastMCP.from_fastapi(app=_schema_app) mcp_http = mcp.http_app(path='/mcp') @@ -41,6 +44,7 @@ def main(): app.include_router(build_dynamic_router(endpoints_path, joint_limits_path)) app.include_router(runner.router) app.include_router(trajectory.router) + app.include_router(positions.router) app.mount("/mcp", mcp_http) uvicorn.run(app, host=host, port=port) diff --git a/src/iiwa_web/iiwa_web/positions.py b/src/iiwa_web/iiwa_web/positions.py new file mode 100644 index 0000000..a728d14 --- /dev/null +++ b/src/iiwa_web/iiwa_web/positions.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from .config_loader import load_named_positions + +router = APIRouter(tags=["robot"]) + + +class NamedPositionResponse(BaseModel): + name: str = Field( + description=( + "Идентификатор позиции. Передайте это значение в поле `name` запроса " + "`POST /robot/move/named`, чтобы переместить робота в эту позу." + ) + ) + group: str = Field( + description="Группа планирования MoveIt, к которой относится позиция (обычно `iiwa_arm`)." + ) + joints: dict[str, float] = Field( + description=( + "Целевые углы суставов в радианах. Ключи: joint1..joint7. " + "Используйте эти значения для оценки конфигурации перед движением " + "или как основу для `POST /robot/move/joints`." + ) + ) + description: str = Field( + description="Человекочитаемое описание назначения позиции." + ) + + +_srdf_path: str | None = None +_meta_path: str | None = None + + +def init(srdf_path: str | None = None, meta_path: str | None = None) -> None: + global _srdf_path, _meta_path + _srdf_path = srdf_path + _meta_path = meta_path + + +@router.get( + "/robot/positions", + response_model=list[NamedPositionResponse], + summary="Список заготовленных именованных позиций робота", + description=( + "Возвращает все именованные позиции (`group_state`) из SRDF-файла конфигурации робота. " + "\n\n" + "**Типичный рабочий процесс для агента:**\n" + "1. Вызовите этот эндпоинт, чтобы узнать доступные позиции и их суставные значения.\n" + "2. Выберите подходящую позицию по полю `description` и значениям `joints`.\n" + "3. Передайте поле `name` выбранной позиции в `POST /robot/move/named`, " + "чтобы переместить робота туда.\n" + "\n" + "Позиции определены статически в SRDF и гарантированно безопасны с точки зрения " + "столкновений и кинематики." + ), +) +def get_named_positions() -> list[NamedPositionResponse]: + positions = load_named_positions(srdf_path=_srdf_path, meta_path=_meta_path) + return [ + NamedPositionResponse( + name=p.name, + group=p.group, + joints=p.joints, + description=p.description, + ) + for p in positions + ]