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