feat(set-protocol): Добавь DEIF XDi, STM Invertor и порт HOPE G474

Объедини реализацию DEIF в C99-ядре, чтобы Python и Android
использовали один кодек и общие эталонные кадры.
Добавь две GAS-проекции единой карты STM Invertor и аппаратные
порты эмулятора и загрузчика для платы HOPE STM32G474VET6.

Проверки: сборка MSVC Debug, 24 теста CTest, 30 тестов Python
и 29 дополнительных проверок, синтаксис 16 файлов Python.
This commit is contained in:
2026-10-24 15:39:59 +03:00
parent 83fb9b6fe8
commit e52ba797e4
46 changed files with 2482 additions and 157 deletions
+28
View File
@@ -0,0 +1,28 @@
import ctypes # Привязка передаёт готовые слова общему C-кодеку без повторения wire-формата на Python.
from .native import _load_library # Стандартный поиск сохраняет существующий механизм доставки SETProtocol.
class StmInvertorCodec: # Один экземпляр владеет только сигнатурами функций общей библиотеки.
def __init__(self, library=None): # Явная DLL позволяет новой панели не заменять библиотеку работающего приложения.
self.lib = library if library is not None else _load_library() # Нет Python fallback при отсутствии нового C-кодека.
self.lib.set_invertor_gas_limit.argtypes = [ctypes.c_uint] # Тип сообщения передаётся без вычисления геометрии карты в Python.
self.lib.set_invertor_gas_limit.restype = ctypes.c_uint # Границы определяются профилем C из Templates.
self.lib.set_invertor_gas_build.argtypes = [ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint16), ctypes.c_uint, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint8)] # Типы совпадают с публичным C ABI общего адаптера.
self.lib.set_invertor_gas_build.restype = ctypes.c_int # C возвращает фактический DLC либо отрицательную ошибку.
self.lib.set_invertor_gas_decode.argtypes = [ctypes.c_uint32, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint8), ctypes.c_uint, ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint16)] # Декодер проверяет ID, тип, направление, длину и маску.
self.lib.set_invertor_gas_decode.restype = ctypes.c_int # Число принятых wire-слов приходит только из C.
def limit(self, message_type): # Интерфейс не хранит отдельную копию константы 250 или 750.
return int(self.lib.set_invertor_gas_limit(message_type)) # Передаём актуальную границу виджету адреса.
def build(self, message_type, address, words=()): # Пустой набор слов означает чтение DLC0 в обоих протоколах.
if not 0 <= address <= 0xFFFF or len(words) > 4 or any(not 0 <= word <= 0xFFFF for word in words): # Проверяем представимость Python-чисел перед сужением до типов ABI.
raise ValueError('Адрес или значение вне диапазона ABI') # Не допускаем тихого усечения и циклического переноса ctypes.
source = (ctypes.c_uint16 * len(words))(*words) if words else None # Значения остаются словами, endian выбирает общий кодек.
identifier = ctypes.c_uint32(); data = (ctypes.c_uint8 * 8)() # Локальные буферы имеют размеры Classic CAN и не переживают вызов.
length = self.lib.set_invertor_gas_build(message_type, address, source, len(words), ctypes.byref(identifier), data) # Ни CAN ID, ни маска не строятся здесь вручную.
if length < 0: # C отклоняет неверную геометрию, длину блока и маску.
raise ValueError('GAS: неверный адрес, длина блока или маска Word0') # Пользователь видит причину отказа до передачи на шину.
return identifier.value, bytes(data[:length]) # Транспорт получает полностью готовые ID и октеты.
def decode(self, identifier, flags, payload): # Входной кадр проверяется до публикации значений интерфейсу.
if not 0 <= identifier <= 0x1FFFFFFF or len(payload) > 8: # Не допускаем сужения большого CAN ID и переполнения временного буфера.
return None # Кадры другого транспорта не считаются ответом инвертора.
data = (ctypes.c_uint8 * 8)(*payload); metadata = (ctypes.c_uint * 2)(); words = (ctypes.c_uint16 * 4)() # ABI работает с фиксированными массивами без указателей на объекты Python.
count = self.lib.set_invertor_gas_decode(identifier, flags, data, len(payload), metadata, words) # Wire-разбор остаётся единственной C-реализацией.
return (metadata[0], metadata[1], tuple(words[:count])) if count > 0 else None # Недопустимый кадр не изменяет отображённые значения.
+52
View File
@@ -0,0 +1,52 @@
"""Готовые операции DEIF для GUI без знания wire-разметки и адресных масок.""" # Все числовые правила реализованы единственным C99-ядром templates.
import ctypes as C # Преобразование фиксированных ABI-структур не является вторым кодеком.
from dataclasses import dataclass # Именованные поля исключают индексы метаданных в GUI.
from .deif_native import get_deif_native, Frame # Общий декодер и builders используются независимо от приложения.
from .can_transport import build_frame # Транспорт получает уже готовые CAN-кадры.
@dataclass(frozen=True) # Неизменяемые описания безопасно используются диагностикой и редакторами.
class SelectorInfo: # Это скалярный результат C, а не wire-структура.
node: int; index: int; subindex: int; dam: bool; legacy_key: str # Последний элемент сохраняет только формат прежних ключей настроек Desktop.
@dataclass(frozen=True) # Описание входного кадра отделено от его транспортного контейнера.
class PacketInfo: # GUI не обращается к смещениям payload.
kind: int; node: int; index: int; subindex: int; raw: int; status: int; dam: bool # Все значения получены из C-декодера.
@property # Диагностический ключ независим от адресного байта SAM/DAM.
def object_key(self): # Для TX-эхо достаточно индекса и подиндекса готового объекта.
return self.index, self.subindex # Это идентичность объекта, а не повторная распаковка CAN-data.
def selector_info(selector): # Редактор получает имена полей вместо ручных масок.
raw = bytes.fromhex(selector) if isinstance(selector, str) else bytes(selector) # HEX — только формат хранения пользовательской настройки.
if len(raw) != 4: return None # Буфер неправильного размера не передаётся C.
metadata = (C.c_int32 * 4)() # Node, index, subindex и DAM определены публичным ABI.
if not get_deif_native().selector_info(raw, metadata): return None # Некорректная привязка не становится источником на рисунке.
return SelectorInfo(metadata[0], metadata[1], metadata[2], bool(metadata[3]), raw[1:].hex()) # Срез используется только для совместимости непрозрачного старого ключа настроек.
def readdress_selector(selector, node, dam=None): # Меняет адрес пользовательской привязки общим алгоритмом C.
if not selector: return "" # Явно отключённое поле остаётся отключённым.
raw = bytes.fromhex(selector) if isinstance(selector, str) else bytes(selector) # Порт преобразует текст настройки в буфер ABI.
output = (C.c_uint8 * 4)() # Готовый селектор создаёт C-код.
if len(raw) != 4 or not get_deif_native().readdress(raw, node, 2 if dam is None else int(bool(dam)), output): raise ValueError("Неверный селектор или Node-ID DEIF") # Индекс не пересобирается в Python.
return bytes(output).hex(" ").upper() # Формат вывода совместим с прежним редактором HEX.
def route_parameters(node, mode, identifier, operation=0): # Согласует параметры редактора с C-правилами адресации.
output = (C.c_uint32 * 3)() # COB-ID, Node и адресный байт возвращаются одним вызовом.
if not get_deif_native().route(node, mode == "dam", identifier, operation, output): return None # Неверный диапазон не вызывает произвольную перенастройку привязок.
return tuple(output) # GUI хранит и отображает готовые значения.
def data_node(identifier, fallback=1): # Получает узел из CAN ID без повторения маски в GUI.
result = route_parameters(fallback or 1, "sam", identifier, 2) # Legacy SAM сохраняет явно выбранный источник.
return result[1] if result else fallback # Вне диапазона данных остаётся пользовательское исходное значение.
def packet_info(frame): # Метаданные для подтверждения команд и TX-эхо.
code, metadata = get_deif_native().inspect_frame(frame.can_id, frame.data, frame.ide, frame.rtr) # Wire-разбор находится в C.
return PacketInfo(metadata[0], metadata[1], metadata[2], metadata[3], metadata[4], metadata[7], bool(metadata[8])) if code > 0 else None # Некорректный кадр не получает выдуманных полей.
def heartbeat_description(identifier, data, extended=False, rtr=False): # Приложение не содержит собственного классификатора CANopen error control.
code, _ = get_deif_native().inspect_frame(identifier, data, extended, rtr) # Проверку COB-ID, DLC и состояния выполняет C.
if code not in (2, -3): return None # Обычные MPDO не попадают в поле heartbeat.
from .deif_xdi import decode # Общий локализованный formatter используется обоими CAN-мониторами и прибором.
return decode(build_frame(identifier, bytes(data), ide=extended, rtr=rtr)).summary # Готовая подпись не подтверждает получение конкретного измерения.
def _frame(output): # Общий адаптер переводит структуру C в существующий транспортный контейнер.
return build_frame(output.can_id, bytes(output.data[:output.dlc]), ide=False) # Идентификатор, длина и данные уже вычислены ядром.
def service_frame(node, value, nmt=False): # Строит heartbeat либо локальную NMT-команду через C.
output = Frame() # Память результата принадлежит Python-порту.
if not get_deif_native().service(bool(nmt), node, value, C.byref(output)): raise ValueError("Неверный Node-ID или CANopen-сервис") # Ни COB-ID, ни служебные байты не собираются в GUI.
return _frame(output) # Физическую отправку выполняет выбранный приложением транспорт.
def angle_setpoint_frame(actual): # Формирует отдельный кадр задания с тем же значением фактического азимута.
source, output = Frame(), Frame() # Оба контейнера имеют стабильный layout ABI.
source.can_id, source.dlc, source.data[:] = actual.can_id, len(actual.data), bytes(actual.data) # Это копирование готового CAN-кадра, не декодирование его полей.
if not get_deif_native().angle_command(C.byref(source), C.byref(output)): raise ValueError("Кадр не содержит поддерживаемый азимут DEIF") # Проверку семейства и сборку 0x07 выполняет C.
return _frame(output) # Штатный передатчик получает второй независимый MPDO.
+129
View File
@@ -0,0 +1,129 @@
"""Независимая от GUI Python-модель индикатора поверх общего C99-ядра DEIF.""" # Настройки и транспортные имена остаются в порту, wire-правила находятся в C.
import ctypes as C # Обмен с ABI использует фиксированные массивы и caller-owned память.
from dataclasses import dataclass # Привязка является простой моделью пользовательской настройки.
from time import monotonic # Порт передаёт часы ядру явно в миллисекундах.
from .deif_native import get_deif_native, Frame # Единственная реализация кодека и автомата загружается без Qt.
FIELDS = ("power", "rpm", "angle", "power_percent", "rpm_percent", "angle_setpoint") # Порядок полей публичного ABI C; это имена модели, не wire-селекторы.
STATUS = {1: "ниже диапазона", 2: "выше диапазона", 3: "ошибка данных"} # Локализованные подписи готовых статусов C.
@dataclass # Привязки можно сохранять в существующих пользовательских профилях JSON.
class Binding: # Четыре HEX-байта и коэффициент поступают из настроек либо C-каталога.
selector: str = "" # Пустая строка означает явно отключённое поле.
scale: float = 1.0 # Коэффициент применяется только в общем C-ядре.
def _milliseconds(now): # Перевод единиц системных часов относится к платформенному порту.
return max(0, round((monotonic() if now is None else now) * 1000)) # ABI использует целые монотонные миллисекунды для одинаковых границ на всех платформах.
def validate_deif_transmit(frame): # Сохраняет прежний публичный Python API ограниченного маршрута MASTER.
if not get_deif_native().validate(frame.can_id, bytes(frame.data), len(frame.data), int(bool(frame.ide)) | (int(bool(frame.rtr)) << 1), 0): # Проверка адреса, объектов и размеров полностью выполняется в C.
raise ValueError("DEIF: разрешены только данные, яркость, Color и heartbeat; команды настройки через Node-ID запрещены") # Текст остаётся локализованной ответственностью приложения.
def brightness_frame(node_id, group, percent, can_id=None, mode="sam"): # Готовит один кадр без отправки или изменения прибора.
return get_deif_native().make_frame(6, node_id, percent, 0 if can_id is None else can_id, mode, group) # Поле ABI 6 обозначает яркость, исходный COB-ID и U8 определяет C.
def colour_palette_frame(node_id, group, palette, can_id=None, mode="sam"): # Готовит независимый от яркости кадр палитры.
return get_deif_native().make_frame(7, node_id, palette, 0 if can_id is None else can_id, mode, group) # Поле ABI 7 обозначает Color; исходный COB-ID и кодирование Day/Night/Dusk находятся в C.
class DeifDisplay: # Пассивная модель пригодна для Desktop, CLI и тестов без Qt.
services = False # Наблюдатель не объявляет себя узлом CAN и никогда не выдаёт heartbeat.
expiry_ms = 5000 # Прежний режим наблюдения скрывает показания после пяти секунд без обновления.
def __init__(self, source="DevBoard", can_id=0x201, power_unit="RAW", device_node=None, bindings=None, xdi_net=True, self_start=True, dam_pdo=2, dimmer_group=1, source_node=1): # Дополнительные параметры передаются серверным портом, прежние профили остаются совместимыми.
self.source, self.can_id, self.power_unit, self.device_node = source, can_id, power_unit, device_node # Только имя транспорта и UI-единица остаются вне C.
self.xdi_net, self.self_start, self.dam_pdo, self.dimmer_group, self._source_node = xdi_net, self_start, dam_pdo, dimmer_group, source_node # Параметры профиля передаются C без копии правил протокола.
self._api, self._context, self._route, self._bindings_signature = get_deif_native(), None, None, None # Один контекст на прибор исключает смешивание показаний MASTER и SLAVE.
self.bindings = {} # Контейнер поддерживает прямое редактирование существующим диалогом привязок.
self._ensure_context() # Исходные селекторы и коэффициенты создаются единственным C-каталогом.
if bindings is None: # Новый профиль получает штатные привязки, включая азимут.
for index, key in enumerate(FIELDS[:5]): # Отдельное задание угла автоматически производно от привязки азимута в C.
selector, factor = (C.c_uint8 * 4)(), C.c_double() # Порт принимает готовые значения без разбора endian.
self._api.get_binding(self._context, index, selector, C.byref(factor)) # Настройки по умолчанию читаются из C-профиля.
self.bindings[key] = Binding(bytes(selector).hex(" ").upper(), factor.value) # HEX нужен только редактору настроек.
else: # Существующая библиотека сохраняет пользовательские объекты и масштабы.
self.bindings = {key: Binding(item.selector, item.scale) for key, item in bindings.items()} # Словари двух приборов не должны делить изменяемые Binding.
def _ensure_context(self): # Изменение адреса требует нового C-контекста, а смена имени транспорта — нет.
route = (self.can_id, self.device_node, self.xdi_net, self.self_start, self.dam_pdo, self.dimmer_group, self.services, self._source_node) # Изменение источника также требует согласованной переинициализации.
if route == self._route: # Повторное чтение снимка не переинициализирует данные.
return # Сохраняет сроки и очередь работающего узла.
size = self._api.state_size() # Размер private C-структуры сообщает библиотека.
self._context = (C.c_uint64 * ((size + 7) // 8))() # uint64 обеспечивает необходимое выравнивание на Windows и ARM.
source = self._source_node # Источник профиля не вычисляется из имени GUI или физического Node-ID SLAVE.
if not self._api.init(self._context, size, self.device_node or 0, source, bool(self.xdi_net), bool(self.self_start), self.dam_pdo, self.dimmer_group, bool(self.services), self.can_id): # C проверяет reserved Node, RPDO и сетевые группы.
raise ValueError("DEIF: неверный профиль SLAVE, Node-ID или RPDO") # Частично инициализированный узел не используется.
self._route, self._bindings_signature = route, None # Привязки повторно синхронизируются после изменения профиля.
def _sync_bindings(self): # Прямые изменения словаря UI передаются в C только при фактическом изменении настройки.
self._ensure_context() # Проверяет, не был ли изменён физический адрес либо CAN ID наблюдения.
signature = tuple((key, binding.selector, binding.scale) for key, binding in self.bindings.items()) # Это сравнение настроек, а не интерпретация wire-формата.
if signature == self._bindings_signature: # Частые тики и приём кадров не должны сбрасывать значения.
return # Состояние C остаётся прежним.
for index, key in enumerate(FIELDS): # Порядок соответствует публичным field IDs ядра.
if index == 5 and key not in self.bindings: # Производное задание азимута настраивает C при изменении поля angle.
continue # Не отключает автоматически созданную независимую привязку 0x07.
binding = self.bindings.get(key, Binding()) # Отсутствующее поле явно отключается.
selector = bytes.fromhex(binding.selector) if binding.selector else None # HEX является форматом хранения настроек, а не CAN-декодером.
if selector is not None and len(selector) != 4: # Порт проверяет размер буфера до передачи указателя C.
raise ValueError("Селектор DEIF должен содержать четыре байта") # Некорректная пользовательская настройка не создаёт чтение за пределами буфера.
if not self._api.bind(self._context, index, selector, binding.scale): # Интерпретация селектора и применение масштаба находятся в C.
raise ValueError("DEIF: неверная привязка или масштаб") # Нечисловой коэффициент не скрывается как отсутствие пакета.
self._bindings_signature = signature # Последующая обработка кадра использует уже настроенный контекст.
def bind_default_angle(self): # Миграция старого профиля с пустым азимутом не требует знания адресов в GUI.
self._sync_bindings() # Источник и режим берутся из уже настроенных остальных полей.
selector, factor = (C.c_uint8 * 4)(), C.c_double() # C возвращает готовую библиотечную привязку.
if not self._api.default_angle(self._context, selector, C.byref(factor)): # Ни SAM/DAM, ни индекс не вычисляются в Python.
raise ValueError("Не удалось восстановить привязку азимута DEIF") # Ошибка миграции остаётся видимой.
self.bindings["angle"] = Binding(bytes(selector).hex(" ").upper(), factor.value) # Обновляется только отсутствующая настройка азимута.
self._sync_bindings() # Новая привязка применяется сразу в текущем сеансе.
def reset(self): # Сохраняет API очистки показаний при смене пользовательского канала.
self._sync_bindings() # Настройки должны соответствовать очищаемому контексту.
self._api.reset_values(self._context) # Очистка выполняется общим ядром.
def accept(self, source, identifier, data, extended=False, rtr=False, now=None): # Порт связывает транспортное имя с независимой моделью прибора.
if source != self.source or not 0 <= identifier <= 0x1FFFFFFF or len(data) > 8: # Имя канала относится к приложению, границы — к безопасному ABI.
return False # Кадры другого транспорта не обновляют C-контекст.
self._sync_bindings() # Диалог мог изменить привязки с момента предыдущего кадра.
accepted = bool(self._api.accept(self._context, identifier, bytes(data), len(data), int(bool(extended)) | (int(bool(rtr)) << 1), _milliseconds(now))) # Единственное место протокольной обработки находится в C.
return accepted and (self.services or not self._api.inspect_frame(identifier, data, extended, rtr)[1][7]) # Пассивный API сохраняет прежний признак отказа только для текущего ошибочного I16, не для других полей.
def _snapshots(self, now=None): # Снимок переводит готовые C-поля в контейнеры Python без повторения алгоритма.
self._sync_bindings() # Требуется также после прямого редактирования масштаба в настройках.
result, moment = {}, _milliseconds(now) # Все поля одного снимка читаются в один момент времени.
for index, key in enumerate(FIELDS): # Порядок ABI не зависит от порядка ключей пользовательского словаря.
value, metadata = C.c_double(), (C.c_int64 * 4)() # C выдаёт масштабированное значение и флаги seen/valid/stale.
self._api.read(self._context, index, moment, self.expiry_ms, C.byref(value), metadata) # Свежесть и служебные статусы вычисляются ядром.
result[key] = (value.value, metadata[0] / 1000, metadata[2], metadata[3]) # Деление переводит только единицы системных часов порта.
return result # Снимок не раскрывает внутренний layout C.
@property # Совместимый read-only снимок прежнего словаря показаний.
def values(self): # Значения остаются в C, Python не хранит вторую копию состояния.
return {key: item[0] for key, item in self._snapshots().items() if item[3] & 2} # Готовый valid-флаг исключает служебные I16.
@property # Временные отметки нужны существующей отрисовке и диагностике.
def received(self): # Даже invalid является принятым обновлением объекта, а не отсутствием связи.
return {key: item[1] for key, item in self._snapshots().items() if item[3] & 1} # Решение о наличии сообщения уже принято C-ядром.
def value(self, key, now=None): # Сохраняет интерфейс получения одного свежего показания.
item = self._snapshots(now).get(key) # Масштаб и свежесть получены из общего C-снимка.
return item[0] if item and item[3] & 2 and not item[3] & 4 else None # Python лишь отображает valid/stale флаги ABI.
def angle_selection(self, now=None): # Одно правило азимута используется и пассивным монитором, и активным SLAVE.
self._sync_bindings() # Профиль мог быть загружен из старого пользовательского файла.
value = C.c_double() # Владение выходным скаляром остаётся у порта.
kind = self._api.angle(self._context, _milliseconds(now), self.expiry_ms, C.byref(value)) # C выбирает фактический угол либо принятое сетевое задание.
return (value.value if kind else None, kind == 2) # Второй элемент нужен только для подписи «задание» в GUI.
@property # Список источников нужен только заголовку прибора, не алгоритму приёма.
def sam_sources(self): # UI получает готовые адреса без масок и смещений селектора.
from .deif_controls import selector_info # Разбор адреса селектора делегируется общему C-порту.
descriptors = [selector_info(binding.selector) for binding in self.bindings.values() if binding.selector] # Сохраняет индивидуальные привязки каждого поля.
return sorted({item.node for item in descriptors if item is not None and not item.dam}) # DAM-получатель не выдаётся за источник SAM.
+36
View File
@@ -0,0 +1,36 @@
"""Регистровый контракт аппаратного эмулятора DEIF из set_emu_deif.h.""" # Один контракт используется всеми MCU и настольными клиентами.
from dataclasses import dataclass # Неизменяемый снимок отделяет подтверждённое состояние платы от редактируемой формы.
DEIF_BASE = 0x1600 # Начало окна управления DEIF в set_emu_server.c, адрес измеряется в 16-битных словах.
DEIF_WORDS = 52 # SET_DEIF_WORDS версии 1 включает профиль, RAW, привязки OD и диагностику.
DEIF_FIELDS = ("Угол, RAW", "Обороты, RAW", "Обороты %, RAW", "Мощность, RAW", "Мощность %, RAW", "Задание угла, RAW") # Порядок соответствует raw[6] переносимой C-модели; физические единицы зависят от словаря прибора.
DEIF_INDICES = (0x3001, 0x3081, 0x3091, 0x3421, 0x3431, 0x3001) # Начальные OD экземпляра 1 взяты из set_deif_init; последний объект имеет подиндекс 7.
@dataclass(frozen=True) # Клиент публикует только целиком проверенный снимок без последующих изменений.
class DeifSnapshot: # Тип не зависит от Qt, HAL, конкретного MCU и способа доставки регистров.
words: tuple # Содержит 52 беззнаковых слова, прочитанных одной командой FC03.
mode: int # Режим из конфигурации STM: 4 Master, 5 Slave либо иной остановленный режим.
@property # Роль берётся из ответа платы, а не из ранее выбранного пункта GUI.
def role(self): # Возвращает 1 Master либо 2 Slave согласно set_emu_deif.h.
return self.words[3] # Слово 3 не совпадает с номерами режимов 4 и 5 блока STM.
@property # Проверка запущенного состояния нужна для запрета изменения сетевого профиля.
def running(self): # Возвращает логическое состояние модели без запуска либо остановки.
return bool(self.words[2]) # Слово 2 равно 1 только после подтверждённой команды пуска.
@property # Знаковое представление сохраняет отрицательные RAW и служебные коды DEIF.
def raw(self): # Возвращает шесть значений I16 в порядке DEIF_FIELDS.
return tuple(value if value < 0x8000 else value - 0x10000 for value in self.words[16:22]) # Старший бит задаёт знак 16-битного дополнения до двух.
def decode_snapshot(words, mode): # Проверяет совместимость прошивки до разрешения любых записей DEIF.
values = tuple(words) # Не сохраняет ссылку на изменяемый транспортный буфер.
if len(values) != DEIF_WORDS or values[:2] != (0x4446, 1): # Сигнатура DF и версия 1 заданы set_deif_read, не совпадают с конфигурацией STM.
raise ValueError("Нужна прошивка с сервисом DEIF Master/Slave версии 1") # Старая прошивка не получает команд в неизвестное окно.
if values[2] not in (0, 1) or values[3] not in (1, 2): # Повреждённые running и role не должны превращаться в разрешение записи.
raise ValueError("STM вернула неверное состояние DEIF") # Отказ сохраняет форму неподтверждённой.
if mode in (4, 5) and values[3] != mode - 3: # Конфигурация и окно состояния должны описывать одну роль текущего экземпляра.
raise ValueError("Роль DEIF не совпадает с режимом STM; повторите чтение") # Между чтениями другая сессия могла изменить режим платы.
return DeifSnapshot(values, mode) # Единственная точка создания проверенного снимка для UI и тестов.
+75
View File
@@ -0,0 +1,75 @@
"""Тонкий ctypes-порт единственного C99-ядра DEIF из templates/c/set-protocol.""" # В Python нет второго кодека или автомата NMT.
import ctypes as C # Фиксированные типы повторяют только публичный ABI.
import os # Путь дополнительной библиотеки передаёт приложение, не общая библиотека.
from pathlib import Path # Дополнительный нативный компонент может лежать рядом с основной библиотекой любого приложения.
from protocan.native import _load_library # Стандартный механизм загрузки используется при отсутствии явного пути DEIF.
class Frame(C.Structure): # Структура содержит готовые STD-кадры, а не wire-структуру транспорта.
_fields_ = [("can_id", C.c_uint32), ("dlc", C.c_uint32), ("data", C.c_uint8 * 8)] # Layout размером 16 байт совпадает с deif_xdi.h.
class NativeDeif: # Экземпляр владеет только сигнатурами C, состояние выделяется отдельными моделями.
def __init__(self): # Отсутствие общего ядра является ошибкой сборки, алгоритмического fallback нет.
self.lib = C.CDLL(os.environ["SETPROTOCOL_DEIF_LIBRARY"]) if os.environ.get("SETPROTOCOL_DEIF_LIBRARY") else _load_library() # Выбор пути относится к загрузке, не к протоколу.
if not hasattr(self.lib, "setp_deif_version"): # Во время обновления основная DLL может оставаться занятой работающим приложением.
primary = Path(self.lib._name) # Путь сообщает стандартный загрузчик, структура gui_desktop здесь неизвестна.
self.lib = C.CDLL(str(primary.with_name(primary.stem + "_deif" + primary.suffix))) # Это дополнительная сборка того же C-ядра, а не алгоритмический Python fallback.
signatures = { # Сигнатуры скалярного ABI не зависят от внутренних C-структур.
"version": ([], C.c_int), "state_size": ([], C.c_size_t), # Проверка версии и caller-owned памяти.
"catalog": ([C.c_uint, C.POINTER(C.c_int32)], C.c_int), # Каталог числовых индексов и разрешений.
"selector": ([C.c_uint, C.c_uint, C.c_uint, C.c_uint, C.c_void_p], C.c_int), # C формирует готовый селектор.
"inspect": ([C.c_uint32, C.c_void_p, C.c_size_t, C.c_uint, C.POINTER(C.c_int32)], C.c_int), # Единственный разбор входящего wire-кадра.
"make": ([C.c_uint, C.c_uint, C.c_uint, C.c_int32, C.c_uint32, C.c_uint, C.POINTER(Frame)], C.c_int), # Единственная сборка MPDO.
"master": ([C.c_uint, C.POINTER(C.c_int32), C.c_uint32, C.c_uint, C.c_uint, C.POINTER(Frame)], C.c_int), # Единственный порядок пяти объектов цикла.
"service": ([C.c_uint, C.c_uint, C.c_uint, C.POINTER(Frame)], C.c_int), "angle_command": ([C.POINTER(Frame), C.POINTER(Frame)], C.c_int), # Служебные кадры и задание азимута собираются общим C-кодом.
"selector_info": ([C.c_void_p, C.POINTER(C.c_int32)], C.c_int), "route": ([C.c_uint, C.c_uint, C.c_uint32, C.c_uint, C.POINTER(C.c_uint32)], C.c_int), # Редакторы не разбирают адресный байт или COB-ID самостоятельно.
"readdress": ([C.c_void_p, C.c_uint, C.c_uint, C.c_void_p], C.c_int), # Перенос селектора сохраняет ручной объект без масок и endian в UI.
"validate": ([C.c_uint32, C.c_void_p, C.c_size_t, C.c_uint, C.c_uint], C.c_int), # Изолированные выходы MASTER и SLAVE.
"init": ([C.c_void_p, C.c_size_t] + [C.c_uint] * 7 + [C.c_uint32], C.c_int), # Семь параметров профиля и отдельный CAN ID наблюдателя.
"bind": ([C.c_void_p, C.c_uint, C.c_void_p, C.c_double], C.c_int), # Селектор и библиотечный масштаб передаются без интерпретации.
"get_binding": ([C.c_void_p, C.c_uint, C.c_void_p, C.POINTER(C.c_double)], C.c_int), # Исходные значения профиля приходят из единственного C-каталога.
"default_angle": ([C.c_void_p, C.c_void_p, C.POINTER(C.c_double)], C.c_int), # Общая миграция отсутствующей привязки.
"start": ([C.c_void_p, C.c_uint64, C.c_uint], C.c_int), "stop": ([C.c_void_p], None), "reset_values": ([C.c_void_p], None), # Жизненный цикл без системных часов C.
"accept": ([C.c_void_p, C.c_uint32, C.c_void_p, C.c_size_t, C.c_uint, C.c_uint64], C.c_int), # C проверяет NMT, адресацию, объекты и длину.
"poll": ([C.c_void_p, C.c_uint64, C.POINTER(Frame), C.c_size_t], C.c_size_t), # Порт владеет выходным буфером событий.
"read": ([C.c_void_p, C.c_uint, C.c_uint64, C.c_uint, C.POINTER(C.c_double), C.POINTER(C.c_int64)], C.c_int), # Готовое значение и временные метаданные индикатора.
"lost": ([C.c_void_p, C.c_uint64], C.c_uint), # Маска устаревших входов вычисляется в C.
"angle": ([C.c_void_p, C.c_uint64, C.c_uint, C.POINTER(C.c_double)], C.c_int), "meta": ([C.c_void_p, C.POINTER(C.c_int64)], C.c_int), # Единый выбор азимута и снимок состояния.
} # Эти объявления описывают ABI, а не копию алгоритма.
for name, (arguments, result) in signatures.items(): # Все отсутствующие символы выявляются при загрузке расширения.
function = getattr(self.lib, "setp_deif_" + name) # Названия совпадают с публичным заголовком templates.
function.argtypes, function.restype = arguments, result # ctypes не должен угадывать размеры указателей и целых.
setattr(self, name, function) # Короткие имена оставляют порты моделей читаемыми.
if self.version() != 1: # Несовместимый ABI не используется с предположением о layout.
raise RuntimeError("Нужна общая библиотека DEIF ABI 1") # Сообщение указывает на необходимость пересборки нативного ядра.
def selector_bytes(self, field, node, instance=1, dam=False): # Возвращает готовый C-селектор для настроек UI.
output = (C.c_uint8 * 4)() # Размер мультиплексора задан публичным контрактом.
if not self.selector(field, node, instance, bool(dam), output): # Проверка диапазонов находится в C.
raise ValueError("DEIF: неверный Node-ID, объект или экземпляр") # Недопустимая привязка не сохраняется в настройках.
return bytes(output) # Порт преобразует только тип контейнера.
def inspect_frame(self, identifier, data, extended=False, rtr=False): # Передаёт целый CAN-кадр общему декодеру.
if not 0 <= identifier <= 0x1FFFFFFF or len(data) > 8: # Проверяет представимость на границе ABI до сужения Python-чисел.
return -1, [0] * 12 # Переполнение нельзя превратить в другой допустимый кадр.
output = (C.c_int32 * 12)() # Индексы метаданных определены в контракте C-декодера.
code = self.inspect(identifier, bytes(data), len(data), int(bool(extended)) | (int(bool(rtr)) << 1), output) # Флаги относятся к типу CAN-кадра, не к содержимому протокола.
return code, list(output) # Локализованный текст формируется поверх готовых полей C.
def make_frame(self, field, node, value, identifier, mode="sam", instance=1): # Возвращает кадр существующего транспортного API.
from .can_transport import build_frame # Пакет транспорта не импортируется до фактической сборки кадра.
if mode not in ("sam", "dam") or type(instance) is not int or not 0 <= instance <= 4294967295 or type(value) is not int or not -2147483648 <= value <= 2147483647 or type(node) is not int or not 0 <= node <= 127 or type(identifier) is not int or not 0 <= identifier <= 0x1FFFFFFF: # Порт проверяет только типы и представимость ABI.
raise ValueError("DEIF: неверный Node-ID, режим, CAN ID или значение") # Значения не усекаются до вызова C.
output = Frame() # Память выходного CAN-кадра принадлежит Python.
if not self.make(field, node, instance, value, identifier, mode == "dam", C.byref(output)): # Wire-байты и границы протокола определяются исключительно C.
raise ValueError("DEIF: Node-ID, RPDO, экземпляр или значение вне диапазона") # Причина отклонения остаётся видимой в интерфейсе.
return build_frame(output.can_id, bytes(output.data[:output.dlc]), ide=False) # Транспорт AA55/адаптер получает уже готовые данные STD.
_native = None # Общая библиотека загружается один раз на процесс.
def get_deif_native(): # Ленивое получение не требует Qt и пригодно для CLI.
global _native # Экземпляр хранит сигнатуры, состояния приборов остаются раздельными.
if _native is None: # Первая реальная операция DEIF проверяет наличие нового нативного ядра.
_native = NativeDeif() # Отсутствие DLL или ABI не скрывается Python-реализацией протокола.
return _native # Все модели используют один и тот же проверенный C-код.
+56
View File
@@ -0,0 +1,56 @@
"""Серверный Python-порт DEIF: протокольное состояние целиком принадлежит общему C99.""" # Пакет не импортирует gui_desktop и пригоден без Qt.
import ctypes as C # Структуры ABI содержат только готовые кадры и скаляры.
from time import monotonic # Порт поставляет монотонные часы, чтобы C оставался независим от ОС.
from .deif_display import DeifDisplay, FIELDS, STATUS, _milliseconds # Пассивный и активный режимы используют одинаковые снимки индикаторов.
from .deif_native import Frame, get_deif_native # Нет Python-копии адресации, NMT, сроков или SDO.
def validate_slave_transmit(frame, node): # Серверный маршрут имеет отдельный контракт по собственному Node-ID.
if type(node) is not int or not 1 <= node <= 127 or not get_deif_native().validate(frame.can_id, bytes(frame.data), len(frame.data), int(bool(frame.ide)) | (int(bool(frame.rtr)) << 1), node): # C разрешает только собственные heartbeat, EMCY и ответы SDO.
raise ValueError("Разрешены только heartbeat, EMCY и ответы SDO своего SLAVE") # Недопустимый кадр не попадёт в физический транспорт.
class DeifSlave(DeifDisplay): # Отличие от монитора состоит в включённых сервисах C и сохранении последнего числа при Data lost.
services, expiry_ms = True, 0 # Частоту heartbeat и срок Data lost определяет C, нулевой expiry сохраняет последнее показание.
def __init__(self, node=11, source_node=1, xdi_net=True, self_start=True, dam_pdo=2, bindings=None, dimmer_group=1): # Сохраняет существующий Python API модели SLAVE.
if any(type(value) is not int for value in (node, source_node, dam_pdo, dimmer_group)): # Порт проверяет типы до преобразования unsigned ABI.
raise ValueError("Параметры профиля DEIF должны быть целыми") # Логические и дробные адреса не превращаются в другой узел.
super().__init__(source="Локальная симуляция", device_node=node, bindings=bindings, source_node=source_node, xdi_net=xdi_net, self_start=self_start, dam_pdo=dam_pdo, dimmer_group=dimmer_group) # Все ограничения и исходные привязки создаются C-ядром.
def start(self, now=None, reset_values=True): # Явное включение не использует физический транспорт напрямую.
self._sync_bindings() # Последние параметры UI должны соответствовать создаваемому сеансу.
self._api.start(self._context, _milliseconds(monotonic() if now is None else now), bool(reset_values)) # Boot-up и self-start формируются общим автоматом C.
def stop(self): # Выключение прекращает в том числе невыданные служебные события.
self._api.stop(self._context) # Никакого отдельного Python-таймера heartbeat здесь нет.
def accept(self, source, identifier, data, extended=False, rtr=False, now=None): # Транспортное имя фильтруется базовым портом, байты принимает C.
return super().accept(source, identifier, data, extended, rtr, monotonic() if now is None else now) # Явные часы позволяют одинаково проверять границы Python и JNI.
def tick(self, now=None): # Порт регулярно выгружает готовые события и передаёт их выбранному транспорту.
from .can_transport import build_frame # Формат транспорта не смешивается с форматом DEIF.
self._sync_bindings() # Прямое изменение профиля не должно оставлять старые привязки.
output = (Frame * 16)() # Вместимость соответствует ограниченному пакету событий публичного ABI.
count = self._api.poll(self._context, _milliseconds(monotonic() if now is None else now), output, len(output)) # Все сроки и NMT-переходы обслуживаются C.
return [build_frame(frame.can_id, bytes(frame.data[:frame.dlc]), ide=False) for frame in output[:count]] # Python меняет только тип контейнера готовых кадров.
def lost_fields(self, now=None): # Локализованный список строится поверх готовой маски C.
self._sync_bindings() # Непривязанное поле не должно участвовать в предупреждении.
mask = self._api.lost(self._context, _milliseconds(monotonic() if now is None else now)) # Независимые сроки по 3000 мс определяются общим ядром.
return [key for index, key in enumerate(FIELDS) if mask & (1 << index)] # Соответствие field ID и имени не повторяет алгоритм свежести.
def _meta(self): # Набор скаляров не раскрывает внутреннюю структуру C.
output = (C.c_int64 * 10)() # Число элементов закреплено в deif_xdi.h.
self._api.meta(self._context, output) # Состояние хранится только в одном месте.
return list(output) # Python-порт не пересчитывает NMT или периоды.
active = property(lambda self: bool(self._meta()[0])) # Флаг нужен жизненному циклу страницы.
state = property(lambda self: self._meta()[1]) # Готовый CANopen NMT-код используется в локализованной подписи.
started = property(lambda self: self._meta()[2] / 1000) # Секунды сохраняют совместимость прежнего Python API.
last_heartbeat = property(lambda self: self._meta()[3] / 1000) # Преобразуется только единица системного времени.
heartbeat_ms = property(lambda self: self._meta()[4]) # Значение стандартного объекта 0x1017 выдаёт C.
accepted = property(lambda self: self._meta()[5]) # Счётчик принятия не имитируется количеством тиков GUI.
brightness = property(lambda self: self._meta()[7]) # Текущий процент подсветки из C-профиля.
palette = property(lambda self: self._meta()[8]) # Day/Night/Dusk хранится как код общего ядра.
invalid = property(lambda self: {key: STATUS[item[2]] for key, item in self._snapshots().items() if item[2]}) # Только текст статуса локализуется в Python.
File diff suppressed because one or more lines are too long
+140
View File
@@ -0,0 +1,140 @@
"""SET v2/RTU session for emulator panels; no I/O or Qt dependencies.
Only discovery may fall back to RTU. An application command is sent once;
failure after choosing v2 must never resend a write using another protocol.
"""
import secrets
import struct
from setprotocol.core import (Frame, FrameFlag, FrameParser, MessageType, Status,
DeviceInfo, build_frame, decode_response)
from set_devices.tms_terminal import crc16_modbus
from set_devices.mcu_profiles import EMULATOR_PROFILES # Проверка DeviceInfo использует модель конкретной платы из общего аппаратного профиля.
REG_REQUEST = 0x1000
TMS_REQUEST = 0x1001
WAVE_BLOCK = 0x1002
MODEL = 'STM32F407-EMU-EXPERIMENT'
def with_crc(body):
return body + struct.pack('<H', crc16_modbus(body))
class EmulatorProtocol:
def __init__(self, mode='auto', *, mcu="STM32F407"): # Необязательный MCU сохраняет старый API и задаёт ожидаемую идентичность до прикладных команд.
if mode not in ('auto', 'rtu'):
raise ValueError('Неизвестный режим протокола: ' + str(mode))
self.mode = mode
self.model = EMULATOR_PROFILES[mcu]["model"] # F407 и G474RB должны подтвердить собственную прошивку, а не только общий формат SET v2.
self.phase = None
self.parser = FrameParser()
self.sequence = secrets.randbelow(65535)
self.pending = b''
self.node = None
self.expected = None
def _frame(self, kind, payload=b'', destination=0):
self.sequence = (self.sequence + 1) & 0xFFFF
self.expected = (self.sequence, kind, destination)
return build_frame(Frame(kind, self.sequence, payload,
FrameFlag.ACK_REQUIRED, 0, destination))
def begin(self, raw):
self.pending = bytes(raw)
if self.mode == 'rtu':
return self.pending
if self.mode == 'v2':
return self._application()
self.parser.reset()
self.phase = 'ping'
return self._frame(MessageType.PING)
def fallback(self):
if self.phase != 'ping' or self.mode != 'auto':
raise ValueError('SET v2 обнаружен, но устройство не подтвердило совместимость')
self.mode, self.phase = 'rtu', None
self.parser.reset()
return self.pending
def cancel(self):
self.pending = b''
self.phase = None
self.expected = None
self.parser.reset()
def _application(self):
raw = self.pending
if not raw or crc16_modbus(raw):
raise ValueError('Неверный исходный запрос эмулятора')
self.phase = 'reply'
if len(raw) >= 9 and raw[1] == 16:
return self._frame(WAVE_BLOCK, raw, raw[0])
if len(raw) == 8 and raw[1] in (3, 6):
unit, fn, address, value = struct.unpack('>BBHH', raw[:6])
return self._frame(REG_REQUEST, struct.pack('<BHH', fn, address, value), unit)
if len(raw) == 18 and raw[1] == 65:
return self._frame(TMS_REQUEST, raw, self.node)
raise ValueError('Эта команда пока не поддерживается эмулятором SET v2')
def feed(self, chunk):
if self.mode == 'rtu':
return b'', bytes(chunk)
for frame in self.parser.feed(chunk):
if self.expected is None:
continue
seq, kind, destination = self.expected
if (frame.sequence != seq or frame.message_type != kind or
frame.destination != 0 or not frame.flags & FrameFlag.RESPONSE or
frame.flags & (FrameFlag.EVENT | FrameFlag.MORE) or
frame.source in (0, 65535) or
(destination and frame.source != destination)):
continue
status, body = decode_response(frame)
if bool(frame.flags & FrameFlag.ERROR) != bool(status):
raise ValueError('Несогласованные status/ERROR в ответе SET v2')
if self.phase == 'ping':
if status or len(body) != 4:
raise ValueError('Неверный PING SET v2')
self.node, self.phase = frame.source, 'info'
return self._frame(MessageType.DEVICE_INFO, destination=self.node), b''
if self.phase == 'info':
if status or DeviceInfo.decode(body).model != self.model: # Запись разрешается только после подтверждения аппаратного профиля выбранного MCU.
raise ValueError('SET v2: подключено другое устройство, команды эмулятора не отправлены')
self.mode = 'v2'
return self._application(), b''
if self.phase == 'reply':
response = self._reply(status, body)
self.phase, self.expected = None, None
return b'', response
return b'', b''
def _reply(self, status, body):
raw = self.pending
if raw[1] == 16:
if status:
raise ValueError(f'SET v2 отклонил блок генератора: status={status}')
# The shared C waveform codec validates CRC, address, count and echo.
return body
if raw[1] == 65:
if status == Status.UNSUPPORTED:
raise ValueError('Обновите прошивку STM: требуется CMD_STD через SET v2')
if status:
raise ValueError(f'SET v2 отклонил CMD_STD: status={status}')
if len(body) != 101 or body[:2] != raw[:2] or crc16_modbus(body[:-1]):
raise ValueError('Неверный ответ CMD_STD внутри SET v2')
return body
if status:
error = {Status.UNSUPPORTED: 1, Status.NOT_FOUND: 2,
Status.INVALID_ARGUMENT: 3, Status.INVALID_LENGTH: 3,
Status.BUSY: 6}.get(status, 4)
return with_crc(bytes([raw[0], raw[1] | 128, error]))
count = int.from_bytes(raw[4:6], 'big') if raw[1] == 3 else 2
if len(body) != 2 + 2 * count or struct.unpack_from('<H', body)[0] != count:
raise ValueError('SET v2: неверная длина ответа регистров')
words = struct.unpack_from(f'<{count}H', body, 2)
if raw[1] == 6:
if words != struct.unpack('>HH', raw[2:6]):
raise ValueError('SET v2: неверное подтверждение записи')
return raw
return with_crc(bytes([raw[0], 3, count * 2]) + struct.pack(f'>{count}H', *words))
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
"""Асинхронное управление общим DEIF через SET v2 или RTU.""" # Обмен не зависит от приложения-потребителя и аппаратного порта MCU.
import struct # FC03 и FC06 используют порядок байтов Modbus big-endian внутри общего транспорта.
from .qt_compat import QObject, QTimer, Signal, QSerialPort # Поддерживает одинаковую машину обмена для Qt5 и Qt6.
from .emulator_serial import EmulatorSerialPort # Автомат обнаруживает протокол и проверяет модель выбранной платы.
from .stm_settings import request, decode_config # Настройки STM читаются тем же кодеком, что явный выбор эмулятора.
from set_devices.tms_terminal import crc16_modbus # CRC проверяется также при транспортировке RTU-команды внутри SET v2.
from set_devices.deif_emulator import DEIF_BASE, DEIF_WORDS, decode_snapshot # Адреса, сигнатура и интерпретация снимка общие для всех MCU.
class DeifEmulatorClient(QObject): # Каждая операция заканчивается закрытием COM, не удерживая общий порт между действиями.
finished = Signal(bool, str, object) # Возвращает подтверждённый снимок либо ошибку без ложного успеха записи.
def __init__(self, parent=None, *, serial_factory=EmulatorSerialPort): # Подмена фабрики предназначена для проверки на реальном C-ядре без оборудования.
super().__init__(parent) # QObject освобождает таймеры вместе с владельцем панели.
self.serial = serial_factory(self) # Один адаптер хранит выбранный MCU и выполняет безопасный handshake.
self.serial.readyRead.connect(self._receive) # Разбор допускает произвольную фрагментацию USB/UART.
self.serial.errorOccurred.connect(self._error) # Ошибка открытия или передачи завершает только текущую операцию.
self.timeout = QTimer(self) # Ограничение ожидания применяется отдельно к каждому запросу.
self.timeout.setSingleShot(True) # Тайм-аут не создаёт повторных записей со сторонними эффектами.
self.timeout.timeout.connect(lambda: self._finish(False, "Тайм-аут DEIF; состояние записи проверьте повторным чтением")) # Потерянный ответ не означает, что команда не исполнилась.
self.delay = QTimer(self) # Следующий запрос отправляется вне стека readyRead.
self.delay.setSingleShot(True) # Каждое подтверждение разрешает ровно один следующий шаг.
self.delay.timeout.connect(self._next) # Последовательные FC06 не перемешиваются с финальной проверкой.
self.active = False # Новая операция разрешена только после полного завершения предыдущей.
def start(self, port, unit, baud, timeout, writes=()): # Профиль связи передаётся потребителем явно, без глобального COM и адреса.
if self.active: # Повторное нажатие не должно сбросить ожидающую подтверждения запись.
return # Текущая операция сохраняет первоначальный профиль.
self.writes = tuple((int(offset), int(value) & 0xFFFF) for offset, value in writes) # RAW I16 кодируется теми же 16 битами без потери знака.
if any(offset not in (2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) and not 32 <= offset < 44 for offset, _value in self.writes): # Только документированные writable слова версии 1 допускаются в очередь.
self.finished.emit(False, "Регистр DEIF недоступен для записи", None) # Локальная ошибка обнаруживается до открытия физического порта.
return # Чужие регистры и конфигурация STM не могут попасть в эту транзакцию.
self.unit, self.timeout_ms = unit, timeout # Адрес управления STM по умолчанию 16; тайм-аут задаётся в мс.
self.active, self.stage, self.rx = True, "config", bytearray() # Первая команда только читает действующий режим MCU.
self.serial.setPortName(port) # Выбор COM не открывает соединение до настройки формата.
self.serial.setBaudRate(baud) # USART использует выбранную скорость; для CDC это поле совместимости.
self.serial.setDataBits(QSerialPort.DataBits.Data8) # Восемь бит данных соответствуют аппаратным портам STM.
self.serial.setParity(QSerialPort.Parity.NoParity) # Проверку целостности выполняет CRC, формат UART без чётности.
self.serial.setStopBits(QSerialPort.StopBits.OneStop) # Один стоп-бит завершает формат 8N1.
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # RTS/CTS прошивкой не используются.
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite): # COM может быть занят другой программой.
self._finish(False, self.serial.errorString()) # Ошибка сообщает реальную причину открытия.
return # Ни чтение, ни запись при закрытом порте не выполняются.
self._send(3, 0x1210, 10) # Адрес и длина блока настроек STM взяты из set_emu_server.
def _send(self, function, address, value): # Запрос отправляется один раз; автоматического повтора записи нет.
self.packet = request(self.unit, function, address, value) # Общий кодек добавляет правильный CRC16.
self.rx.clear() # Байты предыдущего подтверждённого ответа не примешиваются к новому.
self.timeout.start(self.serial.response_timeout(self.timeout_ms)) # Первый обмен учитывает время определения SET v2/RTU.
if self.serial.write(self.packet) != len(self.packet): # Частичная постановка запроса в очередь не считается отправкой.
self._finish(False, "Не удалось передать запрос DEIF") # Операция останавливается без повторной передачи.
def _receive(self): # Накапливает только ответ текущего FC03 или FC06.
chunk = bytes(self.serial.readAll()) # Готовые байты забираются даже при отменённой операции.
if not self.active or not self.timeout.isActive(): # Поздний ответ не возобновляет отменённую последовательность.
return # Команда остаётся завершённой до нового явного действия пользователя.
self.rx.extend(chunk) # USB может разрезать ответ по любым границам.
function = self.packet[1] # Функция текущего запроса определяет размер ответа.
while len(self.rx) >= 3: # Заголовок содержит unit, function и byte count либо exception code.
if self.rx[0] != self.unit or self.rx[1] not in (function, function | 0x80): # Чужие либо старые кадры не подтверждают текущую операцию.
del self.rx[0] # Сдвиг на байт позволяет восстановиться после шумового префикса.
continue # Повторно ищет подходящий заголовок в оставшихся байтах.
expected = self.packet[5] * 2 if function == 3 else 0 # Все чтения короче 256 слов, старший байт количества равен нулю.
size = 5 if self.rx[1] & 0x80 else expected + 5 if function == 3 else 8 # Exception имеет 5 байт, FC06 echo — 8, FC03 — данные плюс заголовок и CRC.
if len(self.rx) < size: # Незавершённый ответ остаётся в буфере до readyRead или тайм-аута.
return # Никаких записей до полного подтверждения не выполняется.
raw = bytes(self.rx[:size]) # Фиксирует кадр до удаления из транспортного буфера.
del self.rx[:size] # Следующий шаг начнёт новый обмен с чистым буфером.
self.timeout.stop() # Полный ответ больше не ожидает истечения тайм-аута.
try: # Ошибки протокола должны возвращаться через finished, не покидая слот Qt.
if crc16_modbus(raw): # Нулевая CRC полного кадра подтверждает целостность.
raise ValueError("Неверная CRC ответа DEIF") # Повреждённый кадр не подтверждает запись.
if raw[1] & 0x80: # Старая прошивка либо неверный режим возвращает Modbus exception.
raise ValueError("STM отклонила DEIF, код %d; проверьте режим и версию прошивки" % raw[2]) # Показывает реальный код без предположения об успешном исполнении.
if function == 6 and raw != self.packet or function == 3 and raw[2] != expected: # Echo и фактическая длина должны точно совпасть с запросом.
raise ValueError("Неверное подтверждение DEIF") # Ответ другой команды не продвигает очередь.
self._accept(raw) # Только проверенный ответ может разрешить следующий шаг.
except ValueError as error: # Локальная проверка сигнатуры или роли имеет тот же путь завершения.
self._finish(False, str(error)) # Все таймеры и COM закрываются одинаково при любой ошибке.
return # За один сигнал обрабатывается только текущий запрос последовательности.
def _accept(self, raw): # Порядок: конфигурация, сигнатура DEIF, записи, итоговый снимок.
if self.stage == "config": # Чтение действующего режима предшествует любым командам DEIF.
self.mode = decode_config(raw)[8] # Общий декодер проверяет сигнатуру STM и допустимые режимы.
if self.writes and self.mode not in (4, 5): # Выбор режима выполняется отдельной явной транзакцией STM.
raise ValueError("Выберите DEIF Master или DEIF Slave в настройках STM и примените режим") # Чтение остаётся доступным для диагностики любой прошивки с DEIF.
self.stage = "probe" # Теперь нужно проверить версию и роль отдельного окна DEIF.
self.steps = [(3, DEIF_BASE, DEIF_WORDS)] # Одно чтение возвращает атомарный снимок 52 слов.
elif self.packet[1] == 3: # Окно читается перед записями и после них.
snapshot = decode_snapshot(struct.unpack(">%dH" % DEIF_WORDS, raw[3:-2]), self.mode) # Совместимость подтверждается данными реального MCU.
if self.stage == "probe" and self.writes: # Совместимый сервис разрешает подготовленные пользователем команды.
self.stage = "write" # С этой точки любой отказ может означать частично применённый профиль.
self.steps = [(6, DEIF_BASE + offset, value) for offset, value in self.writes] + [(3, DEIF_BASE, DEIF_WORDS)] # Финальное чтение обязательно даже после успешных echo.
else: # Чтение без записей и финальная проверка завершают транзакцию.
if any(snapshot.words[offset] != value for offset, value in dict(self.writes).items()): # Повторная запись того же регистра проверяется по последнему значению, включая временную сеть и нулевую маску при настройке.
raise ValueError("DEIF не подтвердил новые значения; повторите чтение состояния") # ACK передачи сам по себе не является проверкой применения.
self._finish(True, "Состояние DEIF прочитано и проверено", snapshot) # Только этот путь публикует подтверждённый снимок формы.
return # Завершённая операция не назначает следующий запрос.
self.delay.start(20) # Пауза 20 мс не блокирует GUI и совпадает с шагом клиента настроек STM.
def _next(self): # Слот таймера продвигает ровно одну команду подтверждённой последовательности.
if self.active and self.steps: # Отмена удаляет разрешение последующих запросов.
self._send(*self.steps.pop(0)) # Следующая запись выполняется только после echo предыдущей.
def _error(self, error): # Обрабатывает ошибки последовательного порта в том же GUI-потоке.
if self.active and error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError): # Собственный таймер уже отвечает за тайм-аут транзакции.
self._finish(False, self.serial.errorString()) # Не скрывает причину отключения или отказа COM.
def cancel(self): # Отмена доступна пользователю без смены режима на MCU.
if self.active: # Повторная отмена не создаёт повторных сигналов завершения.
self._finish(False, "Обмен DEIF отменён; проверьте состояние повторным чтением") # Уже подтверждённые записи не откатываются и не повторяются.
def _finish(self, ok, message, snapshot=None): # Единое завершение используется при успехе, отмене и ошибке.
if not self.active: # Поздняя ошибка Qt после close не должна затронуть новую операцию.
return # finished публикуется ровно один раз за запуск.
self.active = False # Состояние сбрасывается раньше закрытия COM и внешних обработчиков.
self.timeout.stop() # Ожидание текущего ответа больше не действует.
self.delay.stop() # Следующие записи не отправляются после ошибки.
self.serial.close() # Общий COM освобождается для логгера и других панелей.
self.finished.emit(ok, message, snapshot) # UI получает результат после полного освобождения ресурсов.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,181 @@
"""Async COM adapter for direct RTU or automatic emulator discovery.
Panels retain their register/TMS decoders. Only this boundary translates them
to SET v2 on the wire. No protocol cache survives closing/reopening a port.
"""
import time
from .qt_compat import QObject, QTimer, Signal, QSerialPort # Общий порт не зависит от импортов SETGUI и поддерживает Qt6/Qt5.
from set_devices.emulator_protocol import EmulatorProtocol # Автомат SET v2/RTU находится рядом с остальными переносимыми моделями templates.
def response_timeout(port, milliseconds):
return port.response_timeout(milliseconds) if isinstance(port, EmulatorSerialPort) else milliseconds
def cancel_pending(port):
if isinstance(port, EmulatorSerialPort):
port.cancel_pending()
class EmulatorSerialPort(QObject):
readyRead = Signal()
errorOccurred = Signal(object)
protocolChanged = Signal(str)
wireEvent = Signal(str, bytes)
PROBE_MS = 300
def __init__(self, parent=None, port=None, *, protocol_mode='auto', rtu_suffix=b'', mcu="STM32F407"): # Профиль MCU фиксирует проверяемую модель прошивки, сохраняя все старые параметры порта.
super().__init__(parent)
self._port = port if port is not None else QSerialPort(self)
self._port.readyRead.connect(self._receive)
self._port.errorOccurred.connect(self.errorOccurred.emit)
self._probe = QTimer(self)
self._probe.setSingleShot(True)
self._probe.timeout.connect(self._probe_timeout)
self._protocol_mode = protocol_mode
self._rtu_suffix = bytes(rtu_suffix)
self._mcu = mcu # Аппаратная привязка сохраняется между закрытием и повторным открытием того же COM-адаптера.
self.protocol = EmulatorProtocol(protocol_mode, mcu=mcu) # Первичная конфигурация использует ожидаемую модель выбранного MCU.
self._rx = bytearray()
self._error = ''
self._deadline = 0
def __getattr__(self, name):
port = self.__dict__.get('_port')
if port is None:
raise AttributeError(name)
return getattr(port, name)
def open(self, mode):
self._probe.stop()
self._deadline = 0
self.protocol = EmulatorProtocol(self._protocol_mode, mcu=self._mcu) # Повторное открытие начинает новый handshake с той же аппаратной привязкой.
self._rx.clear()
self._error = ''
return self._port.open(mode)
def configure_protocol(self, mode, *, rtu_suffix=b''):
"""Choose the wire format before opening a new connection."""
if self.isOpen():
raise ValueError('Сначала отключите COM-порт для смены протокола')
protocol = EmulatorProtocol(mode, mcu=self._mcu) # Смена wire-формата не должна сбрасывать выбранный MCU на F407.
self.cancel_pending()
self._protocol_mode = mode
self._rtu_suffix = bytes(rtu_suffix)
self.protocol = protocol
self._rx.clear()
def close(self):
self.cancel_pending()
self._port.close()
self._rx.clear()
def set_mcu(self, mcu): # Профиль разрешается менять только до открытия физического соединения.
if self.isOpen(): # Открытая транзакция должна завершиться с первоначальной идентичностью устройства.
raise ValueError("Сначала отключите COM-порт для смены MCU") # Предотвращаем подмену цели незавершённого обмена.
protocol = EmulatorProtocol(self._protocol_mode, mcu=mcu) # Неизвестный профиль отвергается до изменения сохранённых параметров.
self._mcu = mcu # Следующее открытие порта проверит DeviceInfo именно этой платы.
self.protocol = protocol # Старое состояние обнаружения не переносится на другой MCU.
def cancel_pending(self):
self._probe.stop()
self._deadline = 0
self.protocol.cancel()
def response_timeout(self, milliseconds):
return milliseconds + (2 * self.PROBE_MS if self.protocol.mode == 'auto' else 0)
def errorString(self):
return self._error or self._port.errorString()
def clear(self, *args):
self.cancel_pending()
self._rx.clear()
return self._port.clear(*args)
def bytesAvailable(self):
return len(self._rx)
@property
def supports_wave_blocks(self):
return self.protocol.mode == 'v2'
def readAll(self):
result = bytes(self._rx)
self._rx.clear()
return result
def write(self, data):
self._error = ''
self._rx.clear()
try:
packet = self.protocol.begin(bytes(data))
return len(data) if self._send(packet) else -1
except ValueError as error:
self._fail(str(error))
return -1
def _send(self, packet):
self._probe.stop()
self._deadline = 0
if self.protocol.phase in ('ping', 'info'):
self._deadline = time.monotonic() + self.PROBE_MS / 1000
self._probe.start(self.PROBE_MS)
if self.protocol.mode == 'rtu':
packet += self._rtu_suffix
written = self._port.write(packet)
if written > 0:
self.wireEvent.emit('TX', packet[:written])
if written != len(packet):
self._fail('Не удалось передать запрос целиком')
return False
return True
def _receive(self):
raw = bytes(self._port.readAll())
if not raw:
return
self.wireEvent.emit('RX', raw)
try:
old_mode = self.protocol.mode
outgoing, incoming = self.protocol.feed(raw)
if self.protocol.mode != old_mode:
self.protocolChanged.emit('SET v2')
if outgoing and not self._send(outgoing):
return
if incoming:
self._rx.extend(incoming)
self.readyRead.emit()
except ValueError as error:
self._fail(str(error))
def _probe_timeout(self):
if not self._deadline:
return
if time.monotonic() < self._deadline:
self._probe.start(max(1, int((self._deadline-time.monotonic())*1000)+1))
return
try:
packet = self.protocol.fallback()
self.protocolChanged.emit('RTU')
self._send(packet)
except ValueError as error:
self._fail(str(error))
def _fail(self, message):
self._error = message
self.cancel_pending()
self.errorOccurred.emit(QSerialPort.SerialPortError.ReadError)
def waitForReadyRead(self, milliseconds):
# Used only in WaveTransfer's worker. No GUI event loop is blocked.
deadline = time.monotonic() + milliseconds / 1000
while not self._rx and not self._error and time.monotonic() < deadline:
if self._deadline and time.monotonic() >= self._deadline:
self._probe_timeout()
remaining = max(1, int((deadline-time.monotonic())*1000))
self._port.waitForReadyRead(min(remaining, 20))
if self._error:
raise OSError(self._error)
return bool(self._rx)
@@ -0,0 +1,36 @@
from .qt_compat import Signal # Общий сигнал Qt работает в портах PySide6 и PySide2 без зависимости от SETGUI.
try: # Настольные потребители могут использовать любую из поддерживаемых версий Qt.
from PySide6.QtWidgets import QComboBox, QTabWidget # Qt6 предоставляет контейнер общих панелей и явный выбор MCU.
from PySide6.QtCore import Qt, QSignalBlocker # Блокировка сигнала предотвращает рекурсивное переключение профиля.
except ImportError: # Порт Windows 7 использует тот же API поверх PySide2.
from PySide2.QtWidgets import QComboBox, QTabWidget # Состав и поведение панелей сохраняются в Qt5.
from PySide2.QtCore import Qt, QSignalBlocker # Оба поколения Qt поддерживают блокировку сигналов комбобокса.
from set_devices.mcu_profiles import EMULATOR_PROFILES, EMULATOR_TOOLS # Модели и аппаратные привязки задаёт общая библиотека.
class EmulatorWorkspace(QTabWidget): # Потребитель предоставляет UI-панели, а templates выбирает их состав для конкретного MCU.
mcu_changed = Signal(str) # Приложение обновляет локальные настройки и подписи после явного выбора аппаратного профиля.
def __init__(self, parent=None): # Контейнер не открывает COM/CAN и не создаёт экземпляров аппаратных моделей.
super().__init__(parent) # Qt владеет переданными страницами в течение жизни рабочей области.
self.mcu = "STM32F407" # Сохраняем профиль прежнего эмулятора для существующих пользователей и вызовов.
self.mcu_selector = QComboBox(self) # Общая панель явно показывает MCU, которому предназначены команды.
for key, profile in EMULATOR_PROFILES.items(): # В списке появляются только платы с реализованными портами set_emu_server.
self.mcu_selector.addItem(profile["title"], key) # Стабильный ключ отделяет совместимость устройства от отображаемой подписи.
self.setCornerWidget(self.mcu_selector, Qt.Corner.TopRightCorner) # Выбор MCU доступен рядом с вкладками общих инструментов.
self.mcu_selector.currentIndexChanged.connect(lambda index: self.select_mcu(self.mcu_selector.itemData(index))) # Пользовательский выбор проходит тот же метод, что переход из меню конкретного MCU.
self._emulator_pages = {} # Реестр хранит единственные экземпляры переданных панелей, сохраняя общие транспорты приложения.
def add_emulator_page(self, key, widget): # Приложение связывает собственный виджет с публичным ключом общего сервиса.
self._emulator_pages[key] = widget # Повторное использование панели не копирует её обработчики или состояние модели.
self.addTab(widget, EMULATOR_TOOLS[key]) # Название инструмента берётся из общего каталога templates.
def select_mcu(self, key): # Смена профиля меняет только UI и настройки; команды активации остаются явными действиями пользователя.
profile = EMULATOR_PROFILES[key] # Неизвестный MCU отвергается до изменения текущей рабочей области.
self.mcu = key # Текущий ключ используется потребителем для выбора настроек и аппаратных параметров.
with QSignalBlocker(self.mcu_selector): # Программный переход из раздела MCU не вызывает повторный вход через комбобокс.
self.mcu_selector.setCurrentIndex(self.mcu_selector.findData(key)) # Видимый выбор всегда совпадает с применённым профилем.
for name, widget in self._emulator_pages.items(): # Доступность каждого инструмента определяется сервисами выбранного порта.
self.setTabVisible(self.indexOf(widget), name in profile["tools"]) # Новая плата может подключать подмножество тех же панелей без изменения их реализации.
self.mcu_changed.emit(key) # Локальный адаптер обновляет настройки после завершения общего переключения.
return True # Вызывающая навигация может отличить выполненный переход от отказа занятого адаптера.
+5 -4
View File
@@ -7,6 +7,7 @@ import struct
from .qt_compat import QObject, QTimer, Signal
from .qt_compat import QSerialPort
from set_devices.mcu_profiles import EMULATOR_MODES # Допустимые номера режимов берутся из общего каталога, включая обе роли DEIF.
from set_devices.tms_terminal import crc16_modbus
BAUDRATES = (9600, 19200, 38400, 57600, 115200)
@@ -24,7 +25,7 @@ def decode_config(raw):
if words[0] != 0x5343 or words[1] not in (2, 3):
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] > (3 if words[1] == 3 else 1) or words[9] > (3 if words[1] == 3 else 1):
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] not in (EMULATOR_MODES if words[1] == 3 else (0, 1)) or words[9] not in (EMULATOR_MODES if words[1] == 3 else (0, 1)): # Версия 3 C-сервера поддерживает режимы 0…5; версия 2 сохраняет только УМП и 2812.
raise ValueError("STM вернула неверные параметры связи")
return words
@@ -52,7 +53,7 @@ class StmSettingsClient(QObject):
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, 2, 3):
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in EMULATOR_MODES: # Номера 4 и 5 выбирают DEIF Master/Slave того же общего C-сервера.
self.finished.emit(False, "Адреса УМП и 2812 должны различаться; проверьте диапазоны и скорость", None)
return
self.unit, self.baud, self.timeout_ms = unit, baud, timeout
@@ -120,7 +121,7 @@ class StmSettingsClient(QObject):
return
addr, tms, baud, mode = self.desired
if mode >= 2 and words[1] < 3:
raise ValueError("Обновите прошивку STM: для отдельных режимов IGBT и ЦАП нужна версия настроек 3")
raise ValueError("Обновите прошивку STM: для IGBT, ЦАП и DEIF нужна версия настроек 3") # Сообщение учитывает обе роли DEIF и возможное применение настроек при потере ответа.
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)]
@@ -173,5 +174,5 @@ class StmSettingsClient(QObject):
if self.serial.isOpen():
self.serial.close()
if not ok and self.committed:
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812, 2=IGBT, 3=ЦАП). Повторите чтение по ним или перезагрузите плату." % self.desired
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812, 2=IGBT, 3=ЦАП, 4=DEIF Master, 5=DEIF Slave). Повторите чтение по ним или перезагрузите плату." % self.desired # Сообщение учитывает обе роли DEIF и возможное применение настроек при потере ответа.
self.finished.emit(ok, message, values)
+19
View File
@@ -0,0 +1,19 @@
"""Общие DEIF-векторы проверяют ctypes теми же данными, что C и Android/JVM.""" # Тесты не требуют Qt или структуры Desktop-приложения.
from pathlib import Path # Fixture входит в общий репозиторий templates.
import pytest # Параметризация даёт независимый результат для каждой эталонной строки.
from set_devices.deif_native import get_deif_native # Python не использует альтернативный декодер при отсутствии C.
from set_devices.deif_display import DeifDisplay # Пассивная модель является общим API библиотеки.
FIXTURE = Path(__file__).resolve().parents[2] / 'c/set-protocol/tests/fixtures/deif-xdi-v1.tsv' # Точный общий источник тестов для трёх портов.
CASES = [line.split() for line in FIXTURE.read_text(encoding='utf-8').splitlines() if line and not line.startswith('#')] # Здесь разбирается текст fixture, а не протокол CAN.
@pytest.mark.parametrize('row', CASES) # Каждый ошибочный или валидный wire-кадр проверяется отдельно.
def test_ctypes_matches_c_and_jni_vectors(row): # Числовой результат должен совпасть с независимым описанием ожидаемого кадра.
identifier, payload, flags, result, raw, index, sub = row # Столбцы fixture не зависят от внутреннего layout C.
flags = int(flags) # EXT/RTR являются входными метаданными теста.
code, meta = get_deif_native().inspect_frame(int(identifier, 16), bytes.fromhex(payload), bool(flags & 1), bool(flags & 2)) # Wire-разбор выполняется единственным C-кодеком.
assert (code, meta[4], meta[2], meta[3]) == (int(result), int(raw), int(index, 16), int(sub, 16)) # RAW и адрес объекта совпадают с C- и Kotlin-проверкой.
def test_passive_angle_is_received_without_gui_or_simulation(): # Ошибка со снимка воспроизводится без запуска серверных сервисов.
model = DeifDisplay(source='CAN / мост', device_node=13) # Физический адрес SLAVE не становится источником SAM.
assert model.accept('CAN / мост', 0x201, bytes.fromhex('01 01 30 07 84 03 00 00'), now=10) # Точный кадр пользователя несёт RAW 900.
assert model.angle_selection(now=10) == (90.0, True) # Сетевое задание видно как 90°, с явной пометкой.
assert model.value('angle', now=10) is None # Принятое задание не превращается в фиктивный фактический объект 0x02.
assert model.angle_selection(now=15.001) == (None, False) # При полном молчании наблюдатель уважает прежний срок свежести пять секунд.