"""Асинхронный обмен 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.