From ddf7c02d1ee5de76a1cddbf9f88dca1dfe572ccb Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:01:24 -0400 Subject: [PATCH 1/7] parol6: register plugin tools in every process registry; variant TCP honors rpy register_plugin_tools() runs at Controller init, motion_planner_main (spawn re-imports the registry), and Robot init (SelectToolCmd validates client-side before sending); _PLUGIN_KEYS keeps _build_tools native-only regardless of construction order. get_tool_transform's variant branch overrides origin/rpy field-independently to match the client path; _plugin_tool_transform uses waldoctl.resolve_variant_tcp and warns before identity on unknown keys. _ToolsCollection replaced by waldoctl.ToolsCollection. Cold-start home timeout 30s; variant-rpy unit regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- parol6/robot.py | 86 +++++++++++++---------- parol6/server/controller.py | 5 ++ parol6/server/motion_planner.py | 6 ++ parol6/tools.py | 72 +++++++++++++++++-- tests/integration/conftest.py | 3 +- tests/integration/test_tool_operations.py | 5 +- tests/unit/test_tcp_offset.py | 37 ++++++++++ 7 files changed, 165 insertions(+), 49 deletions(-) diff --git a/parol6/robot.py b/parol6/robot.py index e0ebf36..26c87e8 100644 --- a/parol6/robot.py +++ b/parol6/robot.py @@ -41,9 +41,10 @@ PositionLimits, Robot as _RobotABC, ToolSpec, - ToolsSpec, + ToolsCollection, ToolStatus, ToolType, + resolve_variant_tcp, ) from parol6.client.async_client import AsyncRobotClient @@ -56,6 +57,8 @@ ElectricGripperConfig, PneumaticGripperConfig, get_registry, + plugin_tool_keys, + register_plugin_tools, ) from parol6.utils.ik import check_limits, solve_ik @@ -385,38 +388,6 @@ def channel_descriptors(self) -> tuple[ChannelDescriptor, ...]: ) -class _ToolsCollection(ToolsSpec): - """Concrete ToolsSpec for PAROL6.""" - - def __init__(self, tools: tuple[ToolSpec, ...]) -> None: - self._tools = tools - self._by_key = {t.key: t for t in tools} - - @property - def available(self) -> tuple[ToolSpec, ...]: - return self._tools - - @property - def default(self) -> ToolSpec: - return self._by_key.get("NONE", self._tools[0]) - - def __getitem__(self, key: str) -> ToolSpec: - return self._by_key[key] - - def __contains__(self, item: object) -> bool: - # ToolType is a StrEnum, so test it before the plain-str branch — - # otherwise a category like ToolType.GRIPPER would misroute to the - # by-key lookup (keyed by tool key, not category) and return False. - if isinstance(item, ToolType): - return any(t.tool_type == item for t in self._tools) - if isinstance(item, str): - return item in self._by_key - return False - - def by_type(self, tool_type: str | ToolType) -> tuple[ToolSpec, ...]: - return tuple(t for t in self._tools if t.tool_type == tool_type) - - # =========================================================================== # Helper builders # =========================================================================== @@ -462,10 +433,15 @@ def _decompose_transform( return origin, rpy -def _build_tools() -> _ToolsCollection: - """Build typed tool specs from the parol6 tool registry.""" +def _build_tools() -> ToolsCollection: + """Build typed tool specs from the parol6 tool registry. Plugin-registered + keys are excluded — they reach ``robot.tools`` through waldoctl composition + as their own ToolSpec classes, not as registry-derived natives.""" + plugin_keys = plugin_tool_keys() tools: list[ToolSpec] = [] for key, cfg in get_registry().items(): + if key in plugin_keys: + continue origin, rpy = _decompose_transform(cfg.transform) common = dict( key=key, @@ -493,7 +469,7 @@ def _build_tools() -> _ToolsCollection: else: tools.append(_ToolImpl(**common, tool_type=ToolType.NONE)) - return _ToolsCollection(tuple(tools)) + return ToolsCollection(tuple(tools), default_key="NONE") def _resolve_urdf_path() -> str: @@ -555,9 +531,12 @@ def __init__( self._timeout = timeout self._manager = _ServerManager(normalize_logs=normalize_logs) - # Build configuration eagerly + # Build configuration eagerly. Native tools snapshot first, then plugin + # tools join this process's registry so client-side paths (e.g. the + # SelectToolCmd wire validation) accept them. self._joints = _build_joints() self._tools = _build_tools() + register_plugin_tools() self._urdf_path = _resolve_urdf_path() self._mesh_dir = _resolve_mesh_dir() self._motion_profiles = tuple(p.value.upper() for p in ProfileType) @@ -596,7 +575,9 @@ def joints(self) -> JointsSpec: return self._joints @property - def tools(self) -> _ToolsCollection: + def _native_tools(self) -> ToolsCollection: + """PAROL6's built-in tools. The waldoctl ``Robot.tools`` property + composes these with any plugin tools registered via ``waldoctl.tools``.""" return self._tools @property @@ -689,7 +670,12 @@ def set_active_tool( """ from parol6.tools import get_tool_transform - T_tool = get_tool_transform(tool_key, variant_key=variant_key) + try: + T_tool = get_tool_transform(tool_key, variant_key=variant_key) + except ValueError: + # Plugin tool (waldoctl.tools) not in PAROL6's registry — derive its + # TCP from the ToolSpec instead. + T_tool = self._plugin_tool_transform(tool_key, variant_key) if tcp_offset_m is not None and any(v != 0 for v in tcp_offset_m): T_offset = np.eye(4) @@ -703,6 +689,28 @@ def set_active_tool( else: self._pinokin.clear_tool_transform() + def _plugin_tool_transform( + self, tool_key: str, variant_key: str | None + ) -> NDArray[np.float64]: + """Flange→TCP transform for a plugin tool, from its ToolSpec's + ``tcp_origin``/``tcp_rpy`` (variant overrides win). Identity (with a + warning) if the key is unknown to both registries.""" + try: + spec = self.tools[tool_key] + except KeyError: + logger.warning( + "Unknown tool %r; using identity TCP. Available: %s", + tool_key, + [t.key for t in self.tools.available], + ) + return np.eye(4) + origin, rpy = resolve_variant_tcp( + spec.tcp_origin, spec.tcp_rpy, spec.variants, variant_key + ) + T = np.zeros((4, 4), dtype=np.float64) + se3_from_rpy(origin[0], origin[1], origin[2], rpy[0], rpy[1], rpy[2], T) + return T + def fk( self, q_rad: NDArray[np.float64], out: NDArray[np.float64] ) -> NDArray[np.float64]: diff --git a/parol6/server/controller.py b/parol6/server/controller.py index 44d6a5d..6910667 100644 --- a/parol6/server/controller.py +++ b/parol6/server/controller.py @@ -111,6 +111,11 @@ def __init__(self, config: ControllerConfig): self.shutdown_event = threading.Event() self._initialized = False + # Register plugin tools (waldoctl.tools) before any SELECT_TOOL. + from parol6.tools import register_plugin_tools + + register_plugin_tools() + # Core components self.state_manager = StateManager() self.udp_transport: UDPTransport | None = None diff --git a/parol6/server/motion_planner.py b/parol6/server/motion_planner.py index 82fe06f..4261751 100644 --- a/parol6/server/motion_planner.py +++ b/parol6/server/motion_planner.py @@ -527,9 +527,15 @@ def motion_planner_main( """Worker process main loop — compute trajectories and forward inline commands.""" signal.signal(signal.SIGINT, signal.SIG_IGN) from parol6.server import set_pdeathsig + from parol6.tools import register_plugin_tools set_pdeathsig() + # Spawn-mode subprocess: the registry is freshly imported with only native + # tools, so plugin tools must be registered here too or SELECT_TOOL/SyncTool + # of a plugin tool fails in apply_tool. + register_plugin_tools() + worker = PlannerWorker(segment_queue) logger.debug( diff --git a/parol6/tools.py b/parol6/tools.py index c2f890d..42f0b2f 100644 --- a/parol6/tools.py +++ b/parol6/tools.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable import numpy as np -from pinokin import se3_from_trans +from pinokin import se3_from_rpy, se3_from_trans from waldoctl import ( CameraSpec, LinearMotion, @@ -27,7 +27,7 @@ ) if TYPE_CHECKING: - from waldoctl import ToolStatus + from waldoctl import ToolSpec, ToolStatus from parol6.commands.base import MotionCommand from parol6.commands.gripper_commands import ( @@ -385,15 +385,66 @@ def list_tools() -> list[str]: return list(_TOOL_REGISTRY.keys()) +def _tool_config_from_spec(spec: "ToolSpec") -> ToolConfig: + """Build a minimal controller :class:`ToolConfig` from a waldoctl ToolSpec + (TCP transform only; base no-op status/command suit non-actuated tools).""" + transform = np.zeros((4, 4), dtype=np.float64) + ox, oy, oz = spec.tcp_origin + rx, ry, rz = spec.tcp_rpy + se3_from_rpy(ox, oy, oz, rx, ry, rz, transform) + return ToolConfig( + name=spec.display_name, + description=spec.description, + transform=transform, + meshes=spec.meshes, + motions=spec.motions, + variants=spec.variants, + camera_spec=spec.camera_spec, + ) + + +_PLUGIN_KEYS: set[str] = set() + + +def plugin_tool_keys() -> frozenset[str]: + """Keys added by :func:`register_plugin_tools` (vs. native registrations).""" + return frozenset(_PLUGIN_KEYS) + + +def register_plugin_tools() -> int: + """Register ``waldoctl.tools`` entry-point tools into the controller registry + so ``SELECT_TOOL`` resolves their TCP. Idempotent; no-op when none installed; + on collision the first registration wins (natives register at import time). + Returns the number added.""" + from waldoctl.discovery import iter_plugin_tool_specs + + count = 0 + for spec in iter_plugin_tool_specs(): + if spec.key != spec.key.upper(): + logger.warning( + "Plugin tool key %r is not uppercase; SELECT_TOOL uppercases " + "names on the wire, so this tool cannot be selected", + spec.key, + ) + if spec.key in _TOOL_REGISTRY: + continue + _TOOL_REGISTRY[spec.key] = _tool_config_from_spec(spec) + _PLUGIN_KEYS.add(spec.key) + count += 1 + if count: + logger.info("Registered %d plugin tool(s) into the controller registry", count) + return count + + def get_tool_transform( tool_name: str, variant_key: str | None = None, ) -> np.ndarray: """Get the 4x4 transformation matrix for a tool or variant. - When *variant_key* is given and the matching variant has a - ``tcp_origin``, returns a transform built from the variant's TCP - instead of the tool-level transform. + When *variant_key* is given and matches, the variant's ``tcp_origin`` / + ``tcp_rpy`` override the tool-level transform field-independently + (matching the client-side ToolSpec semantics). Raises ValueError if *tool_name* is not registered. """ @@ -402,8 +453,15 @@ def get_tool_transform( raise ValueError(f"Unknown tool '{tool_name}'. Available: {list_tools()}") if variant_key: for v in cfg.variants: - if v.key == variant_key and v.tcp_origin is not None: - return _make_tcp_transform(*v.tcp_origin) + if v.key == variant_key: + out = cfg.transform.copy() + if v.tcp_rpy is not None: + rot = np.zeros((4, 4), dtype=np.float64) + se3_from_rpy(0.0, 0.0, 0.0, *v.tcp_rpy, rot) + out[:3, :3] = rot[:3, :3] + if v.tcp_origin is not None: + out[:3, 3] = v.tcp_origin + return out logger.warning("Variant '%s' not found for tool '%s'", variant_key, tool_name) return cfg.transform diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ef9003c..345c240 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,5 +16,6 @@ def clean_state(server_proc, client): client.select_profile("LINEAR") idx = client.home() assert idx >= 0, "Home command failed to send" - assert client.wait_command(idx, timeout=5.0), "Home did not complete" + # Generous timeout: the first home cold-JITs the numba motion pipeline. + assert client.wait_command(idx, timeout=30.0), "Home did not complete" return client diff --git a/tests/integration/test_tool_operations.py b/tests/integration/test_tool_operations.py index f9b9f9e..c3527de 100644 --- a/tests/integration/test_tool_operations.py +++ b/tests/integration/test_tool_operations.py @@ -217,9 +217,10 @@ def test_registry_completeness(self): robot = Robot() tools = robot.tools - # 5 tools registered + # Native count stays 5; robot.tools may compose plugin tools on top. + native_keys = [t.key for t in robot._native_tools.available] + assert len(native_keys) == 5 keys = [t.key for t in tools.available] - assert len(keys) == 5 for expected in ("NONE", "PNEUMATIC", "SSG-48", "MSG", "VACUUM"): assert expected in keys, f"{expected} not in {keys}" diff --git a/tests/unit/test_tcp_offset.py b/tests/unit/test_tcp_offset.py index 950ca1a..ff290fa 100644 --- a/tests/unit/test_tcp_offset.py +++ b/tests/unit/test_tcp_offset.py @@ -98,6 +98,43 @@ def test_dry_run_select_tool_resets_tcp_offset(): assert client.tcp_offset() == [0.0, 0.0, 0.0] +# ── Variant TCP resolution ─────────────────────────────────────────────── + + +def test_get_tool_transform_variant_honors_rpy(): + """Variant tcp_origin/tcp_rpy override the tool transform field-independently + (matching the client-side ToolSpec semantics).""" + from waldoctl import ToolVariant + + from parol6.tools import _TOOL_REGISTRY, ToolConfig, get_tool_transform + + cfg = ToolConfig( + name="Test", + description="", + transform=np.eye(4), + variants=( + ToolVariant( + key="angled", + display_name="Angled", + tcp_origin=(0.012, 0.0, 0.09), + tcp_rpy=(0.0, 0.26, 0.0), + ), + ToolVariant(key="rpy_only", display_name="RPY", tcp_rpy=(0.0, 0.26, 0.0)), + ), + ) + _TOOL_REGISTRY["_TEST_VARIANT_TOOL"] = cfg + try: + T = get_tool_transform("_TEST_VARIANT_TOOL", variant_key="angled") + np.testing.assert_allclose(T[:3, 3], (0.012, 0.0, 0.09), atol=1e-12) + assert np.isclose(T[0, 0], np.cos(0.26), atol=1e-9) + + T = get_tool_transform("_TEST_VARIANT_TOOL", variant_key="rpy_only") + np.testing.assert_allclose(T[:3, 3], (0.0, 0.0, 0.0), atol=1e-12) + assert np.isclose(T[0, 0], np.cos(0.26), atol=1e-9) + finally: + del _TOOL_REGISTRY["_TEST_VARIANT_TOOL"] + + # ── Planner routing (regression: SetTcpOffsetCmd must reach the planner) ── From a1d5407d50d65b436b0dd7112538abed84fa48a0 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:03:38 -0400 Subject: [PATCH 2/7] parol6: drop tautological tcp-offset struct tests, add CLAUDE.md rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the three "wire round-trip" tests in test_tcp_offset.py that only construct SetTcpOffsetCmd/TcpOffsetResultStruct and assert the fields back — they restate the struct definitions, exercise no encode/decode, catch no bug. The planner-routing and apply_tool behavioral tests stay. Add the no-tautological-tests rule to CLAUDE.md Testing Guidelines. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + tests/unit/test_tcp_offset.py | 26 +------------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 815e6c3..dfc312a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,7 @@ For streamable commands (`streamable = True`), `do_setup()` also runs at high fr **When CI tests fail, fix them.** Don't waste time analyzing whether failures are "related to your changes" — just fix all failing tests. The goal is a green CI, not attribution. Prefer fewer, comprehensive integration tests that mimic manual testing over a large number of unit tests. We have no code coverage requirements—the goal is working features, not metrics. +- **No tautological tests.** Don't assert what's true by construction — e.g. a freshly built object's default field values. Test behavior, not the class's initializers. - **Test results are in `test-results.xml`.** Pytest writes JUnit XML to `test-results.xml` automatically. When diagnosing failures, read this file — it contains test names, durations, failure messages, and captured output. This is more reliable than parsing console output. - **NEVER run parol6 and web commander test suites in parallel** — no proper isolation, they share resources and have timing issues when resource-constrained. Always run sequentially. - **NEVER allow subagents to run tests.** Many tests are timing-sensitive and the system doesn't have enough resources for agents and tests to run simultaneously. Only the main conversation should run tests, and only after all agents have completed. diff --git a/tests/unit/test_tcp_offset.py b/tests/unit/test_tcp_offset.py index ff290fa..239f29f 100644 --- a/tests/unit/test_tcp_offset.py +++ b/tests/unit/test_tcp_offset.py @@ -2,31 +2,7 @@ import numpy as np -from parol6.protocol.wire import SelectToolCmd, SetTcpOffsetCmd, TcpOffsetResultStruct - - -# ── Wire round-trip ────────────────────────────────────────────────────── - - -def test_set_tcp_offset_cmd_fields(): - cmd = SetTcpOffsetCmd(x=1.0, y=2.0, z=-190.0) - assert cmd.x == 1.0 - assert cmd.y == 2.0 - assert cmd.z == -190.0 - - -def test_set_tcp_offset_cmd_defaults(): - cmd = SetTcpOffsetCmd() - assert cmd.x == 0.0 - assert cmd.y == 0.0 - assert cmd.z == 0.0 - - -def test_tcp_offset_result_struct(): - result = TcpOffsetResultStruct(x=10.0, y=20.0, z=-190.0) - assert result.x == 10.0 - assert result.y == 20.0 - assert result.z == -190.0 +from parol6.protocol.wire import SelectToolCmd, SetTcpOffsetCmd # ── apply_tool with tcp_offset_m ───────────────────────────────────────── From 3bde58f77d70e774164d5ad00e1df51b58dc4424 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:40:13 -0400 Subject: [PATCH 3/7] parol6: purge remaining tautological tests (round 2) Remove construct-echo/default tests in test_movecart_command.py (5), test_reset_command.py (2, dropping the now-empty TestResetCommandParsing), test_query_commands_actions.py (2), and the hardware-config assertions in test_gripper_ramp.py (2). The validation tests (bad-input raising), execute_step side-effect tests, pose-conversion tests, and tick-range derivation stay. Broaden the CLAUDE.md rule to name the categories. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- tests/unit/test_gripper_ramp.py | 14 ----- tests/unit/test_movecart_command.py | 73 ----------------------- tests/unit/test_query_commands_actions.py | 19 ------ tests/unit/test_reset_command.py | 17 ------ 5 files changed, 1 insertion(+), 124 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dfc312a..6eba9e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,7 +144,7 @@ For streamable commands (`streamable = True`), `do_setup()` also runs at high fr **When CI tests fail, fix them.** Don't waste time analyzing whether failures are "related to your changes" — just fix all failing tests. The goal is a green CI, not attribution. Prefer fewer, comprehensive integration tests that mimic manual testing over a large number of unit tests. We have no code coverage requirements—the goal is working features, not metrics. -- **No tautological tests.** Don't assert what's true by construction — e.g. a freshly built object's default field values. Test behavior, not the class's initializers. +- **No tautological tests.** Assert behavior, not what's true by construction — not default fields, constructor args echoed back, enum literals, `isinstance`/frozen-raises, or stub-raises-`NotImplementedError`. Drive a method/workflow and assert the outcome. - **Test results are in `test-results.xml`.** Pytest writes JUnit XML to `test-results.xml` automatically. When diagnosing failures, read this file — it contains test names, durations, failure messages, and captured output. This is more reliable than parsing console output. - **NEVER run parol6 and web commander test suites in parallel** — no proper isolation, they share resources and have timing issues when resource-constrained. Always run sequentially. - **NEVER allow subagents to run tests.** Many tests are timing-sensitive and the system doesn't have enough resources for agents and tests to run simultaneously. Only the main conversation should run tests, and only after all agents have completed. diff --git a/tests/unit/test_gripper_ramp.py b/tests/unit/test_gripper_ramp.py index 86444a5..65f064b 100644 --- a/tests/unit/test_gripper_ramp.py +++ b/tests/unit/test_gripper_ramp.py @@ -163,20 +163,6 @@ def test_ssg48_full_travel_time(self): class TestElectricGripperConfigSpecs: """Verify physical specs on tool configs match expected values.""" - def test_ssg48_config(self): - cfg = get_registry()["SSG-48"] - assert isinstance(cfg, ElectricGripperConfig) - assert cfg.encoder_cpr == 16_384 - assert cfg.gear_pd_mm == 12.0 - assert cfg.firmware_speed_range_tps == (40, 80_000) - - def test_msg_config(self): - cfg = get_registry()["MSG"] - assert isinstance(cfg, ElectricGripperConfig) - assert cfg.encoder_cpr == 16_384 - assert cfg.gear_pd_mm == pytest.approx(16.67, abs=0.01) - assert cfg.firmware_speed_range_tps == (500, 60_000) - def test_ssg48_tick_range_derivation(self): """Tick range derived from gear PD + travel_m should match expected ~10,432.""" cfg = get_registry()["SSG-48"] diff --git a/tests/unit/test_movecart_command.py b/tests/unit/test_movecart_command.py index a41f775..f714b96 100644 --- a/tests/unit/test_movecart_command.py +++ b/tests/unit/test_movecart_command.py @@ -7,74 +7,12 @@ import msgspec import pytest -from parol6.commands.cartesian_commands import MoveLCommand from parol6.protocol.wire import MoveLCmd class TestMoveLCommandParsing: """Test MoveLCmd struct parsing and validation.""" - def test_parse_with_speed(self): - """Parse with explicit speed.""" - # Create params struct - params = MoveLCmd( - pose=[100.0, 200.0, 300.0, 0.0, 0.0, 0.0], - speed=0.5, - accel=0.75, - ) - - cmd = MoveLCommand(params) - - assert cmd.p.pose == [100.0, 200.0, 300.0, 0.0, 0.0, 0.0] - assert cmd.p.duration == 0.0 # default - assert cmd.p.speed == 0.5 - assert cmd.p.accel == 0.75 - - def test_parse_accel_default(self): - """Default acceleration should be 1.0.""" - params = MoveLCmd( - pose=[100.0, 200.0, 300.0, 0.0, 0.0, 0.0], - speed=0.5, - ) - - cmd = MoveLCommand(params) - - assert cmd.p.accel == 1.0 # default - - def test_parse_with_duration(self): - """Parse with duration instead of speed.""" - params = MoveLCmd( - pose=[100.0, 200.0, 300.0, 0.0, 0.0, 0.0], - duration=2.5, - accel=0.8, - ) - - cmd = MoveLCommand(params) - - assert cmd.p.duration == 2.5 - assert cmd.p.speed == 0.0 # default - assert cmd.p.accel == 0.8 - - def test_parse_full_accel_range(self): - """Test acceleration values at boundaries.""" - # Min accel (must be > 0) - params1 = MoveLCmd( - pose=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - speed=0.5, - accel=0.001, - ) - cmd1 = MoveLCommand(params1) - assert cmd1.p.accel == 0.001 - - # Max accel - params2 = MoveLCmd( - pose=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - speed=0.5, - accel=1.0, - ) - cmd2 = MoveLCommand(params2) - assert cmd2.p.accel == 1.0 - def test_validation_requires_duration_or_speed(self): """Must have either duration > 0 or speed > 0.""" with pytest.raises((ValueError, msgspec.ValidationError)): @@ -105,14 +43,3 @@ def test_validation_pose_length(self): with pytest.raises(msgspec.ValidationError): decode_command(raw) - - def test_command_init(self): - """Test that MoveLCommand initializes correctly.""" - params = MoveLCmd( - pose=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - speed=0.5, - ) - cmd = MoveLCommand(params) - - assert cmd.p is params - assert not cmd.is_finished diff --git a/tests/unit/test_query_commands_actions.py b/tests/unit/test_query_commands_actions.py index d5ef7b8..56075e8 100644 --- a/tests/unit/test_query_commands_actions.py +++ b/tests/unit/test_query_commands_actions.py @@ -14,7 +14,6 @@ CurrentActionResultStruct, ActivityCmd, QueueCmd, - QueryType, QueueResultStruct, ResponseMsg, decode_message, @@ -28,15 +27,6 @@ def _unpack_response(data: bytes): return msg.result -def test_activity_command_init(): - """Test that ACTIVITY command initializes correctly.""" - cmd = ActivityCommand(ActivityCmd()) - - assert not cmd.is_finished - assert cmd.PARAMS_TYPE is not None - assert cmd.QUERY_TYPE == QueryType.CURRENT_ACTION - - def test_activity_returns_details(): """Test that ACTIVITY compute() returns correct data.""" state = SimpleNamespace( @@ -77,15 +67,6 @@ def test_activity_with_idle_state(): assert result.params == "" -def test_queue_command_init(): - """Test that QUEUE command initializes correctly.""" - cmd = QueueCommand(QueueCmd()) - - assert not cmd.is_finished - assert cmd.PARAMS_TYPE is not None - assert cmd.QUERY_TYPE == QueryType.QUEUE - - def test_queue_returns_details(): """Test that QUEUE compute() returns correct data.""" state = SimpleNamespace( diff --git a/tests/unit/test_reset_command.py b/tests/unit/test_reset_command.py index 4c6fc34..b631369 100644 --- a/tests/unit/test_reset_command.py +++ b/tests/unit/test_reset_command.py @@ -8,23 +8,6 @@ from parol6.server.state import ControllerState -class TestResetCommandParsing: - """Test ResetCommand initialization.""" - - def test_init(self): - """RESET takes no parameters.""" - cmd = ResetCommand(ResetCmd()) - - assert not cmd.is_finished - assert cmd.p is not None - - def test_struct_has_no_params(self): - """ResetCmd struct should have no fields.""" - params = ResetCmd() - # Struct should be valid with no fields - assert params is not None - - class TestResetCommandExecution: """Test ResetCommand.tick resets state correctly.""" From 394ca6bbf32cc9737dc40a864e0e21c8a55e6ccd Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:06:06 -0400 Subject: [PATCH 4/7] Planner/controller: fail-fast on dead worker, broaden affinity-pin except - MotionPlanner.start() polls is_alive() and bails on a dead worker instead of blocking the full 30s, then 'continuing anyway' with a dead planner [S1-7] - _pin_to_core broadens its except to psutil.Error/OSError/ValueError so a benign affinity failure on a restricted host can't crash the controller [S1-8] Co-Authored-By: Claude Opus 4.8 --- parol6/server/controller.py | 8 +++++++- parol6/server/motion_planner.py | 19 +++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/parol6/server/controller.py b/parol6/server/controller.py index fbe6f50..2d42365 100644 --- a/parol6/server/controller.py +++ b/parol6/server/controller.py @@ -892,5 +892,11 @@ def _pin_to_core(self, core: int) -> None: try: psutil.Process().cpu_affinity([core]) logger.debug("Pinned process to CPU core %d", core) - except (AttributeError, NotImplementedError, psutil.AccessDenied) as e: + except ( + AttributeError, + NotImplementedError, + psutil.Error, + OSError, + ValueError, + ) as e: logger.debug("Could not pin to CPU core: %s", e) diff --git a/parol6/server/motion_planner.py b/parol6/server/motion_planner.py index 46786e8..6dd921f 100644 --- a/parol6/server/motion_planner.py +++ b/parol6/server/motion_planner.py @@ -685,12 +685,19 @@ def start(self, avoid_core: int | None = None) -> None: # off the CPU the loop will pin (avoiding ~3s of contention) and ensures # the controller does not accept a motion command before the worker can # process it (which otherwise stalls the first command for ~5s). - if self._ready_event.wait(timeout=30.0): - logger.debug("Motion planner worker ready") - else: - logger.warning( - "Motion planner worker not ready after 30s; continuing anyway" - ) + # Poll for readiness; fail fast if the worker dies during its heavy spawn + # import instead of blocking the full 30s on a dead process. + for _ in range(300): # 300 * 0.1s = 30s + if self._ready_event.wait(timeout=0.1): + logger.debug("Motion planner worker ready") + return + if not self._process.is_alive(): + logger.error( + "Motion planner worker died during startup (exit code %s)", + self._process.exitcode, + ) + return + logger.warning("Motion planner worker not ready after 30s; continuing anyway") def stop(self) -> None: """Shut down the planner subprocess gracefully.""" From 24975c255123c926613278b48fc2dcb88572656c Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:47:22 -0400 Subject: [PATCH 5/7] Dry-run + status: register plugin tools in dry-run worker, reset stale ToolStatus on tool switch - DryRunRobotClient registers plugin tools in its spawn-mode subprocess so a dry-run select_tool() of a plugin tool no longer crashes in apply_tool [S2-9] - update_from_state resets ToolStatus value fields before populate_status so a no-op populate (NONE/passive/plugin tools) doesn't broadcast the previous tool's engaged/part_detected/positions [S2-10] Co-Authored-By: Claude Opus 4.8 --- parol6/client/dry_run_client.py | 8 ++++++++ parol6/server/status_cache.py | 11 ++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/parol6/client/dry_run_client.py b/parol6/client/dry_run_client.py index b32ec71..2ff61b1 100644 --- a/parol6/client/dry_run_client.py +++ b/parol6/client/dry_run_client.py @@ -173,6 +173,14 @@ def __init__( # TCP offset on the module-level robot singleton. PAROL6_ROBOT.apply_tool("NONE") + # Spawn-mode subprocess: the tool registry is freshly imported with only + # native tools, so plugin tools must be registered here too or + # select_tool() of a plugin tool fails in apply_tool (mirrors the planner + # worker). + from parol6.tools import register_plugin_tools + + register_plugin_tools() + self._state = ControllerState() init_deg = np.asarray( initial_joints_deg if initial_joints_deg is not None else HOME_ANGLES_DEG, diff --git a/parol6/server/status_cache.py b/parol6/server/status_cache.py index 5adbda2..0264f0a 100644 --- a/parol6/server/status_cache.py +++ b/parol6/server/status_cache.py @@ -15,7 +15,7 @@ import numpy as np from numba import njit from pinokin import arrays_equal_6 -from waldoctl import ActionState, ToolStatus +from waldoctl import ActionState, ToolState, ToolStatus from parol6.config import speed_steps_to_rad, steps_to_deg, steps_to_rad from parol6.protocol.wire import pack_status @@ -415,6 +415,15 @@ def update_from_state(self, state: ControllerState) -> None: # Populate tool status from hardware state via the tool config ts = self.tool_status ts.key = state.current_tool + # Reset value fields to defaults first so a tool whose populate_status is + # a no-op (NONE / passive / plugin tools) doesn't inherit the previous + # tool's engaged/part_detected/positions/etc. (in-place, zero-alloc). + ts.state = ToolState.OFF + ts.engaged = False + ts.part_detected = False + ts.fault_code = 0 + ts.positions = () + ts.channels = () cfg = get_registry().get(state.current_tool) if cfg is not None: cfg.populate_status(state, ts) From e26bcfe3cd5d416d47bc2be0c4775f915609b36c Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:07:59 -0400 Subject: [PATCH 6/7] Robot: rename _native_tools -> native_tools (follow waldoctl ABC) Co-Authored-By: Claude Opus 4.8 --- parol6/robot.py | 2 +- tests/integration/test_tool_operations.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/parol6/robot.py b/parol6/robot.py index 26c87e8..6e3b9e3 100644 --- a/parol6/robot.py +++ b/parol6/robot.py @@ -575,7 +575,7 @@ def joints(self) -> JointsSpec: return self._joints @property - def _native_tools(self) -> ToolsCollection: + def native_tools(self) -> ToolsCollection: """PAROL6's built-in tools. The waldoctl ``Robot.tools`` property composes these with any plugin tools registered via ``waldoctl.tools``.""" return self._tools diff --git a/tests/integration/test_tool_operations.py b/tests/integration/test_tool_operations.py index c3527de..fee8462 100644 --- a/tests/integration/test_tool_operations.py +++ b/tests/integration/test_tool_operations.py @@ -218,7 +218,7 @@ def test_registry_completeness(self): tools = robot.tools # Native count stays 5; robot.tools may compose plugin tools on top. - native_keys = [t.key for t in robot._native_tools.available] + native_keys = [t.key for t in robot.native_tools.available] assert len(native_keys) == 5 keys = [t.key for t in tools.available] for expected in ("NONE", "PNEUMATIC", "SSG-48", "MSG", "VACUUM"): From fe2565b8c4fa7489e856303b6714bdb2637b1413 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:08:17 -0400 Subject: [PATCH 7/7] deps: cap numpy<2.5 for numba compatibility NumPy 2.5.0 (released 2026-06) violates numba 0.6x's numpy<2.5 pin, breaking collection on Python 3.12+ (3.11 resolves an older numpy and passes). Bound numpy to what numba supports. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1a71886..71ff823 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "ruckig>=0.12.2", "toppra>=0.6.3", "interpolatepy>=2.0.0", - "numpy>=2.0", + "numpy>=2.0,<2.5", # numba (0.6x) requires numpy<2.5 "numba>=0.59", "psutil>=5.9", "msgspec>=0.18",