Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
168
python/set_devices/qt_ports/slcan_adapter.py
Normal file
168
python/set_devices/qt_ports/slcan_adapter.py
Normal 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()
|
||||
Reference in New Issue
Block a user