"""Minimal driver for the CANdapter (Ewert Energy Systems / candapter.com). The CANdapter speaks a Lawicel-like ASCII protocol over its FTDI virtual serial port, but diverges from real SLCAN in ways that break python-can's stock slcan backend: - Standard 11-bit frames are reported as ``Tiiildd..`` and extended 29-bit frames as ``Xiiiiiiiildd..`` (real SLCAN uses lowercase ``t``/``T`` respectively) so the kernel slcan driver and python-can can't parse the extended-ID frames at all. - The optional millisecond timestamp (enabled with ``A1``) is appended after the data bytes but is NOT counted in the DLC field, so it has to be recovered from whatever is left over on the line. """ from __future__ import annotations import dataclasses import time from typing import Optional import serial # Lawicel-style bitrate codes accepted by the 'S' command. BITRATE_CODES = { 10_000: 0, 20_000: 1, 50_000: 2, 100_000: 3, 125_000: 4, 250_000: 5, 500_000: 6, 800_000: 7, 1_000_000: 8, } ACK = 0x06 BELL = 0x07 @dataclasses.dataclass class CanFrame: arbitration_id: int is_extended: bool data: bytes timestamp_ms: Optional[int] recv_time: float class Candapter: def __init__(self, port: str, bitrate: int, control_baud: int = 115200, timestamps: bool = False): if bitrate not in BITRATE_CODES: raise ValueError(f"Unsupported bitrate {bitrate}; choose one of {sorted(BITRATE_CODES)}") self._ser = serial.Serial(port, control_baud, timeout=0.1) self._buf = bytearray() self._send_command("C") # close channel in case it was left open self._send_command(f"S{BITRATE_CODES[bitrate]}") self._send_command("A1" if timestamps else "A0") self._send_command("O") def _send_command(self, cmd: str) -> None: self._ser.reset_input_buffer() self._ser.write((cmd + "\r").encode("ascii")) time.sleep(0.05) self._ser.read(self._ser.in_waiting or 1) # drain ACK/BELL/text reply def close(self) -> None: try: self._send_command("C") finally: self._ser.close() def __enter__(self) -> "Candapter": return self def __exit__(self, *exc) -> None: self.close() def read_frame(self, timeout: Optional[float] = None) -> Optional[CanFrame]: """Read one frame, or None if nothing complete arrived within timeout.""" deadline = None if timeout is None else time.monotonic() + timeout while True: # Check every iteration, not just while waiting for bytes - a flood of # malformed lines (e.g. wrong bitrate) would otherwise spin forever. if deadline is not None and time.monotonic() >= deadline: return None nl = self._buf.find(b"\r") if nl == -1: chunk = self._ser.read(64) if chunk: self._buf.extend(chunk) continue line = bytes(self._buf[:nl]) del self._buf[: nl + 1] if not line or chr(line[0]) not in ("t", "T", "x", "X"): continue # ignore stray ACK/BELL bytes mixed into the stream frame = self._parse_frame(line) if frame is not None: return frame @staticmethod def _parse_frame(line: bytes) -> Optional[CanFrame]: text = line.decode("ascii", errors="replace") prefix = text[0] try: # Observed on this adapter: lowercase 't' = standard 11-bit frame (real # SLCAN convention). Extended frames haven't been observed yet, so both # the documented 'X'/'x' and the real-SLCAN 'T' are accepted as 29-bit. if prefix == "t": id_hex, rest = text[1:4], text[4:] is_extended = False else: # "T", "x", or "X" id_hex, rest = text[1:9], text[9:] is_extended = True dlc = int(rest[0], 16) data_hex = rest[1 : 1 + dlc * 2] data = bytes.fromhex(data_hex) remainder = rest[1 + dlc * 2 :] # leftover = ms timestamp, if 'A1' was set timestamp_ms = int(remainder, 16) if len(remainder) == 4 else None return CanFrame( arbitration_id=int(id_hex, 16), is_extended=is_extended, data=data, timestamp_ms=timestamp_ms, recv_time=time.time(), ) except (ValueError, IndexError): return None def send_frame(self, arbitration_id: int, data: bytes, is_extended: bool = False) -> None: """Transmit a frame onto the bus. Standard frames use the observed 't' RX convention. Extended frames use 'T' (the real-SLCAN convention) - this has NOT been verified against real hardware, since no 29-bit traffic has been seen on this bus yet. """ if is_extended: line = f"T{arbitration_id:08X}{len(data):X}{data.hex().upper()}" else: line = f"t{arbitration_id:03X}{len(data):X}{data.hex().upper()}" self._ser.write((line + "\r").encode("ascii"))