Compare commits

...

2 Commits

Author SHA1 Message Date
Steven Palma 0d788abd85 chore(robots): drive mode calibration gripper 2026-07-29 17:49:41 +02:00
johnnynunez 7b78e751a6 fix(calibration): detect inverted gripper drive_mode on SO follower/leader calibration
The SO follower/leader calibration hardcoded drive_mode=0 for all motors.
When the gripper motor is mounted mirrored relative to the other arm's
(raw position increases when closing instead of decreasing), the
0=closed/100=open normalization convention flips: a 'closed' (0%)
command from the leader unnormalizes to the follower's fully-open hard
stop, slamming the gripper open and triggering the Feetech overload
protection (torque off until power cycle).

FeetechMotorsBus already supports per-motor inversion via drive_mode
(apply_drive_mode=True); calibration just never set it. This adds a
step after range recording that asks the user to fully close the
gripper, reads the raw position, and sets drive_mode=1 when the closed
position sits at range_max. No calibration file format change.

Verified against the exact calibration values reported in #3942 using
the real FeetechMotorsBus normalization: with drive_mode=0 a 0% goal
maps to raw 2035 (open, bug reproduced); with drive_mode=1 it maps to
raw 3528 (closed, expected).

Fixes #3942
2026-07-29 17:34:54 +02:00
3 changed files with 78 additions and 2 deletions
+15 -1
View File
@@ -142,11 +142,25 @@ class SOFollower(Robot):
range_mins[full_turn_motor] = 0
range_maxes[full_turn_motor] = 4095
drive_modes = dict.fromkeys(self.bus.motors, 0)
input(f"Fully close the gripper of {self} and press ENTER....")
gripper_closed_pos = self.bus.read(
"Present_Position", "gripper", normalize=False, num_retry=self.config.num_read_retries
)
distance_to_min = abs(gripper_closed_pos - range_mins["gripper"])
distance_to_max = abs(gripper_closed_pos - range_maxes["gripper"])
if min(distance_to_min, distance_to_max) > (range_maxes["gripper"] - range_mins["gripper"]) * 0.2:
raise ValueError("Gripper is not fully closed. Run calibration again.")
drive_modes["gripper"] = int(distance_to_max < distance_to_min)
if drive_modes["gripper"]:
logger.info("Gripper motor is inverted, setting drive_mode=1 to compensate.")
self.calibration = {}
for motor, m in self.bus.motors.items():
self.calibration[motor] = MotorCalibration(
id=m.id,
drive_mode=0,
drive_mode=drive_modes[motor],
homing_offset=homing_offsets[motor],
range_min=range_mins[motor],
range_max=range_maxes[motor],
@@ -110,11 +110,25 @@ class SOLeader(Teleoperator):
range_mins[full_turn_motor] = 0
range_maxes[full_turn_motor] = 4095
drive_modes = dict.fromkeys(self.bus.motors, 0)
input(f"Fully close the gripper of {self} and press ENTER....")
gripper_closed_pos = self.bus.read(
"Present_Position", "gripper", normalize=False, num_retry=self.config.num_read_retries
)
distance_to_min = abs(gripper_closed_pos - range_mins["gripper"])
distance_to_max = abs(gripper_closed_pos - range_maxes["gripper"])
if min(distance_to_min, distance_to_max) > (range_maxes["gripper"] - range_mins["gripper"]) * 0.2:
raise ValueError("Gripper is not fully closed. Run calibration again.")
drive_modes["gripper"] = int(distance_to_max < distance_to_min)
if drive_modes["gripper"]:
logger.info("Gripper motor is inverted, setting drive_mode=1 to compensate.")
self.calibration = {}
for motor, m in self.bus.motors.items():
self.calibration[motor] = MotorCalibration(
id=m.id,
drive_mode=0,
drive_mode=drive_modes[motor],
homing_offset=homing_offsets[motor],
range_min=range_mins[motor],
range_max=range_maxes[motor],
+48
View File
@@ -149,3 +149,51 @@ def test_configure_writes_position_pid_coefficients():
bus_mock.write.assert_any_call("P_Coefficient", "shoulder_pan", 32)
bus_mock.write.assert_any_call("I_Coefficient", "shoulder_pan", 1)
bus_mock.write.assert_any_call("D_Coefficient", "shoulder_pan", 16)
@pytest.mark.parametrize(
"gripper_closed_pos, expected_drive_mode",
[
(2035, 0), # closed position at range_min -> raw increases when opening -> not inverted
(3528, 1), # closed position at range_max -> raw increases when closing -> inverted
(2781, None), # not near either end stop -> unsafe to infer
],
)
def test_calibrate_detects_gripper_drive_mode(follower, gripper_closed_pos, expected_drive_mode):
"""Regression test for #3942: the follower gripper can be mounted mirrored with respect to the
leader's, in which case its raw position increases when closing. Calibration must detect this
and set drive_mode=1 so that normalized values follow the 0=closed/100=open convention."""
follower.connect()
motors = list(follower.bus.motors)
follower.bus.set_half_turn_homings.return_value = dict.fromkeys(motors, 0)
follower.bus.record_ranges_of_motion.return_value = (
dict.fromkeys(motors, 2035),
dict.fromkeys(motors, 3528),
)
follower.bus.read.return_value = gripper_closed_pos
with (
patch("builtins.input", return_value=""),
patch.object(type(follower), "_save_calibration", lambda self: None),
):
follower.calibration = {}
if expected_drive_mode is None:
with pytest.raises(ValueError, match="Gripper is not fully closed"):
follower.calibrate()
else:
follower.calibrate()
follower.bus.read.assert_called_with(
"Present_Position",
"gripper",
normalize=False,
num_retry=follower.config.num_read_retries,
)
if expected_drive_mode is None:
return
assert follower.calibration["gripper"].drive_mode == expected_drive_mode
for motor in motors:
if motor != "gripper":
assert follower.calibration[motor].drive_mode == 0