"""WinUSB transport for candleLight/gs_usb CAN adapters. The native library is the same Candle API used by CANgaroo. This module keeps all ctypes details out of the UI and exposes a small Qt-friendly connection. """ from __future__ import annotations import ctypes as ct import os from dataclasses import dataclass from pathlib import Path import sys import threading import re from .qt_compat import QObject, Signal _EXTENDED_ID = 0x80000000 _FRAME_RECEIVE = 1 _MODE_LISTEN_ONLY = 0x0001 class CandleError(RuntimeError): pass @dataclass(frozen=True) class CandleChannel: path: str channel: int @property def label(self) -> str: lower = self.path.lower() product = "CANnectivity" if "vid_1209&pid_ca01" in lower else "candle" return f"{product} — канал {self.channel}" @property def adapter_kind(self) -> str: return "candle" class _Frame(ct.Structure): _pack_ = 1 _fields_ = [ ("echo_id", ct.c_uint32), ("can_id", ct.c_uint32), ("can_dlc", ct.c_uint8), ("channel", ct.c_uint8), ("flags", ct.c_uint8), ("reserved", ct.c_uint8), ("data", ct.c_uint8 * 8), ("timestamp_us", ct.c_uint32), ] def _library_path() -> Path: explicit = os.environ.get('CANDLE_LIBRARY') if explicit: return Path(explicit) bundled = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) candidates = ( Path(__file__).resolve().parents[1] / "native" / "candle.dll", bundled / "native" / "candle.dll", bundled / "candle.dll", ) return next((path for path in candidates if path.exists()), candidates[0]) class _Api: def __init__(self) -> None: path = _library_path() if sys.platform != "win32" or not path.exists(): raise CandleError(f"Candle API недоступен: {path}") self.dll = ct.WinDLL(str(path)) handle = ct.c_void_p self._fn("candle_list_scan", [ct.POINTER(handle)]) self._fn("candle_list_free", [handle]) self._fn("candle_list_length", [handle, ct.POINTER(ct.c_uint8)]) self._fn("candle_dev_get", [handle, ct.c_uint8, ct.POINTER(handle)]) self._fn("candle_dev_get_path", [handle], ct.c_wchar_p) self._fn("candle_dev_open", [handle]) self._fn("candle_dev_close", [handle]) self._fn("candle_dev_free", [handle]) self._fn("candle_dev_last_error", [handle], ct.c_int) self._fn("candle_channel_count", [handle, ct.POINTER(ct.c_uint8)]) self._fn("candle_channel_set_bitrate", [handle, ct.c_uint8, ct.c_uint32]) self._fn("candle_channel_start", [handle, ct.c_uint8, ct.c_uint32]) self._fn("candle_channel_stop", [handle, ct.c_uint8]) self._fn("candle_frame_send", [handle, ct.c_uint8, ct.POINTER(_Frame)]) self._fn("candle_frame_read", [handle, ct.POINTER(_Frame), ct.c_uint32]) self._fn("candle_frame_type", [ct.POINTER(_Frame)], ct.c_int) self._fn("candle_frame_id", [ct.POINTER(_Frame)], ct.c_uint32) def _fn(self, name: str, args: list, result=ct.c_bool) -> None: function = getattr(self.dll, name) function.argtypes = args function.restype = result def scan(self) -> list[CandleChannel]: result: list[CandleChannel] = [] physical_devices: set[str] = set() listing = ct.c_void_p() if not self.dll.candle_list_scan(ct.byref(listing)): raise CandleError("Не удалось выполнить поиск candle-адаптеров") try: count = ct.c_uint8() if not self.dll.candle_list_length(listing, ct.byref(count)): raise CandleError("Candle API не вернул список устройств") for index in range(count.value): device = ct.c_void_p() if not self.dll.candle_dev_get(listing, index, ct.byref(device)): continue try: if not self.dll.candle_dev_open(device): continue channels = ct.c_uint8() if self.dll.candle_channel_count(device, ct.byref(channels)): path = self.dll.candle_dev_get_path(device) or "" # Composite gs_usb devices expose MI_00, MI_02, ... as # separate Windows paths although each path reports all # CAN channels. CANgaroo folds them into one device. key = re.sub(r"&mi_[0-9a-f]+", "", path.casefold()) key = re.sub(r"&[0-9a-f]{4}(?=#\{)", "", key) if key not in physical_devices: physical_devices.add(key) result.extend(CandleChannel(path, channel) for channel in range(channels.value)) self.dll.candle_dev_close(device) finally: self.dll.candle_dev_free(device) finally: self.dll.candle_list_free(listing) return result def acquire(self, wanted_path: str) -> ct.c_void_p: listing = ct.c_void_p() if not self.dll.candle_list_scan(ct.byref(listing)): raise CandleError("Не удалось обновить список candle-адаптеров") try: count = ct.c_uint8() self.dll.candle_list_length(listing, ct.byref(count)) for index in range(count.value): device = ct.c_void_p() if not self.dll.candle_dev_get(listing, index, ct.byref(device)): continue path = self.dll.candle_dev_get_path(device) or "" if path.casefold() == wanted_path.casefold(): return device self.dll.candle_dev_free(device) finally: self.dll.candle_list_free(listing) raise CandleError("Выбранный candle-адаптер больше не подключён") class CandleAdapter(QObject): """One opened classic-CAN channel with a background RX loop.""" 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._api: _Api | None = None self._device: ct.c_void_p | None = None self._channel = 0 self._stop = threading.Event() self._reader: threading.Thread | None = None self._send_lock = threading.Lock() self._listen_only = False def _get_api(self) -> _Api: if self._api is None: self._api = _Api() return self._api def scan(self) -> list[CandleChannel]: return self._get_api().scan() @property def connected(self) -> bool: return self._device is not None def connect_channel(self, channel: CandleChannel, bitrate: int, listen_only: bool = False) -> None: self.disconnect_channel() api = self._get_api() device = api.acquire(channel.path) try: if not api.dll.candle_dev_open(device): raise CandleError("Не удалось открыть candle-адаптер") if not api.dll.candle_channel_set_bitrate(device, channel.channel, bitrate): raise CandleError(f"Адаптер не поддерживает {bitrate} бит/с") mode = _MODE_LISTEN_ONLY if listen_only else 0 if not api.dll.candle_channel_start(device, channel.channel, mode): raise CandleError("Не удалось запустить CAN-канал") except Exception: api.dll.candle_dev_close(device) api.dll.candle_dev_free(device) raise self._device = device self._channel = channel.channel self._listen_only = listen_only self._stop.clear() self._reader = threading.Thread(target=self._read_loop, name="candle-rx", daemon=True) self._reader.start() def disconnect_channel(self) -> None: device, self._device = self._device, None if device is None: return self._stop.set() if self._reader is not None: self._reader.join(timeout=0.3) api = self._get_api() api.dll.candle_channel_stop(device, self._channel) api.dll.candle_dev_close(device) api.dll.candle_dev_free(device) self._reader = None self._listen_only = False def send(self, can_id: int, data: bytes) -> None: device = self._device if device is None: raise CandleError("Candle-адаптер не подключён") if self._listen_only: raise CandleError("Candle-адаптер открыт в режиме только приёма") if len(data) > 8: raise CandleError("Classic CAN поддерживает не более 8 байт") frame = _Frame() frame.can_id = (can_id & 0x1FFFFFFF) | _EXTENDED_ID frame.can_dlc = len(data) frame.channel = self._channel frame.data[:len(data)] = data with self._send_lock: if not self._get_api().dll.candle_frame_send( device, self._channel, ct.byref(frame)): raise CandleError("Candle API не передал CAN-кадр") def _read_loop(self) -> None: device = self._device if device is None: return api = self._get_api() while not self._stop.is_set() and self._device is device: frame = _Frame() if not api.dll.candle_frame_read(device, ct.byref(frame), 50): continue if (api.dll.candle_frame_type(ct.byref(frame)) == _FRAME_RECEIVE and frame.channel == self._channel): size = min(frame.can_dlc, 8) self.capture_received.emit(api.dll.candle_frame_id(ct.byref(frame)), bytes(frame.data[:size]), bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000)) self.frame_received.emit(api.dll.candle_frame_id(ct.byref(frame)), bytes(frame.data[:size])) def close(self) -> None: self.disconnect_channel()