Skip to content
Open
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
10 changes: 9 additions & 1 deletion parol6/client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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__(
Expand Down
19 changes: 17 additions & 2 deletions parol6/protocol/wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
"""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions parol6/server/status_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) | (
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions tests/integration/test_profile_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -36,6 +40,29 @@ 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:
Expand Down
Loading