Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions parol6/client/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions parol6/robot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
9 changes: 7 additions & 2 deletions parol6/server/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import sys
import threading
import time
from dataclasses import dataclass, replace
from dataclasses import dataclass
from typing import Any


Expand Down Expand Up @@ -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
Expand Down
45 changes: 8 additions & 37 deletions parol6/utils/error_catalog.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 6 additions & 3 deletions tests/integration/test_shapes_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading