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,168 @@
"""Serial Line CAN (Lawicel/SLCAN) transport."""
from __future__ import annotations
from dataclasses import dataclass
from .qt_compat import QObject, Signal
from .qt_compat import QSerialPort, QSerialPortInfo
class SlcanError(RuntimeError):
pass
@dataclass(frozen=True)
class SlcanChannel:
path: str
channel: int = 0
description: str = ""
manufacturer: str = ""
@property
def label(self) -> str:
details = self.description or self.manufacturer or "COM-порт"
return f"{self.path} — {details}"
@property
def adapter_kind(self) -> str:
return "slcan"
_BITRATE_COMMANDS = {
10_000: "S0", 20_000: "S1", 50_000: "S2", 100_000: "S3",
125_000: "S4", 250_000: "S5", 500_000: "S6", 800_000: "S7",
1_000_000: "S8",
}
def encode_frame(can_id: int, data: bytes, *, extended: bool | None = None) -> bytes:
"""Encode one classic-CAN frame in Lawicel ASCII format."""
if len(data) > 8:
raise SlcanError("Classic CAN поддерживает не более 8 байт")
if not 0 <= can_id <= 0x1FFFFFFF:
raise SlcanError("CAN ID вне диапазона")
if extended is None:
extended = can_id > 0x7FF
if not extended:
if can_id > 0x7FF:
raise SlcanError("Стандартный CAN ID вне диапазона")
return f"t{can_id:03X}{len(data):X}{data.hex().upper()}\r".encode("ascii")
return f"T{can_id:08X}{len(data):X}{data.hex().upper()}\r".encode("ascii")
def decode_frame(line: bytes) -> tuple[int, bytes] | None:
"""Decode a received SLCAN data frame; ignore ACK/status/RTR records."""
if not line or line[:1] not in (b"t", b"T"):
return None
extended = line[:1] == b"T"
id_size = 8 if extended else 3
try:
can_id = int(line[1:1 + id_size], 16)
size = int(line[1 + id_size:2 + id_size], 16)
start = 2 + id_size
payload = bytes.fromhex(line[start:start + size * 2].decode("ascii"))
except (ValueError, UnicodeError):
return None
maximum = 0x1FFFFFFF if extended else 0x7FF
if can_id > maximum or size > 8 or len(payload) != size:
return None
return can_id, payload
class SlcanAdapter(QObject):
"""One SLCAN COM port using the usual 115200 8N1 host connection."""
frame_received = Signal(int, bytes)
capture_received = Signal(int, bytes, bool, bool)
connection_lost = Signal(str)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._serial = QSerialPort(self)
self._serial.readyRead.connect(self._read)
self._serial.errorOccurred.connect(self._error)
self._buffer = bytearray()
self._listen_only = False
@property
def connected(self) -> bool:
return self._serial.isOpen()
def scan(self) -> list[SlcanChannel]:
# Windows не предоставляет надёжного признака протокола SLCAN.
# Показываем COM-кандидаты только внутри явно выбранного режима SLCAN.
return [SlcanChannel(info.portName(), description=info.description(),
manufacturer=info.manufacturer())
for info in QSerialPortInfo.availablePorts()]
def connect_channel(self, channel: SlcanChannel, bitrate: int,
host_baudrate: int = 115200,
listen_only: bool = False) -> None:
self.disconnect_channel()
command = _BITRATE_COMMANDS.get(bitrate)
if command is None:
raise SlcanError(f"SLCAN не поддерживает {bitrate} бит/с")
self._serial.setPortName(channel.path)
if host_baudrate not in (9600, 19200, 38400, 57600, 115200,
230400, 460800, 921600):
raise SlcanError(f"Недопустимая скорость COM: {host_baudrate}")
self._serial.setBaudRate(host_baudrate)
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):
raise SlcanError(f"Не удалось открыть {channel.path}: {self._serial.errorString()}")
self._buffer.clear()
self._listen_only = listen_only
# Close first so reconnecting also works after an interrupted session.
open_command = "L" if listen_only else "O"
if self._serial.write(f"C\r{command}\r{open_command}\r".encode("ascii")) < 0:
message = self._serial.errorString()
self._serial.close()
self._listen_only = False
raise SlcanError(f"Не удалось настроить SLCAN: {message}")
def disconnect_channel(self) -> None:
if not self._serial.isOpen():
return
self._serial.write(b"C\r")
self._serial.waitForBytesWritten(100)
self._serial.close()
self._buffer.clear()
self._listen_only = False
def send(self, can_id: int, data: bytes) -> None:
if not self.connected:
raise SlcanError("SLCAN-адаптер не подключён")
if self._listen_only:
raise SlcanError("SLCAN-адаптер открыт в режиме только приёма")
# The adapter contract is used by ProtoCAN, whose IDs are always EXT,
# including the numerically small values that would also fit in 11 bits.
packet = encode_frame(can_id, data, extended=True)
if self._serial.write(packet) != len(packet):
raise SlcanError(f"SLCAN не передал CAN-кадр: {self._serial.errorString()}")
def _read(self) -> None:
self._buffer.extend(bytes(self._serial.readAll()))
while b"\r" in self._buffer:
raw, _, remainder = self._buffer.partition(b"\r")
self._buffer[:] = remainder
frame = decode_frame(raw)
if frame is not None:
self.capture_received.emit(frame[0], frame[1], raw[:1] == b'T', False)
self.frame_received.emit(*frame)
def _error(self, error: QSerialPort.SerialPortError) -> None:
if error in (QSerialPort.SerialPortError.NoError,
QSerialPort.SerialPortError.NotOpenError):
return
if self._serial.isOpen():
message = self._serial.errorString()
self._serial.close()
self._listen_only = False
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}")
def close(self) -> None:
self.disconnect_channel()