259 lines
10 KiB
Python
259 lines
10 KiB
Python
"""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()
|