diff --git a/src/iiwa_config/config/api_endpoints.yaml b/src/iiwa_config/config/api_endpoints.yaml
index 228871a..80265b0 100644
--- a/src/iiwa_config/config/api_endpoints.yaml
+++ b/src/iiwa_config/config/api_endpoints.yaml
@@ -12,12 +12,23 @@ endpoints:
timeout: 2.0
enabled: true
+ - path: /robot/pose
+ method: GET
+ type: tf
+ parent_frame: base_link
+ child_frame: tcp
+ summary: "Текущая декартова поза TCP"
+ description: "Позиция (м) и ориентация TCP относительно base_link через TF2. Углы Эйлера в конвенции KUKA ABC (ZYX): A=рыскание, B=тангаж, C=крен."
+ tags: [robot]
+ timeout: 1.0
+ enabled: true
+
- path: /robot/stop
method: POST
type: service
ros_name: cobot/stop
msg_type: std_srvs/srv/Trigger
- summary: "[Service] Немедленно остановить движение"
+ summary: "Немедленно остановить движение"
description: "Вызывает сервис экстренной остановки — движение прерывается немедленно."
tags: [robot]
response_fields: [success, message]
@@ -29,7 +40,7 @@ endpoints:
type: service
ros_name: cobot/move_to_named
msg_type: iiwa_msgs/srv/MoveToNamedPose
- summary: "[Action] Переместить в именованную позу из SRDF"
+ summary: "Переместить в именованную позу из SRDF"
description: "Перемещает робота в позу, определённую по имени в SRDF-файле (например, home, work)."
tags: [motion]
timeout: 30.0
@@ -58,7 +69,7 @@ endpoints:
type: action
ros_name: cobot/move_to_pose
msg_type: iiwa_msgs/action/MoveToPose
- summary: "[Action] Переместить в декартову позу"
+ summary: "Переместить в декартову позу"
description: "Перемещает TCP робота в заданную декартову позицию и ориентацию."
tags: [motion]
timeout: 30.0
@@ -111,7 +122,7 @@ endpoints:
type: action
ros_name: cobot/move_to_joints
msg_type: iiwa_msgs/action/MoveToJoints
- summary: "[Action] Переместить в позиции суставов"
+ summary: "Переместить в позиции суставов"
description: "Перемещает все суставы робота в заданные угловые позиции (радианы). Лимиты читаются из joint_limits.yaml."
tags: [motion]
timeout: 30.0
diff --git a/src/iiwa_web/iiwa_web/config_loader.py b/src/iiwa_web/iiwa_web/config_loader.py
index c044ae3..8e92e4b 100644
--- a/src/iiwa_web/iiwa_web/config_loader.py
+++ b/src/iiwa_web/iiwa_web/config_loader.py
@@ -25,9 +25,9 @@ class FieldDef:
class EndpointDef:
path: str
method: str # GET | POST
- type: str # topic | service | action
- ros_name: str
- msg_type: str
+ type: str # topic | service | action | tf
+ ros_name: str = ""
+ msg_type: str = ""
summary: str = ""
description: str = ""
tags: list = field(default_factory=list)
@@ -37,6 +37,8 @@ class EndpointDef:
timeout: float = 5.0
deprecated: bool = False
enabled: bool = True
+ parent_frame: str = "" # tf: родительский фрейм
+ child_frame: str = "" # tf: дочерний фрейм
def _resolve_path(package: str, relative: str) -> Path:
@@ -49,6 +51,31 @@ def _resolve_path(package: str, relative: str) -> Path:
return src / package / relative
+def _parse_joint_limits_data(data: dict) -> tuple[list[str], list[tuple[float, float]]]:
+ joints = data["joint_limits"]
+ names: list[str] = []
+ limits: list[tuple[float, float]] = []
+ i = 1
+ while f"joint{i}" in joints:
+ j = joints[f"joint{i}"]
+ names.append(f"joint{i}")
+ limits.append((j["min_position"], j["max_position"]))
+ i += 1
+ return names, limits
+
+
+def load_joint_names(
+ package: str = "iiwa_config",
+ relative: str = "config/moveit/joint_limits.yaml",
+) -> list[str]:
+ """Возвращает упорядоченный список имён суставов из joint_limits.yaml."""
+ path = _resolve_path(package, relative)
+ with open(path) as f:
+ data = yaml.safe_load(f)
+ names, _ = _parse_joint_limits_data(data)
+ return names
+
+
def load_joint_limits(
package: str = "iiwa_config",
relative: str = "config/moveit/joint_limits.yaml",
@@ -57,13 +84,7 @@ def load_joint_limits(
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
+ _, limits = _parse_joint_limits_data(data)
return limits
@@ -102,8 +123,8 @@ def load_api_config(
path=ep["path"],
method=ep["method"].upper(),
type=ep["type"],
- ros_name=ep["ros_name"],
- msg_type=ep["msg_type"],
+ ros_name=ep.get("ros_name", ""),
+ msg_type=ep.get("msg_type", ""),
summary=ep.get("summary", ""),
description=ep.get("description", ""),
tags=ep.get("tags", []),
@@ -112,6 +133,8 @@ def load_api_config(
request_fields=request_fields,
timeout=ep.get("timeout", 5.0),
deprecated=ep.get("deprecated", False),
+ parent_frame=ep.get("parent_frame", ""),
+ child_frame=ep.get("child_frame", ""),
))
return endpoints
diff --git a/src/iiwa_web/iiwa_web/dynamic_router.py b/src/iiwa_web/iiwa_web/dynamic_router.py
index 4743826..de9bacd 100644
--- a/src/iiwa_web/iiwa_web/dynamic_router.py
+++ b/src/iiwa_web/iiwa_web/dynamic_router.py
@@ -160,6 +160,54 @@ def _make_action_handler(ep: EndpointDef, Body: type, joint_limits: list[tuple[f
return handler
+def _quat_to_euler_zyx(x: float, y: float, z: float, w: float) -> tuple[float, float, float]:
+ """Quaternion → ZYX Euler (KUKA ABC: A=yaw, B=pitch, C=roll)."""
+ sinr = 2 * (w * x + y * z)
+ cosr = 1 - 2 * (x * x + y * y)
+ roll = math.atan2(sinr, cosr)
+
+ sinp = 2 * (w * y - z * x)
+ pitch = math.copysign(math.pi / 2, sinp) if abs(sinp) >= 1 else math.asin(sinp)
+
+ siny = 2 * (w * z + x * y)
+ cosy = 1 - 2 * (y * y + z * z)
+ yaw = math.atan2(siny, cosy)
+
+ return roll, pitch, yaw # C, B, A
+
+
+def _make_tf_handler(ep: EndpointDef):
+ parent = ep.parent_frame
+ child = ep.child_frame
+ timeout = ep.timeout
+
+ def handler():
+ try:
+ tf = get_bridge().lookup_transform(parent, child, timeout)
+ except RuntimeError as e:
+ raise HTTPException(503, str(e))
+
+ t = tf.transform.translation
+ r = tf.transform.rotation
+ roll, pitch, yaw = _quat_to_euler_zyx(r.x, r.y, r.z, r.w)
+
+ return {
+ "position": {"x": t.x, "y": t.y, "z": t.z},
+ "orientation": {
+ "quaternion": {"x": r.x, "y": r.y, "z": r.z, "w": r.w},
+ "euler_rad": {"a": yaw, "b": pitch, "c": roll},
+ "euler_deg": {
+ "a": math.degrees(yaw),
+ "b": math.degrees(pitch),
+ "c": math.degrees(roll),
+ },
+ },
+ "frame": {"parent": parent, "child": child},
+ }
+
+ return handler
+
+
def build_dynamic_router() -> APIRouter:
"""Читает api_endpoints.yaml и joint_limits.yaml, возвращает готовый APIRouter."""
endpoints = load_api_config()
@@ -183,6 +231,10 @@ def build_dynamic_router() -> APIRouter:
if Body is None:
raise ValueError(f"Action-эндпоинт '{ep.path}' не имеет request_fields")
handler = _make_action_handler(ep, Body, joint_limits)
+ elif ep.type == "tf":
+ if not ep.parent_frame or not ep.child_frame:
+ raise ValueError(f"TF-эндпоинт '{ep.path}' требует parent_frame и child_frame")
+ handler = _make_tf_handler(ep)
else:
raise ValueError(f"Неизвестный тип эндпоинта: '{ep.type}'")
diff --git a/src/iiwa_web/iiwa_web/main.py b/src/iiwa_web/iiwa_web/main.py
index b1870e3..67f4419 100644
--- a/src/iiwa_web/iiwa_web/main.py
+++ b/src/iiwa_web/iiwa_web/main.py
@@ -1,22 +1,27 @@
import uvicorn
from contextlib import asynccontextmanager
from fastapi import FastAPI
-from .ros_node import init_ros_node
+from sensor_msgs.msg import JointState
+from .ros_node import init_ros_node, get_bridge
from .dynamic_router import build_dynamic_router
+from . import runner, trajectory
@asynccontextmanager
async def lifespan(_: FastAPI):
init_ros_node()
+ get_bridge().subscribe("/joint_states", JointState)
yield
app = FastAPI(lifespan=lifespan)
app.include_router(build_dynamic_router())
+app.include_router(runner.router)
+app.include_router(trajectory.router)
def main():
- uvicorn.run(app, host="0.0.0.0", port=8007)
+ uvicorn.run(app, host="localhost", port=8007)
if __name__ == "__main__":
main()
diff --git a/src/iiwa_web/iiwa_web/ros_node.py b/src/iiwa_web/iiwa_web/ros_node.py
index 9c11275..3286df7 100644
--- a/src/iiwa_web/iiwa_web/ros_node.py
+++ b/src/iiwa_web/iiwa_web/ros_node.py
@@ -4,6 +4,7 @@ import threading
from rclpy.node import Node
from rclpy.action import ActionClient
+import tf2_ros
class CobotWebNode(Node):
@@ -11,11 +12,14 @@ class CobotWebNode(Node):
super().__init__('cobot_web_node')
self._topic_cache: dict = {}
- self._publishers: dict = {}
+ self._pub_registry: dict = {}
self._service_clients: dict = {}
self._action_clients: dict = {}
self._lock = threading.Lock()
+ self._tf_buffer = tf2_ros.Buffer()
+ self._tf_listener = tf2_ros.TransformListener(self._tf_buffer, self)
+
def subscribe(self, topic_name: str, msg_type):
if topic_name not in self._topic_cache:
self._topic_cache[topic_name] = None
@@ -28,11 +32,11 @@ class CobotWebNode(Node):
self.get_logger().info(f'Subscribed to topic: {topic_name}')
def publish(self, topic_name: str, message_type, msg):
- if topic_name not in self._publishers:
- self._publishers[topic_name] = self.create_publisher(message_type, topic_name, 10)
+ if topic_name not in self._pub_registry:
+ self._pub_registry[topic_name] = self.create_publisher(message_type, topic_name, 10)
self.get_logger().info(f'Created publisher for topic: {topic_name}')
-
- self._publishers[topic_name].publish(msg)
+
+ self._pub_registry[topic_name].publish(msg)
def get_latest(self, topic_name: str):
with self._lock:
@@ -59,16 +63,27 @@ class CobotWebNode(Node):
return future.result()
+ def lookup_transform(self, parent_frame: str, child_frame: str, timeout: float = 1.0):
+ try:
+ return self._tf_buffer.lookup_transform(
+ parent_frame,
+ child_frame,
+ rclpy.time.Time(),
+ timeout=rclpy.duration.Duration(seconds=timeout),
+ )
+ except Exception as e:
+ raise RuntimeError(f"TF lookup {parent_frame} → {child_frame}: {e}")
+
def send_action(self, action_type, action_name: str, goal, timeout: float = 30.0):
if action_name not in self._action_clients:
self._action_clients[action_name] = ActionClient(self, action_type, action_name)
client = self._action_clients[action_name]
- if not client.wait_for_server(timeout_sec=5.0):
+ if not client.wait_for_server(timeout_sec=10.0):
raise RuntimeError(f"Action сервер '{action_name}' недоступен")
goal_future = client.send_goal_async(goal)
- deadline = time.monotonic() + 5.0
+ deadline = time.monotonic() + 10.0
while not goal_future.done():
if time.monotonic() > deadline:
raise TimeoutError(f"Таймаут принятия goal '{action_name}'")
diff --git a/src/iiwa_web/iiwa_web/runner.py b/src/iiwa_web/iiwa_web/runner.py
new file mode 100644
index 0000000..6c5cef5
--- /dev/null
+++ b/src/iiwa_web/iiwa_web/runner.py
@@ -0,0 +1,130 @@
+import os
+import signal
+import subprocess
+import threading
+import tempfile
+from collections import deque
+from pathlib import Path
+from typing import Optional
+
+import yaml
+from fastapi import APIRouter, Form, HTTPException, Query, UploadFile, File
+from std_srvs.srv import Trigger
+
+from .ros_node import get_bridge
+
+router = APIRouter(prefix="/sequences", tags=["sequences"])
+
+_UPLOAD_DIR = Path(tempfile.gettempdir()) / "iiwa_configs"
+_UPLOAD_DIR.mkdir(exist_ok=True)
+
+_LOG_BUFFER = 300
+
+_process: Optional[subprocess.Popen] = None
+_log_lines: deque[str] = deque(maxlen=_LOG_BUFFER)
+_lock = threading.Lock()
+
+
+def _stream_output(proc: subprocess.Popen) -> None:
+ for line in proc.stdout:
+ _log_lines.append(line.rstrip("\n"))
+
+
+def _build_cmd(config_path: str, n_iterations: int, delay: float,
+ bag_path: str, topics: list[str],
+ joints_action: str, pose_action: str) -> list[str]:
+ cmd = [
+ "ros2", "run", "iiwa_planning", "motion_sequence_runner",
+ "--ros-args",
+ "-p", f"config_path:={config_path}",
+ "-p", f"n_iterations:={n_iterations}",
+ "-p", f"delay_between_iterations:={delay}",
+ "-p", f"joints_action:={joints_action}",
+ "-p", f"pose_action:={pose_action}",
+ ]
+ if bag_path:
+ cmd += ["-p", f"bag_path:={bag_path}"]
+ if topics:
+ topics_yaml = yaml.dump(topics, default_flow_style=True).strip()
+ cmd += ["-p", f"topics:={topics_yaml}"]
+ return cmd
+
+
+@router.post("/start", summary="Загрузить конфиг и запустить motion_sequence_runner")
+async def start_runner(
+ config: UploadFile = File(..., description="JSON-файл конфигурации последовательности"),
+ n_iterations: int = Form(3, ge=1, description="Число повторений"),
+ delay_between_iterations: float = Form(5.0, ge=0.0, description="Пауза между итерациями [с]"),
+ bag_path: str = Form("", description="Путь для записи rosbag (пусто = не записывать)"),
+ topics: str = Form("", description="Топики для bag через запятую (пусто = все)"),
+ joints_action: str = Form("cobot/move_to_joints", description="Action для суставного движения"),
+ pose_action: str = Form("cobot/move_to_pose", description="Action для декартова движения"),
+):
+ global _process
+ with _lock:
+ if _process and _process.poll() is None:
+ raise HTTPException(409, f"Runner уже запущен (pid={_process.pid})")
+
+ filename = config.filename or "config.json"
+ dest = _UPLOAD_DIR / filename
+ dest.write_bytes(await config.read())
+
+ topics_list = [t.strip() for t in topics.split(",") if t.strip()]
+
+ _log_lines.clear()
+ cmd = _build_cmd(
+ config_path=str(dest),
+ n_iterations=n_iterations,
+ delay=delay_between_iterations,
+ bag_path=bag_path,
+ topics=topics_list,
+ joints_action=joints_action,
+ pose_action=pose_action,
+ )
+
+ _process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ start_new_session=True,
+ )
+ threading.Thread(target=_stream_output, args=(_process,), daemon=True).start()
+
+ return {"status": "started", "pid": _process.pid, "config": filename}
+
+
+@router.post("/stop", summary="Остановить motion_sequence_runner и послать cobot/stop")
+def stop_runner():
+ global _process
+ with _lock:
+ if not _process or _process.poll() is not None:
+ raise HTTPException(404, "Runner не запущен")
+ pgid = os.getpgid(_process.pid)
+ os.killpg(pgid, signal.SIGTERM)
+ try:
+ _process.wait(timeout=5.0)
+ except subprocess.TimeoutExpired:
+ os.killpg(pgid, signal.SIGKILL)
+ _process.wait()
+ code = _process.returncode
+
+ result = get_bridge().call_service(Trigger, "cobot/stop", Trigger.Request())
+ return {"status": "stopped", "returncode": code, "success": result.success, "message": result.message}
+
+
+@router.get("/status", summary="Статус motion_sequence_runner")
+def runner_status():
+ if not _process:
+ return {"status": "idle"}
+ code = _process.poll()
+ if code is None:
+ return {"status": "running", "pid": _process.pid}
+ return {"status": "finished", "returncode": code}
+
+
+@router.get("/logs", summary="Последние строки вывода motion_sequence_runner")
+def runner_logs(n: int = Query(50, ge=1, le=_LOG_BUFFER, description="Количество последних строк")):
+ lines = list(_log_lines)
+ return {"lines": lines[-n:], "total_buffered": len(lines)}
diff --git a/src/iiwa_web/iiwa_web/trajectory.py b/src/iiwa_web/iiwa_web/trajectory.py
new file mode 100644
index 0000000..aedfe5a
--- /dev/null
+++ b/src/iiwa_web/iiwa_web/trajectory.py
@@ -0,0 +1,201 @@
+import csv
+import io
+from collections import deque
+from datetime import datetime
+from builtin_interfaces.msg import Duration
+from fastapi import APIRouter, File, HTTPException, Query, UploadFile
+from pydantic import BaseModel, Field
+from std_srvs.srv import Trigger
+from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
+
+from .config_loader import load_joint_limits, load_joint_names
+from .ros_node import get_bridge
+
+router = APIRouter(prefix="/trajectory", tags=["trajectory"])
+
+TOPIC = "/iiwa_arm_controller/joint_trajectory"
+JOINT_NAMES = load_joint_names()
+N_JOINTS = len(JOINT_NAMES)
+
+_log_lines: deque[str] = deque(maxlen=300)
+
+
+def _log(msg: str) -> None:
+ _log_lines.append(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] {msg}")
+
+
+def _to_duration(seconds: float) -> Duration:
+ sec = int(seconds)
+ nanosec = int(round((seconds - sec) * 1e9))
+ return Duration(sec=sec, nanosec=nanosec)
+
+
+def _validate_limits(points: list[list[float]]) -> None:
+ limits = load_joint_limits()
+ for row_idx, positions in enumerate(points):
+ for j, (pos, (lo, hi)) in enumerate(zip(positions, limits)):
+ if not (lo <= pos <= hi):
+ raise HTTPException(
+ 422,
+ f"Точка {row_idx + 1}, сустав {j + 1}: "
+ f"{pos:.4f} рад вне диапазона [{lo:.3f}, {hi:.3f}]",
+ )
+
+
+def _build_msg(rows: list[tuple[list[float], float]]) -> JointTrajectory:
+ msg = JointTrajectory()
+ msg.joint_names = JOINT_NAMES
+ for positions, t in rows:
+ pt = JointTrajectoryPoint()
+ pt.positions = positions
+ pt.time_from_start = _to_duration(t)
+ msg.points.append(pt)
+ return msg
+
+
+def _publish(msg: JointTrajectory) -> None:
+ get_bridge().publish(TOPIC, JointTrajectory, msg)
+
+
+class Waypoint(BaseModel):
+ positions: list[float] = Field(
+ ..., min_length=N_JOINTS, max_length=N_JOINTS,
+ description="Позиции суставов [j1..j7] в радианах",
+ )
+ time_from_start: float = Field(..., ge=0.0, description="Время от начала траектории [с]")
+
+
+class SendRequest(BaseModel):
+ points: list[Waypoint] = Field(..., min_length=1, description="Точки траектории")
+ validate_limits: bool = Field(True, description="Проверять лимиты суставов")
+
+
+@router.post("/send", summary="Отправить траекторию вручную (JSON)")
+def send_trajectory(req: SendRequest):
+ """
+ Принимает список точек с позициями суставов и временем от начала.
+ Публикует `JointTrajectory` в `/iiwa_arm_controller/joint_trajectory`.
+ """
+ rows = [(wp.positions, wp.time_from_start) for wp in req.points]
+
+ if req.validate_limits:
+ _validate_limits([r[0] for r in rows])
+
+ _publish(_build_msg(rows))
+ _log(f"[send] {len(rows)} точек, t_end={rows[-1][1]:.2f}с")
+ return {"status": "sent", "points": len(rows)}
+
+
+@router.post("/send_csv", summary="Загрузить CSV и отправить траекторию")
+async def send_csv_trajectory(
+ file: UploadFile = File(
+ ...,
+ description="CSV с заголовком. Колонки суставов: joint_1..joint_7 (или joint1..joint7). Колонка времени: t.",
+ ),
+ separator: str = Query(",", description="Разделитель колонок (например: ',' ';' '\\t')"),
+ validate_limits: bool = Query(True, description="Проверять лимиты суставов"),
+):
+ """
+ Ожидаемый формат (первая строка — обязательный заголовок):
+
+ joint1,joint2,joint3,joint4,joint5,joint6,joint7,t
+ -2.55,-0.71,-0.77,0.028,0.0,-2.09,-0.10,0.0
+ -2.54,-0.71,-0.77,0.029,0.0,-2.09,-0.10,0.01
+
+ Порядок и имена колонок произвольны — сопоставление идёт по заголовку.
+ Имена суставов нормализуются: `joint_1` = `joint1` = `JOINT1`.
+ Колонка времени определяется по заголовку `t`, `time` или `time_from_start`.
+ """
+ sep = separator.replace("\\t", "\t")
+ content = (await file.read()).decode("utf-8")
+ reader = csv.reader(io.StringIO(content), delimiter=sep)
+
+ try:
+ raw_headers = next(reader)
+ except StopIteration:
+ raise HTTPException(422, "Файл пуст")
+
+ headers = [h.strip() for h in raw_headers]
+
+ def _norm(s: str) -> str:
+ return s.lower().replace("_", "").replace(" ", "")
+
+ TIME_ALIASES = {"t", "time", "timefromstart"}
+ norm_joint_to_idx = {_norm(j): i for i, j in enumerate(JOINT_NAMES)}
+
+ col_joint: dict[int, int] = {} # col_index -> joint_index
+ col_time: int | None = None
+
+ for col_idx, h in enumerate(headers):
+ n = _norm(h)
+ if n in TIME_ALIASES:
+ col_time = col_idx
+ elif n in norm_joint_to_idx:
+ col_joint[col_idx] = norm_joint_to_idx[n]
+
+ if col_time is None:
+ raise HTTPException(422, f"Колонка времени не найдена. Ожидалось одно из: t, time, time_from_start. Заголовки: {headers}")
+
+ missing = sorted(set(range(N_JOINTS)) - set(col_joint.values()))
+ if missing:
+ raise HTTPException(422, f"Не найдены колонки для суставов: {[JOINT_NAMES[i] for i in missing]}")
+
+ joint_to_col = {j_idx: c_idx for c_idx, j_idx in col_joint.items()}
+
+ rows: list[tuple[list[float], float]] = []
+ for line_no, row in enumerate(reader, start=2):
+ row = [c.strip() for c in row]
+ if not any(row):
+ continue
+ if len(row) != len(headers):
+ raise HTTPException(
+ 422,
+ f"Строка {line_no}: ожидалось {len(headers)} столбцов, получено {len(row)}",
+ )
+ try:
+ positions = [float(row[joint_to_col[i]]) for i in range(N_JOINTS)]
+ t = float(row[col_time])
+ except ValueError as e:
+ raise HTTPException(422, f"Строка {line_no}: не удалось распарсить число — {e}")
+ if t < 0:
+ raise HTTPException(422, f"Строка {line_no}: t не может быть отрицательным")
+ rows.append((positions, t))
+
+ if not rows:
+ raise HTTPException(422, "CSV не содержит точек траектории")
+
+ if validate_limits:
+ _validate_limits([r[0] for r in rows])
+
+ _publish(_build_msg(rows))
+ _log(f"[csv] {file.filename} → {len(rows)} точек, t_end={rows[-1][1]:.2f}с")
+ return {"status": "sent", "points": len(rows), "filename": file.filename}
+
+
+@router.post("/stop", summary="Остановить выполнение траектории")
+def stop_trajectory():
+ bridge = get_bridge()
+
+ # Replace ongoing trajectory with single point at current position
+ joint_states = bridge.get_latest("/joint_states")
+ if joint_states is not None and len(joint_states.position) >= N_JOINTS:
+ current_positions = list(joint_states.position[:N_JOINTS])
+ hold_msg = _build_msg([(current_positions, 0.5)])
+ _publish(hold_msg)
+ _log("[stop] отправлена точка удержания текущей позиции")
+ else:
+ msg = JointTrajectory()
+ msg.joint_names = JOINT_NAMES
+ _publish(msg)
+ _log("[stop] joint_states недоступны, отправлена пустая траектория")
+
+ # cobot/stop cancels MoveIt action-based motion
+ result = bridge.call_service(Trigger, "cobot/stop", Trigger.Request())
+ _log(f"[stop] cobot/stop -> success={result.success}, message={result.message}")
+ return {"status": "stopped", "success": result.success, "message": result.message}
+
+
+@router.get("/logs", summary="Последние лог-записи траекторного модуля")
+def trajectory_logs(n: int = Query(50, ge=1, le=300, description="Количество последних строк")):
+ lines = list(_log_lines)
+ return {"lines": lines[-n:], "total_buffered": len(lines)}
diff --git a/src/iiwa_web/package.xml b/src/iiwa_web/package.xml
index 6188402..ae46f6a 100644
--- a/src/iiwa_web/package.xml
+++ b/src/iiwa_web/package.xml
@@ -9,6 +9,8 @@
python3-fastapi
python3-uvicorn
+ tf2_ros
+ tf2_py
ament_copyright
ament_flake8