m2m-python/m2m/serial/serial_port.py
2026-02-19 08:14:53 +01:00

136 lines
5.1 KiB
Python

#!/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