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
28 changes: 14 additions & 14 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ jobs:
# ruckig 0.17.3 sdist doesn't build under scikit-build-core 1.0;
# pre-install the fixed commit until a release lands (pantor/ruckig#262).
pip install "ruckig @ git+https://github.com/pantor/ruckig@2249d57ffaa19ecdadeaab62daf97857813629ff"
pip install -e ".[dev]"
# Override the pinned waldoctl with the matching feature branch if one
# exists, AFTER ".[dev]" (and with --force-reinstall) so the pinned tag
# in pyproject can't clobber it. Deps are kept (no --no-deps): the
# refactored waldoctl imports nicegui, which parol6 doesn't otherwise
# install. Skipped on main so main CI exercises the released pin.
# Resolve the shared contract branch before the package: the new
# release tag is created only after its companion PR merges.
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
if [ "$BRANCH" != "main" ] && git ls-remote --heads https://github.com/Jepson2k/waldoctl.git "$BRANCH" 2>/dev/null | grep -q .; then
pip install --force-reinstall "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}"
sed -i.bak "s#waldoctl.git@v[0-9.]*#waldoctl.git@${BRANCH}#" pyproject.toml
fi
pip install -e ".[dev]"
if [ -f pyproject.toml.bak ]; then
mv pyproject.toml.bak pyproject.toml
fi
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
Expand Down Expand Up @@ -112,15 +112,15 @@ jobs:
# ruckig 0.17.3 sdist doesn't build under scikit-build-core 1.0;
# pre-install the fixed commit until a release lands (pantor/ruckig#262).
pip install "ruckig @ git+https://github.com/pantor/ruckig@2249d57ffaa19ecdadeaab62daf97857813629ff"
pip install -e ".[dev]" pytest-timeout
# Override the pinned waldoctl with the matching feature branch if one
# exists, AFTER ".[dev]" (and with --force-reinstall) so the pinned tag
# in pyproject can't clobber it. Deps are kept (no --no-deps): the
# refactored waldoctl imports nicegui, which parol6 doesn't otherwise
# install. Skipped on main so main CI exercises the released pin.
# Resolve the shared contract branch before the package: the new
# release tag is created only after its companion PR merges.
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
if [ "$BRANCH" != "main" ] && git ls-remote --heads https://github.com/Jepson2k/waldoctl.git "$BRANCH" 2>/dev/null | grep -q .; then
pip install --force-reinstall "waldoctl @ git+https://github.com/Jepson2k/waldoctl.git@${BRANCH}"
sed -i.bak "s#waldoctl.git@v[0-9.]*#waldoctl.git@${BRANCH}#" pyproject.toml
fi
pip install -e ".[dev]" pytest-timeout
if [ -f pyproject.toml.bak ]; then
mv pyproject.toml.bak pyproject.toml
fi

# Override the pinned pinokin v0.1.6 wheel with the matching-branch
Expand Down
4 changes: 3 additions & 1 deletion parol6/ack_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
CmdType.SIMULATOR,
CmdType.SELECT_PROFILE,
CmdType.RESET_STATE,
CmdType.WRITE_IO,
CmdType.SET_TCP_OFFSET,
CmdType.SET_SHAPES,
CmdType.SET_STATUS_RATE,
}

# Query command types (use request/response, not ACK)
Expand All @@ -36,6 +36,7 @@
CmdType.IS_SIMULATOR,
CmdType.TCP_OFFSET,
CmdType.SHAPES,
CmdType.STATUS_RATE,
}

# Streaming commands are fire-and-forget (no ACK needed)
Expand All @@ -61,6 +62,7 @@
CmdType.SELECT_TOOL,
CmdType.DELAY,
CmdType.CHECKPOINT,
CmdType.WRITE_IO,
CmdType.TOOL_ACTION,
}

Expand Down
58 changes: 57 additions & 1 deletion parol6/client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@
from waldoctl import RobotClient as _RobotClientABC, Shape, ShapeWorld, ToolStatus
from msgspec.structs import asdict
from waldoctl.shapes import shape_from_wire
from waldoctl.status import ActionState, ActivityResult, LoopStatsResult, ToolResult
from waldoctl.status import (
ActionState,
ActivityResult,
LoopStatsResult,
StatusRate,
ToolResult,
)
from waldoctl.tools import ToolSpec

from .. import config as cfg
Expand Down Expand Up @@ -65,6 +71,9 @@
ReachableCmd,
ResetCmd,
ResetLoopStatsCmd,
SetStatusRateCmd,
StatusRateCmd,
StatusRateResultStruct,
ResetStateCmd,
Response,
StopCmd,
Expand Down Expand Up @@ -202,6 +211,8 @@ def _create_unicast_socket(port: int, host: str) -> socket.socket:
if TYPE_CHECKING:
from typing import Protocol

from parol6.robot import Robot

class _StatusNotifier(Protocol):
_shared_status: StatusBuffer
_status_generation: int
Expand Down Expand Up @@ -243,18 +254,36 @@ class AsyncRobotClient(_RobotClientABC):
Query commands: request/response with timeout and simple retry
"""

_robot: "Robot | None" = None

@property
def robot(self) -> "Robot":
"""The backend this client drives, built on first read when a bare
client (what a user script constructs) supplied none."""
if self._robot is None:
from parol6.robot import Robot

self._robot = Robot()
return self._robot

@robot.setter
def robot(self, value: "Robot | None") -> None:
self._robot = value

def __init__(
self,
host: str = "127.0.0.1",
port: int = 5001,
timeout: float = 1.0,
retries: int = 1,
robot: "Robot | None" = None,
) -> None:
# host/port are immutable after endpoint creation
self._host = host
self._port = port
self.timeout = timeout
self.retries = retries
self._robot = robot

# Pre-allocated buffers for pose() RPY conversion
self._R_buf = np.zeros((3, 3), dtype=np.float64)
Expand Down Expand Up @@ -931,6 +960,33 @@ async def reset_loop_stats(self) -> int:
"""
return await self._send(ResetLoopStatsCmd())

async def set_status_rate(self, hz: float) -> int:
"""Set the rate the controller broadcasts status at.

Category: Configuration

Example:
rbt.set_status_rate(100)
"""
return await self._send(SetStatusRateCmd(hz=float(hz)))

async def status_rate(self) -> StatusRate | None:
"""Current broadcast rate and the control rate it divides.

Category: Query

Example:
rate = rbt.status_rate()
"""
resp = await self._request(StatusRateCmd())
if not isinstance(resp, StatusRateResultStruct):
return None
return StatusRate(
hz=resp.hz,
control_hz=resp.control_hz,
servable=tuple(float(v) for v in resp.servable),
)

async def select_tool(self, tool_name: str, variant_key: str = "") -> int:
"""Set the active end-effector tool on the controller.

Expand Down
91 changes: 87 additions & 4 deletions parol6/client/dry_run_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import logging
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING, Any

import numpy as np

Expand Down Expand Up @@ -38,6 +38,7 @@
import re as _re

import parol6.protocol.wire as _wire
from waldoctl.commands import CommandKind, command_table
from ..protocol.wire import (
HomeCmd,
SelectToolCmd,
Expand All @@ -58,6 +59,9 @@
from ..utils.error_codes import ErrorCode
from parol6.tools import get_registry

if TYPE_CHECKING:
from parol6.robot import Robot


def _pascal_to_snake(name: str) -> str:
"""Convert PascalCase to snake_case: MoveJPose → move_j_pose"""
Expand All @@ -77,6 +81,9 @@ def _pascal_to_snake(name: str) -> str:

_UPPER_FIELDS: frozenset[str] = frozenset({"tool_name", "tool_key", "profile"})

_COMMANDS = command_table()
_AXIS_INDEX: dict[str, int] = {"X": 0, "Y": 1, "Z": 2, "RX": 3, "RY": 4, "RZ": 5}


def build_cmd(name: str, *args: Any, **kwargs: Any) -> Any:
"""Build a command struct by method name."""
Expand Down Expand Up @@ -163,12 +170,31 @@ class DryRunRobotClient:
and delay (no-op).
"""

_robot: Robot | None = None

@property
def robot(self) -> Robot:
"""The backend this preview stands in for, built on first read when
the host constructed the client bare. A real descriptor on the class,
so the read never reaches ``__getattr__``'s command dispatch."""
if self._robot is None:
from parol6.robot import Robot

self._robot = Robot()
return self._robot

@robot.setter
def robot(self, value: Robot | None) -> None:
self._robot = value

def __init__(
self,
initial_joints_deg: list[float] | None = None,
max_snapshot_points: int = 200,
initial_homed: bool = True,
robot: Robot | None = None,
) -> None:
self._robot = robot
# Reset tool transform — process pool workers persist across
# invocations, so a previous run's select_tool() leaves a stale
# TCP offset on the module-level robot singleton.
Expand Down Expand Up @@ -572,6 +598,52 @@ def servo_j(
return self._dispatch(build_cmd("servo_j_pose", pose, **kwargs))
return self._dispatch(build_cmd("servo_j", angles or [], **kwargs))

def jog_j(
self,
joint: int = -1,
speed: float = 0.0,
duration: float = 0.1,
*,
joints: list[int] | None = None,
speeds: list[float] | None = None,
accel: float = 1.0,
) -> DryRunResult | None:
"""The live client's signature, so a script's jog previews as written."""
speed_arr = [0.0] * 6
if joints is not None and speeds is not None:
for j, s in zip(joints, speeds):
speed_arr[j] = s
elif joint >= 0:
speed_arr[joint] = speed
else:
raise ValueError("jog_j requires either joint= or joints=/speeds=")
return self._dispatch(
_wire.JogJCmd(speeds=speed_arr, duration=duration, accel=accel)
)

def jog_l(
self,
frame: str,
axis: str | None = None,
speed: float = 0.0,
duration: float = 0.1,
*,
axes: list[str] | None = None,
speeds_list: list[float] | None = None,
accel: float = 1.0,
) -> DryRunResult | None:
vel = [0.0] * 6
if axes is not None and speeds_list is not None:
for a, s in zip(axes, speeds_list):
vel[_AXIS_INDEX[a]] = s
elif axis is not None:
vel[_AXIS_INDEX[axis]] = speed
else:
raise ValueError("jog_l requires either axis= or axes=/speeds_list=")
return self._dispatch(
_wire.JogLCmd(frame=frame, velocities=vel, duration=duration, accel=accel)
)

def delay(self, seconds: float = 0.0) -> None:
pass

Expand All @@ -586,8 +658,19 @@ def __getattr__(self, name: str) -> Any:
if name not in _CMD_STRUCTS:
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")

def method(*args: Any, **kwargs: Any) -> DryRunResult | None:
cmd = build_cmd(name, *args, **kwargs)
return self._dispatch(cmd)
spec = _COMMANDS.get(name)
applies = spec is not None and spec.kind in (
CommandKind.SYSTEM,
CommandKind.CONTROL,
)

def method(*args: Any, **kwargs: Any) -> DryRunResult | int | None:
result = self._dispatch(build_cmd(name, *args, **kwargs))
if not applies:
return result
# A system or control command answers as the live client does:
# 1 when it applied, negative when the planner refused it. Its
# planner result carries no path a program could wait on.
return -1 if result is not None and result.error is not None else 1

return method
Loading
Loading