feat(web): expose planner scene and graph runs over HTTP and MCP
This commit is contained in:
@@ -10,13 +10,14 @@ from std_srvs.srv import Trigger
|
|||||||
|
|
||||||
from .dynamic_router import build_dynamic_router
|
from .dynamic_router import build_dynamic_router
|
||||||
from .ros_node import CobotWebNode, get_bridge, set_bridge
|
from .ros_node import CobotWebNode, get_bridge, set_bridge
|
||||||
from . import runner, trajectory, positions
|
from . import runner, trajectory, positions, planner
|
||||||
|
|
||||||
|
|
||||||
stop_router = APIRouter(tags=["stop"])
|
stop_router = APIRouter(tags=["stop"])
|
||||||
|
|
||||||
@stop_router.post("/stop", summary="Остановить всё: runner, траекторию и планировщик")
|
@stop_router.post("/stop", summary="Остановить всё: runner, траекторию и планировщик")
|
||||||
def stop_all():
|
def stop_all():
|
||||||
|
planner.cancel_active_run()
|
||||||
runner.stop_if_running()
|
runner.stop_if_running()
|
||||||
trajectory.send_stop_trajectory()
|
trajectory.send_stop_trajectory()
|
||||||
|
|
||||||
@@ -41,6 +42,22 @@ def stop_all():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_application_routes(app: FastAPI, endpoints_path, joint_limits_path) -> None:
|
||||||
|
"""Add the same routers to the schema app and the serving app."""
|
||||||
|
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.include_router(planner.router)
|
||||||
|
app.include_router(stop_router)
|
||||||
|
|
||||||
|
|
||||||
|
def build_schema_app(endpoints_path=None, joint_limits_path=None) -> FastAPI:
|
||||||
|
schema_app = FastAPI()
|
||||||
|
add_application_routes(schema_app, endpoints_path, joint_limits_path)
|
||||||
|
return schema_app
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
rclpy.init()
|
rclpy.init()
|
||||||
node = CobotWebNode()
|
node = CobotWebNode()
|
||||||
@@ -54,12 +71,7 @@ def main():
|
|||||||
|
|
||||||
positions.init()
|
positions.init()
|
||||||
|
|
||||||
_schema_app = FastAPI()
|
_schema_app = build_schema_app(endpoints_path, joint_limits_path)
|
||||||
_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)
|
|
||||||
_schema_app.include_router(stop_router)
|
|
||||||
|
|
||||||
mcp = FastMCP.from_fastapi(app=_schema_app)
|
mcp = FastMCP.from_fastapi(app=_schema_app)
|
||||||
mcp_http = mcp.http_app(path='/')
|
mcp_http = mcp.http_app(path='/')
|
||||||
@@ -71,11 +83,7 @@ def main():
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
app.include_router(build_dynamic_router(endpoints_path, joint_limits_path))
|
add_application_routes(app, endpoints_path, joint_limits_path)
|
||||||
app.include_router(runner.router)
|
|
||||||
app.include_router(trajectory.router)
|
|
||||||
app.include_router(positions.router)
|
|
||||||
app.include_router(stop_router)
|
|
||||||
app.mount("/mcp", mcp_http)
|
app.mount("/mcp", mcp_http)
|
||||||
|
|
||||||
uvicorn.run(app, host=host, port=port)
|
uvicorn.run(app, host=host, port=port)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Thin HTTP/MCP adapter for the scene snapshot and graph executor services.
|
||||||
|
|
||||||
|
This module contains no motion logic and no local scene revision. It only
|
||||||
|
forwards JSON to the ROS services using the existing web bridge.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from std_srvs.srv import Trigger
|
||||||
|
import webots_ros2_msgs.srv
|
||||||
|
from iiwa_msgs.srv import GetRunStatus
|
||||||
|
|
||||||
|
from .ros_node import get_bridge
|
||||||
|
|
||||||
|
|
||||||
|
SCENE_SERVICE = '/iiwa_scene_context/get_scene'
|
||||||
|
SUBMIT_SERVICE = '/iiwa_llm_plan_executor/submit'
|
||||||
|
CANCEL_SERVICE = '/iiwa_llm_plan_executor/cancel'
|
||||||
|
STATUS_SERVICE = '/iiwa_llm_plan_executor/get_status'
|
||||||
|
|
||||||
|
router = APIRouter(prefix='/planner', tags=['planner'])
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_active_run_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RunRequest(BaseModel):
|
||||||
|
run_id: str = Field(..., min_length=1, max_length=128)
|
||||||
|
scene_revision: int = Field(..., ge=0)
|
||||||
|
response: dict[str, Any] | str = Field(
|
||||||
|
..., description='The untouched Qwen3 graph response',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _status(run_id: str) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
response = get_bridge().call_service(
|
||||||
|
GetRunStatus,
|
||||||
|
STATUS_SERVICE,
|
||||||
|
GetRunStatus.Request(run_id=run_id),
|
||||||
|
)
|
||||||
|
except (RuntimeError, TimeoutError):
|
||||||
|
return None
|
||||||
|
if not response.found:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(response.status_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return payload if isinstance(payload, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _rejection(run_id: str) -> tuple[int, str, dict[str, Any] | None]:
|
||||||
|
status = _status(run_id)
|
||||||
|
reason = '' if status is None else str(status.get('reason', ''))
|
||||||
|
lowered = reason.lower()
|
||||||
|
if 'busy' in lowered:
|
||||||
|
return 409, reason or 'another graph is already active', status
|
||||||
|
if 'not_ready' in lowered:
|
||||||
|
return 503, reason or 'the executor is not ready yet', status
|
||||||
|
return 422, reason or 'graph was rejected', status
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/scene', summary='Текущий scene_json для внешнего планировщика')
|
||||||
|
def get_scene():
|
||||||
|
try:
|
||||||
|
response = get_bridge().call_service(
|
||||||
|
Trigger, SCENE_SERVICE, Trigger.Request(),
|
||||||
|
)
|
||||||
|
except (RuntimeError, TimeoutError) as error:
|
||||||
|
raise HTTPException(503, str(error)) from error
|
||||||
|
if not response.success:
|
||||||
|
raise HTTPException(409, response.message or 'scene is not ready')
|
||||||
|
try:
|
||||||
|
payload = json.loads(response.message)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise HTTPException(502, f'invalid scene JSON: {error}') from error
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(502, 'scene envelope must be a JSON object')
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/runs', summary='Передать готовый граф Qwen3 исполнителю')
|
||||||
|
def submit_run(request: RunRequest):
|
||||||
|
global _active_run_id
|
||||||
|
payload = {
|
||||||
|
'run_id': request.run_id,
|
||||||
|
'scene_revision': int(request.scene_revision),
|
||||||
|
'response': request.response,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = get_bridge().call_service(
|
||||||
|
webots_ros2_msgs.srv.SetString,
|
||||||
|
SUBMIT_SERVICE,
|
||||||
|
webots_ros2_msgs.srv.SetString.Request(
|
||||||
|
value=json.dumps(payload, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (RuntimeError, TimeoutError) as error:
|
||||||
|
raise HTTPException(503, str(error)) from error
|
||||||
|
if not response.success:
|
||||||
|
code, reason, status = _rejection(request.run_id)
|
||||||
|
raise HTTPException(code, detail=reason)
|
||||||
|
with _lock:
|
||||||
|
_active_run_id = request.run_id
|
||||||
|
return {
|
||||||
|
'status': 'accepted',
|
||||||
|
'run_id': request.run_id,
|
||||||
|
'scene_revision': request.scene_revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/runs/{run_id}', summary='Статус одного запуска графа')
|
||||||
|
def get_run(run_id: str):
|
||||||
|
global _active_run_id
|
||||||
|
status = _status(run_id)
|
||||||
|
if status is None:
|
||||||
|
raise HTTPException(404, f'run {run_id!r} was not submitted')
|
||||||
|
if status.get('state') in (
|
||||||
|
'completed', 'replan_required', 'operator_required', 'failed',
|
||||||
|
'canceled', 'rejected',
|
||||||
|
):
|
||||||
|
with _lock:
|
||||||
|
if _active_run_id == run_id:
|
||||||
|
_active_run_id = None
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/runs/{run_id}/cancel', summary='Отменить запуск графа')
|
||||||
|
def cancel_run(run_id: str):
|
||||||
|
global _active_run_id
|
||||||
|
try:
|
||||||
|
response = get_bridge().call_service(
|
||||||
|
webots_ros2_msgs.srv.SetString,
|
||||||
|
CANCEL_SERVICE,
|
||||||
|
webots_ros2_msgs.srv.SetString.Request(
|
||||||
|
value=json.dumps({'run_id': run_id}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (RuntimeError, TimeoutError) as error:
|
||||||
|
raise HTTPException(503, str(error)) from error
|
||||||
|
if not response.success:
|
||||||
|
status = _status(run_id)
|
||||||
|
if status is None:
|
||||||
|
raise HTTPException(404, f'run {run_id!r} was not submitted')
|
||||||
|
raise HTTPException(409, status.get('reason') or 'run is not active')
|
||||||
|
with _lock:
|
||||||
|
if _active_run_id == run_id:
|
||||||
|
_active_run_id = None
|
||||||
|
return {'status': 'cancel_requested', 'run_id': run_id}
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_active_run() -> None:
|
||||||
|
"""Stop the accepted graph before the regular cobot/stop command."""
|
||||||
|
global _active_run_id
|
||||||
|
with _lock:
|
||||||
|
run_id = _active_run_id
|
||||||
|
_active_run_id = None
|
||||||
|
if not run_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
get_bridge().call_service(
|
||||||
|
webots_ros2_msgs.srv.SetString,
|
||||||
|
CANCEL_SERVICE,
|
||||||
|
webots_ros2_msgs.srv.SetString.Request(
|
||||||
|
value=json.dumps({'run_id': run_id}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (RuntimeError, TimeoutError):
|
||||||
|
return
|
||||||
@@ -15,6 +15,10 @@
|
|||||||
<exec_depend>tf2_py</exec_depend>
|
<exec_depend>tf2_py</exec_depend>
|
||||||
<exec_depend>moveit_msgs</exec_depend>
|
<exec_depend>moveit_msgs</exec_depend>
|
||||||
<exec_depend>iiwa_planning</exec_depend>
|
<exec_depend>iiwa_planning</exec_depend>
|
||||||
|
<exec_depend>iiwa_msgs</exec_depend>
|
||||||
|
<exec_depend>std_srvs</exec_depend>
|
||||||
|
<exec_depend>sensor_msgs</exec_depend>
|
||||||
|
<exec_depend>webots_ros2_msgs</exec_depend>
|
||||||
<exec_depend>python3-yaml</exec_depend>
|
<exec_depend>python3-yaml</exec_depend>
|
||||||
|
|
||||||
<test_depend>ament_copyright</test_depend>
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""HTTP adapter tests for scene, submit, status and cancellation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter, FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from iiwa_web import main, planner
|
||||||
|
|
||||||
|
|
||||||
|
def envelope():
|
||||||
|
return {
|
||||||
|
'experiment_id': 20,
|
||||||
|
'scene_revision': 4,
|
||||||
|
'state_version': 2,
|
||||||
|
'timestamp': 10.0,
|
||||||
|
'scene_json': {'experiment_id': 20, 'components': []},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBridge:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.submit_success = True
|
||||||
|
self.cancel_success = True
|
||||||
|
self.scene_success = True
|
||||||
|
self.status_payload = {
|
||||||
|
'found': True,
|
||||||
|
'run_id': 'r1',
|
||||||
|
'state': 'running',
|
||||||
|
'reason': '',
|
||||||
|
}
|
||||||
|
self.raise_on = set()
|
||||||
|
|
||||||
|
def call_service(self, srv_type, srv_name, request):
|
||||||
|
self.calls.append((srv_name, request))
|
||||||
|
if srv_name in self.raise_on:
|
||||||
|
raise RuntimeError(f"{srv_name} unavailable")
|
||||||
|
if srv_name == planner.SCENE_SERVICE:
|
||||||
|
return SimpleNamespace(
|
||||||
|
success=self.scene_success,
|
||||||
|
message=json.dumps(envelope()) if self.scene_success else 'scene not ready',
|
||||||
|
)
|
||||||
|
if srv_name == planner.SUBMIT_SERVICE:
|
||||||
|
return SimpleNamespace(success=self.submit_success)
|
||||||
|
if srv_name == planner.CANCEL_SERVICE:
|
||||||
|
return SimpleNamespace(success=self.cancel_success)
|
||||||
|
if srv_name == planner.STATUS_SERVICE:
|
||||||
|
if self.status_payload is None:
|
||||||
|
return SimpleNamespace(found=False, status_json='{}')
|
||||||
|
return SimpleNamespace(
|
||||||
|
found=bool(self.status_payload.get('found', True)),
|
||||||
|
status_json=json.dumps(self.status_payload),
|
||||||
|
)
|
||||||
|
raise AssertionError(srv_name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bridge(monkeypatch):
|
||||||
|
fake = FakeBridge()
|
||||||
|
monkeypatch.setattr(planner, 'get_bridge', lambda: fake)
|
||||||
|
planner._active_run_id = None
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(planner.router)
|
||||||
|
return fake, TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scene_returns_the_envelope(bridge):
|
||||||
|
fake, client = bridge
|
||||||
|
response = client.get('/planner/scene')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()['scene_revision'] == 4
|
||||||
|
assert fake.calls[0][0] == planner.SCENE_SERVICE
|
||||||
|
|
||||||
|
|
||||||
|
def test_scene_not_ready_and_service_failure(bridge):
|
||||||
|
fake, client = bridge
|
||||||
|
fake.scene_success = False
|
||||||
|
assert client.get('/planner/scene').status_code == 409
|
||||||
|
fake.raise_on.add(planner.SCENE_SERVICE)
|
||||||
|
assert client.get('/planner/scene').status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_accepts_without_waiting_for_completion(bridge):
|
||||||
|
fake, client = bridge
|
||||||
|
response = client.post('/planner/runs', json={
|
||||||
|
'run_id': 'r1',
|
||||||
|
'scene_revision': 4,
|
||||||
|
'response': {
|
||||||
|
'status': 'ready',
|
||||||
|
'entry': 'a',
|
||||||
|
'plan': [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()['status'] == 'accepted'
|
||||||
|
service, request = fake.calls[0]
|
||||||
|
assert service == planner.SUBMIT_SERVICE
|
||||||
|
payload = json.loads(request.value)
|
||||||
|
assert payload['run_id'] == 'r1'
|
||||||
|
assert payload['scene_revision'] == 4
|
||||||
|
assert payload['response']['entry'] == 'a'
|
||||||
|
assert planner._active_run_id == 'r1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_conflict_and_not_ready_mapping(bridge):
|
||||||
|
fake, client = bridge
|
||||||
|
fake.submit_success = False
|
||||||
|
fake.status_payload = {
|
||||||
|
'found': True, 'run_id': 'r1', 'state': 'rejected',
|
||||||
|
'reason': 'busy: another run is active',
|
||||||
|
}
|
||||||
|
response = client.post('/planner/runs', json={
|
||||||
|
'run_id': 'r1', 'scene_revision': 4,
|
||||||
|
'response': {'status': 'blocked', 'entry': '', 'plan': []},
|
||||||
|
})
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert 'busy' in response.json()['detail']
|
||||||
|
|
||||||
|
fake.status_payload['reason'] = 'not_ready: scene is not ready'
|
||||||
|
response = client.post('/planner/runs', json={
|
||||||
|
'run_id': 'r2', 'scene_revision': 4,
|
||||||
|
'response': {'status': 'blocked', 'entry': '', 'plan': []},
|
||||||
|
})
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
fake.raise_on.add(planner.SUBMIT_SERVICE)
|
||||||
|
assert client.post('/planner/runs', json={
|
||||||
|
'run_id': 'r3', 'scene_revision': 4,
|
||||||
|
'response': {'status': 'blocked', 'entry': '', 'plan': []},
|
||||||
|
}).status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_returns_404_for_unknown_run(bridge):
|
||||||
|
fake, client = bridge
|
||||||
|
response = client.get('/planner/runs/r1')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()['state'] == 'running'
|
||||||
|
fake.status_payload = None
|
||||||
|
assert client.get('/planner/runs/nope').status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_clears_the_active_run_on_terminal(bridge):
|
||||||
|
fake, client = bridge
|
||||||
|
planner._active_run_id = 'r1'
|
||||||
|
fake.status_payload = {
|
||||||
|
'found': True, 'run_id': 'r1', 'state': 'completed',
|
||||||
|
'reason': 'done',
|
||||||
|
}
|
||||||
|
assert client.get('/planner/runs/r1').json()['state'] == 'completed'
|
||||||
|
assert planner._active_run_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_and_unknown_cancel(bridge):
|
||||||
|
fake, client = bridge
|
||||||
|
response = client.post('/planner/runs/r1/cancel')
|
||||||
|
assert response.status_code == 200
|
||||||
|
service, request = fake.calls[0]
|
||||||
|
assert service == planner.CANCEL_SERVICE
|
||||||
|
assert json.loads(request.value)['run_id'] == 'r1'
|
||||||
|
|
||||||
|
fake.cancel_success = False
|
||||||
|
fake.status_payload = None
|
||||||
|
assert client.post('/planner/runs/nope/cancel').status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_cancels_the_active_graph_before_the_robot_stop(monkeypatch):
|
||||||
|
order = []
|
||||||
|
fake = FakeBridge()
|
||||||
|
|
||||||
|
def call_service(srv_type, srv_name, request):
|
||||||
|
order.append(srv_name)
|
||||||
|
return SimpleNamespace(success=True, message='stopped')
|
||||||
|
|
||||||
|
fake.call_service = call_service
|
||||||
|
monkeypatch.setattr(main, 'get_bridge', lambda: fake)
|
||||||
|
monkeypatch.setattr(main.planner, 'cancel_active_run', lambda: order.append('cancel_graph'))
|
||||||
|
monkeypatch.setattr(main.runner, 'stop_if_running', lambda: order.append('runner'))
|
||||||
|
monkeypatch.setattr(main.trajectory, 'send_stop_trajectory', lambda: order.append('trajectory'))
|
||||||
|
result = main.stop_all()
|
||||||
|
assert result['status'] == 'stopped'
|
||||||
|
assert order == ['cancel_graph', 'runner', 'trajectory', 'cobot/stop']
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_app_and_serving_app_share_the_planner_router(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, 'build_dynamic_router', lambda *args: APIRouter())
|
||||||
|
schema = main.build_schema_app(None, None)
|
||||||
|
paths = {route.path for route in schema.routes}
|
||||||
|
assert {
|
||||||
|
'/planner/scene', '/planner/runs', '/planner/runs/{run_id}',
|
||||||
|
'/planner/runs/{run_id}/cancel', '/stop',
|
||||||
|
} <= paths
|
||||||
|
|
||||||
|
mcp = FastMCP.from_fastapi(app=schema)
|
||||||
|
tools = asyncio.run(mcp.list_tools())
|
||||||
|
names = {tool.name for tool in tools}
|
||||||
|
assert 'get_scene_planner_scene_get' in names
|
||||||
|
assert 'submit_run_planner_runs_post' in names
|
||||||
|
assert 'get_run_planner_runs' in names
|
||||||
|
assert 'cancel_run_planner_runs' in names
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
main.add_application_routes(app, None, None)
|
||||||
|
served = {route.path for route in app.routes}
|
||||||
|
assert paths <= served
|
||||||
Reference in New Issue
Block a user