Добавить аппаратный IGBT v4 и автономный стенд DEIF

Реализовать наносекундное управление IGBT для HOPE STM32G474 и клиент обмена с проверкой версии и записанных параметров.
Расширить Android/JNI API DEIF, настройки и локальную симуляцию; добавить тесты и документацию.
This commit is contained in:
2026-11-06 16:25:47 +03:00
parent 45a34db657
commit 149d19f3f9
15 changed files with 976 additions and 4 deletions
+154
View File
@@ -0,0 +1,154 @@
"""Асинхронный обмен IGBT с определением версии, записью и проверкой состояния.""" # Общий клиент последовательно проверяет плату, записывает задания и подтверждает результат полным чтением.
import struct # Распаковывает сетевые 16-битные регистры независимо от порядка байтов компьютера.
from .qt_compat import QObject, QTimer, Signal, QSerialPort # Клиент общий для Qt6 и Qt5 и не импортирует приложение-потребитель.
from set_devices.qt_ports.stm_settings import request # Использует штатный формирователь кадров RTU управления STM.
from set_devices.tms_terminal import crc16_modbus # Проверяет CRC тем же общим алгоритмом, что остальные последовательные клиенты.
from .emulator_serial import EmulatorSerialPort, response_timeout # Тот же транспорт SET v2/RTU обслуживает эмуляторы всех поддержанных MCU.
class IgbtClient(QObject): # Один Qt-автомат обслуживает версии 3/мс и 4/нс без параллельного захвата COM.
finished = Signal(bool, str, object) # Возвращает успех, русское описание и подтверждённые 112 слов состояния либо None.
def __init__(self, parent=None): # Родитель QObject связывает время жизни клиента и панели приложения.
super().__init__(parent) # Регистрирует объект в дереве Qt без открытия подключения к плате.
self.serial = EmulatorSerialPort(self) # Создаёт общий транспорт MCU с определением SET v2/RTU и проверкой DEVICE_INFO.
self.serial.readyRead.connect(self._receive) # Направляет произвольные порции принятых байтов в единственный приёмный автомат.
self.serial.errorOccurred.connect(self._error) # Обрабатывает физические ошибки COM отдельно от протокольных исключений.
self.timer = QTimer(self) # Создаёт таймер ожидания ответа транспорта, который не отмеряет длительности импульсов.
self.timer.setSingleShot(True) # Один запрос имеет один предельный срок ожидания до явного перезапуска.
self.timer.timeout.connect(lambda: self._finish(False, 'Тайм-аут ответа IGBT')) # По истечении срока освобождает COM и сообщает об отсутствии ответа платы.
self.active = False # До явного start клиент не удерживает COM и не принимает ответы как действующие.
self.rx = bytearray() # Накапливает фрагментированный RTU-кадр без предположения о границах readyRead.
def start(self, port, unit, baud, timeout, writes=(), *, expected_version=None): # Ожидаемая версия фиксирует единицы времени, которые пользователь видел перед записью.
if self.active: # Повторный start не должен смешивать адреса и ответы двух выполняемых планов.
return # Прекращает текущую ветвь автомата после завершения действия либо отказа.
self.writes = list(writes) # Сохраняет план, но допускает его к отправке только после чтения версии платы.
self.expected_version = expected_version if expected_version is not None else 3 # Прежний публичный API записи по умолчанию использует миллисекунды версии 3.
if any(not isinstance(off, int) or not isinstance(value, int) or not 0 <= off < 112 or not 0 <= value <= 65535 for off, value in self.writes): # До открытия COM проверяет только тип и размер протокольного слова; единицы определит probe.
self.finished.emit(False, 'Недопустимые параметры IGBT', None) # Неверный план отвергается до открытия порта и не имеет физических действий.
return # Не продолжает start после отказа предварительной проверки.
self.unit, self.timeout_ms = unit, timeout # Сохраняет адрес устройства и пользовательский тайм-аут в миллисекундах ожидания связи.
self.active, self.write_sent = True, False # Отмечает активный сеанс и сбрасывает признак уже отправленных записей.
self.stage = 'probe' # Начинает новый сеанс чтением версии, чтобы не переносить единицы времени с прежней платы.
self.serial.setPortName(port) # Применяет выбранный пользователем COM к общему транспорту MCU.
self.serial.setBaudRate(baud) # Сохраняет выбранную скорость UART в бодах; для USB CDC это не скорость USB.
self.serial.setDataBits(QSerialPort.DataBits.Data8) # Устанавливает восемь бит данных штатного формата UART 8N1.
self.serial.setParity(QSerialPort.Parity.NoParity) # Отключает бит чётности согласно формату 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()) # Передаёт точную аппаратную ошибку Qt и освобождает состояние автомата.
return # Не отправляет запрос после отказа открытия COM.
self._send(3, 0x1400, 16) # FC03 читает 16 слов заголовка по адресу 0x1400 до первой возможной FC06-записи.
def _valid_writes(self, version): # Проверяет диапазоны в единицах уже опознанной прошивки, сохраняя прежние ограничения версии 3.
limits = {2: (0, 1), 6: (0, 255), 9: (0, 0), 12: (0, 1), 13: (0, 255), 14: (1, 255)} # Общие команды имеют одинаковые адреса, но их физический смысл определяется версией.
if version == 3: # Старая логическая модель продолжает принимать только миллисекундные значения.
limits.update({7: (0, 1000), 8: (1, 255)}) # Прежние общие задержка и ширина существуют только у модели версии 3.
timing = [(50, 60000)] * 5 if version == 4 else [(0, 1000), (1, 1000), (0, 1000), (1, 1000), (1, 60000)] # Версия 4 принимает реальные интервалы 50…60000 нс, без преобразования в мс.
fields = [(0, 7), (0, 3), *timing, (0, 3), (0, 60000)] # Формирует поканальные ограничения флагов, режима, времён, фронтов и FAULT.
for ch in range(8): # Проверяет все восемь независимых блоков по 12 регистров.
limits.update({16 + ch * 12 + field: bounds for field, bounds in enumerate(fields)}) # Сохраняет прежние смещения без зависимости от выбранного MCU.
if version == 4: # Измеренная компенсация входного тракта отсутствует в миллисекундной модели.
limits[26 + ch * 12] = (0, 1000) # Поле 10 блока версии 4 задаёт компенсацию в наносекундах.
return all(off in limits and limits[off][0] <= value <= limits[off][1] and not (version == 4 and off >= 16 and (off - 16) % 12 == 8 and 0 < value < 50) for off, value in self.writes) # Ненулевой FAULT также должен воспроизводиться аппаратным таймером.
def _send(self, function, register, value): # Отправляет один кадр и устанавливает ожидание соответствующего ответа.
self.rx.clear() # Удаляет остаток прежнего кадра перед отдельным новым запросом.
self.packet = request(self.unit, function, register, value) # Сохраняет точные байты запроса для проверки адреса, длины и echo записи.
self.write_sent |= function == 6 # FC06 помечает возможность частично изменённых либо подготовленных заданий при дальнейшей потере связи.
self.timer.start(response_timeout(self.serial, self.timeout_ms)) # Добавляет транспортное время определения SET v2/RTU к ожиданию ответа IGBT.
if self.serial.write(self.packet) != len(self.packet): # Неполная передача кадра не может подтверждаться как записанная команда.
self._finish(False, 'Ошибка передачи IGBT') # Завершает сеанс при отказе записи байтов и не продолжает план.
def _receive(self): # Собирает ответы COM независимо от фрагментации, проверяя CRC, длину и прочитанную версию.
chunk = bytes(self.serial.readAll()) # Получает текущую порцию байтов до проверки активного состояния сеанса.
if not self.active: # Поздние байты завершённого обмена не могут изменить его уже сообщённый результат.
return # Не анализирует байты вне активной транзакции.
self.rx.extend(chunk) # Сохраняет неполный ответ до получения оставшихся байтов.
function = self.packet[1] # Ожидает ту же FC03 либо FC06, которая реально отправлена текущим запросом.
while len(self.rx) >= 3: # Минимальные три байта определяют адрес, функцию и длину ответа.
if self.rx[0] != self.unit or self.rx[1] not in (function, function | 0x80): # Ищет ответ нужного узла и ожидаемой функции, включая код исключения с битом 0x80.
del self.rx[0] # Пропускает один чужой байт, сохраняя возможность найти правильный заголовок далее.
continue # Продолжает синхронизацию кадра после удаления постороннего байта.
size = 5 if self.rx[1] & 0x80 else (5 + self.rx[2] if function == 3 else 8) # Исключение занимает 5 байт, FC03 содержит byte-count плюс 5 служебных байт, echo FC06 занимает 8 байт.
if len(self.rx) < size: # Незавершённый кадр остаётся в буфере до следующего readyRead.
return # Ожидает недостающие байты, не объявляя неполный ответ ошибочным.
raw = bytes(self.rx[:size]) # Проверяет ровно ожидаемый кадр без включения следующих байтов потока.
self.timer.stop() # После полного кадра прекращает ожидание текущего запроса до его проверки.
if crc16_modbus(raw): # Ненулевой остаток Modbus CRC означает повреждённый ответ.
self._finish(False, 'Неверная CRC ответа IGBT') # Не разрешает дальнейшие записи после ошибки целостности ответа.
return # Прекращает обработку повреждённого кадра.
if raw[1] & 0x80: # Старший бит функции обозначает протокольное исключение устройства.
self._finish(False, f'Устройство отклонило запрос IGBT, код {raw[2]}') # Показывает реальный код отказа платы вместо ложного подтверждения задания.
return # Прекращает транзакцию после исключения устройства.
if function == 6: # Запись FC06 требует буквального совпадения echo с адресом и значением запроса.
if raw != self.packet: # Иное echo не может продвинуть последовательность подготовленных команд.
self._finish(False, 'Неверное подтверждение записи IGBT') # Сообщает отказ подтверждения записи и освобождает COM.
return # Не продолжает план после неверного echo.
self._next() # Переходит к следующей записи либо полному чтению подтверждённого результата.
return # Один вызов обработчика подтверждает только один ожидаемый кадр.
count = int.from_bytes(self.packet[4:6], 'big') # Число слов извлекается из реально переданного FC03 в сетевом порядке байтов.
if len(raw) != 5 + count * 2 or raw[2] != count * 2: # Проверяет и общую длину кадра, и byte-count, где каждое слово занимает два байта.
self._finish(False, 'Неверная длина состояния IGBT') # Не распаковывает короткий либо чрезмерный снимок состояния.
return # Прекращает обработку кадра неверной длины.
words = struct.unpack(f'>{count}H', raw[3:-2]) # Распаковывает ровно count беззнаковых 16-битных регистров big-endian, исключая адрес, заголовок и CRC.
version = words[1] # Версия задаёт единицы времени и проверяется заново при каждом открытии COM.
if words[0] != 0x4947 or version not in (3, 4) or words[10:12] != (8, 0 if version == 4 else 1) or words[15] != 12: # Принимает только известные структуры и явный код наносекунд у аппаратной версии 4.
self._finish(False, 'Требуется IGBT версии 3 (мс) или версии 4 (аппаратные нс) с 8 каналами') # Не допускает управление неизвестным форматом регистров.
return # Не управляет неизвестной структурой либо кодом единиц времени.
if self.stage == 'probe': # Заголовок должен подтвердить версию и единицы до чтения полного состояния и FC06.
self.interface_version = version # Сохраняет результат probe для проверки полного снимка и запрета смены единиц посреди обмена.
if self.writes and any(item != (2, 0) for item in self.writes) and version != self.expected_version: # Запись времён требует совпадения единиц; безусловный останов одинаков у обеих версий и остаётся доступным.
self._finish(False, 'Версия IGBT и единицы времени изменились; прочитайте состояние и примените задания заново') # Завершает обмен до первой записи, сохраняя выходы платы.
return # Не отправляет подготовленные в других единицах значения.
if not self._valid_writes(version): # Проверяет все записи до первой команды FC06.
self._finish(False, 'Недопустимые параметры IGBT для прочитанной версии прошивки') # Ошибка диапазона также не оставляет частично отправленного задания.
return # Не начинает запись неверного профиля.
self.stage = 'state' # После probe начинает этап полного чтения известных поканальных заданий.
self._send(3, 0x1400, 112) # Полный FC03 запрашивает 112 слов окна IGBT, включая все восемь каналов.
return # Ожидает полный снимок отдельным следующим ответом.
if version != self.interface_version: # Не допускает подмену протокола между заголовком и полным чтением.
self._finish(False, 'Версия IGBT изменилась во время обмена; повторите чтение') # Прерывает транзакцию до дальнейших записей или ложного подтверждения.
return # Требует нового определения единиц времени.
if self.stage == 'state' and self.writes: # Записи выполняются только после успешного полного чтения известной версии.
self.pending = list(self.writes) # Копирует проверенный план, сохраняя исходный список для сравнения чтением.
self.stage = 'write' # Отмечает этап последовательного выполнения FC06.
self._next() # Отправляет первую подготовленную команду после завершения всех предварительных чтений.
else: # Ветка без новых записей либо после их выполнения возвращает фактический снимок.
# Счётчик может измениться сразу после сброса из-за нового внешнего фронта.
expected = dict(self.writes) # При повторной записи одного смещения проверяется последнее отправленное значение.
if any(words[off] != value for off, value in expected.items() if off not in (9, 14)): # Команда импульса 14 и изменяемый фронтами счётчик 9 исключены из буквального readback; остальные задания должны совпасть.
self._finish(False, 'Записанные параметры IGBT не подтвердились') # Отклоняет успешное echo, если последующее чтение не подтвердило задания.
else: # Подтверждение выдаётся только после сравнения фактических регистров с планом.
self._finish(True, 'IGBT: параметры записаны и проверены' if self.writes # Отличает успешную запись с проверкой от обычного чтения без физических действий.
else 'IGBT: состояние прочитано', words) # Передаёт панели полный подтверждённый снимок после чтения либо readback.
return # Завершает обработку одного ожидаемого ответа текущего шага.
def _next(self): # Продвигает проверенный план без параллельных запросов на общий COM.
if self.pending: # Следующая запись разрешается только после echo предыдущей команды.
off, value = self.pending.pop(0) # Извлекает одно относительное смещение и соответствующее 16-битное значение.
self._send(6, 0x1400 + off, value) # FC06 использует прежнюю абсолютную базу 0x1400 плюс смещение регистра.
else: # После последнего echo начинается подтверждение всего результата чтением.
self.stage = 'verify' # Отмечает этап проверки, чтобы полный ответ уже не запускал новый план записи.
self._send(3, 0x1400, 112) # Подтверждает все восемь каналов одним FC03 на 112 слов.
def _error(self, error): # Значимые физические ошибки Qt завершают обмен, тайм-ауты обрабатываются собственным таймером.
if self.active and error not in (QSerialPort.SerialPortError.NoError, # Не выдаёт NoError за отказ и не завершает неактивный сеанс поздним сигналом.
QSerialPort.SerialPortError.TimeoutError): # Qt TimeoutError не дублирует собственный контролируемый тайм-аут ответа IGBT.
self._finish(False, self.serial.errorString()) # Показывает точную аппаратную причину отказа COM и освобождает транспорт.
def cancel(self): # Отмена прерывает текущую транзакцию, не заменяя команду физического останова платы.
self._finish(False, 'Обмен IGBT отменён') # Запрещает дальнейшую отправку плана и сообщает пользователю отмену обмена.
def _finish(self, ok, message, words=None): # Единая точка завершения освобождает COM, таймер и блокировку UI при любом исходе.
if not self.active: # Поздний timeout либо error не должен отправлять повторный результат завершённого обмена.
return # Не изменяет состояние и не повторяет finished для уже завершённого сеанса.
self.active = False # Снимает блокировку последовательного автомата до уведомления панели.
self.timer.stop() # Прекращает транспортное ожидание, которое уже не относится к активному запросу.
self.serial.close() # Освобождает общий COM для настроек STM и других разрешённых инструментов.
if not ok and self.write_sent: # После отправленной FC06 нельзя считать прежнее состояние платы подтверждённым при потере связи.
message += '. Часть настроек могла примениться; прочитайте состояние повторно.' # Напоминает перечитать действующие и подготовленные задания после частичного обмена.
self.finished.emit(ok, message, words) # Передаёт панели успех, причину отказа и только подтверждённый снимок либо None.