Skip to content

Commit f4b8348

Browse files
authored
Feat/clean can bus (#3526)
* change timeout for handshake * enforce last state read when querry * change import order * fix(motors): flush stale robstride RX and harden feedback drain * robstride: remove redundant timeout and max_messages casts * bugfix + %-style * update exception catch
1 parent dfdc48a commit f4b8348

2 files changed

Lines changed: 102 additions & 19 deletions

File tree

src/lerobot/motors/robstride/robstride.py

Lines changed: 100 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
CAN_CMD_SET_ZERO,
4444
DEFAULT_BAUDRATE,
4545
DEFAULT_TIMEOUT_MS,
46+
HANDSHAKE_TIMEOUT_S,
4647
MODEL_RESOLUTION,
4748
MOTOR_LIMIT_PARAMS,
4849
NORMALIZED_DATA,
@@ -215,14 +216,16 @@ def connect(self, handshake: bool = True) -> None:
215216
self._is_connected = False
216217
raise ConnectionError(f"Failed to connect to CAN bus: {e}") from e
217218

218-
def _query_status_via_clear_fault(self, motor: NameOrID) -> tuple[bool, can.Message | None]:
219+
def _query_status_via_clear_fault(
220+
self, motor: NameOrID, timeout: float = RUNNING_TIMEOUT
221+
) -> tuple[bool, can.Message | None]:
219222
motor_name = self._get_motor_name(motor)
220223
motor_id = self._get_motor_id(motor_name)
221224
recv_id = self._get_motor_recv_id(motor_name)
222225
data = [0xFF] * 7 + [CAN_CMD_CLEAR_FAULT]
223226
msg = can.Message(arbitration_id=motor_id, data=data, is_extended_id=False)
224227
self._bus().send(msg)
225-
return self._recv_status_via_clear_fault(expected_recv_id=recv_id)
228+
return self._recv_status_via_clear_fault(expected_recv_id=recv_id, timeout=timeout)
226229

227230
def _recv_status_via_clear_fault(
228231
self, expected_recv_id: int | None = None, timeout: float = RUNNING_TIMEOUT
@@ -280,7 +283,7 @@ def _handshake(self) -> None:
280283
faulted_motors = []
281284

282285
for motor_name in self.motors:
283-
has_fault, msg = self._query_status_via_clear_fault(motor_name)
286+
has_fault, msg = self._query_status_via_clear_fault(motor_name, timeout=HANDSHAKE_TIMEOUT_S)
284287
if msg is None:
285288
missing_motors.append(motor_name)
286289
elif has_fault:
@@ -505,6 +508,87 @@ def _recv_all_responses(
505508

506509
return responses
507510

511+
def _recv_all_messages_until_quiet(
512+
self,
513+
*,
514+
timeout: float = RUNNING_TIMEOUT,
515+
max_messages: int = 4096,
516+
) -> list[can.Message]:
517+
"""
518+
Receive frames until the bus goes quiet.
519+
520+
Args:
521+
timeout: Poll timeout used for each recv() call. Collection stops
522+
when one recv() times out (quiet gap).
523+
max_messages: Safety cap to prevent unbounded loops.
524+
"""
525+
out: list[can.Message] = []
526+
max_messages = max(1, max_messages)
527+
timeout = max(0.0, timeout)
528+
529+
try:
530+
while len(out) < max_messages:
531+
msg = self._bus().recv(timeout=timeout)
532+
if msg is None:
533+
break
534+
out.append(msg)
535+
except (can.CanError, OSError) as e:
536+
logger.debug(f"Error draining CAN RX queue on {self.port}: {e}")
537+
538+
return out
539+
540+
def _process_feedback_messages(self, messages: list[can.Message]) -> set[int]:
541+
"""
542+
Decode all received feedback frames and update cached motor states.
543+
544+
Returns:
545+
Set of payload recv_ids that were successfully mapped to motors.
546+
"""
547+
processed_recv_ids: set[int] = set()
548+
for msg in messages:
549+
if len(msg.data) < 1:
550+
logger.debug(
551+
f"Dropping short CAN frame on {self.port} "
552+
f"(arb=0x{int(msg.arbitration_id):02X}, data={bytes(msg.data).hex()})"
553+
)
554+
continue
555+
556+
recv_id = int(msg.data[0])
557+
motor_name = self._recv_id_to_motor.get(recv_id)
558+
if motor_name is None:
559+
logger.debug(
560+
f"Unmapped CAN frame on {self.port} "
561+
f"(arb=0x{int(msg.arbitration_id):02X}, recv_id=0x{recv_id:02X}, data={bytes(msg.data).hex()})"
562+
)
563+
continue
564+
565+
self._process_response(motor_name, msg)
566+
processed_recv_ids.add(recv_id)
567+
568+
return processed_recv_ids
569+
570+
def flush_rx_queue(self, poll_timeout_s: float = 0.0005, max_messages: int = 4096) -> int:
571+
"""
572+
Drain pending RX frames from the CAN interface.
573+
574+
This is used by higher-level controllers to drop stale feedback before issuing
575+
a fresh read cycle, so subsequent state reads are based on most recent replies.
576+
It should also be called once when a controller instance is created/connected,
577+
to clear residual frames left on the interface from previous sessions.
578+
"""
579+
drained = 0
580+
poll_timeout_s = max(0.0, poll_timeout_s)
581+
max_messages = max(1, max_messages)
582+
try:
583+
while drained < max_messages:
584+
msg = self._bus().recv(timeout=poll_timeout_s)
585+
if msg is None:
586+
break
587+
drained += 1
588+
except (can.CanError, OSError) as e:
589+
logger.debug(f"Failed to flush CAN RX queue on {self.port}: {e}")
590+
return drained
591+
508592
def _speed_control(
509593
self,
510594
motor: NameOrID,
@@ -644,11 +728,14 @@ def _mit_control_batch(
644728
msg = can.Message(arbitration_id=motor_id, data=data, is_extended_id=False)
645729
self._bus().send(msg)
646730
recv_id_to_motor[self._get_motor_recv_id(motor)] = motor_name
731+
# Read every feedback frame until RX goes quiet, then decode all of them.
732+
# This avoids dropping useful frames when responses from different motors interleave.
733+
messages = self._recv_all_messages_until_quiet()
734+
processed_recv_ids = self._process_feedback_messages(messages)
647735

648-
responses = self._recv_all_responses(list(recv_id_to_motor.keys()), timeout=RUNNING_TIMEOUT)
649736
for recv_id, motor_name in recv_id_to_motor.items():
650-
if msg := responses.get(recv_id):
651-
self._process_response(motor_name, msg)
737+
if recv_id not in processed_recv_ids:
738+
logger.warning(f"Packet drop: {motor_name} (ID: 0x{recv_id:02X}). Using last known state.")
652739

653740
def _float_to_uint(self, x: float, x_min: float, x_max: float, bits: int) -> int:
654741
"""Convert float to unsigned integer for CAN transmission."""
@@ -711,7 +798,10 @@ def _process_response(self, motor: str, msg: can.Message) -> None:
711798
try:
712799
self._decode_motor_state(msg.data)
713800
except Exception as e:
714-
logger.warning(f"Failed to decode response from {motor}: {e}")
801+
logger.warning(
802+
f"Failed to decode response from {motor} "
803+
f"(arb=0x{int(msg.arbitration_id):02X}, data={bytes(msg.data).hex()}): {e}"
804+
)
715805

716806
def _get_cached_value(self, motor: str, data_name: str) -> Value:
717807
"""Retrieve a specific value from the state cache."""
@@ -848,20 +938,12 @@ def _batch_refresh(self, motors: list[str]) -> None:
848938
self._bus().send(msg)
849939
updated_motors.append(motor)
850940

851-
expected_recv_ids = [self._get_motor_recv_id(motor) for motor in updated_motors]
852-
responses = self._recv_all_responses(expected_recv_ids, timeout=RUNNING_TIMEOUT)
853-
854-
for response in responses.values():
855-
payload_motor_name = self._recv_id_to_motor.get(response.data[0])
856-
if payload_motor_name is not None:
857-
self._process_response(payload_motor_name, response)
858-
else:
859-
# Fallback: still attempt to decode based on payload byte0 mapping.
860-
self._decode_motor_state(response.data)
941+
messages = self._recv_all_messages_until_quiet()
942+
processed_recv_ids = self._process_feedback_messages(messages)
861943

862944
for motor in updated_motors:
863945
recv_id = self._get_motor_recv_id(motor)
864-
if recv_id not in responses:
946+
if recv_id not in processed_recv_ids:
865947
logger.warning(f"Packet drop: {motor} (ID: 0x{recv_id:02X}). Using last known state.")
866948

867949
def read_calibration(self) -> dict[str, MotorCalibration]:

src/lerobot/motors/robstride/tables.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ class ControlMode(IntEnum):
114114
CAN_PARAM_ID = 0x7FF
115115

116116

117-
RUNNING_TIMEOUT = 0.001
117+
RUNNING_TIMEOUT = 0.003
118+
HANDSHAKE_TIMEOUT_S = 0.05
118119
PARAM_TIMEOUT = 0.01
119120

120121
STATE_CACHE_TTL_S = 0.02

0 commit comments

Comments
 (0)