#!/usr/bin/env python3 # -*- coding: utf-8 -*- import logging import time import threading from typing import Optional, Union import serial from m2m.serial.urc_handler import UrcHandler from m2m.serial.mock_serial import MockSerial from m2m.exceptions import SerialError logger = logging.getLogger('m2m.serial') class SerialPort(UrcHandler): """ Serial port class tailored for AT-command communication. Thread-safe using RLock. """ def __init__(self, port: str, baudrate: int = 9600, timeout: float = 1.0, delimiter: bytes = b'\r', **kwargs): super().__init__() self._delimiter = delimiter self._port_name = port self.lock = threading.RLock() try: if port.upper() == 'MOCK': self._serial = MockSerial(port=port, baudrate=baudrate, timeout=timeout, **kwargs) else: self._serial = serial.serial_for_url(url=port, baudrate=baudrate, timeout=timeout, **kwargs) if port.startswith(('socket://', 'rfc2217://')): time.sleep(0.5) # Settling time for network sockets except (serial.SerialException, ValueError) as e: logger.error(f"Failed to open port {port}: {e}") raise SerialError(f"Could not open {port}") from e logger.debug(f"Opened serial port: {port} @ {baudrate}") def __enter__(self): return self def __exit__(self, *args): self.close() def close(self): """Closes the serial port.""" if self._serial and self._serial.is_open: self._serial.close() logger.debug(f"Closed serial port: {self._port_name}") def send_cmd(self, cmd: Union[bytes, str]) -> None: """Sends a command appended with the delimiter.""" if isinstance(cmd, str): cmd = cmd.encode('ascii') full_cmd = cmd + self._delimiter with self.lock: try: logger.debug(f"TX: {full_cmd!r}") self._serial.write(full_cmd) except (serial.SerialException, ConnectionError, OSError) as e: raise SerialError(f"Write failed: {e}") from e def read_until(self, terminator: bytes = b'OK', timeout: float = 5.0) -> bytes: """Reads until the terminator is found or timeout occurs.""" buffer = bytearray() end_time = time.time() + timeout with self.lock: while time.time() < end_time: # We read whatever is available to catch partial lines or prompts try: in_waiting = self._serial.in_waiting except (AttributeError, TypeError): in_waiting = 0 if isinstance(in_waiting, int) and in_waiting > 0: data = self._serial.read(in_waiting) if data: buffer.extend(data) # Process URCs inside the buffer for line in bytes(buffer).splitlines(keepends=True): if line.endswith((b'\r', b'\n')): self.check_urc(line.strip()) if terminator in buffer: break else: time.sleep(0.01) res = bytes(buffer) if res: logger.debug(f"RX: {res!r}") return res def read_until_notify(self, notification: bytes, timeout: float = 5.0) -> Optional[bytes]: """Waits specifically for a URC notification.""" end_time = time.time() + timeout while time.time() < end_time: self._read_line() # Pumping the reader if val := self.pop_urc_value(notification): return val time.sleep(0.01) return None def read_any(self, timeout: float = 5.0) -> bytes: """Reads anything available within the timeout.""" buffer = bytearray() end_time = time.time() + timeout with self.lock: while time.time() < end_time: try: in_waiting = self._serial.in_waiting except (AttributeError, TypeError): in_waiting = 0 if isinstance(in_waiting, int) and in_waiting > 0: buffer.extend(self._serial.read(in_waiting)) else: if timeout < 0.2: break time.sleep(0.01) return bytes(buffer) def _read_line(self) -> bytes: """Internal method to read a single line and process URCs.""" try: try: in_waiting = self._serial.in_waiting except (AttributeError, TypeError): in_waiting = 0 line = self._serial.readline() if line: logger.debug(f"RX Line: {line!r}") if self.check_urc(line.strip()): return b'' elif self._port_name.startswith(('socket://', 'rfc2217://')) and (isinstance(in_waiting, int) and in_waiting == 0): time.sleep(0.01) return line except (serial.SerialException, ConnectionError, OSError) as e: raise SerialError(f"Read failed: {e}") from e