Extract reusable device protocols, ports and logic analyzers from SETGUI

This commit is contained in:
2026-09-23 20:11:37 +03:00
parent 80ba17d77d
commit 795a1279b1
63 changed files with 10181 additions and 6 deletions
@@ -0,0 +1,258 @@
"""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()