From 4d940ad765e72812b2aa54f3b419cb5daaae80a9 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:04:09 +0000 Subject: [PATCH 1/3] Carry the active motion profile in the status stream Clients can show and preview with the profile the controller runs, including after a reset or a program's select_profile, without a query that would hold the request lock a stop waits on. Older peers decode it as unknown. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016uVshJDKTz7nCqrMGVxVJU --- parol6/protocol/wire.py | 19 ++++++++++++++-- parol6/server/status_cache.py | 3 +++ tests/integration/test_profile_commands.py | 25 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/parol6/protocol/wire.py b/parol6/protocol/wire.py index 6523c72..30955cd 100644 --- a/parol6/protocol/wire.py +++ b/parol6/protocol/wire.py @@ -15,7 +15,7 @@ Wire format uses msgpack arrays with integer type codes: - OK: [MsgType.OK, req_id, index?] - ERROR: [MsgType.ERROR, req_id, message] -- STATUS: [MsgType.STATUS, proto_version, pose, angles, speeds, io, action_current, action_state, joint_en, cart_en_wrf, cart_en_trf, executing_index, completed_index, last_checkpoint, error, queued_segments, queued_duration, action_params, tool_status, tcp_speed, simulator_active, collision_active, collision_pairs, scene_epoch, accepted_index, homed, enabled, homing_step, joints_homed, loop_health, drive_faults] +- STATUS: [MsgType.STATUS, proto_version, pose, angles, speeds, io, action_current, action_state, joint_en, cart_en_wrf, cart_en_trf, executing_index, completed_index, last_checkpoint, error, queued_segments, queued_duration, action_params, tool_status, tcp_speed, simulator_active, collision_active, collision_pairs, scene_epoch, accepted_index, homed, enabled, homing_step, joints_homed, loop_health, drive_faults, session_id, seq, mono_time_ns, motion_profile] - RESPONSE: [MsgType.RESPONSE, req_id, [query_type, ...fields]] - COMMAND: [CmdType.XXX, ...params] """ @@ -1692,6 +1692,10 @@ def pack_response(result: Response, req_id: int) -> bytes: _NO_JOINTS_HOMED: tuple[int, ...] = (0, 0, 0, 0, 0, 0) +# Append-only so existing profile IDs remain stable, without importing the planner. +_MOTION_PROFILES = ("TOPPRA", "RUCKIG", "QUINTIC", "TRAPEZOID", "LINEAR") +_MOTION_PROFILE_IDS = {name: index for index, name in enumerate(_MOTION_PROFILES)} + def pack_status( pose: np.ndarray, @@ -1727,6 +1731,7 @@ def pack_status( session_id: int = 0, seq: int = 0, mono_time_ns: int = 0, + motion_profile: str | None = None, ) -> bytes: """Pack a status broadcast message. @@ -1781,6 +1786,7 @@ def pack_status( session_id, seq, mono_time_ns, + _MOTION_PROFILE_IDS[motion_profile] if motion_profile is not None else None, ), option=ormsgpack.OPT_SERIALIZE_NUMPY, ) @@ -1834,6 +1840,7 @@ class StatusBuffer: # Whether the controller accepts motion (False while disabled, e.g. the # e-stop latch). True from producers that predate the field. enabled: bool = True + motion_profile: str | None = None # Remaining waldoctl StatusBuffer Protocol members. parol6 has no torque # sensing, fieldbus, or warning source, so these hold their empty values. torques: np.ndarray = field(default_factory=lambda: np.zeros(6, dtype=np.float64)) @@ -1920,6 +1927,7 @@ def copy(self) -> "StatusBuffer": accepted_index=self.accepted_index, homed=self.homed, enabled=self.enabled, + motion_profile=self.motion_profile, torques=self.torques.copy(), torques_ext=self.torques_ext.copy(), warnings=list(self.warnings), @@ -1973,7 +1981,8 @@ def decode_status_bin_into(data: bytes, buf: StatusBuffer) -> bool: tool_status_tuple, tcp_speed, simulator_active, collision_active, collision_pairs, scene_epoch, accepted_index, homed, enabled, homing_step, joints_homed, - loop_health, drive_faults, session_id, seq, mono_time_ns] + loop_health, drive_faults, session_id, seq, mono_time_ns, + motion_profile] Args: data: Raw msgpack bytes @@ -2068,6 +2077,12 @@ def decode_status_bin_into(data: bytes, buf: StatusBuffer) -> bool: buf.accepted_index = int(msg[24]) if len(msg) > 24 else -1 buf.homed = bool(msg[25]) if len(msg) > 25 else True buf.enabled = bool(msg[26]) if len(msg) > 26 else True + profile_id = msg[34] if len(msg) > 34 else None + buf.motion_profile = ( + _MOTION_PROFILES[profile_id] + if type(profile_id) is int and 0 <= profile_id < len(_MOTION_PROFILES) + else None + ) if len(msg) > 28: _apply_homing_progress(buf, int(msg[27]), msg[28]) if len(msg) > 29: diff --git a/parol6/server/status_cache.py b/parol6/server/status_cache.py index 96418b2..e1050e6 100644 --- a/parol6/server/status_cache.py +++ b/parol6/server/status_cache.py @@ -192,6 +192,7 @@ def __init__(self) -> None: self._p99_period_s: float = 0.0 self._overruns: int = 0 self._enabled: bool = True + self._motion_profile: str | None = None self._homing_step: int = 0 self._joints_homed: list[int] = [0] * 6 @@ -570,6 +571,7 @@ def update_from_state(self, state: ControllerState) -> None: enabled_changed = self._enabled != state.enabled if enabled_changed: self._enabled = state.enabled + self._motion_profile = state.motion_profile for i in range(6): bits = (1 if state.Temperature_error_in[i] else 0) | ( @@ -642,6 +644,7 @@ def to_binary( accepted_index=self._accepted_index, homed=self._homed, enabled=self._enabled, + motion_profile=self._motion_profile, homing_step=self._homing_step, joints_homed=self._joints_homed, p99_period_s=self._p99_period_s, diff --git a/tests/integration/test_profile_commands.py b/tests/integration/test_profile_commands.py index 9b1a8af..6fd2627 100644 --- a/tests/integration/test_profile_commands.py +++ b/tests/integration/test_profile_commands.py @@ -4,12 +4,16 @@ Tests SETPROFILE/GETPROFILE through the client API with a running server. """ +import asyncio import threading import time import numpy as np import pytest +from parol6 import AsyncRobotClient +from parol6.protocol.wire import StatusBuffer + @pytest.mark.integration class TestProfileCommands: @@ -36,6 +40,27 @@ def test_select_profile_case_insensitive(self, client, server_proc): assert client.select_profile("Quintic") > 0 assert client.profile() == "QUINTIC" + @pytest.mark.asyncio + async def test_status_tracks_profile_selection_and_reset(self, server_proc, ports): + async with ( + AsyncRobotClient(host=ports.server_ip, port=ports.server_port) as writer, + AsyncRobotClient(host=ports.server_ip, port=ports.server_port) as observer, + ): + + async def reported_profile(name: str) -> StatusBuffer: + async with asyncio.timeout(5.0): + async for status in observer.stream_status(): + if status.motion_profile == name: + return status + raise AssertionError("Status stream closed before reporting the profile") + + assert await writer.select_profile("QUINTIC") > 0 + selected = await reported_profile("QUINTIC") + assert await writer.reset_state() > 0 + reset = await reported_profile("TOPPRA") + assert selected.motion_profile == "QUINTIC" + assert reset.motion_profile == "TOPPRA" + @pytest.mark.integration class TestProfileMotionBehavior: From c22b94df562d588e72d745d70a95635e32e362eb Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:36:46 +0000 Subject: [PATCH 2/3] Wrap a long assertion message for the current ruff format Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016uVshJDKTz7nCqrMGVxVJU --- tests/integration/test_profile_commands.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_profile_commands.py b/tests/integration/test_profile_commands.py index 6fd2627..c30ac73 100644 --- a/tests/integration/test_profile_commands.py +++ b/tests/integration/test_profile_commands.py @@ -52,7 +52,9 @@ async def reported_profile(name: str) -> StatusBuffer: async for status in observer.stream_status(): if status.motion_profile == name: return status - raise AssertionError("Status stream closed before reporting the profile") + raise AssertionError( + "Status stream closed before reporting the profile" + ) assert await writer.select_profile("QUINTIC") > 0 selected = await reported_profile("QUINTIC") From 4ba1c21da390cca52f729c1bd644abcd8ff1f858 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:28:05 +0000 Subject: [PATCH 3/3] Accept any backend's robot in the client's robot setter The interface declares robot as a waldoctl Robot, so a setter narrowed to parol6's Robot is an invalid override to current ty. Same change as #55. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016uVshJDKTz7nCqrMGVxVJU --- parol6/client/async_client.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/parol6/client/async_client.py b/parol6/client/async_client.py index 18cce13..f551573 100644 --- a/parol6/client/async_client.py +++ b/parol6/client/async_client.py @@ -242,6 +242,8 @@ def _create_unicast_socket(port: int, host: str) -> socket.socket: if TYPE_CHECKING: from typing import Protocol + from waldoctl import Robot as WaldoRobot + from parol6.robot import Robot class _StatusNotifier(Protocol): @@ -320,7 +322,13 @@ def robot(self) -> "Robot": return self._robot @robot.setter - def robot(self, value: "Robot | None") -> None: + def robot(self, value: "WaldoRobot | None") -> None: + # The interface takes any backend's robot; this client drives only + # its own. + if value is not None: + from parol6.robot import Robot + + assert isinstance(value, Robot) self._robot = value def __init__(