From e747bae2dc41737dae819f116ddd6df9b643f28e Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 5 Dec 2025 14:37:02 +0000 Subject: [PATCH 1/4] add ftdi rest fix --- pylabrobot/io/serial.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 80d11b19606..bc0371fc233 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -183,6 +183,20 @@ def _open_serial() -> serial.Serial: self._executor = None raise e + # --- FIX: Prevent FTDI reset on open/close (critical for Inheco on Raspberry Pi) --- + try: + # Some pyserial versions require direct attribute access: + self._ser.dtr = False + self._ser.rts = False + + # Others only respect the explicit setter: + self._ser.setDTR(False) + self._ser.setRTS(False) + + logger.info(f"[{candidate_port}] Disabled FTDI DTR/RTS (prevent USB disconnect).") + except Exception as e: + logger.warning(f"[{candidate_port}] Could not disable DTR/RTS: {e}") + self._port = candidate_port async def stop(self): From b5cbdbfbae319806a4152100c973cbaa524a5c01 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 5 Dec 2025 14:57:02 +0000 Subject: [PATCH 2/4] fix type checking --- pylabrobot/io/serial.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index bc0371fc233..67524a57c31 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -183,6 +183,8 @@ def _open_serial() -> serial.Serial: self._executor = None raise e + assert self._ser is not None + # --- FIX: Prevent FTDI reset on open/close (critical for Inheco on Raspberry Pi) --- try: # Some pyserial versions require direct attribute access: From 3fa445cda14acc0778dabb95a148f58c90bb216a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 7 Dec 2025 17:51:00 -0800 Subject: [PATCH 3/4] add dsrdtr --- pylabrobot/io/serial.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 67524a57c31..9526f498d29 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -46,6 +46,7 @@ def __init__( write_timeout=1, timeout=1, rtscts: bool = False, + dsrdtr: bool = False, ): self._port = port self._vid = vid @@ -59,6 +60,7 @@ def __init__( self.write_timeout = write_timeout self.timeout = timeout self.rtscts = rtscts + self.dsrdtr = dsrdtr # Instant parameter validation at init time if not self._port and not (self._vid and self._pid): @@ -171,6 +173,7 @@ def _open_serial() -> serial.Serial: write_timeout=self.write_timeout, timeout=self.timeout, rtscts=self.rtscts, + dsrdtr=self.dsrdtr, ) try: @@ -326,6 +329,7 @@ def serialize(self): "write_timeout": self.write_timeout, "timeout": self.timeout, "rtscts": self.rtscts, + "dsrdtr": self.dsrdtr, } @classmethod @@ -339,6 +343,7 @@ def deserialize(cls, data: dict) -> "Serial": write_timeout=data["write_timeout"], timeout=data["timeout"], rtscts=data["rtscts"], + dsrdtr=data["dsrdtr"], ) @@ -354,6 +359,7 @@ def __init__( write_timeout=1, timeout=1, rtscts: bool = False, + dsrdtr: bool = False, ): super().__init__( port=port, @@ -364,6 +370,7 @@ def __init__( write_timeout=write_timeout, timeout=timeout, rtscts=rtscts, + dsrdtr=dsrdtr, ) self.cr = cr From 210eb241dcead6ffa2c0237779ae0dae4428b181 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 7 Dec 2025 18:12:19 -0800 Subject: [PATCH 4/4] use using properties, dont do it in setup --- pylabrobot/io/serial.py | 98 +++++++++++++++---- .../inheco/incubator_shaker_backend.py | 2 + 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 9526f498d29..92b1fd1c88d 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -120,7 +120,7 @@ async def setup(self): self._executor = ThreadPoolExecutor(max_workers=1) # 1. VID:PID specified - port maybe - if self._vid and self._pid: + if self._vid is not None and self._pid is not None: matching_ports = [ p.device for p in serial.tools.list_ports.comports() @@ -128,7 +128,7 @@ async def setup(self): ] # 1.a. No matching devices found AND no port specified - if not self._port and not matching_ports: + if self._port is None and len(matching_ports) == 0: raise RuntimeError( f"No machines found for VID={self._vid}, PID={self._pid}, and no port specified." ) @@ -141,7 +141,7 @@ async def setup(self): candidate_port = self._port # 2.a. Port specified but does not match VID:PID - sanity check (e.g. typo in port) - if (self._vid and self._pid) and candidate_port not in matching_ports: + if (self._vid is not None and self._pid is not None) and candidate_port not in matching_ports: raise RuntimeError( f"Specified port {candidate_port} not found among machines: {matching_ports} " f"with VID={self._vid}:PID={self._pid}." @@ -188,20 +188,6 @@ def _open_serial() -> serial.Serial: assert self._ser is not None - # --- FIX: Prevent FTDI reset on open/close (critical for Inheco on Raspberry Pi) --- - try: - # Some pyserial versions require direct attribute access: - self._ser.dtr = False - self._ser.rts = False - - # Others only respect the explicit setter: - self._ser.setDTR(False) - self._ser.setRTS(False) - - logger.info(f"[{candidate_port}] Disabled FTDI DTR/RTS (prevent USB disconnect).") - except Exception as e: - logger.warning(f"[{candidate_port}] Could not disable DTR/RTS: {e}") - self._port = candidate_port async def stop(self): @@ -319,6 +305,38 @@ async def reset_output_buffer(self): logger.log(LOG_LEVEL_IO, "[%s] reset_output_buffer", self._port) capturer.record(SerialCommand(device_id=self._port, action="reset_output_buffer", data="")) + @property + def dtr(self) -> bool: + """Get the DTR (Data Terminal Ready) status.""" + assert self._ser is not None and self._port is not None, "forgot to call setup?" + value = self._ser.dtr + capturer.record(SerialCommand(device_id=self._port, action="get_dtr", data=str(value))) + return value # type: ignore # ? + + @dtr.setter + def dtr(self, value: bool): + """Set the DTR (Data Terminal Ready) status.""" + assert self._ser is not None and self._port is not None, "forgot to call setup?" + logger.log(LOG_LEVEL_IO, "[%s] set DTR %s", self._port, value) + capturer.record(SerialCommand(device_id=self._port, action="set_dtr", data=str(value))) + self._ser.dtr = value + + @property + def rts(self) -> bool: + """Get the RTS (Request To Send) status.""" + assert self._ser is not None and self._port is not None, "forgot to call setup?" + value = self._ser.rts + capturer.record(SerialCommand(device_id=self._port, action="get_rts", data=str(value))) + return value # type: ignore # ? + + @rts.setter + def rts(self, value: bool): + """Set the RTS (Request To Send) status.""" + assert self._ser is not None and self._port is not None, "forgot to call setup?" + logger.log(LOG_LEVEL_IO, "[%s] set RTS %s", self._port, value) + capturer.record(SerialCommand(device_id=self._port, action="set_rts", data=str(value))) + self._ser.rts = value + def serialize(self): return { "port": self._port, @@ -438,3 +456,49 @@ async def reset_output_buffer(self): and next_command.action == "reset_output_buffer" ): raise ValidationError(f"Next line is {next_command}, expected Serial reset_output_buffer") + + @property + def dtr(self) -> bool: + next_command = SerialCommand(**self.cr.next_command()) + if not ( + next_command.module == "serial" + and next_command.device_id == self._port + and next_command.action == "get_dtr" + ): + raise ValidationError(f"Next line is {next_command}, expected Serial get_dtr") + return next_command.data.lower() == "true" + + @dtr.setter + def dtr(self, value: bool): + next_command = SerialCommand(**self.cr.next_command()) + if not ( + next_command.module == "serial" + and next_command.device_id == self._port + and next_command.action == "set_dtr" + ): + raise ValidationError(f"Next line is {next_command}, expected Serial set_dtr") + if next_command.data.lower() != str(value).lower(): + raise ValidationError("Data mismatch: difference was written to stdout.") + + @property + def rts(self) -> bool: + next_command = SerialCommand(**self.cr.next_command()) + if not ( + next_command.module == "serial" + and next_command.device_id == self._port + and next_command.action == "get_rts" + ): + raise ValidationError(f"Next line is {next_command}, expected Serial get_rts") + return next_command.data.lower() == "true" + + @rts.setter + def rts(self, value: bool): + next_command = SerialCommand(**self.cr.next_command()) + if not ( + next_command.module == "serial" + and next_command.device_id == self._port + and next_command.action == "set_rts" + ): + raise ValidationError(f"Next line is {next_command}, expected Serial set_rts") + if next_command.data.lower() != str(value).lower(): + raise ValidationError("Data mismatch: difference was written to stdout.") diff --git a/pylabrobot/storage/inheco/incubator_shaker_backend.py b/pylabrobot/storage/inheco/incubator_shaker_backend.py index 70f473590f8..21df82324a6 100644 --- a/pylabrobot/storage/inheco/incubator_shaker_backend.py +++ b/pylabrobot/storage/inheco/incubator_shaker_backend.py @@ -183,6 +183,8 @@ async def setup(self, port: Optional[str] = None): # --- Establish serial connection --- await self.io.setup() + self.io.dtr = False + self.io.rts = False try: # --- Verify DIP switch ID via RTS --- probe = self._build_message("RTS", stack_index=0)