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

View File

@@ -0,0 +1 @@
"""Reusable device libraries from setcorp/templates."""

View File

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

View File

@@ -0,0 +1,187 @@
"""@file mock_port.py
@brief Qt-таймер, имитирующий подключённый контроллер без COM-порта.
Реализует mock-устройство для тестирования и разработки без физического
оборудования. Использует детерминированные значения для надёжного тестирования.
"""
from __future__ import annotations
from .qt_compat import QElapsedTimer, QObject, QTimer, Signal
from set_devices.ds18b20 import MockSensorBus
from set_devices.eeprom import CatalogEntry, MemoryInfo
from set_devices.models import MockSignalSource, SignalCatalog
from set_devices.panel import MockPanel
class MockDevicePort(QObject):
"""@brief Асинхронный mock-порт с интерфейсом реального порта.
Qt-таймер является платформенным планировщиком, а генерация значений
делегируется переносимому ``MockSignalSource``.
"""
connected_changed = Signal(bool, str)
snapshot_received = Signal(object)
sensors_received = Signal(object)
memory_info_received = Signal(object)
catalog_received = Signal(object)
sensor_list_received = Signal(object)
panel_received = Signal(object)
tx_logged = Signal(str)
rx_logged = Signal(str)
error_occurred = Signal(str)
def __init__(self, catalog: SignalCatalog, parent: QObject | None = None) -> None:
super().__init__(parent)
self._source = MockSignalSource(catalog)
self._bus = MockSensorBus()
# Каталог mock-прибора: два датчика уже разложены по позициям одной
# сборки (assembly_serial=7), третий остаётся без записи.
self._catalog = [
CatalogEntry(rom=self._bus.roms()[0], position=1, assembly_serial=7),
CatalogEntry(rom=self._bus.roms()[2], position=258, assembly_serial=7),
]
self._panel = MockPanel(self._panel_rows)
self._elapsed = QElapsedTimer()
self._timer = QTimer(self)
self._timer.setInterval(250)
self._timer.timeout.connect(self._publish_snapshot)
def open(self) -> None:
"""@brief Запускает генерацию четырёх снимков в секунду.
Повторный вызов безопасен и не создаёт второй таймер.
"""
if self._timer.isActive():
return
self._elapsed.start()
self._timer.start()
self.connected_changed.emit(True, "MOCK")
self.rx_logged.emit("Mock-контроллер подключён")
self._publish_snapshot()
def close(self) -> None:
"""@brief Останавливает генератор и публикует отключение.
Повторное закрытие не создаёт ложное событие изменения состояния.
"""
was_active = self._timer.isActive()
self._timer.stop()
if was_active:
self.connected_changed.emit(False, "MOCK")
def scan_sensors(self) -> None:
"""@brief Публикует список ROM mock-шины 1-Wire.
Поиск выполняется мгновенно: модель шины известна заранее.
"""
self.tx_logged.emit("SENSOR_SCAN mock")
self.sensor_list_received.emit(self._bus.roms())
self.publish_sensors()
def publish_sensors(self) -> None:
"""@brief Публикует очередной набор измерений DS18B20."""
self.sensors_received.emit(self._bus.snapshot(self._timestamp_s()))
def scan_memory(self) -> None:
"""@brief Публикует состояние mock-памяти и каталога позиций."""
self.tx_logged.emit("EEPROM_SCAN mock")
self.memory_info_received.emit(
MemoryInfo(
present=True,
persistent=True,
count=len(self._catalog),
capacity=32,
i2c_address=0x50,
page_size=64,
size=32768,
base_address=1024,
bus_errors=0,
blob_size=332,
)
)
def read_catalog(self) -> None:
"""@brief Публикует записи каталога mock-прибора."""
self.tx_logged.emit("EEPROM_READ mock")
self.catalog_received.emit(list(self._catalog))
def set_user_bytes(self, rom: bytes, user_byte1: int, user_byte2: int) -> None:
"""@brief Применяет пользовательские байты к mock-датчику.
@param rom Идентификатор датчика на mock-шине.
@param user_byte1 Новое значение TH.
@param user_byte2 Новое значение TL.
"""
try:
self._bus.set_user_bytes(rom, user_byte1, user_byte2)
except (KeyError, ValueError) as error:
self.error_occurred.emit(str(error))
return
self.tx_logged.emit(f"SET_USER_BYTES mock {user_byte1:#04x} {user_byte2:#04x}")
self.publish_sensors()
def set_resolution(self, rom: bytes, bits: int) -> None:
"""@brief Меняет разрешение mock-датчика.
@param rom Идентификатор датчика на mock-шине.
@param bits Разрешение 9..12 бит.
"""
try:
self._bus.set_resolution(rom, bits)
except (KeyError, ValueError) as error:
self.error_occurred.emit(str(error))
return
self.tx_logged.emit(f"SET_RESOLUTION mock {bits} бит")
self.publish_sensors()
def read_panel(self) -> None:
"""@brief Публикует снимок экрана mock-панели."""
self.panel_received.emit(self._panel.screen())
def press_panel_key(self, key: int, hold: bool = False) -> None:
"""@brief Применяет нажатие кнопки к mock-панели.
@param key Код кнопки из PanelKey.
@param hold Длинное нажатие.
"""
self._panel.press(key, hold)
self.tx_logged.emit(f"UI_KEY mock {int(key)}{' hold' if hold else ''}")
self.read_panel()
def _panel_rows(self) -> list[tuple[str, str]]:
"""Строки экрана датчиков mock-панели: номер и температура."""
rows: list[tuple[str, str]] = []
for index, reading in enumerate(self._bus.snapshot(self._timestamp_s())):
temperature = reading.temperature
rows.append((
f"{index + 1} {reading.serial}",
"---" if temperature is None else f"{temperature:+.1f}",
))
return rows
def set_output(self, key: str, value: bool) -> None:
"""@brief Применяет управляющую дискрету к mock-модели.
@param key Ключ разрешённого выхода.
@param value Новое состояние; ошибка неизвестного ключа идёт в signal.
"""
try:
self._source.set_output(key, value)
except KeyError as error:
self.error_occurred.emit(str(error))
return
self.tx_logged.emit(f"{key} = {int(value)}")
self._publish_snapshot()
def _timestamp_s(self) -> float:
"""Возвращает монотонное время mock-порта в секундах."""
return self._elapsed.elapsed() / 1000.0
def _publish_snapshot(self) -> None:
"""@brief Создаёт и публикует атомарный снимок вне UI-виджетов."""
timestamp_s = self._timestamp_s()
self.snapshot_received.emit(self._source.snapshot(timestamp_s))
self.sensors_received.emit(self._bus.snapshot(timestamp_s))

View File

@@ -0,0 +1,7 @@
"""Qt transport dependencies; importing set_devices itself never loads Qt."""
try:
from PySide6.QtCore import QObject, Signal, QTimer, QElapsedTimer
from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo
except ImportError:
from PySide2.QtCore import QObject, Signal, QTimer, QElapsedTimer
from PySide2.QtSerialPort import QSerialPort, QSerialPortInfo

View File

@@ -0,0 +1,212 @@
"""@file serial_port.py
@brief Неблокирующий QSerialPort и потоковый parser GUI transport.
Реализует асинхронный интерфейс к последовательному порту с полностью
неблокирующей обработкой данных и автоматическим разбором кадров протокола.
"""
from __future__ import annotations
from set_devices.protocol_capture import uart_event
from .qt_compat import QObject, Signal
from .qt_compat import QSerialPort, QSerialPortInfo
from set_devices.protocol import Frame, MessageType, build_frame
from set_devices.protocol_router import ProtocolMode, ProtocolRouter
from setprotocol.core import (
Frame as SetFrame,
FrameFlag as SetFrameFlag,
MessageType as SetMessageType,
build_frame as build_set_frame,
)
class SerialDevicePort(QObject):
"""@brief Реальный COM-порт без блокирующих ожиданий в GUI thread.
Управляет связью с устройством через последовательный порт, обеспечивая
асинхронный обмен данными и передачу кадров по протоколу GUI.
"""
connected_changed = Signal(bool, str)
frame_received = Signal(object)
tx_logged = Signal(str)
capture_event = Signal(object)
rx_logged = Signal(str)
error_occurred = Signal(str)
protocol_changed = Signal(object)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._serial = QSerialPort(self)
self._router = ProtocolRouter()
self._sequence = 0
self._serial.readyRead.connect(self._read_available)
self._serial.errorOccurred.connect(self._handle_error)
@staticmethod
def available_ports() -> list[str]:
"""Возвращает стабильный отсортированный список имён COM-портов."""
return [name for name, _description in SerialDevicePort.available_port_infos()]
@staticmethod
def available_port_infos() -> list[tuple[str, str]]:
"""@brief Перечисляет COM-порты вместе с описанием драйвера.
Порядок естественный: COM3 идёт перед COM10, поэтому список не
перестраивается при подключении новых преобразователей.
@return Пары «имя порта, описание устройства».
"""
infos = [
(info.portName(), info.description() or info.manufacturer())
for info in QSerialPortInfo.availablePorts()
]
return sorted(infos, key=lambda item: SerialDevicePort._sort_key(item[0]))
@staticmethod
def _sort_key(port_name: str) -> tuple[str, int, str]:
"""Разделяет имя порта на буквенный префикс и числовой индекс."""
digits = "".join(char for char in port_name if char.isdigit())
prefix = port_name[: len(port_name) - len(digits)] if digits else port_name
return (prefix.upper(), int(digits) if digits else 0, port_name)
def open(self, port_name: str, baud_rate: int) -> bool:
"""Открывает 8N1 без flow control; результат сообщает синхронную ошибку."""
if not port_name:
self.error_occurred.emit("COM-порт не выбран")
return False
self.close()
self._serial.setPortName(port_name)
self._serial.setBaudRate(baud_rate)
self._serial.setDataBits(QSerialPort.DataBits.Data8)
self._serial.setParity(QSerialPort.Parity.NoParity)
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self.error_occurred.emit(self._serial.errorString())
return False
self.set_protocol_mode(ProtocolMode.AUTO)
self.connected_changed.emit(True, port_name)
return True
@property
def is_open(self) -> bool:
"""Признак открытого физического COM-порта."""
return self._serial.isOpen()
@property
def port_name(self) -> str:
"""Имя текущего COM-порта, например ``COM11``."""
return self._serial.portName()
@property
def baud_rate(self) -> int:
"""Фактически установленная скорость последовательного порта."""
return self._serial.baudRate()
def close(self) -> None:
"""Закрывает порт и не оставляет активных Qt-операций чтения."""
if not self._serial.isOpen():
return
name = self._serial.portName()
self._serial.close()
self.connected_changed.emit(False, name)
def send(self, frame: Frame) -> bool:
"""Ставит целый кадр в буфер Qt без ожидания физической передачи."""
if not self._serial.isOpen():
self.error_occurred.emit("Последовательный порт не подключён")
return False
packet = build_frame(frame)
written = self._serial.write(packet)
if written > 0:
self.capture_event.emit(uart_event('SET', 'TX', packet[:written], self.baud_rate))
if written != len(packet):
self.error_occurred.emit(self._serial.errorString())
return False
self.tx_logged.emit(packet.hex(" ").upper())
return True
def send_set(self, frame: SetFrame) -> bool:
"""Передаёт SETProtocol v2 кадр, в том числе пробный кадр AUTO."""
if not self._serial.isOpen():
self.error_occurred.emit("Последовательный порт не подключён")
return False
if self.protocol_mode is ProtocolMode.GUI_V1:
self.error_occurred.emit("Канал уже работает в режиме GUI protocol v1")
return False
packet = build_set_frame(frame)
written = self._serial.write(packet)
if written > 0:
self.capture_event.emit(uart_event('SET', 'TX', packet[:written], self.baud_rate))
if written != len(packet):
self.error_occurred.emit(self._serial.errorString())
return False
self.tx_logged.emit(packet.hex(" ").upper())
return True
def next_sequence(self) -> int:
"""Выделяет очередной sequence для адаптера транзакций."""
self._sequence = (self._sequence + 1) & 0xFFFF
return self._sequence
def send_message(self, message_type: MessageType, payload: bytes = b"") -> int | None:
"""Передаёт запрос и возвращает его sequence либо ``None``."""
sequence = self.next_sequence()
return sequence if self.send(Frame(message_type, sequence, payload)) else None
def send_set_message(
self,
message_type: int | SetMessageType,
payload: bytes = b"",
*,
flags: SetFrameFlag = SetFrameFlag.ACK_REQUIRED,
destination: int = 0,
) -> int | None:
"""Передаёт запрос SETProtocol v2 и возвращает sequence."""
sequence = self.next_sequence()
frame = SetFrame(
message_type,
sequence,
payload,
flags=flags,
destination=destination,
)
return sequence if self.send_set(frame) else None
@property
def protocol_mode(self) -> ProtocolMode:
return self._router.mode
def set_protocol_mode(self, mode: ProtocolMode) -> None:
"""Сбрасывает parser-ы и выбирает режим текущего соединения."""
changed = self._router.mode is not mode
self._router.select(mode)
if changed or mode is ProtocolMode.AUTO:
self.protocol_changed.emit(mode)
def ping(self) -> bool:
"""Отправляет совместимый PING с новым 16-битным sequence."""
return self.send_message(MessageType.PING) is not None
def _read_available(self) -> None:
"""Выгружает доступный фрагмент и передаёт parser-у без блокировки."""
data = bytes(self._serial.readAll())
if not data:
return
self.rx_logged.emit(data.hex(" ").upper())
self.capture_event.emit(uart_event('SET', 'RX', data, self.baud_rate))
previous_mode = self._router.mode
frames = self._router.feed(data)
if self._router.mode is not previous_mode:
self.protocol_changed.emit(self._router.mode)
for frame in frames:
self.frame_received.emit(frame)
def _handle_error(self, error: QSerialPort.SerialPortError) -> None:
"""Публикует только реальные ошибки, игнорируя NoError."""
if error == QSerialPort.SerialPortError.NoError:
return
self.error_occurred.emit(self._serial.errorString())

View File

@@ -0,0 +1,168 @@
"""Serial Line CAN (Lawicel/SLCAN) transport."""
from __future__ import annotations
from dataclasses import dataclass
from .qt_compat import QObject, Signal
from .qt_compat import QSerialPort, QSerialPortInfo
class SlcanError(RuntimeError):
pass
@dataclass(frozen=True)
class SlcanChannel:
path: str
channel: int = 0
description: str = ""
manufacturer: str = ""
@property
def label(self) -> str:
details = self.description or self.manufacturer or "COM-порт"
return f"{self.path} — {details}"
@property
def adapter_kind(self) -> str:
return "slcan"
_BITRATE_COMMANDS = {
10_000: "S0", 20_000: "S1", 50_000: "S2", 100_000: "S3",
125_000: "S4", 250_000: "S5", 500_000: "S6", 800_000: "S7",
1_000_000: "S8",
}
def encode_frame(can_id: int, data: bytes, *, extended: bool | None = None) -> bytes:
"""Encode one classic-CAN frame in Lawicel ASCII format."""
if len(data) > 8:
raise SlcanError("Classic CAN поддерживает не более 8 байт")
if not 0 <= can_id <= 0x1FFFFFFF:
raise SlcanError("CAN ID вне диапазона")
if extended is None:
extended = can_id > 0x7FF
if not extended:
if can_id > 0x7FF:
raise SlcanError("Стандартный CAN ID вне диапазона")
return f"t{can_id:03X}{len(data):X}{data.hex().upper()}\r".encode("ascii")
return f"T{can_id:08X}{len(data):X}{data.hex().upper()}\r".encode("ascii")
def decode_frame(line: bytes) -> tuple[int, bytes] | None:
"""Decode a received SLCAN data frame; ignore ACK/status/RTR records."""
if not line or line[:1] not in (b"t", b"T"):
return None
extended = line[:1] == b"T"
id_size = 8 if extended else 3
try:
can_id = int(line[1:1 + id_size], 16)
size = int(line[1 + id_size:2 + id_size], 16)
start = 2 + id_size
payload = bytes.fromhex(line[start:start + size * 2].decode("ascii"))
except (ValueError, UnicodeError):
return None
maximum = 0x1FFFFFFF if extended else 0x7FF
if can_id > maximum or size > 8 or len(payload) != size:
return None
return can_id, payload
class SlcanAdapter(QObject):
"""One SLCAN COM port using the usual 115200 8N1 host connection."""
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._serial = QSerialPort(self)
self._serial.readyRead.connect(self._read)
self._serial.errorOccurred.connect(self._error)
self._buffer = bytearray()
self._listen_only = False
@property
def connected(self) -> bool:
return self._serial.isOpen()
def scan(self) -> list[SlcanChannel]:
# Windows не предоставляет надёжного признака протокола SLCAN.
# Показываем COM-кандидаты только внутри явно выбранного режима SLCAN.
return [SlcanChannel(info.portName(), description=info.description(),
manufacturer=info.manufacturer())
for info in QSerialPortInfo.availablePorts()]
def connect_channel(self, channel: SlcanChannel, bitrate: int,
host_baudrate: int = 115200,
listen_only: bool = False) -> None:
self.disconnect_channel()
command = _BITRATE_COMMANDS.get(bitrate)
if command is None:
raise SlcanError(f"SLCAN не поддерживает {bitrate} бит/с")
self._serial.setPortName(channel.path)
if host_baudrate not in (9600, 19200, 38400, 57600, 115200,
230400, 460800, 921600):
raise SlcanError(f"Недопустимая скорость COM: {host_baudrate}")
self._serial.setBaudRate(host_baudrate)
self._serial.setDataBits(QSerialPort.DataBits.Data8)
self._serial.setParity(QSerialPort.Parity.NoParity)
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
raise SlcanError(f"Не удалось открыть {channel.path}: {self._serial.errorString()}")
self._buffer.clear()
self._listen_only = listen_only
# Close first so reconnecting also works after an interrupted session.
open_command = "L" if listen_only else "O"
if self._serial.write(f"C\r{command}\r{open_command}\r".encode("ascii")) < 0:
message = self._serial.errorString()
self._serial.close()
self._listen_only = False
raise SlcanError(f"Не удалось настроить SLCAN: {message}")
def disconnect_channel(self) -> None:
if not self._serial.isOpen():
return
self._serial.write(b"C\r")
self._serial.waitForBytesWritten(100)
self._serial.close()
self._buffer.clear()
self._listen_only = False
def send(self, can_id: int, data: bytes) -> None:
if not self.connected:
raise SlcanError("SLCAN-адаптер не подключён")
if self._listen_only:
raise SlcanError("SLCAN-адаптер открыт в режиме только приёма")
# The adapter contract is used by ProtoCAN, whose IDs are always EXT,
# including the numerically small values that would also fit in 11 bits.
packet = encode_frame(can_id, data, extended=True)
if self._serial.write(packet) != len(packet):
raise SlcanError(f"SLCAN не передал CAN-кадр: {self._serial.errorString()}")
def _read(self) -> None:
self._buffer.extend(bytes(self._serial.readAll()))
while b"\r" in self._buffer:
raw, _, remainder = self._buffer.partition(b"\r")
self._buffer[:] = remainder
frame = decode_frame(raw)
if frame is not None:
self.capture_received.emit(frame[0], frame[1], raw[:1] == b'T', False)
self.frame_received.emit(*frame)
def _error(self, error: QSerialPort.SerialPortError) -> None:
if error in (QSerialPort.SerialPortError.NoError,
QSerialPort.SerialPortError.NotOpenError):
return
if self._serial.isOpen():
message = self._serial.errorString()
self._serial.close()
self._listen_only = False
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}")
def close(self) -> None:
self.disconnect_channel()

View File

@@ -0,0 +1,177 @@
"""Non-blocking client for the STM32 ROM UART bootloader described by AN3155."""
from __future__ import annotations
from collections import deque
from typing import Callable
from .qt_compat import QObject, QTimer, Signal
from .qt_compat import QSerialPort
from set_devices.firmware import FirmwareImage
from protocan.stm32_boot import (
ACK, NACK, SYNC, address, command, erase_pages_payload, write_payload,
)
class Stm32Bootloader(QObject):
"""Programs STM32 system Flash over the factory UART bootloader (8E1)."""
progress = Signal(int, str)
finished = Signal(bool, str)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._serial = QSerialPort(self)
self._serial.readyRead.connect(self._read_available)
self._serial.errorOccurred.connect(self._serial_error)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(lambda: self._fail("Нет ответа STM32 bootloader"))
self._rx = bytearray()
self._steps: deque[tuple[bytes, int, str, Callable[[], None]]] = deque()
self._waiting: tuple[int, str, Callable[[], None]] | None = None
self._image: FirmwareImage | None = None
self._offset = 0
self._cancelled = False
def start(self, image: FirmwareImage, port_name: str, baud_rate: int) -> None:
if self._serial.isOpen() or self._waiting is not None:
self.finished.emit(False, "Прошивка STM32 уже выполняется")
return
if not port_name or baud_rate <= 0:
self.finished.emit(False, "Неверные настройки UART")
return
if not 0x08000000 <= image.base_address <= 0x080FFFFF:
self.finished.emit(False, "Для STM32 укажите адрес Flash, например 0x08000000")
return
if image.base_address + len(image.data) > 0x08100000:
self.finished.emit(False, "Образ выходит за допустимый диапазон Flash STM32")
return
self._serial.setPortName(port_name)
self._serial.setBaudRate(baud_rate)
self._serial.setDataBits(QSerialPort.DataBits.Data8)
self._serial.setParity(QSerialPort.Parity.EvenParity)
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self.finished.emit(False, f"Не удалось открыть {port_name}: {self._serial.errorString()}")
return
self._image = image
self._offset = 0
self._cancelled = False
self._rx.clear()
self._steps.clear()
self.progress.emit(0, "Синхронизация с STM32 (BOOT0=1)")
self._queue(SYNC, 1500, "синхронизации", self._erase)
self._next()
def cancel(self) -> None:
if not self._serial.isOpen():
return
self._cancelled = True
self._finish(False, "Операция отменена")
def _queue(self, packet: bytes, timeout_ms: int, stage: str,
callback: Callable[[], None]) -> None:
self._steps.append((packet, timeout_ms, stage, callback))
def _next(self) -> None:
if self._waiting is not None or not self._steps or self._cancelled:
return
packet, timeout_ms, stage, callback = self._steps.popleft()
if self._serial.write(packet) != len(packet):
self._fail(f"Ошибка передачи на этапе {stage}")
return
self._waiting = (timeout_ms, stage, callback)
self._timer.start(timeout_ms)
def _erase(self) -> None:
self.progress.emit(1, "Стирание Flash")
self._queue(command(0x43), 1500, "команды Erase", self._erase_payload)
self._next()
def _erase_payload(self) -> None:
assert self._image is not None
first = (self._image.base_address - 0x08000000) // 1024
last = (self._image.base_address + len(self._image.data) - 1 - 0x08000000) // 1024
pages = list(range(first, last + 1))
self._queue(erase_pages_payload(pages), 20000, "стирания страниц Flash",
self._write_next)
self._next()
def _write_next(self) -> None:
image = self._image
if image is None:
return
if self._offset >= len(image.data):
self.progress.emit(100, "Запуск приложения")
self._queue(command(0x21), 1500, "команды Go", self._go_address)
self._next()
return
chunk = image.data[self._offset:self._offset + 256]
# STM32 Flash is programmed by words; erased bytes safely pad the tail.
if len(chunk) & 3:
chunk += b"\xff" * (4 - (len(chunk) & 3))
self._queue(command(0x31), 1500, "команды Write Memory", self._write_address)
self._next()
def _write_address(self) -> None:
assert self._image is not None
self._queue(address(self._image.base_address + self._offset), 1500,
"адреса блока", self._write_data)
self._next()
def _write_data(self) -> None:
assert self._image is not None
chunk = self._image.data[self._offset:self._offset + 256]
actual = len(chunk)
if len(chunk) & 3:
chunk += b"\xff" * (4 - (len(chunk) & 3))
def accepted() -> None:
self._offset += actual
percent = int(self._offset * 100 / len(self._image.data))
self.progress.emit(percent, f"Записано {self._offset} байт")
self._write_next()
self._queue(write_payload(chunk), 2500, "записи блока", accepted)
self._next()
def _go_address(self) -> None:
assert self._image is not None
self._queue(address(self._image.base_address), 1500, "адреса запуска",
lambda: self._finish(True, "Прошивка STM32 завершена, приложение запущено"))
self._next()
def _read_available(self) -> None:
self._rx.extend(bytes(self._serial.readAll()))
while self._waiting is not None and self._rx:
response = self._rx.pop(0)
timeout_ms, stage, callback = self._waiting
if response not in (ACK, NACK):
continue
self._timer.stop()
self._waiting = None
if response == NACK:
self._fail(f"STM32 отклонил операцию на этапе {stage}")
return
callback()
def _serial_error(self, error: QSerialPort.SerialPortError) -> None:
if error not in (QSerialPort.SerialPortError.NoError,
QSerialPort.SerialPortError.TimeoutError):
self._fail(self._serial.errorString())
def _fail(self, message: str) -> None:
self._finish(False, message)
def _finish(self, success: bool, message: str) -> None:
self._timer.stop()
self._waiting = None
self._steps.clear()
self._image = None
if self._serial.isOpen():
self._serial.close()
self.progress.emit(100 if success else 0, message)
self.finished.emit(success, message)

View File

@@ -0,0 +1,170 @@
"""Transactional STM configuration extension (FC03/FC06, 0x1210)."""
import struct
from .qt_compat import QObject, QTimer, Signal
from .qt_compat import QSerialPort
from set_devices.tms_terminal import crc16_modbus
BAUDRATES = (9600, 19200, 38400, 57600, 115200)
def request(unit, function, register, value):
body = struct.pack(">BBHH", unit, function, register, value)
return body + struct.pack("<H", crc16_modbus(body))
def decode_config(raw):
if len(raw) != 25:
raise ValueError("Нужна прошивка STM с выбором режима эмуляции")
words = struct.unpack(">10H", raw[3:-2])
if words[:2] != (0x5343, 2):
raise ValueError("Прошивка STM не поддерживает настройку связи (нужна новая версия)")
address, tms, rate = words[2:5]
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] > 1 or words[9] > 1:
raise ValueError("STM вернула неверные параметры связи")
return words
class StmSettingsClient(QObject):
finished = Signal(bool, str, object)
def __init__(self, parent=None):
super().__init__(parent)
self.serial = QSerialPort(self)
self.serial.readyRead.connect(self._receive)
self.serial.errorOccurred.connect(self._error)
self.timeout = QTimer(self)
self.timeout.setSingleShot(True)
self.timeout.timeout.connect(lambda: self._finish(False, "Тайм-аут настройки STM"))
self.delay = QTimer(self)
self.delay.setSingleShot(True)
self.delay.timeout.connect(self._next)
self.active = False
self.committed = False
self.rx = bytearray()
def start(self, port, unit, baud, timeout, desired=None):
if self.active:
return
if desired is not None:
addr, tms, rate, mode = desired
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in (0, 1):
self.finished.emit(False, "Адреса УМП и 2812 должны различаться; проверьте диапазоны и скорость", None)
return
self.unit, self.baud, self.timeout_ms = unit, baud, timeout
self.desired = desired
self.active, self.committed = True, False
self.stage = "probe"
self.serial.setPortName(port)
self.serial.setBaudRate(baud)
self.serial.setDataBits(QSerialPort.DataBits.Data8)
self.serial.setParity(QSerialPort.Parity.NoParity)
self.serial.setStopBits(QSerialPort.StopBits.OneStop)
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self._finish(False, self.serial.errorString())
return
self._send(3, 0x1210, 10)
def _send(self, function, register, value):
self.rx.clear()
self.packet = request(self.unit, function, register, value)
self.timeout.start(self.timeout_ms)
if self.stage == "commit":
# If ACK is lost, the board may nevertheless switch settings.
self.committed = True
if self.serial.write(self.packet) != len(self.packet):
self._finish(False, "Ошибка передачи настройки STM")
def _receive(self):
chunk = bytes(self.serial.readAll())
if not self.active or not self.timeout.isActive():
return
self.rx.extend(chunk)
function = self.packet[1]
while len(self.rx) >= 2:
if self.rx[0] != self.unit or self.rx[1] not in (function, function | 0x80):
del self.rx[0]
continue
size = 5 if self.rx[1] & 0x80 else (25 if function == 3 else 8)
if len(self.rx) < size:
return
raw = bytes(self.rx[:size])
del self.rx[:size]
if crc16_modbus(raw):
self._finish(False, "Неверная CRC ответа STM")
return
if raw[1] & 0x80:
self._finish(False, "STM отклонила настройку, код %d. Для старой прошивки требуется обновление." % raw[2])
return
if (function == 6 and raw != self.packet) or (function == 3 and raw[2] != 20):
self._finish(False, "Неверный ответ настройки STM")
return
self.timeout.stop()
try:
self._accept(raw)
except ValueError as error:
self._finish(False, str(error))
return
def _accept(self, raw):
if self.stage == "probe":
words = decode_config(raw)
if self.desired is None:
self._finish(True, "Настройки STM прочитаны", (words[2], words[3], BAUDRATES[words[4]], words[8]))
return
addr, tms, baud, mode = self.desired
self.staged = (addr, tms, BAUDRATES.index(baud), mode)
self.steps = [(6, 0x1218, 0), (6, 0x1215, addr), (6, 0x1216, tms),
(6, 0x1217, self.staged[2]), (6, 0x1219, mode), (3, 0x1210, 10)]
self.stage = "stage"
elif self.stage == "stage" and self.packet[1] == 3:
words = decode_config(raw)
if (*words[5:8], words[9]) != self.staged:
raise ValueError("STM не подтвердила подготовленные настройки")
self.stage = "commit"
self.steps = [(6, 0x1218, 0xA55A)]
elif self.stage == "commit":
self.stage = "switch"
self.delay.start(700)
return
elif self.stage == "verify":
words = decode_config(raw)
if (*words[2:5], words[8]) != self.staged:
raise ValueError("Новые настройки STM не подтвердились")
self._finish(True, "Настройки применены в STM и проверены. Действуют до перезагрузки платы.", self.desired)
return
self.delay.start(20)
def _next(self):
if not self.active:
return
if self.stage == "switch":
self.unit, _tms, baud, _mode = self.desired
if not self.serial.setBaudRate(baud):
self._finish(False, "Не удалось переключить скорость COM")
return
self.stage = "verify"
self._send(3, 0x1210, 10)
else:
self._send(*self.steps.pop(0))
def _error(self, error):
if self.active and error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError):
self._finish(False, self.serial.errorString())
def cancel(self):
if self.active:
self._finish(False, "Настройка прервана")
def _finish(self, ok, message, values=None):
if not self.active:
return
self.active = False
self.timeout.stop()
self.delay.stop()
if self.serial.isOpen():
self.serial.close()
if not ok and self.committed:
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812). Повторите чтение по ним или перезагрузите плату." % self.desired
self.finished.emit(ok, message, values)

View File

@@ -0,0 +1,137 @@
"""Non-blocking port of Gui_Android's INITLOAD / LOAD / TFLASH workflow."""
from .qt_compat import QObject, QTimer, Signal
from .qt_compat import QSerialPort
from protocan import tms_firmware as wire
class TmsBootloader(QObject):
progress = Signal(int, str)
finished = Signal(bool, str)
def __init__(self, parent=None):
super().__init__(parent)
self._serial = QSerialPort(self)
self._serial.readyRead.connect(self._read)
self._serial.errorOccurred.connect(self._error)
self._timeout = QTimer(self)
self._timeout.setSingleShot(True)
self._timeout.timeout.connect(lambda: self._finish(False, f"Тайм-аут {self._stage}"))
self._settle = QTimer(self)
self._settle.setSingleShot(True)
self._settle.setInterval(100)
self._settle.timeout.connect(self._accept)
self._delay = QTimer(self)
self._delay.setSingleShot(True)
self._delay.setInterval(100)
self._delay.timeout.connect(self._advance)
self._active = False
self._waiting = False
self._rx = bytearray()
self._stage = ""
def start(self, image, port: str, baud: int, target: wire.TmsTarget):
if self._active:
return
try:
target.validate(len(image.data))
if not port or baud <= 0:
raise ValueError("Неверные настройки COM-порта")
except ValueError as error:
self.finished.emit(False, str(error))
return
self._image, self._target = image, target
self._active = True
self._serial.setPortName(port)
self._serial.setBaudRate(baud)
self._serial.setDataBits(QSerialPort.DataBits.Data8)
self._serial.setParity(QSerialPort.Parity.NoParity)
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self._finish(False, self._serial.errorString())
return
self._response = None
self._steps = wire.programming_steps(image.data, target)
self._advance()
def _advance(self):
if not self._active:
return
try:
request, self._size, timeout, self._stage, percent = self._steps.send(self._response)
except StopIteration:
self._finish(True, "Образ загружен в RAM TMS" if self._target.load_only
else "Запись Spartan-6 подтверждена платой" if self._target.kind == "spartan6"
else "Прошивка завершена, записанные данные проверены")
return
except (ValueError, RuntimeError) as error:
self._finish(False, str(error))
return
self._command = request[1]
self._rx.clear()
self._waiting = True
self.progress.emit(percent, self._stage)
# LOAD is one continuous write, without pauses inside its packet.
if self._serial.write(request) != len(request):
self._finish(False, "Ошибка передачи " + self._stage)
return
if self._active:
self._timeout.start(timeout + int(len(request) * 10000 / self._serial.baudRate()))
def _read(self):
chunk = bytes(self._serial.readAll())
if not self._waiting:
return
self._rx.extend(chunk)
header = bytes((self._target.controller, self._command))
start = self._rx.find(header)
if start < 0:
self._rx[:] = self._rx[-1:]
return
del self._rx[:start]
# Some devices send an ACK before an UPLOAD data response.
if self._size > 6:
for ack_size in (6, 4):
if (self._rx[ack_size:ack_size + 2] == header
and wire.normalize_reply(bytes(self._rx[:ack_size]), *header, 6)):
del self._rx[:ack_size]
break
if len(self._rx) >= self._size:
self._accept()
else:
self._settle.start()
def _accept(self):
if not self._waiting:
return
reply = wire.normalize_reply(bytes(self._rx[:self._size]), self._target.controller, self._command, self._size)
if reply is None:
if len(self._rx) >= self._size:
self._finish(False, "Неверный CRC ответа " + self._stage)
return
self._timeout.stop()
self._settle.stop()
self._waiting = False
self._response = reply
self._delay.start()
def cancel(self):
if self._active:
self._finish(False, "Операция TMS отменена; запись Flash могла быть начата")
def _error(self, error):
if self._active and error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError):
self._finish(False, self._serial.errorString())
def _finish(self, success, message):
if not self._active:
return
self._active = self._waiting = False
for timer in (self._timeout, self._settle, self._delay):
timer.stop()
if self._serial.isOpen():
self._serial.close()
if success:
self.progress.emit(100, message)
self.finished.emit(success, message)

View File

@@ -0,0 +1,69 @@
"""Последовательный клиент CAN-логгера поверх уже подключённого Dima-адаптера."""
import secrets
from .qt_compat import QObject, QTimer, Signal
from set_devices.ump_logger_can import Response, build_request
class UmpCanClient(QObject):
completed = Signal(str, object)
failed = Signal(str)
def __init__(self, send, parent=None):
super().__init__(parent)
self._send = send
self._token = secrets.randbelow(255) + 1
self._pending = None
self._operation = ''
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._timeout)
@property
def busy(self):
return self._pending is not None
def cancel(self):
# Поздний ответ после переключения транспорта не должен продолжить
# старую цепочку и отправить команду в новое подключение.
self._timer.stop()
self._pending = None
self._operation = ''
def request(self, operation, mode, function, address, value, timeout_ms=2000):
if self.busy:
self.failed.emit('CAN-логгер занят предыдущим запросом')
return
self._token = self._token % 255 + 1
try:
can_id, data = build_request(mode, self._token, function, address, value)
self._pending = Response(mode, self._token, function, address, value)
self._operation = operation
self._timer.start(timeout_ms)
self._send(can_id, data)
except (ValueError, RuntimeError, OSError) as error:
self.cancel()
self.failed.emit(str(error))
def receive(self, can_id, data):
if self._pending is None:
return
try:
words = self._pending.feed(can_id, data)
except ValueError as error:
self.cancel()
self.failed.emit(str(error))
return
if words is None:
return
operation = self._operation
self.cancel()
self.completed.emit(operation, words)
def _timeout(self):
writing = self._pending is not None and self._pending.function == 6
self.cancel()
text = ('Таймаут CAN-логгера: проверьте адаптер, скорость, номер платы '
'и прошивку с поддержкой CAN-логгера.')
if writing:
text += ' Подтверждение записи потеряно; команда могла выполниться.'
self.failed.emit(text)