Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
435 lines
41 KiB
Python
435 lines
41 KiB
Python
# Qt-совместимый транспорт WinUSB для candleLight/gs_usb. Детали ctypes скрыты в адаптере;
|
||
# жизненный цикл устройства и канала должен завершаться закрытием ресурсов независимо от
|
||
# состояния интерфейса.
|
||
|
||
"""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, field # Имя продукта хранится рядом с адресом канала, но не меняет идентичность физического подключения.
|
||
from pathlib import Path
|
||
import sys
|
||
import threading
|
||
import re
|
||
|
||
from .qt_compat import QObject, Signal
|
||
from ..can_adapter_settings import CandleOptions, CanTiming, FD_LENGTHS, calculate_timing # Общая модель проверяет флаги, DLC и аппаратные пределы тайминга.
|
||
from collections import deque # Ограниченный журнал FD сохраняет кадры даже при закрытом окне диагностики.
|
||
|
||
|
||
_EXTENDED_ID = 0x80000000
|
||
_FRAME_RECEIVE = 1
|
||
_FRAME_ECHO = 2
|
||
_FRAME_ERROR = 3
|
||
_MODE_LISTEN_ONLY = 0x0001
|
||
|
||
|
||
class CandleError(RuntimeError):
|
||
pass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CandleChannel:
|
||
path: str
|
||
channel: int
|
||
product: str = field(default="", compare=False) # Настоящая USB-строка продукта; смена подписи не создаёт другое подключение с тем же путём и каналом.
|
||
|
||
@property
|
||
def label(self) -> str:
|
||
lower = self.path.lower()
|
||
product = self.product.strip() or ("CANnectivity" if "vid_1209&pid_ca01" in lower else "candle") # Предпочитает имя из прошивки, сохраняя прежние подписи при отсутствии USB-строки или старой DLL.
|
||
return f"{product} — канал {self.channel}" # Показывает пользовательский номер в имени продукта и независимый индекс CAN-канала.
|
||
|
||
@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),
|
||
]
|
||
|
||
|
||
class _FdFrame(ct.Structure): # gs_usb сохраняет общий заголовок Classic и FD, расширяя только поле данных.
|
||
_pack_ = 1 # USB-пакет не содержит выравнивающих отступов компилятора.
|
||
_fields_ = _Frame._fields_[:-2] + [("data", ct.c_uint8 * 64), ("timestamp_us", ct.c_uint32)] # 64 байта — максимальная длина данных CAN FD.
|
||
|
||
|
||
class _Capability(ct.Structure): # Поля соответствуют candle_capability_t и ответу BT_CONST.
|
||
_fields_ = [(name, ct.c_uint32) for name in ("feature", "fclk_can", "tseg1_min", "tseg1_max", "tseg2_min", "tseg2_max", "sjw_max", "brp_min", "brp_max", "brp_inc")] # Частота измеряется в герцах, сегменты — в квантах CAN.
|
||
|
||
|
||
class _Timing(ct.Structure): # Структура передаёт пять параметров в управляющий endpoint USB.
|
||
_fields_ = [(name, ct.c_uint32) for name in ("prop_seg", "phase_seg1", "phase_seg2", "sjw", "brp")] # Порядок совпадает с публичным C ABI.
|
||
|
||
|
||
def _timing(value: CanTiming) -> _Timing: # Преобразуем проверенную переносимую модель в ctypes без зависимости модели от Windows.
|
||
return _Timing(value.prop_seg, value.phase_seg1, value.phase_seg2, value.sjw, value.brp) # Передаём сегменты и делитель без округления.
|
||
|
||
|
||
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)
|
||
if hasattr(self.dll, "candle_dev_get_product"): # Поддерживает прежние версии библиотеки без функции чтения имени USB.
|
||
self._fn("candle_dev_get_product", [handle, ct.c_wchar_p, ct.c_uint32]) # Согласует выходной UTF-16-буфер и его размер в кодовых единицах с публичным C ABI.
|
||
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)
|
||
self._fn("candle_channel_get_capabilities", [handle, ct.c_uint8, ct.POINTER(_Capability)]) # Базовый API возможностей доступен и у прежней библиотеки.
|
||
self._fn("candle_channel_set_timing", [handle, ct.c_uint8, ct.POINTER(_Timing)]) # Ручной и рассчитанный тайминги используют один USB-запрос.
|
||
optional = {"candle_channel_start_configured": [handle, ct.c_uint8, ct.c_uint32], "candle_channel_get_data_capabilities": [handle, ct.c_uint8, ct.POINTER(_Capability)], "candle_channel_set_termination": [handle, ct.c_uint8, ct.c_uint32], "candle_channel_get_termination": [handle, ct.c_uint8, ct.POINTER(ct.c_uint32)], "candle_channel_get_diagnostics": [handle, ct.c_uint8, ct.POINTER(ct.c_uint32), ct.POINTER(ct.c_uint32), ct.POINTER(ct.c_uint32)], "candle_channel_bus_off_recover": [handle, ct.c_uint8], "candle_channel_set_data_timing": [handle, ct.c_uint8, ct.POINTER(_Timing)], "candle_fd_frame_send": [handle, ct.c_uint8, ct.POINTER(_FdFrame)], "candle_fd_frame_read": [handle, ct.POINTER(_FdFrame), ct.c_uint32], "candle_fd_frame_type": [ct.POINTER(_FdFrame)]} # Необязательные экспорты позволяют сообщить понятную ошибку старой DLL.
|
||
for name, arguments in optional.items(): # Регистрация выполняется один раз при загрузке библиотеки.
|
||
if hasattr(self.dll, name): # Отсутствующее расширение проверяется при его явном выборе.
|
||
self._fn(name, arguments, ct.c_int if name.endswith("frame_type") else ct.c_bool) # Тип кадра — enum, остальные функции возвращают bool.
|
||
|
||
def _fn(self, name: str, args: list, result=ct.c_bool) -> None:
|
||
function = getattr(self.dll, name)
|
||
function.argtypes = args
|
||
function.restype = result
|
||
|
||
def enter_dfu(self, path): # Открывает отдельный runtime-интерфейс выбранного адаптера через проверяемый экспорт DLL.
|
||
function = getattr(self.dll, "candle_dev_enter_dfu", None) # Старая библиотека не должна молча имитировать успешный переход.
|
||
if function is None: # Проверяет ABI до остановки действующего CAN-сеанса.
|
||
raise CandleError("Обновите candle.dll: переход в DFU недоступен") # Оставляет пользователю понятную причину отсутствующего расширения.
|
||
function.argtypes, function.restype = [ct.c_wchar_p, ct.POINTER(ct.c_uint8)], ct.c_uint32 # Передаёт точный USB-путь UTF-16, шестибайтовый статус и код Windows фиксированной ширины.
|
||
status = (ct.c_uint8 * 6)() # DFU 1.1 возвращает bStatus, три байта задержки, bState и iString.
|
||
error = function(path, status) # DLL выбирает интерфейс обновления по общему родителю с CAN-интерфейсом.
|
||
if error: # Отказ доступа, отсутствие интерфейса и потеря USB не являются подтверждением DFU.
|
||
detail = "Интерфейс обновления не найден: нужна прошивка CANable 2.5 / weCANdle и драйвер WinUSB для Firmware Update Interface" if error == 1168 else ct.FormatError(ct.c_int32(error).value).strip() # ERROR_NOT_FOUND равен 1168; знаковое представление сохраняет 32 бита кодов SetupAPI 0xE000xxxx без OverflowError в ctypes.
|
||
if error in (0xE000020B, 0xE000021C, 0xE0000225): # SetupAPI обозначает этими кодами отсутствующий узел, удалённый интерфейс или неизвестный путь USB.
|
||
detail = "Выбранный USB-адаптер больше не найден. Переподключите его и обновите список устройств." # Даёт понятный следующий шаг вместо отсутствующего системного описания SetupAPI.
|
||
raise CandleError(f"Не удалось подтвердить переход в DFU: {detail}") # Не предлагает записывать flash до обнаружения настоящего загрузчика.
|
||
return bytes(status) # Сохраняет точные поля ответа прошивки для проверки протокола вызывающим методом.
|
||
|
||
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)
|
||
product = self.product_name(device) # Читает имя один раз для всех каналов уже открытого физического USB-адаптера.
|
||
result.extend(CandleChannel(path, channel, product) 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 product_name(self, device): # Читает отображаемое имя устройства без изменения его USB-пути и состояния CAN.
|
||
function = getattr(self.dll, "candle_dev_get_product", None) # Не требует нового экспорта от старой совместимой библиотеки.
|
||
if function is None: return "" # Отсутствие функции включает существующую резервную подпись вместо отказа сканирования.
|
||
product = ct.create_unicode_buffer(128) # USB-строка содержит максимум 126 кодовых единиц UTF-16 плюс завершающий ноль.
|
||
return product.value.strip() if function(device, product, len(product)) else "" # Не выдаёт пустой или неудачно прочитанный дескриптор за новое имя адаптера.
|
||
|
||
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)
|
||
transmit_echo = Signal(int, bytes, bool, bool)
|
||
bus_error = Signal(int, bytes, int)
|
||
connection_lost = Signal(str)
|
||
fd_received = Signal(int, bytes, bool, int, int) # FD получает отдельный маршрут: ID, данные, EXT, флаги gs_usb и аппаратная метка в микросекундах.
|
||
|
||
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
|
||
self.options = CandleOptions() # Настройки сеанса не меняются после запуска канала.
|
||
self.capabilities = None # Реальная маска и пределы становятся известны после открытия устройства.
|
||
self.nominal_timing, self.fd_timing = None, None # Диагностика покажет фактические сегменты и достигнутую точку выборки.
|
||
self.fd_history = deque(maxlen=500) # Последние 500 FD-кадров доступны в окне диагностики без неограниченного роста памяти.
|
||
|
||
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, *, options: CandleOptions | None = None) -> None: # Необязательный объект сохраняет совместимость прежних вызовов.
|
||
self.disconnect_channel()
|
||
selected = options or CandleOptions() # При отсутствии расширенных настроек сохраняются обычный режим и автоматические метки.
|
||
api = self._get_api()
|
||
device = api.acquire(channel.path)
|
||
try:
|
||
if not api.dll.candle_dev_open(device):
|
||
raise CandleError("Не удалось открыть candle-адаптер")
|
||
cap = _Capability() # У каждого канала могут быть собственные ограничения контроллера.
|
||
self._check(api.dll.candle_channel_get_capabilities(device, channel.channel, ct.byref(cap)), "прочитать возможности канала") # Отсутствие ответа не считается отсутствием всех возможностей.
|
||
mode = selected.flags(listen_only, cap.feature) # Неподдерживаемый явный флаг останавливает подключение до настройки шины.
|
||
nominal = selected.timing or calculate_timing(cap, bitrate, selected.sample_point) # Автоматический расчёт поддерживает частоты, отличные от 48 МГц.
|
||
nominal.validate(cap, bitrate) # Ручной тайминг также обязан совпадать с выбранной скоростью.
|
||
data = None # Отсутствие FD оставляет быструю фазу неподключённой и не показывает фиктивный тайминг.
|
||
timing = _timing(nominal) # ctypes-объект живёт до завершения синхронного USB-запроса.
|
||
self._check(api.dll.candle_channel_set_timing(device, channel.channel, ct.byref(timing)), "установить номинальный тайминг") # Скорость не округляется по старой таблице фиксированных делителей.
|
||
if selected.fd: # До запуска FD настраиваем и проверяем быструю фазу.
|
||
for name in ("candle_channel_get_data_capabilities", "candle_channel_set_data_timing", "candle_fd_frame_read", "candle_fd_frame_send", "candle_fd_frame_type"): # Полноценная работа требует и чтения, и записи FD.
|
||
self._require(name) # Устаревшая DLL выдаёт явную ошибку вместо обрезания 64-байтовых кадров.
|
||
data_cap = _Capability() # Расширенный запрос возвращает отдельные пределы фазы данных.
|
||
self._check(api.dll.candle_channel_get_data_capabilities(device, channel.channel, ct.byref(data_cap)), "прочитать пределы CAN FD") # Без пределов нельзя безопасно рассчитать быструю фазу.
|
||
data = selected.data_timing or calculate_timing(data_cap, selected.data_bitrate, selected.data_sample_point) # Быстрая фаза получает собственную скорость и точку выборки.
|
||
data.validate(data_cap, selected.data_bitrate) # Проверяем ручной тайминг по ограничениям FD, а не номинальной фазы.
|
||
data_timing = _timing(data) # Подготавливаем структуру стандартного DATA_BITTIMING.
|
||
self._check(api.dll.candle_channel_set_data_timing(device, channel.channel, ct.byref(data_timing)), "установить тайминг CAN FD") # Канал не запускается при отказе быстрой фазы.
|
||
if selected.termination is not None: # Состояние терминатора меняется только по явному выбору пользователя.
|
||
self._check(self._require("candle_channel_set_termination")(device, channel.channel, int(selected.termination)), "переключить терминатор") # Проверенная возможность исключает попытку управления внешним резистором.
|
||
starter = getattr(api.dll, "candle_channel_start_configured", None) # Новый экспорт не добавляет HW_TIMESTAMP к точной маске настроек.
|
||
if starter is None and selected.hardware_timestamp is False: # Старый start автоматически включает аппаратные метки.
|
||
raise CandleError("Для отключения аппаратных меток обновите candle.dll") # Не показываем настройку выполненной, когда старая DLL её отменяет.
|
||
self._check((starter or api.dll.candle_channel_start)(device, channel.channel, mode), "запустить 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.options = selected # Читатель выбирает размер структуры по реально запущенному режиму.
|
||
self.capabilities = cap # Диагностика показывает фактическую маску функций и частоту канала.
|
||
self.nominal_timing, self.fd_timing = nominal, data # Сохраняем действительно переданные сегменты, а не только желаемый процент выборки.
|
||
self.fd_history.clear() # Кадры предыдущего устройства не смешиваются с новым сеансом.
|
||
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 enter_dfu_mode(self): # Останавливает текущий CAN-сеанс перед обращением к отдельному USB-интерфейсу загрузчика.
|
||
api = self._get_api() # Использует уже загруженную библиотеку транспорта, не создавая второго владельца CAN.
|
||
if not hasattr(api.dll, "candle_dev_enter_dfu"): # Не закрывает рабочий канал при заведомо несовместимой версии DLL.
|
||
raise CandleError("Обновите candle.dll: переход в DFU недоступен") # Объясняет необходимость обновления встроенной библиотеки SETGUI.
|
||
with self._send_lock: # Запрещает одновременную передачу кадра во время остановки и освобождения USB.
|
||
if not self.connected: # Команда должна относиться к конкретному подключённому адаптеру пользователя.
|
||
raise CandleError("Сначала подключите Candle / weCANdle-адаптер") # Не выбирает первый найденный USB-адаптер автоматически.
|
||
path = api.dll.candle_dev_get_path(self._device) # Копирует точный путь до освобождения нативного handle устройства.
|
||
if not path: # Пустой путь не позволяет надёжно выбрать физический интерфейс обновления.
|
||
raise CandleError("Не удалось определить USB-путь выбранного адаптера") # Сохраняет подключение при невозможности адресовать команду.
|
||
self.disconnect_channel() # Останавливает RX-поток, сбрасывает канал CAN и освобождает WinUSB-сеанс до DETACH.
|
||
self.connection_lost.emit("CAN-сеанс остановлен для перехода адаптера в DFU") # Обновляет владельца соединения и отменяет его пинг, обмен и отправку кадров.
|
||
status = api.enter_dfu(path) # Отдельный управляющий сеанс не использует освобождённый CAN-handle.
|
||
if len(status) != 6: # Неполный ответ не должен выглядеть как подтверждённый статус DFU.
|
||
raise CandleError("Неполный ответ DFU_GETSTATUS") # Защищает разбор полей при несовместимой обёртке DLL.
|
||
if status[0] or status[4] == 10: # bStatus ненулевой либо bState=dfuERROR по DFU 1.1 означают отказ прошивки.
|
||
raise CandleError(f"Прошивка отклонила DFU: статус {status[0]}, код {status[5]}") # iString передаёт дополнительный код ошибки CANable 2.5.
|
||
if status[4] == 1: # appDETACH означает необходимость аппаратного сброса после восстановления настройки BOOT0.
|
||
return "Для входа в DFU отключите и снова подключите USB-кабель адаптера." # Не утверждает, что устройство уже появилось в загрузчике.
|
||
if status[4] != 0: # CANable 2.5 подтверждает отложенный переход только состоянием appIDLE.
|
||
raise CandleError(f"Неожиданное состояние DFU: {status[4]}") # Не принимает неизвестное состояние за успешную команду.
|
||
return "Команда входа в DFU принята. Откройте STM32CubeProgrammer → USB и обновите список устройств." # Отделяет подтверждение команды от фактического обнаружения ROM-загрузчика.
|
||
|
||
def send(self, can_id: int, data: bytes, *, extended: bool = True) -> 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()
|
||
if not 0 <= can_id <= (0x1FFFFFFF if extended else 0x7FF):
|
||
raise CandleError("CAN ID вне диапазона")
|
||
frame.can_id = can_id | (_EXTENDED_ID if extended else 0)
|
||
frame.can_dlc = len(data)
|
||
frame.channel = self._channel
|
||
frame.data[:len(data)] = data
|
||
with self._send_lock:
|
||
if self._device is not device: # Переход в DFU мог освободить handle, пока отправитель ожидал блокировку передачи.
|
||
raise CandleError("CAN-сеанс уже остановлен") # Не передаёт сохранённый указатель в DLL после закрытия USB-адаптера.
|
||
if not self._get_api().dll.candle_frame_send(
|
||
device, self._channel, ct.byref(frame)):
|
||
raise CandleError("Candle API не передал CAN-кадр")
|
||
|
||
def _require(self, name): # Проверяем необязательный экспорт непосредственно перед использованием расширения.
|
||
function = getattr(self._get_api().dll, name, None) # Имя берётся из внутреннего списка функций, а не от пользователя.
|
||
if function is None: # Старый бинарный файл не должен приводить к необработанному AttributeError.
|
||
raise CandleError(f"Обновите candle.dll: отсутствует {name}") # Указываем конкретное несовместимое расширение.
|
||
return function # Возвращаем функцию с уже зарегистрированной сигнатурой ctypes.
|
||
|
||
@staticmethod # Проверка результата не зависит от состояния Qt-объекта.
|
||
def _check(result, action): # Все USB-настройки требуют подтверждённого результата bool.
|
||
if not result: # false означает, что устройство не приняло настройку или запрос завершился ошибкой.
|
||
raise CandleError(f"Candle: не удалось {action}") # Контекст этапа помогает исправить выбранную конфигурацию.
|
||
|
||
def diagnostics(self) -> str: # Чтение выполняется только у уже открытого канала без повторного захвата устройства.
|
||
if not self.connected: # Опрос не должен неявно подключать CAN или менять выбранный режим.
|
||
raise CandleError("Сначала подключите адаптер") # Пользователь явно управляет жизненным циклом соединения.
|
||
cap = self.capabilities # Используем сохранённый при подключении ответ BT_CONST.
|
||
lines = [f"Возможности: 0x{cap.feature:08X}; частота CAN: {cap.fclk_can} Гц", f"TSEG1 {cap.tseg1_min}…{cap.tseg1_max}; TSEG2 {cap.tseg2_min}…{cap.tseg2_max}; SJW ≤ {cap.sjw_max}; BRP {cap.brp_min}…{cap.brp_max}, шаг {cap.brp_inc}"] # Единицы и пределы показываются вместе с маской.
|
||
for name, value in (("Номинальная фаза", self.nominal_timing), ("Данные FD", self.fd_timing)): # Обе фазы могут иметь разные достижимые точки выборки.
|
||
if value is not None: # Classic-сеанс не должен показывать параметры неиспользуемой быстрой фазы.
|
||
total = 1 + value.prop_seg + value.phase_seg1 + value.phase_seg2 # Полное число квантов в бите включает SYNC_SEG.
|
||
lines.append(f"{name}: {cap.fclk_can / value.brp / total:g} бит/с, выборка {(total - value.phase_seg2) / total * 100:.2f} %, PROP/PH1/PH2/SJW/BRP = {value.prop_seg}/{value.phase_seg1}/{value.phase_seg2}/{value.sjw}/{value.brp}") # Показываем фактически настроенную скорость и точку вместо обещания недостижимого точного процента.
|
||
if cap.feature & 8192: # GET_STATE объявляется битом 13 протокола gs_usb.
|
||
state, rx, tx = ct.c_uint32(), ct.c_uint32(), ct.c_uint32() # Все три значения относятся к одному ответу контроллера.
|
||
self._check(self._require("candle_channel_get_diagnostics")(self._device, self._channel, ct.byref(state), ct.byref(rx), ct.byref(tx)), "прочитать состояние") # Не подменяем отказ нулевыми счётчиками.
|
||
names = ("Error-active", "Error-warning", "Error-passive", "Bus-off", "Stopped", "Sleeping") # Стандартные состояния контроллера CAN согласно gs_usb.
|
||
lines.append(f"Состояние: {names[state.value] if state.value < len(names) else state.value}; RX errors: {rx.value}; TX errors: {tx.value}") # Показываем счётчики ошибок отдельно от числа принятых кадров.
|
||
if cap.feature & 2048: # Только аппаратно переключаемый терминатор имеет читаемое состояние.
|
||
state = ct.c_uint32() # Нулевое значение означает OFF, единица — ON.
|
||
self._check(self._require("candle_channel_get_termination")(self._device, self._channel, ct.byref(state)), "прочитать терминатор") # GET_TERMINATION не меняет сопротивление на шине.
|
||
lines.append("Терминатор: " + ("120 Ом" if state.value else "выключен")) # Указываем физический смысл переключателя.
|
||
return "\n".join(lines) # Текст пригоден для диагностики и копирования без дополнительных файлов.
|
||
|
||
def recover_bus_off(self) -> None: # Восстановление выполняется только по явной кнопке диагностики.
|
||
if not self.connected or not self.capabilities.feature & 0x40000: # Расширение BUS_OFF_RECOVERY не является универсальной функцией gs_usb.
|
||
raise CandleError("Адаптер не объявил поддержку восстановления Bus-off") # Не отправляем неизвестный vendor-запрос старой прошивке.
|
||
self._check(self._require("candle_channel_bus_off_recover")(self._device, self._channel), "восстановить Bus-off") # Результат определяется реальным ответом устройства.
|
||
|
||
def send_fd(self, can_id: int, data: bytes, *, extended: bool = True, brs: bool = True) -> None: # Отдельный API не меняет семантику Classic CAN send.
|
||
if not self.connected or self._listen_only or not self.options.fd: # Передача требует активного канала с настроенной быстрой фазой.
|
||
raise CandleError("Передача FD требует активного подключения CAN FD") # Пассивный режим защищён и при прямом вызове API.
|
||
if len(data) not in FD_LENGTHS or not 0 <= can_id <= (0x1FFFFFFF if extended else 0x7FF): # Стандарт FD допускает только длины из таблицы DLC.
|
||
raise CandleError("Неверный CAN ID или длина CAN FD (0…8, 12, 16, 20, 24, 32, 48, 64)") # Не дополняем незаметно пользовательский пакет.
|
||
frame = _FdFrame() # Полная структура предотвращает выход за пределы восьмибайтового буфера Classic.
|
||
frame.can_id = can_id | (_EXTENDED_ID if extended else 0) # Старший бит указывает расширенный 29-битный идентификатор.
|
||
frame.can_dlc, frame.flags = FD_LENGTHS.index(len(data)), 2 | (4 if brs else 0) # Биты 1 и 2 означают FD и переключение скорости BRS.
|
||
frame.data[:len(data)] = data # Остаток буфера обнулён конструктором ctypes.
|
||
with self._send_lock: # Classic и FD используют один USB OUT endpoint и один native event.
|
||
self._check(self._require("candle_fd_frame_send")(self._device, self._channel, ct.byref(frame)), "передать CAN FD") # Ошибка записи остаётся видимой вызывающему коду.
|
||
|
||
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 = _FdFrame() if self.options.fd else _Frame() # В FD-сеансе библиотека возвращает оба формата в безопасной структуре на 64 байта.
|
||
reader = api.dll.candle_fd_frame_read if self.options.fd else api.dll.candle_frame_read # Выбор функции согласован с размером выходного буфера.
|
||
if not reader(device, ct.byref(frame), 50): # 50 мс ограничивает задержку остановки фонового потока.
|
||
continue
|
||
if frame.channel != self._channel:
|
||
continue
|
||
kind = (api.dll.candle_fd_frame_type if self.options.fd else api.dll.candle_frame_type)(ct.byref(frame)) # ctypes не смешивает указатели на разные структуры.
|
||
if frame.flags & 2: # FD даже с короткими данными нельзя передавать декодерам как Classic CAN.
|
||
if frame.can_dlc > 15: # DLC имеет только четыре значащих бита согласно CAN FD.
|
||
continue # Повреждённый пакет не должен индексировать таблицу длин вне диапазона.
|
||
record = (frame.can_id & 0x1FFFFFFF, bytes(frame.data[:FD_LENGTHS[frame.can_dlc]]), bool(frame.can_id & _EXTENDED_ID), frame.flags, frame.timestamp_us) # Сохраняем полный payload и исходную метку в микросекундах.
|
||
self.fd_history.append(("TX echo" if kind == _FRAME_ECHO else "RX", record)) # Журнал явно отличает эхо от получения кадра с шины.
|
||
self.fd_received.emit(*record) # Просмотр FD использует самостоятельный маршрут и не попадает в мост RS485.
|
||
continue # Короткий FD-кадр также не маскируется под Classic.
|
||
size = min(frame.can_dlc, 8)
|
||
data = bytes(frame.data[:size])
|
||
if kind == _FRAME_ERROR or frame.flags & 0x01:
|
||
self.bus_error.emit(frame.can_id & 0x1FFFFFFF if kind == _FRAME_ERROR else 0,
|
||
data, frame.flags)
|
||
if kind == _FRAME_ECHO:
|
||
self.transmit_echo.emit(frame.can_id & 0x1FFFFFFF, data, # Маска выделяет идентификатор без приведения FD-структуры к указателю Classic.
|
||
bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000))
|
||
elif kind == _FRAME_RECEIVE:
|
||
self.capture_received.emit(frame.can_id & 0x1FFFFFFF, # Сохраняем STD/EXT ID независимо от выбранной структуры приёма.
|
||
data, bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000))
|
||
self.frame_received.emit(frame.can_id & 0x1FFFFFFF, # Совместимый сигнал получает только Classic-кадры, обработанные выше.
|
||
data)
|
||
|
||
def close(self) -> None:
|
||
self.disconnect_channel()
|