Extract reusable device protocols, ports and logic analyzers from SETGUI

This commit is contained in:
2026-09-23 20:11:37 +03:00
parent 80ba17d77d
commit 795a1279b1
63 changed files with 10181 additions and 6 deletions

View 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())