From f33e792ee62929e04694cbff1efe68827bf6fe2a Mon Sep 17 00:00:00 2001 From: Iva Laginja Date: Fri, 8 May 2026 17:33:31 +0200 Subject: [PATCH 1/8] Draft doble backend --- .../services/thorlabs_mcls1/thorlabs_mcls1.py | 171 ++++++++++++++---- 1 file changed, 138 insertions(+), 33 deletions(-) diff --git a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py index 9f89ddf9a..cda346537 100644 --- a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py +++ b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py @@ -49,7 +49,7 @@ def setter(self, value): self.set_active_channel(self.channel) # Execute command. - self.UART_lib.fnUART_LIBRARY_Set(self.instrument_handle, command_str.encode(), 32) + self._set_command(command_str) return setter @@ -58,7 +58,6 @@ def make_getter(command, stream_name): def getter(self): # Form command. command_str = command.value + MCLS1_COM.TERM_CHAR.value - response_buffer = ctypes.create_string_buffer(MCLS1_COM.BUFFER_SIZE.value) # Lock first to ensure the next two statements are uninterrupted. with self.lock: @@ -67,15 +66,13 @@ def getter(self): self.set_active_channel(self.channel) # Execute command. - self.UART_lib.fnUART_LIBRARY_Get(self.instrument_handle, command_str.encode(), response_buffer) + response = self._get_command(command_str) # Decode result. - response_buffer = response_buffer.value - value = response_buffer.rstrip(b"\x00").decode().lstrip(command.value).strip('\r').rstrip('\r> ') + value = self._parse_response_value(response, command.value) # Submit retrieved value to stream. stream = getattr(self, stream_name) - print(f"value= {value}") try: stream.submit_data(np.array([value]).astype(stream.dtype)) except ValueError: @@ -104,48 +101,156 @@ def __init__(self): super().__init__('thorlabs_mcls1') self.threads = {} + self.instrument_handle = None + self.serial_handle = None + self.UART_lib = None + self.transport = self.config.get('transport', 'auto').lower() self.vcp_port = self.config.get('vcp_port', 'VCP0') + self.serial_port = self.config.get('serial_port', self.config.get('port')) + self.serial_timeout = float(self.config.get('serial_timeout', 1.0)) + self.baud_rate = int(self.config.get('baud_rate', MCLS1_COM.BAUD_RATE.value)) + + self.backend = self._select_backend() + # Use a reentrant lock to avoid deadlock when setting the channel. self.lock = threading.RLock() - try: + if self.backend == 'uart_lib': uart_lib_path = os.environ.get('CATKIT_THORLABS_UART_LIB_PATH') + if not uart_lib_path: + raise RuntimeError('CATKIT_THORLABS_UART_LIB_PATH is not set for UART-library transport') self.UART_lib = ctypes.cdll.LoadLibrary(uart_lib_path) - except ImportError as error: - raise error - def open(self): - # Make datastreams - self.current_setpoint = self.make_data_stream('current_setpoint', 'float32', [1], 20) - self.emission = self.make_data_stream('emission', 'uint8', [1], 20) - self.target_temperature = self.make_data_stream('target_temperature', 'float32', [1], 20) - self.temperature = self.make_data_stream('temperature', 'float32', [1], 20) - self.power = self.make_data_stream('power', 'float32', [1], 20) + def _select_backend(self): + if self.transport in ('uart_lib', 'serial'): + return self.transport + + if self.serial_port: + return 'serial' + + # Default to UART library for backward compatibility with existing Windows configs. + return 'uart_lib' + + def _connect(self): + if self.backend == 'uart_lib': + self._connect_uart_lib() + elif self.backend == 'serial': + self._connect_serial() + else: + raise RuntimeError(f'Unsupported transport backend: {self.backend}') - # Open connection to device + def _disconnect(self): + if self.backend == 'uart_lib' and self.instrument_handle is not None: + self.UART_lib.fnUART_LIBRARY_close(self.instrument_handle) + self.instrument_handle = None + elif self.backend == 'serial' and self.serial_handle is not None: + self.serial_handle.close() + self.serial_handle = None + + def _connect_uart_lib(self): response_buffer = ctypes.create_string_buffer(MCLS1_COM.BUFFER_SIZE.value) self.UART_lib.fnUART_LIBRARY_list(response_buffer, MCLS1_COM.BUFFER_SIZE.value) - response_buffer = response_buffer.value.decode() - split = response_buffer.split(",") - print(split) + devices = response_buffer.value.decode(errors='ignore') + split = [item.strip() for item in devices.split(',') if item.strip()] + + selected_port = None for i, thing in enumerate(split): - # The list has a format of "Port, Device, Port, Device". Once we find device named VCP11, minus 1 for port. - # It seems that the VCP port can change occasionally for reasons we do not understand. - # Last time this happened we identified the COM port in the device manager by unplugging / replugging - # and then figuring the corresponding VCP port from the above debugging message. + if self.vcp_port in thing and i > 0: + selected_port = split[i - 1] + break - # Another way to figure out the COM port is to go to the MCLS1 Application and dicsonnect the source. - # When you reconnect the source, COM port it is connected to will be displayed. + if selected_port is None: + raise RuntimeError( + f'Device {self.vcp_port} not found - MCLS1 may have switched COM/VCP port after a reboot' + ) + + self.port = selected_port + self.instrument_handle = self.UART_lib.fnUART_LIBRARY_open(self.port.encode(), self.baud_rate, 3) + + def _resolve_serial_port(self): + _, list_ports_module = self._require_pyserial() + + if self.serial_port: + return self.serial_port + + ports = list_ports_module.comports() + for port in ports: + description = port.description or '' + if self.vcp_port in description or self.vcp_port in port.device: + return port.device + + raise RuntimeError(f'Unable to find serial port matching {self.vcp_port}') + + def _connect_serial(self): + serial_module, _ = self._require_pyserial() + self.port = self._resolve_serial_port() + self.serial_handle = serial_module.Serial( + port=self.port, + baudrate=self.baud_rate, + timeout=self.serial_timeout, + write_timeout=self.serial_timeout, + ) + self.serial_handle.reset_input_buffer() + self.serial_handle.reset_output_buffer() + + @staticmethod + def _require_pyserial(): + try: + import serial as serial_module + from serial.tools import list_ports as list_ports_module + except ImportError as exc: + raise RuntimeError('pyserial is required for serial transport but is not installed') from exc + + return serial_module, list_ports_module + + def _set_command(self, command_str): + payload = command_str.encode() + if self.backend == 'uart_lib': + self.UART_lib.fnUART_LIBRARY_Set(self.instrument_handle, payload, len(payload)) + return + + self.serial_handle.write(payload) + self.serial_handle.flush() + + def _get_command(self, command_str): + payload = command_str.encode() + if self.backend == 'uart_lib': + response_buffer = ctypes.create_string_buffer(MCLS1_COM.BUFFER_SIZE.value) + self.UART_lib.fnUART_LIBRARY_Get(self.instrument_handle, payload, response_buffer) + return response_buffer.value + + self.serial_handle.reset_input_buffer() + self.serial_handle.write(payload) + self.serial_handle.flush() + return self.serial_handle.read_until(b'>') + + @staticmethod + def _parse_response_value(response, command): + text = response.rstrip(b"\x00").decode(errors='ignore').strip('\r\n\t >') + candidates = [ + command, + command.rstrip('?'), + f"{command.rstrip('?')}=", + ] - if self.vcp_port in thing: - self.port = split[i - 1] - print(f'port number from thing ={self.port}') + for candidate in candidates: + if candidate and text.startswith(candidate): + text = text[len(candidate):].lstrip('= ') break - else: - raise Exception(f'Device {self.vcp_port} not found - The MCLS1 probably switched COM port after a reboot') - self.instrument_handle = self.UART_lib.fnUART_LIBRARY_open(self.port.encode(), MCLS1_COM.BAUD_RATE.value, 3) + return text.strip('\r\n\t >') + + def open(self): + # Make datastreams + self.current_setpoint = self.make_data_stream('current_setpoint', 'float32', [1], 20) + self.emission = self.make_data_stream('emission', 'uint8', [1], 20) + self.target_temperature = self.make_data_stream('target_temperature', 'float32', [1], 20) + self.temperature = self.make_data_stream('temperature', 'float32', [1], 20) + self.power = self.make_data_stream('power', 'float32', [1], 20) + + # Open connection to device. + self._connect() self.setters = { 'emission': self.set_emission, @@ -190,7 +295,7 @@ def close(self): thread.join() # Close the instrument. - self.UART_lib.fnUART_LIBRARY_close(self.instrument_handle) + self._disconnect() def update_status(self): while not self.should_shut_down: From 9f8b09730ebaac7f2b27ba459bbd610674f34e7f Mon Sep 17 00:00:00 2001 From: Iva Laginja Date: Fri, 8 May 2026 17:35:19 +0200 Subject: [PATCH 2/8] Detect OS at runtime for backend choice --- catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py index cda346537..30c2fb9c6 100644 --- a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py +++ b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py @@ -2,6 +2,7 @@ import os import ctypes +import platform import numpy as np import threading @@ -105,7 +106,6 @@ def __init__(self): self.serial_handle = None self.UART_lib = None - self.transport = self.config.get('transport', 'auto').lower() self.vcp_port = self.config.get('vcp_port', 'VCP0') self.serial_port = self.config.get('serial_port', self.config.get('port')) self.serial_timeout = float(self.config.get('serial_timeout', 1.0)) @@ -123,14 +123,10 @@ def __init__(self): self.UART_lib = ctypes.cdll.LoadLibrary(uart_lib_path) def _select_backend(self): - if self.transport in ('uart_lib', 'serial'): - return self.transport + if platform.system().lower().startswith('win'): + return 'uart_lib' - if self.serial_port: - return 'serial' - - # Default to UART library for backward compatibility with existing Windows configs. - return 'uart_lib' + return 'serial' def _connect(self): if self.backend == 'uart_lib': From 1fe0fabf1eb0f61a6b7508d7d600450b7cd0909b Mon Sep 17 00:00:00 2001 From: Iva Laginja Date: Fri, 8 May 2026 17:39:57 +0200 Subject: [PATCH 3/8] Fix/add documentation --- .../services/thorlabs_mcls1/thorlabs_mcls1.py | 6 +++--- docs/services/thorlabs_mcls1.rst | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py index 30c2fb9c6..30bcce438 100644 --- a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py +++ b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py @@ -119,7 +119,7 @@ def __init__(self): if self.backend == 'uart_lib': uart_lib_path = os.environ.get('CATKIT_THORLABS_UART_LIB_PATH') if not uart_lib_path: - raise RuntimeError('CATKIT_THORLABS_UART_LIB_PATH is not set for UART-library transport') + raise RuntimeError('CATKIT_THORLABS_UART_LIB_PATH is not set for the Windows UART-library backend') self.UART_lib = ctypes.cdll.LoadLibrary(uart_lib_path) def _select_backend(self): @@ -134,7 +134,7 @@ def _connect(self): elif self.backend == 'serial': self._connect_serial() else: - raise RuntimeError(f'Unsupported transport backend: {self.backend}') + raise RuntimeError(f'Unsupported backend: {self.backend}') def _disconnect(self): if self.backend == 'uart_lib' and self.instrument_handle is not None: @@ -196,7 +196,7 @@ def _require_pyserial(): import serial as serial_module from serial.tools import list_ports as list_ports_module except ImportError as exc: - raise RuntimeError('pyserial is required for serial transport but is not installed') from exc + raise RuntimeError('pyserial is required for the serial backend but is not installed') from exc return serial_module, list_ports_module diff --git a/docs/services/thorlabs_mcls1.rst b/docs/services/thorlabs_mcls1.rst index 472c125b5..656caefd8 100644 --- a/docs/services/thorlabs_mcls1.rst +++ b/docs/services/thorlabs_mcls1.rst @@ -1,7 +1,13 @@ Thorlabs MCLS1 ============== Thorlabs MCLS1 service contains software for controlling the `Thorlabs MCLS1` laser source. -The communication is done through serial communication. +Backend selection is automatic at runtime: + +- On Windows, the service uses the Thorlabs UART library backend. +- On non-Windows platforms (Linux/macOS), the service communicates directly over serial (pyserial). + +On Windows, the environment variable ``CATKIT_THORLABS_UART_LIB_PATH`` must point to the Thorlabs UART library file used by this service. + Thorlabs software suite is publicly available at https://www.thorlabs.com/software-pages/mcls1/ @@ -16,8 +22,16 @@ Configuration simulated_service_type: thorlabs_mcls1_sim interface: thorlabs_mcls1 requires_safety: false + + # Used by the Windows UART-library backend to find the device in the VCP list. vcp_port: 'VCP3' + # Optional for non-Windows serial backend; if omitted, the service tries to + # discover the port using vcp_port in the serial description/device name. + # serial_port: '/dev/ttyUSB0' + # baud_rate: 115200 + # serial_timeout: 1.0 + emission: 1 current_setpoint: 100 low_flux_current_setpoint: 35 From 24cca6c0e5ad8948ec034348fe285463dad2673e Mon Sep 17 00:00:00 2001 From: Iva Laginja Date: Fri, 8 May 2026 17:49:16 +0200 Subject: [PATCH 4/8] Simplify --- .../services/thorlabs_mcls1/thorlabs_mcls1.py | 26 ++++--------------- docs/services/thorlabs_mcls1.rst | 4 +-- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py index 30bcce438..554ab2a36 100644 --- a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py +++ b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py @@ -107,9 +107,9 @@ def __init__(self): self.UART_lib = None self.vcp_port = self.config.get('vcp_port', 'VCP0') - self.serial_port = self.config.get('serial_port', self.config.get('port')) + self.serial_port = self.config.get('serial_port', '/dev/ttyUSB0') self.serial_timeout = float(self.config.get('serial_timeout', 1.0)) - self.baud_rate = int(self.config.get('baud_rate', MCLS1_COM.BAUD_RATE.value)) + self.baud_rate = MCLS1_COM.BAUD_RATE.value self.backend = self._select_backend() @@ -164,25 +164,10 @@ def _connect_uart_lib(self): self.port = selected_port self.instrument_handle = self.UART_lib.fnUART_LIBRARY_open(self.port.encode(), self.baud_rate, 3) - def _resolve_serial_port(self): - _, list_ports_module = self._require_pyserial() - - if self.serial_port: - return self.serial_port - - ports = list_ports_module.comports() - for port in ports: - description = port.description or '' - if self.vcp_port in description or self.vcp_port in port.device: - return port.device - - raise RuntimeError(f'Unable to find serial port matching {self.vcp_port}') - def _connect_serial(self): - serial_module, _ = self._require_pyserial() - self.port = self._resolve_serial_port() + serial_module = self._require_pyserial() self.serial_handle = serial_module.Serial( - port=self.port, + port=self.serial_port, baudrate=self.baud_rate, timeout=self.serial_timeout, write_timeout=self.serial_timeout, @@ -194,11 +179,10 @@ def _connect_serial(self): def _require_pyserial(): try: import serial as serial_module - from serial.tools import list_ports as list_ports_module except ImportError as exc: raise RuntimeError('pyserial is required for the serial backend but is not installed') from exc - return serial_module, list_ports_module + return serial_module def _set_command(self, command_str): payload = command_str.encode() diff --git a/docs/services/thorlabs_mcls1.rst b/docs/services/thorlabs_mcls1.rst index 656caefd8..305b5cf8d 100644 --- a/docs/services/thorlabs_mcls1.rst +++ b/docs/services/thorlabs_mcls1.rst @@ -26,10 +26,8 @@ Configuration # Used by the Windows UART-library backend to find the device in the VCP list. vcp_port: 'VCP3' - # Optional for non-Windows serial backend; if omitted, the service tries to - # discover the port using vcp_port in the serial description/device name. + # Optional for non-Windows serial backend. # serial_port: '/dev/ttyUSB0' - # baud_rate: 115200 # serial_timeout: 1.0 emission: 1 From f7ae1c2c44b7795514044a3b09d8af5173cf3325 Mon Sep 17 00:00:00 2001 From: Iva Laginja Date: Fri, 8 May 2026 17:59:32 +0200 Subject: [PATCH 5/8] Put in the original port comment (without typos though) --- catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py index 554ab2a36..3c80fc2b5 100644 --- a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py +++ b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py @@ -149,16 +149,26 @@ def _connect_uart_lib(self): self.UART_lib.fnUART_LIBRARY_list(response_buffer, MCLS1_COM.BUFFER_SIZE.value) devices = response_buffer.value.decode(errors='ignore') split = [item.strip() for item in devices.split(',') if item.strip()] + print(split) selected_port = None for i, thing in enumerate(split): if self.vcp_port in thing and i > 0: + # The list has a format of "Port, Device, Port, Device". Once we find device named VCP11, minus 1 in the + # list index to get the corresponding port. + # It seems that the VCP port can change occasionally for reasons we do not understand. + # Last time this happened we identified the COM port in the device manager by unplugging / replugging + # and then figuring the corresponding VCP port from the above debugging message. + + # Another way to figure out the COM port is to go to the MCLS1 Application and disconnect the source. + # When you reconnect the source, COM port it is connected to will be displayed. selected_port = split[i - 1] + print(f'port number from thing ={selected_port}') break if selected_port is None: raise RuntimeError( - f'Device {self.vcp_port} not found - MCLS1 may have switched COM/VCP port after a reboot' + f'Device {self.vcp_port} not found - The MCLS1 may have switched COM/VCP port after a reboot' ) self.port = selected_port From 6c0158149f53aeb74e6c92ae9885d2e7b65d69ae Mon Sep 17 00:00:00 2001 From: Iva Laginja Date: Fri, 8 May 2026 18:06:06 +0200 Subject: [PATCH 6/8] Make sure serial_handle only gets called when we are using serial backend --- .../services/thorlabs_mcls1/thorlabs_mcls1.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py index 3c80fc2b5..0d9f9d729 100644 --- a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py +++ b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py @@ -198,10 +198,9 @@ def _set_command(self, command_str): payload = command_str.encode() if self.backend == 'uart_lib': self.UART_lib.fnUART_LIBRARY_Set(self.instrument_handle, payload, len(payload)) - return - - self.serial_handle.write(payload) - self.serial_handle.flush() + elif self.backend == 'serial': + self.serial_handle.write(payload) + self.serial_handle.flush() def _get_command(self, command_str): payload = command_str.encode() @@ -209,11 +208,11 @@ def _get_command(self, command_str): response_buffer = ctypes.create_string_buffer(MCLS1_COM.BUFFER_SIZE.value) self.UART_lib.fnUART_LIBRARY_Get(self.instrument_handle, payload, response_buffer) return response_buffer.value - - self.serial_handle.reset_input_buffer() - self.serial_handle.write(payload) - self.serial_handle.flush() - return self.serial_handle.read_until(b'>') + elif self.backend == 'serial': + self.serial_handle.reset_input_buffer() + self.serial_handle.write(payload) + self.serial_handle.flush() + return self.serial_handle.read_until(b'>') @staticmethod def _parse_response_value(response, command): From 9f74fd628b14b0430d303783c193863804460d8e Mon Sep 17 00:00:00 2001 From: Iva Laginja Date: Fri, 8 May 2026 18:08:25 +0200 Subject: [PATCH 7/8] Keep unused defaults to None --- catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py | 6 +++--- docs/services/thorlabs_mcls1.rst | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py index 0d9f9d729..7d6f1edcf 100644 --- a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py +++ b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py @@ -106,9 +106,9 @@ def __init__(self): self.serial_handle = None self.UART_lib = None - self.vcp_port = self.config.get('vcp_port', 'VCP0') - self.serial_port = self.config.get('serial_port', '/dev/ttyUSB0') - self.serial_timeout = float(self.config.get('serial_timeout', 1.0)) + self.vcp_port = self.config.get('vcp_port', None) + self.serial_port = self.config.get('serial_port', None) + self.serial_timeout = self.config.get('serial_timeout', None) self.baud_rate = MCLS1_COM.BAUD_RATE.value self.backend = self._select_backend() diff --git a/docs/services/thorlabs_mcls1.rst b/docs/services/thorlabs_mcls1.rst index 305b5cf8d..7a789e1fa 100644 --- a/docs/services/thorlabs_mcls1.rst +++ b/docs/services/thorlabs_mcls1.rst @@ -24,6 +24,7 @@ Configuration requires_safety: false # Used by the Windows UART-library backend to find the device in the VCP list. + # Can be omitted if not on Windows. vcp_port: 'VCP3' # Optional for non-Windows serial backend. From 8e5028d696cc1558717761d09413fe662e741b9e Mon Sep 17 00:00:00 2001 From: Iva Laginja Date: Sun, 24 May 2026 19:53:29 +0200 Subject: [PATCH 8/8] update response buffer parsing --- .../services/thorlabs_mcls1/thorlabs_mcls1.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py index 7d6f1edcf..dbc51d71f 100644 --- a/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py +++ b/catkit2/services/thorlabs_mcls1/thorlabs_mcls1.py @@ -70,7 +70,7 @@ def getter(self): response = self._get_command(command_str) # Decode result. - value = self._parse_response_value(response, command.value) + value = self._parse_response_value(response) # Submit retrieved value to stream. stream = getattr(self, stream_name) @@ -215,20 +215,19 @@ def _get_command(self, command_str): return self.serial_handle.read_until(b'>') @staticmethod - def _parse_response_value(response, command): - text = response.rstrip(b"\x00").decode(errors='ignore').strip('\r\n\t >') - candidates = [ - command, - command.rstrip('?'), - f"{command.rstrip('?')}=", + def _parse_response_value(response): + text = response.rstrip(b"\x00").decode(errors="ignore") + + lines = [ + line.strip(" \r\n\t> <") + for line in text.splitlines() + if line.strip(" \r\n\t> <") ] - for candidate in candidates: - if candidate and text.startswith(candidate): - text = text[len(candidate):].lstrip('= ') - break + if not lines: + raise ValueError(f"Empty serial response: {text!r}") - return text.strip('\r\n\t >') + return lines[-1] def open(self): # Make datastreams