Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
1
python/set_devices/qt_ports/__init__.py
Normal file
1
python/set_devices/qt_ports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable device libraries from setcorp/templates."""
|
||||
258
python/set_devices/qt_ports/candle_adapter.py
Normal file
258
python/set_devices/qt_ports/candle_adapter.py
Normal 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()
|
||||
187
python/set_devices/qt_ports/mock_port.py
Normal file
187
python/set_devices/qt_ports/mock_port.py
Normal 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))
|
||||
7
python/set_devices/qt_ports/qt_compat.py
Normal file
7
python/set_devices/qt_ports/qt_compat.py
Normal 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
|
||||
212
python/set_devices/qt_ports/serial_port.py
Normal file
212
python/set_devices/qt_ports/serial_port.py
Normal 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())
|
||||
168
python/set_devices/qt_ports/slcan_adapter.py
Normal file
168
python/set_devices/qt_ports/slcan_adapter.py
Normal 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()
|
||||
177
python/set_devices/qt_ports/stm32_bootloader.py
Normal file
177
python/set_devices/qt_ports/stm32_bootloader.py
Normal 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)
|
||||
170
python/set_devices/qt_ports/stm_settings.py
Normal file
170
python/set_devices/qt_ports/stm_settings.py
Normal 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)
|
||||
137
python/set_devices/qt_ports/tms_bootloader.py
Normal file
137
python/set_devices/qt_ports/tms_bootloader.py
Normal 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)
|
||||
69
python/set_devices/qt_ports/ump_can_client.py
Normal file
69
python/set_devices/qt_ports/ump_can_client.py
Normal 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)
|
||||
Reference in New Issue
Block a user