Skip to content

Add the PAA KX2 arm driver - #880

Draft
rickwierenga wants to merge 3 commits into
mainfrom
kx2-backend
Draft

rickwierenga wants to merge 3 commits into
mainfrom
kx2-backend

Conversation

@rickwierenga

@rickwierenga rickwierenga commented Feb 7, 2026 •

Copy link
Copy Markdown
Member

Adds a direct asynchronous KX2 API for calibrated joint and Cartesian motion, with the physical servo gripper exposed as kx2.gripper and an independently usable barcode reader.

Uses a pylabrobot.io.CANopen transport and PLR serial I/O, with local kinematics, scoped register configuration, cancellation cleanup, and setup guides.

@intrepid-pasha

Copy link
Copy Markdown

Hi @rickwierenga! First off thanks for the incredible work, we are big fans :).
Decided to test this PR on Hardware.
Pulling this branch down to try out for a KX-2 +
CS-10 plate-hotel setup, and we got it talking to a real robot today
on macOS 26.3 / Apple Silicon via PCBUSB (the MacCAN community
build that exposes PEAK's PCAN-Basic API on macOS). The interface="pcan"
default works as-is — no Mac-specific code changes needed.

Tested at HEAD dd6434b. Three small deltas to flag — the first two
look like things missed on the #1044 rebase pass (you already
re-parented KX2ArmBackend onto OrientableGripperArmBackend and
landed c1368ff, so most of that work is done); the third is an
over-cautious guard that's already obsolete given how the rest of the
stack is structured. Sharing in case any of these are useful — happy
to take them or leave them.

1. OrientableArm → OrientableGripperArm rename in kx2.py

KX2ArmBackend is now correctly subclassing OrientableGripperArmBackend
(👍), but pylabrobot/paa/kx2/kx2.py still imports the old OrientableArm
name and constructs it on self.arm. Every other consumer in the tree
already moved over, so I think this is just a missed file:

-from pylabrobot.capabilities.arms.orientable_arm import OrientableArm
+from pylabrobot.capabilities.arms.orientable_arm import OrientableGripperArm
@@
-    self.arm = OrientableArm(backend=backend, reference_resource=self.reference)
+    self.arm = OrientableGripperArm(backend=backend, reference_resource=self.reference)

2. KX2ArmBackend missing the new abstract members from
OrientableGripperArmBackend

OrientableGripperArmBackend is now abstract over move_gripper,
min_gripper_width, max_gripper_width — KX2ArmBackend extends
that base but doesn't implement those three, so today it's an abstract
class and KX2() can't instantiate. The existing open_gripper /
close_gripper on KX2ArmBackend (lines ~1207, ~1221) are still
referenced by the internal pick-up / drop sequences, so the minimal
shim is just to add the three new members and have move_gripper
dispatch into the existing two. Inserted right after is_gripper_closed:

@property
def min_gripper_width(self) -> Optional[float]:
    if self._config is None:
        return None
    axis_cfg = self._config.axes.get(Axis.SERVO_GRIPPER)
    return None if axis_cfg is None else axis_cfg.min_travel

@property
def max_gripper_width(self) -> Optional[float]:
    if self._config is None:
        return None
    axis_cfg = self._config.axes.get(Axis.SERVO_GRIPPER)
    return None if axis_cfg is None else axis_cfg.max_travel

async def move_gripper(
    self,
    width: float,
    force_sensing: bool = False,
    backend_params: Optional[BackendParams] = None,
) -> None:
    if force_sensing:
        await self.close_gripper(gripper_width=width, backend_params=backend_params)
    else:
        await self.open_gripper(gripper_width=width, backend_params=backend_params)

The properties intentionally read self._config (the Optional
form) instead of going through the _cfg accessor — that way they
correctly return None pre-setup(), which the abstract docstring
permits ("None if the gripper cannot be commanded …").

A note while you're in this area: the capability-level wrappers on
OrientableGripperArm itself (arm.open_gripper() / arm.close_gripper())
also lost their gripper_width= parameter — they now always drive to
max_/min_gripper_width. If you decide to push the same simplification
into KX2ArmBackend.open_gripper / close_gripper, we'd be happy to
adapt our test scripts; right now we route width-specific calls through
arm.move_gripper(width=…, force_sensing=…) which works fine.

3. has_rail=True is rejected up front, but the rest of the stack
already tolerates it

Our KX-2 has the rail axis on the bus (node 5). has_rail=False trips
node-list mismatch in KX2Driver.setup(); has_rail=True is hard-rejected
in KX2Driver.__init__. Reading the rest of the code, though:

  • KX2ArmBackend._on_setup enables only MOTION_AXES = (SHOULDER, Z, ELBOW, WRIST) — the rail motor is never enabled.
  • KX2ArmBackend._read_config already does its own rail detection from
    driver.node_id_list and warnings.warns "Rails has not been
    tested", then continues.
  • request_joint_position hard-codes the four motion axes plus the
    servo gripper; rail position is silently dropped.
  • kinematics.py doesn't reference KX2Config.robot_on_rail anywhere,
    so FK/IK/plan_joint_move are rail-blind.

So has_rail=True already means "rail on bus, dormant, ignored". The
guard in __init__ is the only thing in the way. Demoting it to a
warning let us run end-to-end on the real robot with the rail powered
up:

-    if has_rail or not has_servo_gripper:
-      raise NotImplementedError(
-        "KX2 has only been tested with the default 4-axis arm + servo "
-        "gripper topology (has_rail=False, has_servo_gripper=True). "
-        ...
-      )
+    if not has_servo_gripper:
+      raise NotImplementedError(
+        "KX2 has only been tested with the default servo-gripper "
+        "topology (has_servo_gripper=True). The gripper-less path has "
+        "shim code but the setup / homing layer needs work — see "
+        "KX2ArmBackend._on_setup and servo_gripper_initialize."
+      )
+    if has_rail:
+      warnings.warn(
+        "has_rail=True: rail (node 5) is included in CAN bus discovery "
+        "and PDO remapping, but the rail motor is NOT enabled and "
+        "kinematics / motion code paths ignore it. Rail position is "
+        "not queried. Verify your task does not need to move the rail."
+      )

(import warnings added near the other stdlib imports.) All 148 KX-2
tests still pass with this. Happy to flip back to NotImplementedError
if you prefer to keep the rail topology gated until full rail support
lands — totally fair call.

What's working on real hardware as of today

  • Bus brought up over PCAN-USB → PCBUSB → python-can → canopen on
    macOS 26.3 / arm64. interface="pcan" default needed no changes.
  • All six drives discovered ([1, 2, 3, 4, 5, 6]), motion axes enabled,
    rail dormant.
  • servo_gripper_initialize ran the full hard-stop + index-pulse home
    routine successfully on the first try.
  • request_gripper_pose, request_joint_position, motion_limits,
    get_estop_state all returning sensible values from the drives.
  • arm.move_gripper(width=…, force_sensing=…) reaches commanded width.
  • First closed-loop arm-axis motion: Z bump +30 mm at 30 mm/s.
    Arrived within 0.025 mm of target (tol 0.5 mm). 🎉

Multi-axis moves and rail-axis support are next on our side. Happy to
open a PR against your branch with these three patches if that's
useful, otherwise feel free to fold them in directly — really no
preference. Thanks again for the work on this branch; the
CANopen-direct approach is exactly what we needed, and it's
surprisingly close to working out of the box.

@rickwierenga rickwierenga changed the title add kx2 backend Add the PAA KX2 arm driver Nov 14, 2026
@rickwierenga
rickwierenga changed the base branch from v1b1 to main November 14, 2026 04:30

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants