diff --git a/lelab/calibrate.py b/lelab/calibrate.py index bae5b78b..450e7f44 100644 --- a/lelab/calibrate.py +++ b/lelab/calibrate.py @@ -38,6 +38,8 @@ ) from lerobot.utils.utils import init_logging +from .utils.devices import safe_disconnect_device + logger = logging.getLogger(__name__) @@ -523,14 +525,16 @@ def _cleanup_and_finish(self, message: str, status: str = "completed"): self._update_status(calibration_active=False, status=status, message=message) def _cleanup_device(self): - """Clean up device connection""" - try: - if self.device: - logger.info("Disconnecting device...") - self.device.disconnect() - self.device = None - except Exception as e: - logger.error(f"Error disconnecting device: {e}") + """Clean up device connection. + + Uses safe_disconnect_device so a failed disconnect (flaky USB/serial) + force-releases the port/cameras instead of leaving the device busy and + blocking the next calibration/teleop/record run. + """ + if self.device: + logger.info("Disconnecting device...") + safe_disconnect_device(self.device, logger, context="calibration cleanup") + self.device = None # Global calibration manager instance diff --git a/lelab/record.py b/lelab/record.py index 3a086293..f3a54b09 100644 --- a/lelab/record.py +++ b/lelab/record.py @@ -31,6 +31,7 @@ from lerobot.teleoperators.so_leader import SO101LeaderConfig from .utils.config import setup_calibration_files, with_lelab_tag +from .utils.devices import safe_disconnect_device logger = logging.getLogger(__name__) @@ -862,9 +863,12 @@ def record_with_web_events(cfg: RecordConfig, web_events: dict) -> LeRobotDatase log_say("Stop recording", cfg.play_sounds, blocking=True) finally: - robot.disconnect() + # safe_disconnect_device force-releases the serial port / cameras if a + # normal disconnect fails, so a flaky teardown can't leave the device + # busy and block the next recording session (see issue #50). + safe_disconnect_device(robot, logger, context="recording cleanup") if teleop: - teleop.disconnect() + safe_disconnect_device(teleop, logger, context="recording cleanup") if cfg.dataset.push_to_hub: dataset.push_to_hub(tags=cfg.dataset.tags, private=cfg.dataset.private) diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py index b69364fb..e19a78aa 100644 --- a/tests/test_calibrate.py +++ b/tests/test_calibrate.py @@ -72,3 +72,32 @@ def test_calibration_manager_rejects_double_start_via_message() -> None: ) assert result.get("success") is False assert "already" in result.get("message", "").lower() + + +def test_cleanup_device_force_releases_and_clears_when_disconnect_fails() -> None: + """A failed device.disconnect() must still force-close the port and clear the + device handle — otherwise the COM port stays busy and blocks the next run.""" + from lelab.calibrate import CalibrationManager + + class PortHandler: + def __init__(self) -> None: + self.closed = False + + def closePort(self) -> None: # noqa: N802 - mirrors LeRobot port handler API + self.closed = True + + class Device: + def __init__(self) -> None: + self.bus = type("Bus", (), {"port_handler": PortHandler()})() + + def disconnect(self) -> None: + raise RuntimeError("Failed to write 'Torque_Enable' on id_=6") + + mgr = CalibrationManager() + device = Device() + mgr.device = device + + mgr._cleanup_device() + + assert device.bus.port_handler.closed is True # force-released despite failure + assert mgr.device is None # handle cleared so a new calibration can start