new control robot

This commit is contained in:
Даниил Грабарь
2026-09-13 10:07:19 +03:00
parent 80c87682ea
commit 229de6d686
13 changed files with 1519 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
import sys
import types
from contextlib import asynccontextmanager
from pathlib import Path
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
if str(PACKAGE_ROOT) not in sys.path:
sys.path.insert(0, str(PACKAGE_ROOT))
def _install_ros_stubs() -> None:
ament_index_python = types.ModuleType("ament_index_python")
ament_index_packages = types.ModuleType("ament_index_python.packages")
def get_package_share_directory(package_name: str) -> str:
return str(PACKAGE_ROOT.parent / package_name)
ament_index_packages.get_package_share_directory = get_package_share_directory
ament_index_python.packages = ament_index_packages
sys.modules.setdefault("ament_index_python", ament_index_python)
sys.modules.setdefault("ament_index_python.packages", ament_index_packages)
builtin_interfaces = types.ModuleType("builtin_interfaces")
builtin_interfaces_msg = types.ModuleType("builtin_interfaces.msg")
class Duration:
def __init__(self, sec: int = 0, nanosec: int = 0):
self.sec = sec
self.nanosec = nanosec
builtin_interfaces_msg.Duration = Duration
builtin_interfaces.msg = builtin_interfaces_msg
sys.modules.setdefault("builtin_interfaces", builtin_interfaces)
sys.modules.setdefault("builtin_interfaces.msg", builtin_interfaces_msg)
trajectory_msgs = types.ModuleType("trajectory_msgs")
trajectory_msgs_msg = types.ModuleType("trajectory_msgs.msg")
class JointTrajectoryPoint:
def __init__(self):
self.positions = []
self.time_from_start = None
class JointTrajectory:
def __init__(self):
self.joint_names = []
self.points = []
trajectory_msgs_msg.JointTrajectory = JointTrajectory
trajectory_msgs_msg.JointTrajectoryPoint = JointTrajectoryPoint
trajectory_msgs.msg = trajectory_msgs_msg
sys.modules.setdefault("trajectory_msgs", trajectory_msgs)
sys.modules.setdefault("trajectory_msgs.msg", trajectory_msgs_msg)
sensor_msgs = types.ModuleType("sensor_msgs")
sensor_msgs_msg = types.ModuleType("sensor_msgs.msg")
class JointState:
def __init__(self, position=()):
self.position = position
sensor_msgs_msg.JointState = JointState
sensor_msgs.msg = sensor_msgs_msg
sys.modules.setdefault("sensor_msgs", sensor_msgs)
sys.modules.setdefault("sensor_msgs.msg", sensor_msgs_msg)
std_srvs = types.ModuleType("std_srvs")
std_srvs_srv = types.ModuleType("std_srvs.srv")
class Trigger:
class Request:
pass
std_srvs_srv.Trigger = Trigger
std_srvs.srv = std_srvs_srv
sys.modules.setdefault("std_srvs", std_srvs)
sys.modules.setdefault("std_srvs.srv", std_srvs_srv)
fastmcp = types.ModuleType("fastmcp")
class _Router:
@asynccontextmanager
async def lifespan_context(self, _):
yield
class _HttpApp:
def __init__(self):
self.router = _Router()
class FastMCP:
@classmethod
def from_fastapi(cls, app):
return cls()
def http_app(self, path="/mcp"):
return _HttpApp()
fastmcp.FastMCP = FastMCP
sys.modules.setdefault("fastmcp", fastmcp)
rclpy = types.ModuleType("rclpy")
rclpy_time = types.ModuleType("rclpy.time")
rclpy_duration = types.ModuleType("rclpy.duration")
rclpy_node = types.ModuleType("rclpy.node")
rclpy_action = types.ModuleType("rclpy.action")
class Time:
pass
class DurationValue:
def __init__(self, seconds: float = 0.0):
self.seconds = seconds
class _Logger:
def info(self, *_args, **_kwargs):
return None
class _Parameter:
def __init__(self, value):
self.value = value
class _Publisher:
def __init__(self):
self.published = []
def publish(self, msg):
self.published.append(msg)
class _Client:
def wait_for_service(self, timeout_sec):
return True
def call_async(self, request):
future = types.SimpleNamespace()
future.done = lambda: True
future.result = lambda: types.SimpleNamespace(success=True, message="")
return future
class Node:
def __init__(self, name):
self.name = name
self._parameters = {}
self._logger = _Logger()
def declare_parameter(self, name, default):
self._parameters[name] = default
def get_parameter(self, name):
return _Parameter(self._parameters[name])
def create_subscription(self, *_args, **_kwargs):
return object()
def create_publisher(self, *_args, **_kwargs):
return _Publisher()
def create_client(self, *_args, **_kwargs):
return _Client()
def get_logger(self):
return self._logger
class ActionClient:
def __init__(self, *_args, **_kwargs):
pass
rclpy.init = lambda: None
rclpy.spin = lambda node: None
rclpy.time = rclpy_time
rclpy.duration = rclpy_duration
rclpy.Time = Time
rclpy_time.Time = Time
rclpy_duration.Duration = DurationValue
rclpy_node.Node = Node
rclpy_action.ActionClient = ActionClient
sys.modules.setdefault("rclpy", rclpy)
sys.modules.setdefault("rclpy.time", rclpy_time)
sys.modules.setdefault("rclpy.duration", rclpy_duration)
sys.modules.setdefault("rclpy.node", rclpy_node)
sys.modules.setdefault("rclpy.action", rclpy_action)
tf2_ros = types.ModuleType("tf2_ros")
class Buffer:
def lookup_transform(self, *_args, **_kwargs):
raise RuntimeError("tf unavailable in tests")
class TransformListener:
def __init__(self, *_args, **_kwargs):
pass
tf2_ros.Buffer = Buffer
tf2_ros.TransformListener = TransformListener
sys.modules.setdefault("tf2_ros", tf2_ros)
_install_ros_stubs()
@@ -0,0 +1,65 @@
import itertools
import pytest
from iiwa_web.ros_node import CobotWebNode
from iiwa_web import ros_node
class DoneFuture:
def __init__(self, result):
self._result = result
def done(self):
return True
def result(self):
return self._result
class PendingFuture:
def done(self):
return False
def result(self):
return None
class GoalHandle:
accepted = True
def __init__(self):
self.cancel_calls = 0
def get_result_async(self):
return PendingFuture()
def cancel_goal_async(self):
self.cancel_calls += 1
return DoneFuture(None)
class ActionClientStub:
def __init__(self, goal_handle):
self.goal_handle = goal_handle
def wait_for_server(self, timeout_sec):
return True
def send_goal_async(self, goal):
return DoneFuture(self.goal_handle)
def test_send_action_cancels_goal_when_result_times_out(monkeypatch):
node = object.__new__(CobotWebNode)
goal_handle = GoalHandle()
node._action_clients = {"cobot/move_to_pose": ActionClientStub(goal_handle)}
timestamps = itertools.chain([0.0, 0.3, 0.6], itertools.repeat(0.9))
monkeypatch.setattr(ros_node.time, "monotonic", lambda: next(timestamps))
monkeypatch.setattr(ros_node.time, "sleep", lambda _seconds: None)
with pytest.raises(TimeoutError):
node.send_action(object, "cobot/move_to_pose", object(), timeout=0.5)
assert goal_handle.cancel_calls == 1
@@ -0,0 +1,130 @@
import asyncio
from pathlib import Path
import pytest
from iiwa_web import runner
class DummyProcess:
def __init__(self, cmd):
self.cmd = cmd
self.pid = 4321
self.stdout = iter(())
self.returncode = None
def poll(self):
return None
class DummyThread:
def __init__(self, target=None, args=(), daemon=False):
self.target = target
self.args = args
self.daemon = daemon
def start(self):
return None
class UploadStub:
def __init__(self, filename: str, content: bytes):
self.filename = filename
self._content = content
async def read(self) -> bytes:
return self._content
@pytest.fixture
def runner_module(monkeypatch, tmp_path):
upload_dir = tmp_path / "uploads"
upload_dir.mkdir()
bag_root = tmp_path / "bags"
bag_root.mkdir()
monkeypatch.setattr(runner, "_UPLOAD_DIR", upload_dir)
monkeypatch.setattr(runner, "_BAG_ROOT", bag_root, raising=False)
monkeypatch.setattr(runner, "MAX_UPLOAD_BYTES", 8, raising=False)
monkeypatch.setattr(runner, "_process", None)
started = {}
def fake_popen(cmd, **kwargs):
started["cmd"] = cmd
proc = DummyProcess(cmd)
started["proc"] = proc
return proc
monkeypatch.setattr(runner.subprocess, "Popen", fake_popen)
monkeypatch.setattr(runner.threading, "Thread", DummyThread)
return runner, upload_dir, bag_root, started
def _upload_file(name: str, content: bytes) -> UploadStub:
return UploadStub(filename=name, content=content)
def test_start_runner_sanitizes_uploaded_filename(runner_module):
runner, upload_dir, _bag_root, started = runner_module
response = asyncio.run(
runner.start_runner(
config=_upload_file("../escape.json", b"{}"),
n_iterations=3,
delay_between_iterations=5.0,
bag_path="",
topics="",
joints_action="cobot/move_to_joints",
pose_action="cobot/move_to_pose",
)
)
assert response["config"] == "escape.json"
assert (upload_dir / "escape.json").exists()
assert not (upload_dir.parent / "escape.json").exists()
config_arg = next(part for part in started["cmd"] if part.startswith("config_path:="))
assert Path(config_arg.split(":=", 1)[1]).parent == upload_dir
def test_start_runner_rejects_oversized_upload(runner_module):
runner, _upload_dir, _bag_root, started = runner_module
with pytest.raises(runner.HTTPException) as excinfo:
asyncio.run(
runner.start_runner(
config=_upload_file("config.json", b"123456789"),
n_iterations=3,
delay_between_iterations=5.0,
bag_path="",
topics="",
joints_action="cobot/move_to_joints",
pose_action="cobot/move_to_pose",
)
)
assert excinfo.value.status_code == 413
assert "слишком" in excinfo.value.detail.lower()
assert "cmd" not in started
def test_start_runner_rejects_bag_path_outside_allowed_root(runner_module):
runner, _upload_dir, _bag_root, started = runner_module
with pytest.raises(runner.HTTPException) as excinfo:
asyncio.run(
runner.start_runner(
config=_upload_file("config.json", b"{}"),
n_iterations=3,
delay_between_iterations=5.0,
bag_path="/tmp/escape",
topics="",
joints_action="cobot/move_to_joints",
pose_action="cobot/move_to_pose",
)
)
assert excinfo.value.status_code == 422
assert "bag_path" in excinfo.value.detail
assert "cmd" not in started
@@ -0,0 +1,118 @@
import importlib
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from iiwa_web import config_loader
class RecordingBridge:
def __init__(self):
self.messages = []
def publish(self, topic_name, message_type, msg):
self.messages.append((topic_name, message_type, msg))
def get_latest(self, topic_name):
return None
@pytest.fixture
def trajectory_module(monkeypatch):
monkeypatch.setattr(
config_loader,
"load_joint_names",
lambda *args, **kwargs: [f"joint{i}" for i in range(1, 8)],
)
monkeypatch.setattr(
config_loader,
"load_joint_limits",
lambda *args, **kwargs: [(-1.0, 1.0)] * 7,
)
trajectory = importlib.import_module("iiwa_web.trajectory")
trajectory = importlib.reload(trajectory)
bridge = RecordingBridge()
monkeypatch.setattr(trajectory, "get_bridge", lambda: bridge)
return trajectory, bridge
@pytest.mark.parametrize(
("payload", "detail"),
[
(
{
"points": [
{
"positions": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"time_from_start": 0.0,
}
]
},
"positions",
),
(
{
"points": [
{
"positions": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, float("nan")],
"time_from_start": 0.0,
}
]
},
"конеч",
),
],
)
def test_send_request_rejects_invalid_payloads(trajectory_module, payload, detail):
trajectory, bridge = trajectory_module
with pytest.raises((ValidationError, HTTPException)) as excinfo:
request = trajectory.SendRequest.model_validate(payload)
trajectory.send_trajectory(request)
assert detail.lower() in str(excinfo.value).lower()
assert bridge.messages == []
def test_send_trajectory_rejects_non_monotonic_time(trajectory_module):
trajectory, bridge = trajectory_module
payload = {
"points": [
{
"positions": [0.0] * trajectory.N_JOINTS,
"time_from_start": 0.5,
},
{
"positions": [0.1] * trajectory.N_JOINTS,
"time_from_start": 0.4,
},
]
}
with pytest.raises(ValidationError) as excinfo:
request = trajectory.SendRequest.model_validate(payload)
trajectory.send_trajectory(request)
assert "монотон" in str(excinfo.value).lower()
assert bridge.messages == []
def test_send_trajectory_enforces_joint_limits_even_when_bypass_requested(trajectory_module):
trajectory, bridge = trajectory_module
payload = {
"points": [
{
"positions": [100.0] * trajectory.N_JOINTS,
"time_from_start": 0.0,
}
],
"validate_limits": False,
}
with pytest.raises(HTTPException) as excinfo:
request = trajectory.SendRequest.model_validate(payload)
trajectory.send_trajectory(request)
assert "вне диапазона" in excinfo.value.detail.lower()
assert bridge.messages == []
+137
View File
@@ -0,0 +1,137 @@
import pytest
from iiwa_web import config_loader, ros_node
def test_web_node_defaults_to_loopback_host():
node = ros_node.CobotWebNode()
assert node.get_parameter("host").value == "127.0.0.1"
def test_external_bind_requires_token():
import importlib
monkeypatchable_main = _import_main_with_stubbed_config()
with pytest.raises(RuntimeError):
monkeypatchable_main.ensure_safe_bind("0.0.0.0", token=None)
monkeypatchable_main.ensure_safe_bind("0.0.0.0", token="secret-token")
def test_web_settings_load_token():
import sys
from pathlib import Path
utils_root = Path(__file__).resolve().parents[2] / "iiwa_utils"
if str(utils_root) not in sys.path:
sys.path.insert(0, str(utils_root))
from iiwa_utils.setting_loader import _parse_web
settings = _parse_web(
{"enabled": True, "host": "0.0.0.0", "token": "configured-secret"},
"/tmp",
)
assert settings.token == "configured-secret"
def test_web_server_passes_configured_token_to_process_environment(monkeypatch):
import sys
import types
from pathlib import Path
from types import SimpleNamespace
class FakeNode:
def __init__(self, *args, **kwargs):
self.additional_env = kwargs.get("additional_env")
launch_ros = types.ModuleType("launch_ros")
launch_ros_actions = types.ModuleType("launch_ros.actions")
launch_ros_actions.Node = FakeNode
launch_ros.actions = launch_ros_actions
monkeypatch.setitem(sys.modules, "launch_ros", launch_ros)
monkeypatch.setitem(sys.modules, "launch_ros.actions", launch_ros_actions)
launch_root = str(Path(__file__).resolve().parents[2] / "iiwa_bringup" / "launch")
if launch_root not in sys.path:
sys.path.insert(0, launch_root)
from supported.optional_nodes import make_web_server_node
settings = SimpleNamespace(
web=SimpleNamespace(
host="0.0.0.0",
port=8007,
endpoints="/tmp/endpoints.yaml",
joint_limits="/tmp/joint_limits.yaml",
token="configured-secret",
)
)
node = make_web_server_node(settings, use_sim_time=False)
assert node.additional_env == {"IIWA_WEB_TOKEN": "configured-secret"}
def test_bearer_token_is_required_and_compared_in_constant_time(monkeypatch):
main = _import_main_with_stubbed_config()
compared = []
def fake_compare(left, right):
compared.append((left, right))
return left == right
monkeypatch.setattr(main.hmac, "compare_digest", fake_compare)
assert main.has_valid_bearer_token(None, "secret-token") is False
assert main.has_valid_bearer_token("Bearer wrong", "secret-token") is False
assert main.has_valid_bearer_token("Bearer secret-token", "secret-token") is True
assert compared == [("wrong", "secret-token"), ("secret-token", "secret-token")]
def test_docs_paths_are_public_but_api_routes_are_not():
main = _import_main_with_stubbed_config()
assert main.is_public_auth_path("/docs")
assert main.is_public_auth_path("/docs/oauth2-redirect")
assert main.is_public_auth_path("/openapi.json")
assert main.is_public_auth_path("/redoc")
assert not main.is_public_auth_path("/protected")
def test_openapi_declares_bearer_security_for_swagger_authorize_button():
from fastapi import FastAPI
main = _import_main_with_stubbed_config()
app = FastAPI()
@app.get("/protected")
def protected():
return {"ok": True}
main.install_bearer_openapi(app, "secret-token")
schema = app.openapi()
assert schema["components"]["securitySchemes"]["BearerAuth"] == {
"type": "http",
"scheme": "bearer",
"bearerFormat": "opaque-token",
}
assert schema["paths"]["/protected"]["get"]["security"] == [
{"BearerAuth": []}
]
def _import_main_with_stubbed_config():
monkeypatch_joint_names = [f"joint{i}" for i in range(1, 8)]
monkeypatch_joint_limits = [(-1.0, 1.0)] * 7
config_loader.load_joint_names = lambda *args, **kwargs: list(monkeypatch_joint_names)
config_loader.load_joint_limits = lambda *args, **kwargs: list(monkeypatch_joint_limits)
import importlib
main = importlib.import_module("iiwa_web.main")
return importlib.reload(main)