diff --git a/parol6/client/sync_client.py b/parol6/client/sync_client.py index 8c2448f..803edd4 100644 --- a/parol6/client/sync_client.py +++ b/parol6/client/sync_client.py @@ -11,7 +11,7 @@ from collections.abc import Callable, Coroutine from typing import Any, TypeVar, overload -from waldoctl.tools import ToolSpec +from waldoctl.sync_tools import SyncTool from waldoctl import PingResult, ToolStatus from waldoctl.status import ActivityResult, LoopStatsResult, ToolResult @@ -131,7 +131,7 @@ def __init__( # `from parol6 import RobotClient; rbt = RobotClient(...)` works without # going through Robot.create_sync_client(). The Robot factory rebinds # these afterwards from the same registry. - self._bound_tools: dict[str, ToolSpec] = {} + self._bound_tools: dict[str, SyncTool] = {} self._bind_default_tools() def _bind_default_tools(self) -> None: @@ -146,7 +146,7 @@ def _bind_default_tools(self) -> None: # ---------- tool access ---------- @property - def tool(self) -> ToolSpec: + def tool(self) -> SyncTool: """Active bound tool. Raises if no tool has been set.""" key = (self._inner._active_tool_key or "").upper() if not key: diff --git a/parol6/robot.py b/parol6/robot.py index 3795156..a1bac31 100644 --- a/parol6/robot.py +++ b/parol6/robot.py @@ -967,7 +967,7 @@ def create_sync_client(self, **kwargs: Any) -> SyncRobotClient: import copy from parol6.client.sync_client import _run - from waldoctl.sync_tools import make_sync_tool + from waldoctl.sync_tools import SyncTool, make_sync_tool host: str = kwargs.get("host", self._host) port: int = kwargs.get("port", self._port) @@ -980,7 +980,7 @@ def create_sync_client(self, **kwargs: Any) -> SyncRobotClient: bound_spec._get_status = client._inner._tool_status # type: ignore[attr-defined, ty:unresolved-attribute] async_bound[spec.key] = bound_spec client._inner._bound_tools = async_bound - bound: dict[str, ToolSpec] = {} + bound: dict[str, SyncTool] = {} for key, async_tool in async_bound.items(): bound[key] = make_sync_tool(async_tool, _run) client._bound_tools = bound diff --git a/parol6/server/controller.py b/parol6/server/controller.py index 2d1adac..0f6252c 100644 --- a/parol6/server/controller.py +++ b/parol6/server/controller.py @@ -10,7 +10,7 @@ import sys import threading import time -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Any @@ -422,7 +422,12 @@ def _tick_tool_cmd(self, state: ControllerState) -> None: raw_error = self._tool_cmd.robot_error or make_error( ErrorCode.MOTN_TICK_FAILED, detail=type(self._tool_cmd).__name__ ) - state.error = replace(raw_error, command_index=self._tool_cmd_index) + # Rebuilt from the wire, not `replace`d: the refusal is an + # exception now, and a dataclass replace on one does not + # survive the copy the state makes of it. + attributed = raw_error.to_wire() + attributed[0] = self._tool_cmd_index + state.error = RobotError.from_wire(attributed) state.action_state = ActionState.ERROR state.completed_command_index = max( state.completed_command_index, self._tool_cmd_index diff --git a/parol6/utils/error_catalog.py b/parol6/utils/error_catalog.py index b102a6a..396d3cd 100644 --- a/parol6/utils/error_catalog.py +++ b/parol6/utils/error_catalog.py @@ -1,4 +1,4 @@ -"""Structured robot error types and error catalog. +"""The error catalog: waldoctl's RobotError plus this backend's templates. RobotError carries KUKA-style structured fields: code, title, cause, effect, remedy. The catalog maps ErrorCode → template; make_error() instantiates with runtime params. @@ -8,45 +8,16 @@ from dataclasses import dataclass -from .error_codes import ErrorCode - +from waldoctl.errors import RobotError as _RobotError -@dataclass(frozen=True) -class RobotError: - """Structured error with code, title, cause, effect, and remedy.""" +from .error_codes import ErrorCode - command_index: int - code: int - title: str - cause: str - effect: str - remedy: str - def to_wire(self) -> list: - """Serialize to a list for ormsgpack packing.""" - return [ - self.command_index, - self.code, - self.title, - self.cause, - self.effect, - self.remedy, - ] - - @staticmethod - def from_wire(data: list) -> RobotError: - """Reconstruct from a wire-format list.""" - return RobotError( - command_index=data[0], - code=data[1], - title=data[2], - cause=data[3], - effect=data[4], - remedy=data[5], - ) - - def __str__(self) -> str: - return f"[{self.code}] {self.title}: {self.cause}" +# The refusal type is the contract's, not this backend's: a frontend +# represents a refused command the same way whichever arm raised it, and +# the six fields and the wire list are identical in both directions. An +# exception rather than a dataclass, so a client can raise it as-is. +RobotError = _RobotError @dataclass(frozen=True) diff --git a/pyproject.toml b/pyproject.toml index 4ea8ec9..692601c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "psutil>=5.9", "msgspec>=0.18", "ormsgpack>=1.4.0", - "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@v0.10.0", + "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@v0.12.0", ] [tool.setuptools.packages.find] diff --git a/tests/integration/test_shapes_e2e.py b/tests/integration/test_shapes_e2e.py index 32856fe..e602165 100644 --- a/tests/integration/test_shapes_e2e.py +++ b/tests/integration/test_shapes_e2e.py @@ -17,6 +17,8 @@ import pytest from parol6 import MotionError, RobotClient + +from tests.conftest import free_udp_port from waldoctl import Box pytestmark = pytest.mark.integration @@ -75,9 +77,10 @@ def test_set_shapes_ack_readback_rejection_and_timeout( assert tuple(s.name for s in world.program) == ("table",) # Unreachable controller → unconfirmed (0), never a fake success. - dead = RobotClient( - host=ports.server_ip, port=ports.server_port + 91, timeout=0.3 - ) + # A port the kernel just handed out and nothing bound: arithmetic on + # the live port runs past 65535 whenever the ephemeral range hands + # out a high one, which is a connect() overflow, not a dead server. + dead = RobotClient(host=ports.server_ip, port=free_udp_port(), timeout=0.3) assert dead.set_shapes([box]) == 0 assert dead.shapes() is None finally: