# 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()