Files
templates/python/set_devices/qt_ports/candle_adapter.py
T
Andrey a5e46d4627 Расширить обмен по шинам, CAN-адаптеры и обработку графиков
Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
2026-10-17 14:36:14 +03:00

435 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()