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
38 changes: 37 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,29 @@ 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)

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

Expand Down
4 changes: 4 additions & 0 deletions parol6/client/dry_run_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,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
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
20 changes: 20 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,24 @@ 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,
control_hz=1.0 / max(cfg.INTERVAL_S, 1e-9),
)
)


@register_command(CmdType.PING)
class PingCommand(QueryCommand[PingCmd]):
"""Respond to ping requests."""
Expand Down
44 changes: 44 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
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,46 @@ 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(
str(control // n) for n in range(1, control + 1) if control % n == 0
)
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
12 changes: 11 additions & 1 deletion parol6/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,23 @@ def _trace(self, msg, *args, **kwargs):
STATUS_RATE_HZ: float = float(os.getenv("PAROL6_STATUS_RATE_HZ", "50"))
STATUS_STALE_S: float = float(os.getenv("PAROL6_STATUS_STALE_S", "0.5"))


def status_broadcast_interval(hz: float) -> int:
"""Control ticks between status broadcasts at *hz*.

Derived on demand rather than fixed at import: the rate is a session
knob (SET_STATUS_RATE), and a constant computed from the boot rate would
be a second answer to the question of how often status goes out.
"""
return max(1, int(CONTROL_RATE_HZ) // int(hz))


# Validate STATUS_RATE_HZ divides evenly into CONTROL_RATE_HZ for polling
if int(CONTROL_RATE_HZ) % int(STATUS_RATE_HZ) != 0:
raise ValueError(
f"STATUS_RATE_HZ ({STATUS_RATE_HZ}) must divide evenly into "
f"CONTROL_RATE_HZ ({CONTROL_RATE_HZ})"
)
STATUS_BROADCAST_INTERVAL: int = int(CONTROL_RATE_HZ) // int(STATUS_RATE_HZ)

# Max ticks to hold MOVE at trajectory endpoint waiting for Position_in to converge.
# At 100Hz control rate, 20 ticks = 200ms. If the robot hasn't reached the target
Expand Down
Loading
Loading