Skip to content
Draft
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
2 changes: 2 additions & 0 deletions parol6/ack_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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 +37,7 @@
CmdType.IS_SIMULATOR,
CmdType.TCP_OFFSET,
CmdType.SHAPES,
CmdType.STATUS_RATE,
}

# Streaming commands are fire-and-forget (no ACK needed)
Expand Down
42 changes: 41 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 @@ -243,6 +252,10 @@ class AsyncRobotClient(_RobotClientABC):
Query commands: request/response with timeout and simple retry
"""

@property
def skill_capabilities(self) -> frozenset[str]:
return super().skill_capabilities | {"backend.parol6"}

def __init__(
self,
host: str = "127.0.0.1",
Expand Down Expand Up @@ -931,6 +944,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
71 changes: 68 additions & 3 deletions parol6/client/dry_run_client.py
Original file line number Diff line number Diff line change
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 Down Expand Up @@ -77,6 +78,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 @@ -520,6 +524,10 @@ def _simulate_cartesian_jog(self, cmd: JogLCommand) -> DryRunResult | None:

# ---- Explicit methods for state reads ----

@property
def skill_capabilities(self) -> frozenset[str]:
return frozenset({"motion.joint", "motion.linear", "backend.parol6"})

def angles(self) -> list[float]:
steps_to_rad(self._state.Position_in, self._q_rad_buf)
return np.degrees(self._q_rad_buf).tolist()
Expand Down Expand Up @@ -572,6 +580,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 +640,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
25 changes: 24 additions & 1 deletion parol6/client/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
from waldoctl.sync_tools import SyncTool

from waldoctl import PingResult, ToolStatus
from waldoctl.status import ActivityResult, LoopStatsResult, ToolResult
from waldoctl.status import (
ActivityResult,
LoopStatsResult,
StatusRate,
ToolResult,
)

from waldoctl.types import Axis, Frame
from ..protocol.wire import (
Expand Down Expand Up @@ -145,6 +150,16 @@ def _bind_default_tools(self) -> None:

# ---------- tool access ----------

def run_skill(
self, invoke: Callable[[AsyncRobotClient], Coroutine[Any, Any, T]]
) -> T:
"""Execute a Python skill using this connection and its existing loop."""
return _run(invoke(self._inner))

@property
def skill_capabilities(self) -> frozenset[str]:
return self._inner.skill_capabilities

@property
def tool(self) -> SyncTool:
"""Active bound tool. Raises if no tool has been set."""
Expand Down Expand Up @@ -319,6 +334,14 @@ def reset_loop_stats(self) -> int:
"""Reset control-loop min/max metrics and overrun count."""
return _run(self._inner.reset_loop_stats())

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

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

def tools(self) -> ToolResult | None:
"""Current tool and available tools.

Expand Down
24 changes: 24 additions & 0 deletions parol6/commands/query_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
JointSpeedsCmd,
LoopStatsCmd,
LoopStatsResultStruct,
StatusRateCmd,
StatusRateResultStruct,
PingCmd,
PingResultStruct,
PoseCmd,
Expand Down Expand Up @@ -182,6 +184,28 @@ def compute(self, state: "ControllerState") -> bytes:
)


@register_command(CmdType.STATUS_RATE)
class StatusRateCommand(QueryCommand[StatusRateCmd]):
"""Return the broadcast rate and the control rate it divides."""

PARAMS_TYPE = StatusRateCmd
QUERY_TYPE = QueryType.STATUS_RATE

__slots__ = ()

def compute(self, state: "ControllerState") -> bytes:
return pack_response(
StatusRateResultStruct(
hz=state.status_rate_hz,
# The configured rate, not 1/INTERVAL_S: inverting the
# interval adds float noise to a value `achievable()` and the
# divisor arithmetic treat as exact (1/(1/49) is 49.000000001).
control_hz=float(cfg.CONTROL_RATE_HZ),
servable=cfg.servable_status_rates(),
)
)


@register_command(CmdType.PING)
class PingCommand(QueryCommand[PingCmd]):
"""Respond to ping requests."""
Expand Down
42 changes: 42 additions & 0 deletions parol6/commands/utility_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
MotionCommand,
SystemCommand,
)
from parol6.config import CONTROL_RATE_HZ, servable_status_rates
from parol6.protocol.wire import (
CheckpointCmd,
CmdType,
DelayCmd,
ResetLoopStatsCmd,
ResetStateCmd,
SetStatusRateCmd,
)
from parol6.utils.error_catalog import make_error
from parol6.utils.error_codes import ErrorCode
from parol6.protocol.wire import CommandCode
from parol6.server.command_registry import register_command
from parol6.server.state import ControllerState
Expand Down Expand Up @@ -89,6 +93,44 @@ def execute_step(self, state: "ControllerState") -> ExecutionStatusCode:
return ExecutionStatusCode.COMPLETED


@register_command(CmdType.SET_STATUS_RATE)
class SetStatusRateCommand(SystemCommand[SetStatusRateCmd]):
"""Change the status broadcast rate for this session.

Status is emitted every Nth control tick, so a rate that does not divide
the control rate evenly cannot be served. It is refused rather than
rounded to a neighbour: a capture taken at a rate nobody asked for is
wrong in a way nothing reports.
"""

PARAMS_TYPE = SetStatusRateCmd

__slots__ = ()

def execute_step(self, state: "ControllerState") -> ExecutionStatusCode:
hz = float(self.p.hz)
control = int(CONTROL_RATE_HZ)
# Ordered so the modulo only ever sees a finite, in-range, integral
# divisor: int(0.5) is 0 and int(nan) raises, and either would leave
# as a generic tick failure instead of the refusal that names the
# rates this controller can serve.
if not (1.0 <= hz <= control) or not hz.is_integer() or control % int(hz) != 0:
allowed = ", ".join(f"{hz:g}" for hz in servable_status_rates())
self.fail(
make_error(
ErrorCode.SYS_STATUS_RATE_INVALID,
requested=hz,
control=control,
allowed=allowed,
)
)
return ExecutionStatusCode.FAILED
state.status_rate_hz = hz
logger.info("Status broadcast rate set to %g Hz", hz)
self.finish()
return ExecutionStatusCode.COMPLETED


@register_command(CmdType.CHECKPOINT)
class CheckpointCommand(MotionCommand[CheckpointCmd]):
"""Queue marker that sets state.last_checkpoint on execution.
Expand Down
Loading
Loading