Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
212
python/set_devices/qt_ports/serial_port.py
Normal file
212
python/set_devices/qt_ports/serial_port.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""@file serial_port.py
|
||||
@brief Неблокирующий QSerialPort и потоковый parser GUI transport.
|
||||
|
||||
Реализует асинхронный интерфейс к последовательному порту с полностью
|
||||
неблокирующей обработкой данных и автоматическим разбором кадров протокола.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from set_devices.protocol_capture import uart_event
|
||||
|
||||
from .qt_compat import QObject, Signal
|
||||
from .qt_compat import QSerialPort, QSerialPortInfo
|
||||
|
||||
from set_devices.protocol import Frame, MessageType, build_frame
|
||||
from set_devices.protocol_router import ProtocolMode, ProtocolRouter
|
||||
from setprotocol.core import (
|
||||
Frame as SetFrame,
|
||||
FrameFlag as SetFrameFlag,
|
||||
MessageType as SetMessageType,
|
||||
build_frame as build_set_frame,
|
||||
)
|
||||
|
||||
|
||||
class SerialDevicePort(QObject):
|
||||
"""@brief Реальный COM-порт без блокирующих ожиданий в GUI thread.
|
||||
|
||||
Управляет связью с устройством через последовательный порт, обеспечивая
|
||||
асинхронный обмен данными и передачу кадров по протоколу GUI.
|
||||
"""
|
||||
|
||||
connected_changed = Signal(bool, str)
|
||||
frame_received = Signal(object)
|
||||
tx_logged = Signal(str)
|
||||
capture_event = Signal(object)
|
||||
rx_logged = Signal(str)
|
||||
error_occurred = Signal(str)
|
||||
protocol_changed = Signal(object)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._router = ProtocolRouter()
|
||||
self._sequence = 0
|
||||
self._serial.readyRead.connect(self._read_available)
|
||||
self._serial.errorOccurred.connect(self._handle_error)
|
||||
|
||||
@staticmethod
|
||||
def available_ports() -> list[str]:
|
||||
"""Возвращает стабильный отсортированный список имён COM-портов."""
|
||||
return [name for name, _description in SerialDevicePort.available_port_infos()]
|
||||
|
||||
@staticmethod
|
||||
def available_port_infos() -> list[tuple[str, str]]:
|
||||
"""@brief Перечисляет COM-порты вместе с описанием драйвера.
|
||||
|
||||
Порядок естественный: COM3 идёт перед COM10, поэтому список не
|
||||
перестраивается при подключении новых преобразователей.
|
||||
|
||||
@return Пары «имя порта, описание устройства».
|
||||
"""
|
||||
infos = [
|
||||
(info.portName(), info.description() or info.manufacturer())
|
||||
for info in QSerialPortInfo.availablePorts()
|
||||
]
|
||||
return sorted(infos, key=lambda item: SerialDevicePort._sort_key(item[0]))
|
||||
|
||||
@staticmethod
|
||||
def _sort_key(port_name: str) -> tuple[str, int, str]:
|
||||
"""Разделяет имя порта на буквенный префикс и числовой индекс."""
|
||||
digits = "".join(char for char in port_name if char.isdigit())
|
||||
prefix = port_name[: len(port_name) - len(digits)] if digits else port_name
|
||||
return (prefix.upper(), int(digits) if digits else 0, port_name)
|
||||
|
||||
def open(self, port_name: str, baud_rate: int) -> bool:
|
||||
"""Открывает 8N1 без flow control; результат сообщает синхронную ошибку."""
|
||||
if not port_name:
|
||||
self.error_occurred.emit("COM-порт не выбран")
|
||||
return False
|
||||
self.close()
|
||||
self._serial.setPortName(port_name)
|
||||
self._serial.setBaudRate(baud_rate)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
return False
|
||||
self.set_protocol_mode(ProtocolMode.AUTO)
|
||||
self.connected_changed.emit(True, port_name)
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Признак открытого физического COM-порта."""
|
||||
return self._serial.isOpen()
|
||||
|
||||
@property
|
||||
def port_name(self) -> str:
|
||||
"""Имя текущего COM-порта, например ``COM11``."""
|
||||
return self._serial.portName()
|
||||
|
||||
@property
|
||||
def baud_rate(self) -> int:
|
||||
"""Фактически установленная скорость последовательного порта."""
|
||||
return self._serial.baudRate()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Закрывает порт и не оставляет активных Qt-операций чтения."""
|
||||
if not self._serial.isOpen():
|
||||
return
|
||||
name = self._serial.portName()
|
||||
self._serial.close()
|
||||
self.connected_changed.emit(False, name)
|
||||
|
||||
def send(self, frame: Frame) -> bool:
|
||||
"""Ставит целый кадр в буфер Qt без ожидания физической передачи."""
|
||||
if not self._serial.isOpen():
|
||||
self.error_occurred.emit("Последовательный порт не подключён")
|
||||
return False
|
||||
packet = build_frame(frame)
|
||||
written = self._serial.write(packet)
|
||||
if written > 0:
|
||||
self.capture_event.emit(uart_event('SET', 'TX', packet[:written], self.baud_rate))
|
||||
if written != len(packet):
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
return False
|
||||
self.tx_logged.emit(packet.hex(" ").upper())
|
||||
return True
|
||||
|
||||
def send_set(self, frame: SetFrame) -> bool:
|
||||
"""Передаёт SETProtocol v2 кадр, в том числе пробный кадр AUTO."""
|
||||
if not self._serial.isOpen():
|
||||
self.error_occurred.emit("Последовательный порт не подключён")
|
||||
return False
|
||||
if self.protocol_mode is ProtocolMode.GUI_V1:
|
||||
self.error_occurred.emit("Канал уже работает в режиме GUI protocol v1")
|
||||
return False
|
||||
packet = build_set_frame(frame)
|
||||
written = self._serial.write(packet)
|
||||
if written > 0:
|
||||
self.capture_event.emit(uart_event('SET', 'TX', packet[:written], self.baud_rate))
|
||||
if written != len(packet):
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
return False
|
||||
self.tx_logged.emit(packet.hex(" ").upper())
|
||||
return True
|
||||
|
||||
def next_sequence(self) -> int:
|
||||
"""Выделяет очередной sequence для адаптера транзакций."""
|
||||
self._sequence = (self._sequence + 1) & 0xFFFF
|
||||
return self._sequence
|
||||
|
||||
def send_message(self, message_type: MessageType, payload: bytes = b"") -> int | None:
|
||||
"""Передаёт запрос и возвращает его sequence либо ``None``."""
|
||||
sequence = self.next_sequence()
|
||||
return sequence if self.send(Frame(message_type, sequence, payload)) else None
|
||||
|
||||
def send_set_message(
|
||||
self,
|
||||
message_type: int | SetMessageType,
|
||||
payload: bytes = b"",
|
||||
*,
|
||||
flags: SetFrameFlag = SetFrameFlag.ACK_REQUIRED,
|
||||
destination: int = 0,
|
||||
) -> int | None:
|
||||
"""Передаёт запрос SETProtocol v2 и возвращает sequence."""
|
||||
sequence = self.next_sequence()
|
||||
frame = SetFrame(
|
||||
message_type,
|
||||
sequence,
|
||||
payload,
|
||||
flags=flags,
|
||||
destination=destination,
|
||||
)
|
||||
return sequence if self.send_set(frame) else None
|
||||
|
||||
@property
|
||||
def protocol_mode(self) -> ProtocolMode:
|
||||
return self._router.mode
|
||||
|
||||
def set_protocol_mode(self, mode: ProtocolMode) -> None:
|
||||
"""Сбрасывает parser-ы и выбирает режим текущего соединения."""
|
||||
changed = self._router.mode is not mode
|
||||
self._router.select(mode)
|
||||
if changed or mode is ProtocolMode.AUTO:
|
||||
self.protocol_changed.emit(mode)
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Отправляет совместимый PING с новым 16-битным sequence."""
|
||||
return self.send_message(MessageType.PING) is not None
|
||||
|
||||
def _read_available(self) -> None:
|
||||
"""Выгружает доступный фрагмент и передаёт parser-у без блокировки."""
|
||||
data = bytes(self._serial.readAll())
|
||||
if not data:
|
||||
return
|
||||
self.rx_logged.emit(data.hex(" ").upper())
|
||||
self.capture_event.emit(uart_event('SET', 'RX', data, self.baud_rate))
|
||||
previous_mode = self._router.mode
|
||||
frames = self._router.feed(data)
|
||||
if self._router.mode is not previous_mode:
|
||||
self.protocol_changed.emit(self._router.mode)
|
||||
for frame in frames:
|
||||
self.frame_received.emit(frame)
|
||||
|
||||
def _handle_error(self, error: QSerialPort.SerialPortError) -> None:
|
||||
"""Публикует только реальные ошибки, игнорируя NoError."""
|
||||
if error == QSerialPort.SerialPortError.NoError:
|
||||
return
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
Reference in New Issue
Block a user