169 lines
6.7 KiB
Python
169 lines
6.7 KiB
Python
"""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()
|