Files
templates/python/set_devices/qt_ports/slcan_adapter.py

169 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()