Repository navigation
refactor(motors): one rustypot-backed SerialMotorsBus for Feetech and Dynamixel - #4672
Conversation
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
There was a problem hiding this comment.
🟡 Changes recommended
Optional-dependency tests will fail isolated CI tiers, and protocol-1 scanning remains incorrectly blocked.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Replaces vendor motor SDKs with a shared rustypot-backed transport and introduces automatic serial motor-family selection.
Changes:
- Adds
MotorTransport/RustypotTransportand centralizes serial I/O. - Adds the
SerialMotorsBusfactory and migrates robot/teleoperator consumers. - Reworks dependencies and tests around an in-memory transport.
File summaries
| File | Description |
|---|---|
uv.lock |
Replaces vendor SDK packages with rustypot. |
pyproject.toml |
Defines rustypot motor dependencies. |
docs/source/integrate_hardware.mdx |
Documents SerialMotorsBus. |
src/lerobot/motors/__init__.py |
Exports the bus factory. |
src/lerobot/motors/transport.py |
Adds the rustypot transport seam. |
src/lerobot/motors/motors_bus.py |
Centralizes transport I/O and family resolution. |
src/lerobot/motors/feetech/feetech.py |
Adapts Feetech behavior to raw transport bytes. |
src/lerobot/motors/dynamixel/dynamixel.py |
Adapts Dynamixel behavior to rustypot. |
src/lerobot/motors/dynamixel/tables.py |
Extracts the model-number address. |
src/lerobot/utils/import_utils.py |
Removes vendor SDK availability flags. |
src/lerobot/robots/so_follower/so_follower.py |
Uses automatic bus selection. |
src/lerobot/robots/omx_follower/omx_follower.py |
Uses automatic bus selection. |
src/lerobot/robots/lekiwi/lekiwi.py |
Uses automatic bus selection. |
src/lerobot/robots/koch_follower/koch_follower.py |
Uses automatic bus selection. |
src/lerobot/robots/hope_jr/hope_jr_arm.py |
Uses automatic bus selection. |
src/lerobot/robots/hope_jr/hope_jr_hand.py |
Retains explicit protocol-specific construction. |
src/lerobot/teleoperators/so_leader/so_leader.py |
Uses automatic bus selection. |
src/lerobot/teleoperators/openarm_mini/openarm_mini.py |
Uses automatic bus selection. |
src/lerobot/teleoperators/omx_leader/omx_leader.py |
Uses automatic bus selection. |
src/lerobot/teleoperators/koch_leader/koch_leader.py |
Uses automatic bus selection. |
tests/motors/test_motors_bus.py |
Tests shared I/O and factory behavior. |
tests/motors/test_feetech.py |
Tests Feetech-specific encoding and calibration. |
tests/motors/test_dynamixel.py |
Tests Dynamixel-specific behavior. |
tests/mocks/mock_motors_bus.py |
Adds an in-memory transport fake. |
tests/mocks/mock_feetech.py |
Removes packet-level Feetech mocks. |
tests/mocks/mock_dynamixel.py |
Removes packet-level Dynamixel mocks. |
tests/robots/test_so100_follower.py |
Patches the new bus factory. |
Review details
- Files reviewed: 26/27 changed files
- Comments generated: 6
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
e01585d to
f7be6b1
Compare
f7be6b1 to
b68a245
Compare
b68a245 to
c513b74
Compare
|
Community review Tested this branch on a real SO-101 (follower + leader, Jetson Orin NX, aarch64, Python 3.12, rustypot 1.10.0) and compared against Read-only, same arm and pose on both:
Through the real scripts, on the PR:
Everything above works on the SO-101 with this PR, no regression compared to main. |
|
Updated, in four commits on top of the reviewed ones:
The description lists every behaviour and API change. Each commit passes |
|
Hi, I ran into an intermittent calibration write failure on an SO-101 / Feetech STS3215 and was looking through the related motor-bus work when I found this refactor. On LeRobot v0.6.0, calibration would intermittently fail on persistent register writes with errors like: I saw this repeatedly enough that it prevented calibration from completing reliably. What was interesting was that after one of these failures, I immediately read the calibration registers back and found that the write reported as failed had actually been applied. At that point the range-limit state was roughly: ( So at least on the STS3215 I tested, this sequence appears possible: This leaves a partially applied calibration even though the failed write itself may already be stored on the servo. As a local workaround, I changed the calibration write path so that on a communication failure it reads the same register back before deciding that the write failed: try:
write(register, motor, value)
except ConnectionError:
readback = read(register, motor)
if readback == value:
# The write was applied; only the reply appears to have been lost.
continue
raiseWith that verification in place, I was able to complete calibration despite the intermittent missing status replies. I also tried writing the same register/value repeatedly, running I have also tested the current #4672 branch on the same STS3215 hardware, but I have not been able to reproduce the failure again so far. Since the original issue was intermittent, I don't know whether the rustypot backend changes its occurrence rate. However, looking at the current write path, my understanding is that a communication failure is retried by issuing the write operation again. Unless I am missing another verification mechanism, the semantic ambiguity still seems possible: after a lost status reply, the caller does not know whether the write failed or whether it succeeded and only the reply was lost. I also noticed that #4695 deliberately kept torque-register readback / write semantic changes out of scope, so I am not assuming this necessarily belongs in this PR. Would you prefer this kind of verification to be handled here in the new serial write path, in For persistent readable configuration registers, readback-after-communication-failure worked well as a practical workaround for me. I have only observed the write-applied / reply-lost behavior on Feetech STS3215 hardware; I have not tested the same failure mode on Dynamixel. |
|
Ran the bus merge on hardware: two more SO-101 arms (six STS3215 each, firmware 3.10, 1 Mbps), driven through
Not covered yet: calibration and teleoperation on these arms, and anything Dynamixel. |
Read/write asymmetry diagram, the self-perpetuating is_calibrated loop, EEPROM write-safety reasoning, the tests/motors silent-skip trap, why it is distinct from PR huggingface#4672, and the PR description.
Read/write asymmetry diagram, the self-perpetuating is_calibrated loop, EEPROM write-safety reasoning, the tests/motors silent-skip trap, why it is distinct from PR huggingface#4672, and the PR description.
The Feetech and Dynamixel buses reached the wire through the vendors' pure-Python SDKs, behind a layer shaped like them (port and packet handlers, group sync readers and writers), and carried their own control tables: addresses, sizes, sign bits, resolutions, baud rate codes and model numbers transcribed from the vendor manuals, plus the code applying them -- byte order, sign encoding, the ID sweep and its timeout. rustypot implements both protocols in Rust, releases the GIL for the whole transaction, and states and applies those facts itself. Each family now names its servo definitions in `_servos()`, the rustypot controller classes. The base bus resolves a model to one of them (rustypot spells XL330M288 where LeRobot writes xl330-m288) and a register name by lowering it, and on connect opens a `rustypot.Bus` with each motor's definition. Models of one family therefore share a port -- STS and SCS, XL430 and XL330 -- each read and written in its own byte order and sign encoding. Retries, sync reads on servos without Sync Read, and the bus scan are rustypot's as well. Feetech's `protocol_version` goes: STS and SCS differ in byte order, in whether they answer Sync Read and in which registers they have, and the definition knows all three. The handshake accepts any model number a definition covers, `scan_port()` sweeps the bus itself in place of `broadcast_ping()`, and `setup_motor()` reaches a new motor at whatever id it answers through the motor's own definition. `disconnect()` is safe on a closed bus and releases the port even when disabling torque fails. Gone: both tables.py, encoding_utils.py, the SDK-shaped protocols, the `MotorsBus` alias, and deepdiff from the motor extras. pyserial stays in them for `lerobot-find-port`. rustypot 1.10.0 is the first release that carries the servo metadata and the multi-definition `Bus`. The devices that relied on the old surface follow: HopeJR's hand drops `protocol_version` and sync-reads its SCS servos, OpenArm Mini asks the bus for the motor resolution, and LeKiwi clamps wheel speeds to the sign-magnitude range of Goal_Velocity. Tests replace the byte-level SDK mocks with an in-memory `rustypot.Bus` keyed by motor id and register name. The IO helpers shared by both families are tested once, on the base bus.
The install steps stop sending users to the vendor SDKs, the hardware guide lists the servos rustypot defines and points new models at rustypot rather than at control tables that no longer exist, and the API page documents SerialMotorsBus and both family buses.
rustypot 1.11.0 finds a servo model by name with its exact model number, states what differs between families (homing offset sign, operating mode values, factory baud rate, whether the EEPROM has a lock), turns the torque of a whole bus on or off, scans with a broadcast ping, and sets a new motor's id and baud rate. LeRobot's bus is about to rely on all of it. One bus class will drive both families, so there is one extra to point users at: serial-motors installs rustypot and pyserial. feetech and dynamixel stay, as the names the setup guides use, and now alias it.
FeetechMotorsBus and DynamixelMotorsBus had become a list of rustypot
classes plus a handful of facts about each family: which way the homing
offset goes, which operating mode value means position control, whether
the drive mode is a register, which acceleration and firmware registers
exist, and the Feetech lock. rustypot 1.11.0 states those facts in its
servo definitions, so SerialMotorsBus is now the one, concrete bus:
- each motor's model is looked up with rustypot.find_model, which gives
its exact model number and its definition. The handshake and the motor
search check that exact number again, so an SM8512BL set up as an
sts3215 is refused, though both share a control table;
- calibration reads and writes the homing offset where the register
exists (not on the SCS), compares the drive mode where it is a register
(Dynamixel) and applies it in software elsewhere (Feetech), and centres
a joint with the homing offset sign of the definition;
- the firmware check runs on the motors that report a firmware major and
minor version (Feetech); configure_motors writes the acceleration
registers a motor has; set_operating_mode("position") writes each
motor's own value for a mode named the same on every family;
- torque goes on or off through rustypot's Bus.set_torque, which tries
every motor, moves the Feetech lock with the torque, and reports the
motors that failed: one that does not answer no longer leaves the
motors after it under torque, and the error names it;
- setup_motor has rustypot change the id and the baud rate (torque off
and lock open first), with no reopening of the bus, and puts the port
back at the default rate even when that fails. The motor search tries
the default baud rate, then the factory one, then the rest in a fixed
order, with a broadcast ping where the servo answers one, which covers
a USB adapter's latency once. scan_port takes the model it looks for,
releases the port when a scan fails, and reports ids only;
- a status packet is waited for 50 ms, not a second, so one lost reply
costs a 30 Hz loop a tick (the SDKs waited about 35 to 50 ms).
FeetechMotorsBus and DynamixelMotorsBus stay for one release as
deprecated aliases (FeetechMotorsBus still accepts protocol_version), and
so do their OperatingMode and TorqueMode enums. A missing rustypot points
to the serial-motors extra. Resetting the calibration of an SCS no longer
writes a homing offset it does not have. On Dynamixel, is_calibrated no
longer compares the id stored in the calibration file, like Feetech.
The SO, Koch, OMX, LeKiwi, HopeJR and OpenArm Mini devices built a
FeetechMotorsBus or a DynamixelMotorsBus and wrote operating modes as the
value of a family's enum. They now build a SerialMotorsBus and name the
mode: set_operating_mode("position"), "extended_position", "velocity", or
"current_based_position" for the Dynamixel grippers. DriveMode comes from
lerobot.motors.
The hardware guide builds its example robot on SerialMotorsBus, names operating modes, and says what the definitions settle between families. The API page documents SerialMotorsBus and DriveMode, and mentions the two family classes as deprecated aliases.
…port With the torque and the motor setup handled by rustypot, _write, _sync_read and _sync_write each had a single caller: write, sync_read and sync_write now talk to the rustypot bus themselves. _read stays, shared by read and ping, and always raises; its raise_on_error and err_msg parameters, which only ping used, go, and ping catches the error instead of asking not to get one. The Feetech firmware check reads the major and minor versions with one sync read each rather than two reads per motor. scan_port goes: nothing in LeRobot calls it, and rustypot's Bus.scan and Bus.broadcast_scan do the same for anyone who needs it. The tests that went through the private helpers now go through read, write, sync_read and sync_write against the in-memory bus.
…t-up motor setup_motor started its cleanup only after finding the motor. The search walks the baud rates, so a motor that was not there left the port on the last rate tried, and anything done next on the same bus went unanswered. The search now runs inside the try whose finally puts the port back at the default rate. A motor answers the id and baud rate writes before they take effect, and nothing checked that they did. setup_motor now reads the model number back at the new id and the default baud rate, and raises if the motor does not answer there with the model it is configured as.
… errors, document the bus - set_operating_mode() and set_half_turn_homings() check every selected motor before writing any register, so an unsupported motor leaves no partial configuration. - sync_read() raises on a motor's error status, as read() does. - Register names normalise whatever their case; set_baudrate() and ping() need a connected bus; resolution() raises for a model that counts no steps; the firmware check compares the motors of each servo definition on their own. - Each model is looked up once, and the bus opens rustypot's Bus through _open_bus() and reaches it, typed, through _connected_bus. - Errors name motors by motor ID; docstrings follow the documentation format and its cross-references. The hardware tutorial and the motor API page gain the serial-motors extra, the SCS limits, a migration note and the DriveMode members. - Tests cover write and sync-read error statuses, sync writes no motor answers, calibrated commands and calibration mismatches of each family, through bus helpers shared by the family suites.
A status byte also carries conditions a motor reports while it carries the access out: a Feetech status only reports alarms (input voltage, temperature, current, load), and on protocol 2 bit 7 is the alert of a latched hardware fault. Raising on them stopped a control loop on a gripper squeezing an object, and made ping() miss an overloaded motor. read(), write(), sync_read() and ping() now fail only when the status says the motor refused the access: an error number on protocol 2. The protocol-1 servos LeRobot drives are Feetech ones, which never refuse through their status.
…cess rustypot 1.13.1's ServoDefinition.refuses tells a refusal from an alarm for every servo it defines, so the bus no longer keeps its own copy of the rule, which assumed every protocol-1 servo is a Feetech one. The serial-motors extra now needs rustypot 1.13.1, and the tutorial links rustypot's servo guide at that version.
ecc1b54 to
2eea94e
Compare
s1lent4gnt
left a comment
There was a problem hiding this comment.
Thanks @nicolas-rabault, this is an exciting step for LeRobot. Consolidating Feetech and Dynamixel under SerialMotorsBus eliminates duplicate SDK code and control tables by offloading that logic to rustypot. It sets a much cleaner foundation for our current motor support and future arm integrations. Excited to see this land on main, approved, great work!
…ypot refactor Upstream (huggingface#4672) replaced the SDK-based SerialMotorsBus with a rustypot-backed one and dropped motors/encoding_utils.py. rustypot does not speak the LewanSoul protocol, so keep the previous bus base inside the lewansol package for the LX-16A driver used by Rebearm. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
What
Replaces the vendor Feetech and Dynamixel Python SDKs with rustypot (>= 1.13.1), removes the control tables LeRobot carried for them, and merges the two family buses into one
SerialMotorsBus.feetech-servo-sdkanddynamixel-sdkare pure-Python implementations of the two serial protocols. rustypot implements both in Rust behind pyo3 bindings: framing, checksums and packet parsing run in compiled code, and the GIL is released for the whole transaction. It also states the facts LeRobot transcribed by hand from the vendor manuals (register addresses and sizes, byte order, sign encoding, resolution, baud-rate codes, model numbers), and since 1.11.0 the ones that told the two families apart: the homing offset sign, the operating mode values, whether the drive mode is a register, the Feetech EEPROM lock, and since 1.13.1 which status bits mean a refused access. So bothtables.pyand the per-family bus classes go.How
Each motor's model (
sts3215,xl330-m288, ...) is looked up withrustypot.find_model, which returns its exact model number and its servo definition. On connect, the bus opens arustypot.Buswith each motor's definition, so models share a port (STS and SCS, XL430 and XL330), each read and written with its own layout, byte order and sign encoding.What used to differ between
FeetechMotorsBusandDynamixelMotorsBusnow comes from the definition:homing_offset_signset_operating_mode("position")Drive_ModeregisterFirmware_Major_Versionexistsconfigure_motorsaccelerationsMaximum_Acceleration(STS),AccelerationBus.set_torqueWhat stays LeRobot's: normalisation, the calibration procedure, the recommended settings (
configure_motors), the operating mode each robot uses.Behaviour and API changes
feetechanddynamixelinstallrustypotinstead of the vendor SDKs and no longer pulldeepdiff. They now alias a newserial-motorsextra (rustypot +pyserial, whichlerobot-find-portneeds); a missing rustypot points to it.FeetechMotorsBusandDynamixelMotorsBus, now aliases ofSerialMotorsBusthat emit aDeprecationWarning;FeetechMotorsBus(protocol_version=...), accepted and ignored; theirOperatingModeandTorqueModeenums. UseSerialMotorsBus(exported fromlerobot.motors) andbus.set_operating_mode("position").SerialMotorsBus.set_operating_mode(mode, motors=None), withposition,velocityandpwmon both families,extended_position,current_based_positionandcurrenton Dynamixel,stepon Feetech;DriveModeinlerobot.motors.lerobot.motors.{feetech,dynamixel}.tablesand their star re-exports,encoding_utils, theMotorsBusalias, themodel_*_tableattributes (bus.resolution(model)gives the encoder resolution),models,apply_drive_mode,set_timeout(),get_baudrate(),broadcast_ping(),scan_port()(nothing in LeRobot called it; rustypot'sBus.scanandBus.broadcast_scando the same), the SDK objects (port_handler,packet_handler,sync_reader,sync_writer) and thePortHandler,PacketHandler,GroupSyncReadandGroupSyncWriteprotocols ofmotors_bus.py.setup_motor()check the exact model number of the configured model: an SM8512BL set assts3215is refused, although both share a control table.disable_torque()andenable_torque()go through rustypot, which attempts every selected motor: failures are reported in aConnectionErrornaming them, and a motor that does not answer may stay under torque. On a closed bus they raiseDeviceNotConnectedError. The Feetech lock still follows the torque.disconnect()costs 0.3 s.set_operating_mode()andset_half_turn_homings()check every selected motor before writing any register.read(),write()andsync_read()raise aRuntimeErrorwhen a motor refuses the access, as rustypot'sServoDefinition.refusestells it (an error number on Dynamixel), not on an alarm it reports while carrying it out: a Feetech status only reports alarms, and the alert bit of a Dynamixel is a latched hardware fault. Error messages name motors by motor ID. Register names are normalised whatever their case.set_baudrate()andping()raiseDeviceNotConnectedErroron a closed bus.main. A Feetech homing offset that does not fit its sign-magnitude register (2048, for a joint held at position 4095) is clamped to 2047, the bound coming from the register's sign bit in the definition. TheAcceleration_Multipliername fixed in fix(motors): remove trailing space from Feetech Acceleration_Multiplier name #4926 now comes from rustypot, andtest_control_tables.pyno longer checks the removed tables.setup_motor()tries the default baud rate, then the factory one, then the rest in a fixed order, has rustypot write the ID and the baud rate (torque off and lock open first), puts the port back at the default rate even when the search or the writes fail, and reads the model number back at the new ID and the default rate, raising if the motor does not answer there as the model it is configured as.ping()readsModel_Numberinstead of sending a ping instruction.Present_Velocity,Present_LoadandGoal_Velocityof the SCS0009 are decoded with their sign bit (e.g. -232 instead of the raw 33 000), as they already were on STS servos. Resetting an SCS calibration no longer writes a homing offset it does not have.is_calibratedno longer compares the ID stored in the calibration file, like Feetech.disconnect()is safe on a closed bus and releases the port even when disabling torque fails.SerialMotorsBusand names its operating modes. HopeJR's hand dropsprotocol_versionand reads its SCS servos withsync_read. OpenArm Mini asks the bus for the motor resolution. LeKiwi clamps wheel speeds to ±32767, the sign-magnitude range ofGoal_Velocity.Tests
The byte-level SDK mocks are replaced by an in-memory
rustypot.Bus(MockBus) keyed by motor ID and register name. Register access and its error statuses, connection handling, torque, scanning and setup are tested once, onSerialMotorsBuswith dummy definitions. The family files run the real rustypot definitions: Feetech (STS and SCS sharing a bus, the SCS without a homing offset, the acceleration registers, the firmware check, the drive mode applied in software to reads and commands, the STS3215 Phase quirk, an SM8512BL refused as an sts3215, the deprecated alias) and Dynamixel (two definitions sharing a bus, the homing offset sign, the operating mode values, the drive mode left to the register, the calibration mismatches,configure_motors, the deprecated alias).tests/motors,tests/robots,tests/teleoperatorsandtests/test_control_robot.py: 321 passed at 2eea94e, 3 skipped (they need onnx, the Unitree SDK and Damiao hardware).Hardware
main, a 47-minute and a 2-minute teleop at 30 fps with no communication error, a lost reply costing 52 ms. A community review (above) found no regression againstmainon an SO-101 driven from a Jetson Orin NX, includinglerobot-record.python lerobot/scripts/eval.py \ -p lerobot/diffusion_pusht \ eval.n_episodes=10 \ eval.batch_size=10on apple silicon Mac #143 and Override pretrained model config #147.read_calibration(identical to the registers read through rustypot directly),sync_readat p50 1.00 ms / p99 1.09 to 1.22 ms,disable_torquein about 5 ms,setup_motorrewriting a motor's own id and rate and reading its model back, and a failed motor search leaving the port at 1 Mbps (see the comment below).is_calibratedand a 20 s teleoperation at 60 Hz.