Добавить общий кодек и Qt-порт Modbus
Реализовать маршруты TCP/RTU, корреляцию ответов, настройки и общий канал Linux-консоли FTDI. Проверить протокольные эталоны и взаимодействие Qt с единым C-ядром.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,107 @@
|
||||
"""Общий владелец FTDI и независимые логические TCP-каналы CAN/Modbus поверх Linux shell.""" # Прикладные кодеки не зависят от способа совместного использования COM.
|
||||
from PySide6.QtCore import QObject, Signal, QTimer, QByteArray, QCoreApplication, QElapsedTimer, QIODevice # Один Qt event loop обслуживает все логические каналы без фоновых гонок.
|
||||
from PySide6.QtSerialPort import QSerialPort # Физический COM открывается ровно одним экземпляром менеджера.
|
||||
import shlex # Путь установленного либо явно выбранного тестового helper передаётся shell одним безопасным аргументом.
|
||||
import re # Подтверждённый root prompt отделяет shell от login/getty и запроса пароля после холодной загрузки.
|
||||
class ConsoleChannel(QObject): # Контракт readAll/write/close позволяет подключить существующие thin-порты протоколов.
|
||||
readyRead=Signal(); opened=Signal(); errorOccurred=Signal(object) # OPEN подтверждается loopback службой отдельно от открытия FTDI.
|
||||
def __init__(self,link,number,port,parent): # Канал принадлежит конкретному потребителю, физическая сессия — приложению.
|
||||
super().__init__(parent); self.link=link; self.number=number; self.port=port; self.active=False; self.closed=False; self.incoming=bytearray(); self.error='' # Соседние протоколы имеют независимые буферы и состояния.
|
||||
def isOpen(self): return not self.closed # Ожидающую handshake сессию можно отменить той же кнопкой отключения.
|
||||
def portName(self): return self.link.name # Панель показывает реальный COM, общий для обеих служб платы.
|
||||
def errorString(self): return self.error # Ошибка сохраняется до уничтожения конкретной логической сессии.
|
||||
def readAll(self): data=QByteArray(bytes(self.incoming)); self.incoming.clear(); return data # Каждый прикладной байт читается только своим протоколом.
|
||||
def write(self,data): # Возвращает длину принятых в очередь данных без подтверждения внешней операции.
|
||||
raw=bytes(data) # QByteArray и bytes преобразуются без изменения бинарного содержимого.
|
||||
if self.closed or not self.active: return -1 # Команда не уходит в неподтверждённый или уже закрытый канал.
|
||||
for offset in range(0,len(raw),512): # Короткая строка HEX позволяет чередовать CAN и Modbus при 115200 бод.
|
||||
if not self.link.send(f'D{self.number:02X}:'.encode('ascii')+raw[offset:offset+512].hex().encode('ascii')+b'\n'): return -1 # Переполнение физической очереди прекращает сессию без повтора команды.
|
||||
return len(raw) # Успех означает только локальную постановку в очередь QSerialPort.
|
||||
def close(self): # Закрытие протокола не отбирает общий COM у оставшихся потребителей.
|
||||
if self.closed: return # Идемпотентный cleanup безопасен при синхронных Qt error callbacks.
|
||||
self.closed=True; self.active=False; self.incoming.clear(); self.link.release(self) # Менеджер завершает shell helper только после последнего канала.
|
||||
def failed(self,message): # Ошибка одного backend не маскируется состоянием другого протокола.
|
||||
if self.closed: return # Запоздалое событие не возобновляет отключённую страницу.
|
||||
self.error=message; self.close(); self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Потребитель очищает собственные незавершённые запросы.
|
||||
class ConsoleLink(QObject): # Один экземпляр на имя COM обслуживает сколько угодно независимых backend сессий.
|
||||
links={} # Реестр процесса сохраняет одного физического владельца между вкладками SETGUI.
|
||||
program='/opt/zynq-link/console_link.py' # Проверка staged-пакета может выбрать его точный локальный путь до постоянной установки helper.
|
||||
@classmethod # Потребитель запрашивает канал, не управляя временем жизни общей UART-консоли.
|
||||
def acquire(cls,name,port,parent=None): # Сетевой адрес не передаётся из UI: разрешены только две локальные службы платы.
|
||||
if int(port) not in (37579,502): raise ValueError('Общий USB поддерживает CAN 37579 и Modbus 502') # Разрешены штатный CAN и стандартный Modbus TCP, без произвольного loopback proxy.
|
||||
name=str(name).strip(); key=name.upper() if name.upper().startswith('COM') else name # Windows COM нечувствителен к регистру, Linux PTY сохраняет точное имя.
|
||||
link=cls.links.get(key) # Повторная вкладка присоединяется к уже открытому физическому каналу.
|
||||
if link is None: link=cls(name,key); cls.links[key]=link; QTimer.singleShot(0,link.start) # Асинхронный запуск даёт потребителю время подключить error/opened callbacks.
|
||||
number=next((value for value in range(1,256) if value not in link.used),None) # Номер не переиспользуется до закрытия физической сессии, поэтому поздний DATA старого клиента не попадёт новому.
|
||||
if number is None: raise RuntimeError('Исчерпаны USB-каналы платы') # Ограничение совпадает с однобайтовым номером общего протокола.
|
||||
channel=ConsoleChannel(link,number,int(port),parent); link.channels[number]=channel; link.used.add(number) # Множество использованных номеров защищает от смешения с поздними queued ответами закрытого backend.
|
||||
if link.ready: QTimer.singleShot(0,lambda:link.open_channel(channel)) # Существующий helper открывает только новый backend без повторной shell команды.
|
||||
return channel # Никакая прикладная команда ещё не отправляется в CAN или Modbus.
|
||||
def __init__(self,name,key): # Общая сессия переживает переключение и закрытие одной рабочей страницы.
|
||||
super().__init__(QCoreApplication.instance()); self.name=name; self.key=key; self.serial=QSerialPort(self); self.channels={}; self.used=set(); self.incoming=bytearray(); self.ready=False; self.closing=False; self.console_restored=True # QObject приложения освобождает менеджер до завершения Qt runtime, а история номеров изолирует поздние DATA.
|
||||
self.timer=QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(lambda:self.fail('Нет ответа общего USB-туннеля: проверьте root shell и /opt/zynq-link/console_link.py')) # Ошибка начального handshake имеет конечный срок 5 секунд.
|
||||
self.serial.readyRead.connect(self.receive); self.serial.errorOccurred.connect(self.serial_error) # Единственный readyRead читает физические байты для всех протоколов.
|
||||
def start(self): # FTDI открывается только после явного запроса первой страницы подключения.
|
||||
if self.closing or not self.channels: return # Закрытый до первого event loop запрос не занимает COM.
|
||||
self.serial.setPortName(self.name); self.serial.setBaudRate(115200); self.serial.setDataBits(QSerialPort.DataBits.Data8); self.serial.setParity(QSerialPort.Parity.NoParity); self.serial.setStopBits(QSerialPort.StopBits.OneStop); self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # Параметры соответствуют проверенной root-консоли платы, независимо от внешней RS485.
|
||||
if not self.serial.open(QIODevice.OpenModeFlag.ReadWrite): self.fail(self.serial.errorString()); return # Занятый сторонним терминалом COM не перехватывается.
|
||||
self.console_restored=False; self.phase='shell'; self.login_sent=False; self.cursor_replied=False; self.timer.start(5000); self.send(b'\x15\r') # Сначала запрашивается фактический prompt: после холодного boot COM может ещё принадлежать getty, а не shell.
|
||||
def start_helper(self): # Команды tty разрешены только после подтверждения root shell известной платы.
|
||||
self.phase='helper'; self.incoming.clear(); self.send(("stty -echo -icanon -ixon -ixoff min 1 time 0; python3 "+shlex.quote(self.program)+"; stty sane; printf '\\n#ZYNQ-LINK-CLOSED\\n'\r").encode('ascii')) # Путь экранирован одним аргументом, завершение helper восстанавливает обычный терминал.
|
||||
def send(self,data): # Общая очередь Windows ограничена независимо от скорости прикладных клиентов.
|
||||
if self.closing or not self.serial.isOpen(): return False # Закрытый physical fd не накапливает команды для будущего запуска.
|
||||
if self.serial.bytesToWrite()+len(data)>262144 or self.serial.write(data)!=len(data): self.fail('Переполнена очередь общего USB; уменьшите поток CAN/Modbus'); return False # Предел 256 КиБ не допускает бесконечного отставания от UART.
|
||||
return True # Реальное подтверждение операции остаётся обязанностью прикладного протокола.
|
||||
def open_channel(self,channel): # Каждый backend соединяется отдельно внутри уже работающего общего helper.
|
||||
if not channel.closed and self.ready: self.send(f'O{channel.number:02X}:{channel.port}\n'.encode('ascii')) # Номер и порт сформированы из проверенных локальных чисел.
|
||||
def receive(self): # Произвольные USB фрагменты собираются в ограниченные ASCII строки обрамления.
|
||||
if self.closing: return # Во время короткого cleanup принятые байты проверяет finish, избегая рекурсии callback.
|
||||
self.incoming.extend(bytes(self.serial.readAll())) # Физический поток читается ровно одним QObject.
|
||||
if len(self.incoming)>16384: self.fail('Неверный поток общего USB-туннеля'); return # Ни shell, ни повреждённый backend не могут бесконечно накапливать данные.
|
||||
if not self.ready and self.phase=='shell': # Login и prompt не обязаны оканчиваться переводом строки, поэтому проверяются до разбора multiplex строк.
|
||||
if b'Password:' in self.incoming: self.fail('Консоль платы требует пароль: войдите через терминал и повторите подключение'); return # Пароль не угадывается и не отправляется автоматически.
|
||||
if b'\x1b[6n' in self.incoming and not self.cursor_replied: self.cursor_replied=True; self.send(b'\x1b[24;80R') # Factory autologin вызывает resize с ANSI запросом курсора; стандартный ответ 24 строки и 80 столбцов освобождает shell без ввода фиктивной команды.
|
||||
plain=re.sub(rb'\x1b(?:\[[0-?]*[ -/]*[@-~]|[78])',b'',bytes(self.incoming)) # ANSI save/restore cursor и resize не являются частью реального root prompt.
|
||||
if re.search(rb'(?:^|[\r\n])root@image_7020:[^\r\n]*#[ \t]*$',plain): self.start_helper(); return # Только фактическая root shell разрешает запуск общего helper после штатного factory autologin.
|
||||
if re.search(rb'login:[ \t]*$',plain) and not self.login_sent: self.login_sent=True; self.incoming.clear(); self.send(b'root\r') # Известное имя отправляется только getty, действительно ожидающему ввод; строка automatic login не вызывает лишнюю root команду.
|
||||
return # Текст getty не попадает в Modbus/CAN parser и не считается banner helper.
|
||||
while b'\n' in self.incoming: # Одно уведомление может содержать ответы нескольких логических каналов.
|
||||
raw,_,tail=self.incoming.partition(b'\n'); self.incoming[:]=tail; line=raw.rstrip(b'\r') # Терминальные CR не входят в HEX данные прикладных протоколов.
|
||||
if not self.ready: # Вывод shell и echo стартовой команды не становятся Modbus ответом.
|
||||
if line==b'#ZYNQ-LINK 1': self.ready=True; self.timer.stop(); [self.open_channel(channel) for channel in list(self.channels.values())] # Только настоящий banner разрешает OPEN backend каналов.
|
||||
continue # Старый консольный вывод не передаётся ни одному прикладному parser.
|
||||
if len(line)<3: continue # Пустые строки не влияют на состояние готовых каналов.
|
||||
try: number=int(line[1:3],16) # Однобайтовый номер позволяет выбрать единственного получателя.
|
||||
except ValueError: self.fail('Повреждено обрамление общего USB'); return # Ошибка multiplex framing закрывает физическую сессию без повторов.
|
||||
channel=self.channels.get(number) # Закрытый потребитель может оставить поздние байты, которые следует игнорировать.
|
||||
if channel is None: continue # Поздний DATA не создаёт сессию автоматически.
|
||||
if line[:1]==b'O' and len(line)==3: channel.active=True; channel.opened.emit() # Connected относится к установленному loopback соединению конкретной службы.
|
||||
elif line[:1]==b'C' and len(line)==3: channel.failed('Служба платы закрыла USB-канал') # FIN одного сервиса не отключает остальные протоколы.
|
||||
elif line[:1]==b'E' and line[3:4]==b':': channel.failed('Ошибка USB-канала службы: errno '+line[4:].decode('ascii','replace')) # Числовая причина Linux доступна рядом с соответствующей вкладкой.
|
||||
elif line[:1]==b'D' and line[3:4]==b':': # Прикладные данные раскодируются только после выбора активного канала.
|
||||
try: data=bytes.fromhex(line[4:].decode('ascii')) # Все управляющие значения байта остаются неизменными данными TCP потока.
|
||||
except (ValueError,UnicodeError): self.fail('Повреждены HEX данные общего USB'); return # Неисправное обрамление не проходит к Modbus C-кодеку как частичный успех.
|
||||
if len(channel.incoming)+len(data)>65536: channel.failed('Переполнен буфер USB-канала'); continue # Ограничение одного потребителя не мешает работе остальных.
|
||||
channel.incoming.extend(data); channel.readyRead.emit() # Существующий parser получает произвольный фрагмент исходного TCP.
|
||||
else: self.fail('Неизвестный ответ общего USB-туннеля'); return # Неожиданное возвращение shell не выдаётся за действующий протокольный поток.
|
||||
def serial_error(self,error): # Qt NoError не считается отказом физического FTDI.
|
||||
if not self.closing and self.serial.isOpen() and error not in (QSerialPort.SerialPortError.NoError,QSerialPort.SerialPortError.TimeoutError): self.fail(self.serial.errorString()) # Отключение кабеля завершает все каналы одной общей сессии.
|
||||
def release(self,channel): # Канал удаляется до посылки CLOSE, исключая повторную обработку поздних данных.
|
||||
self.channels.pop(channel.number,None) # Повторный cleanup не затрагивает другой номер сессии.
|
||||
if self.ready and not self.closing: self.send(f'C{channel.number:02X}\n'.encode('ascii')) # Backend получает штатный обрыв собственного TCP соединения.
|
||||
if not self.channels: self.finish() # Последняя страница возвращает обычную Linux-консоль и освобождает COM.
|
||||
def fail(self,message): # Физическая ошибка закрывает всех владельцев без автоматического повторного запуска.
|
||||
if self.closing: return # Синхронный QSerialPort error во время close не вызывает рекурсию.
|
||||
channels=list(self.channels.values()); self.closing=True # Сначала запрещаются новые записи всех потребителей.
|
||||
for channel in channels: channel.failed(message) # Каждая вкладка получает собственный cleanup и прекращает опрос.
|
||||
self.finish() # Общий UART освобождается после доставки ошибок всем потребителям.
|
||||
def finish(self): # Только последний потребитель завершает собственный foreground helper.
|
||||
self.closing=True; self.timer.stop() # Остальные Qt callbacks не читают tty во время подтверждения возврата shell.
|
||||
if self.serial.isOpen(): # Отсоединённый физический кабель не заставляет ждать несуществующее подтверждение.
|
||||
self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # ISIG остаётся включённым, поэтому Ctrl-C завершает только собственную foreground группу helper.
|
||||
if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Очередь передачи ограничена коротким ожиданием фактической отправки Ctrl-C.
|
||||
elapsed=QElapsedTimer(); elapsed.start(); response=bytearray() # Консоль должна подтвердить выполнение stty sane за ограниченные 500 мс.
|
||||
while elapsed.elapsed()<500 and not self.console_restored: self.serial.waitForReadyRead(25); response.extend(bytes(self.serial.readAll())); self.console_restored=b'\n#ZYNQ-LINK-CLOSED\r' in response or b'\n#ZYNQ-LINK-CLOSED\n' in response # Собственный marker отличает реальный возврат shell от простого закрытия COM.
|
||||
self.serial.close() # Сторонний терминал может открыть COM после завершения последнего протокола.
|
||||
if self.links.get(self.key) is self: self.links.pop(self.key) # Новый запрос пользователя создаст новую чистую физическую сессию.
|
||||
self.deleteLater() # Удаление QObject выполняется штатным Qt event loop до выгрузки runtime.
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Асинхронный USB-порт шлюза и локальный TCP endpoint для существующих Modbus-клиентов.""" # Wire-кодек и проверка ответов выполняются только C-ядром templates.
|
||||
from collections import deque # Ограниченная последовательная очередь исключает перемешивание RTU-ответов разных клиентов.
|
||||
import ctypes as ct # Передаёт native-запрос в общую C-проверку до отправки по USB.
|
||||
from PySide6.QtCore import QObject, QIODevice, QTimer, Signal # Жизненный цикл и дедлайны остаются в GUI event loop без фоновых гонок.
|
||||
from PySide6.QtNetwork import QHostAddress, QTcpServer # Loopback связывает существующий Qt Modbus-клиент с единственным владельцем USB.
|
||||
from PySide6.QtSerialPort import QSerialPort # Выделенный USB CDC COM открывается только по действию пользователя.
|
||||
from ..modbus_gateway import GatewayCodec # Python не вычисляет CRC, MBAP, смещения конфигурации и правила корреляции.
|
||||
class ModbusGatewayPort(QObject): # Один экземпляр обслуживает настройки и опрос через один физический USB endpoint.
|
||||
state = Signal(str) # Сообщает текущую фазу подключения и подтверждённые ошибки транспорта.
|
||||
configuration = Signal(object) # Передаёт только прочитанную с Zynq конфигурацию ABI.
|
||||
connected = Signal(bool) # Позволяет UI закрыть встроенный опрос при потере USB.
|
||||
def __init__(self, parent=None, codec=None): # Тест может предоставить библиотеку конкретной проверяемой сборки.
|
||||
super().__init__(parent); self.codec = codec or GatewayCodec(); self.serial = QSerialPort(self); self.server = QTcpServer(self); self.queue = deque(); self.pending = None; self.buffers = {}; self.received = bytearray(); self.config = self.codec.default(); self.ready = False; self.closing = False # Все очереди и callbacks принадлежат одному Qt-потоку.
|
||||
self.direct=self.serial; self.tcp_transport=False # Прямой CDC сохраняет RTU, а общий FTDI канал переносит стандартный Modbus TCP к той же службе.
|
||||
self.timer = QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(lambda: self.fail('Тайм-аут USB; проверьте OTG-порт и службу шлюза на Zynq')) # После тайм-аута сессия закрывается, чтобы поздний RTU-ответ не подтвердил следующий запрос.
|
||||
self.serial.readyRead.connect(self._receive); self.serial.errorOccurred.connect(self._serial_error); self.server.newConnection.connect(self._accept) # Уведомления не запускают блокирующих read/waitForReadyRead.
|
||||
@property # Локальный endpoint назначает ОС после успешного чтения настроек платы.
|
||||
def port(self): return self.server.serverPort() # Нулевой порт до подключения не выдаётся за готовый канал опроса.
|
||||
def open(self, name, console=False): # Прямой CDC либо общий FTDI сначала подтверждают службу и читают настройки без внешних операций.
|
||||
self.close() # Прежняя сессия и её локальные клиенты завершаются до выбора нового физического канала.
|
||||
if console: # Linux host для HDMI сохраняется, а существующий FTDI разделяется с CAN.
|
||||
from .console_link_port import ConsoleLink # Общий менеджер является единственным владельцем COM для обеих страниц.
|
||||
channel=ConsoleLink.acquire(name,502,self); self.serial=channel; self.tcp_transport=True; channel.readyRead.connect(self._receive); channel.opened.connect(self.read_configuration); channel.errorOccurred.connect(lambda error:self.fail(channel.errorString()) if not self.closing else None) # Чтение конфигурации начинается только после OPEN стандартного Modbus TCP listener той же службы платы.
|
||||
self.state.emit('Общий USB FTDI: подключение к Modbus-службе Zynq…'); return True # Ожидающую сессию можно отменить, не закрывая соседний CAN.
|
||||
self.serial.setPortName(name); self.serial.setBaudRate(115200); self.serial.setDataBits(QSerialPort.DataBits.Data8); self.serial.setParity(QSerialPort.Parity.NoParity); self.serial.setStopBits(QSerialPort.StopBits.OneStop); self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # Параметры прямого CDC не зависят от физической линии RS485.
|
||||
if not self.serial.open(QIODevice.OpenModeFlag.ReadWrite): self.state.emit(self.serial.errorString()); return False # Занятый CAN-монитором COM не перехватывается и не закрывается принудительно.
|
||||
self.serial.clear(); self.serial.setDataTerminalReady(True); self.state.emit('USB открыт; чтение настроек Zynq…'); self.read_configuration(); return True # DTR объявляет gadget готовность host к передаче данных.
|
||||
def close(self): # Вызывается при закрытии общей вкладки, ошибке и ручном отключении.
|
||||
self.closing = True; self.timer.stop(); self.server.close(); sockets = list(self.buffers); self.buffers.clear(); self.queue.clear(); self.pending = None; self.received.clear(); self.ready = False # Ни один старый запрос не переносится в следующую сессию USB.
|
||||
for socket in sockets: socket.abort(); socket.deleteLater() # Loopback-клиенты немедленно получают потерю соединения и прекращают опрос.
|
||||
if self.serial.isOpen(): self.serial.close() # Физический COM освобождается для других приложений.
|
||||
if self.tcp_transport: self.serial.deleteLater(); self.serial=self.direct; self.tcp_transport=False # Только логический FTDI канал возвращается к прямому порту; тестовый либо другой прямой I/O сохраняет свой контракт.
|
||||
self.closing = False; self.connected.emit(False) # UI отключает рабочие действия после очистки транспорта.
|
||||
def fail(self, message): # Ошибка не провоцирует повтор внешней команды записи.
|
||||
if self.closing: return # Синхронные serial error callbacks во время close не вызывают рекурсию.
|
||||
self.close(); self.state.emit(message) # Пользователь явно восстанавливает соединение после неопределённого результата записи.
|
||||
def _invalid_response(self,reason): # Диагностические bytes сохраняются до закрытия сессии, очищающего буфер.
|
||||
preview=bytes(self.received[:32]).hex(' ').upper(); suffix=' …' if len(self.received)>32 else ''; message=f'{self.serial.portName()}: {reason}. Получено {len(self.received)} байт; HEX: {preview}{suffix}.' # Предел 32 байта сохраняет полезное начало ответа без неограниченного роста сообщения GUI.
|
||||
if not self.ready and not self.tcp_transport: message+=' Для штатного FTDI включите «Linux-консоль FTDI»; прямой CDC/UART требует выделенной службы шлюза.' # Единый образ не отправляет бинарный RTU в консоль Linux при правильно выбранном общем транспорте.
|
||||
self.fail(message) # Строгая проверка ответа по-прежнему закрывает сессию без повтора внешней операции.
|
||||
def _serial_error(self, error): # Ошибки NoError и ожидания не закрывают исправный endpoint.
|
||||
if error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError) and self.serial.isOpen(): self.fail(self.serial.errorString()) # Отсоединение USB прекращает и настройки, и встроенный опрос.
|
||||
def read_configuration(self): self._enqueue(self.codec.control(), None) # Чтение настроек не посылает запросы внешним устройствам.
|
||||
def apply_configuration(self, config): self._enqueue(self.codec.control(config), None, config) # Полная таблица отправляется одной атомарной командой управления.
|
||||
def _enqueue(self, request, socket, candidate=None): # Один незавершённый RTU запрос сохраняет однозначность ответа без Transaction ID.
|
||||
if not self.serial.isOpen(): self.state.emit('Сначала подключите USB Zynq'); return # Закрытый COM не накапливает команды на будущий запуск.
|
||||
if len(self.queue) >= 32: self.fail('Переполнена очередь Modbus; уменьшите частоту опроса'); return # Ограничивает память при слишком быстром loopback-клиенте.
|
||||
self.queue.append((request, socket, candidate)); self._next() # Кандидат настроек нужен только для достаточного дедлайна применения.
|
||||
def _next(self): # Следующая команда отправляется только после полного проверенного ответа предыдущей.
|
||||
if self.pending is not None or not self.queue: return # Исключает перемешивание данных опроса с чтением конфигурации.
|
||||
self.pending = self.queue.popleft(); request, socket, candidate = self.pending # Неподтверждённая запись остаётся ровно одной записью в очереди.
|
||||
if socket is not None and socket not in self.buffers: self.pending = None; self._next(); return # Запрос закрытого клиента удаляется до обращения к внешней шине.
|
||||
wire = self.codec.tcp_reply(request,bytes(request.pdu[:request.length])) if self.tcp_transport else self.codec.rtu(request); self.received.clear() # Общий FTDI передаёт MBAP, прямой CDC — RTU; оба формата собирает один native-кодек.
|
||||
if not wire or self.serial.write(wire) != len(wire): self.fail('Не удалось передать запрос по USB'); return # Ошибка локальной очереди записи не приводит к автоматическому повтору.
|
||||
timeout = max(self.config.timeout_ms, candidate.timeout_ms if candidate is not None else 0); self.timer.start(max(5000, timeout + 1500)) # Учитывает внешний тайм-аут платы, USB и небольшую очередь обслуживания.
|
||||
def _receive(self): # Границы USB transfer не считаются границами Modbus RTU ADU.
|
||||
chunk = bytes(self.serial.readAll()) # Читает уже доступные байты без ожидания.
|
||||
if self.pending is None: return # Посторонние байты вне транзакции не становятся ответом будущего запроса.
|
||||
self.received.extend(chunk); size = (self.codec.lib.smg_tcp_size if self.tcp_transport else self.codec.lib.smg_rtu_response_size)(bytes(self.received), len(self.received)) # Границы MBAP либо RTU, включая частную FC65, вычисляет только C-ядро.
|
||||
if size < 0 or len(self.received) > (260 if self.tcp_transport else 256) or (size > 0 and len(self.received) > size): self._invalid_response('Неверный формат или длина ответа USB Zynq'); return # Сохраняет диагностические bytes установленной панели при строгих native границах RTU/MBAP.
|
||||
if not size or len(self.received) < size: return # Незавершённый ADU остаётся в ограниченном буфере до следующего readyRead.
|
||||
request, socket, candidate = self.pending # Ответ принадлежит единственному исходящему запросу.
|
||||
try: pdu = self.codec.accept(request, self.received, tcp=self.tcp_transport) # Native проверка CRC/MBAP и корреляции обязательна для обоих вариантов физического USB.
|
||||
except ValueError as error: self._invalid_response(str(error)); return # Ошибки CRC, адреса и MBAP получают ту же диагностику физического канала и фактических bytes.
|
||||
self.timer.stop(); self.pending = None; self.received.clear() # Освобождает транзакцию только после проверки полного ответа.
|
||||
if socket is None: # Управление шлюзом обрабатывается отдельно от обычных регистров устройств.
|
||||
try: config = self.codec.read_config(pdu) # Отказ ARM при настройке UART остаётся видимой ошибкой применения.
|
||||
except ValueError as error: self.state.emit(str(error)); self._next(); return # Прежняя подтверждённая конфигурация остаётся действующей.
|
||||
self.config = config # Тайм-аут и маршруты подтверждены работающей службой платы.
|
||||
if not self.ready: # Локальный endpoint открывается после handshake с правильной службой, а не после простого открытия COM.
|
||||
if not self.server.listen(QHostAddress.SpecialAddress.LocalHost, 0): self.fail(self.server.errorString()); return # ОС выделяет свободный порт без конфликтов с другими приложениями.
|
||||
self.ready = True; self.connected.emit(True) # Встроенная панель может подключиться к готовому каналу опроса.
|
||||
self.configuration.emit(config); self.state.emit('USB Zynq подключён; настройки подтверждены платой') # UI не выдаёт локальный черновик за применённую таблицу.
|
||||
elif socket in self.buffers: socket.write(self.codec.tcp_reply(request, pdu)) # Исходные Transaction ID и Unit ID возвращаются клиенту через C builder.
|
||||
self._next() # Продолжает очередь после доставки ответа его владельцу.
|
||||
def _accept(self): # Loopback принимает только локальные Modbus-клиенты процесса или ПК.
|
||||
while self.server.hasPendingConnections(): # Несколько ожидающих подключений обрабатываются за одно уведомление Qt.
|
||||
socket = self.server.nextPendingConnection() # QObject TCP-сокета остаётся дочерним серверу.
|
||||
if len(self.buffers) >= 8: socket.abort(); socket.deleteLater(); continue # Лимит совпадает с числом TCP-клиентов ARM-службы.
|
||||
self.buffers[socket] = bytearray(); socket.readyRead.connect(lambda s=socket: self._tcp_read(s)); socket.disconnected.connect(lambda s=socket: self._tcp_closed(s)) # Каждая сессия имеет свой MBAP stream buffer.
|
||||
def _tcp_closed(self, socket): self.buffers.pop(socket, None); socket.deleteLater() # Запросы закрытого клиента удаляются из очереди перед отправкой по USB.
|
||||
def _tcp_read(self, socket): # Qt Modbus использует существующий TCP backend, сохраняя единый UI опроса.
|
||||
if socket not in self.buffers: return # Позднее queued notification после disconnect не обращается к удалённому буферу.
|
||||
buffer = self.buffers[socket]; buffer.extend(bytes(socket.readAll())) # Склеенные и частичные TCP ADU сохраняются без потери байтов.
|
||||
if len(buffer) > 1040: socket.abort(); return # Четыре максимальных ADU ограничивают память недоверенного локального клиента.
|
||||
while buffer: # Один read может содержать несколько законченных Modbus-запросов.
|
||||
size = self.codec.lib.smg_tcp_size(bytes(buffer), len(buffer)) # Границы MBAP проверяет тот же код, что ARM-служба.
|
||||
if size < 0: socket.abort(); return # Неизвестный Protocol ID и длина прекращают только эту TCP-сессию.
|
||||
if not size or len(buffer) < size: return # Неполный заголовок не считается ошибкой и ждёт следующий пакет.
|
||||
request = self.codec.decode_tcp(buffer[:size]); del buffer[:size]; error = self.codec.lib.smg_check(ct.byref(request)) # Неподдерживаемая функция либо ошибочный размер PDU не превращаются в неклассифицируемый USB stream.
|
||||
if error: socket.write(self.codec.tcp_reply(request,self.codec.exception(request,error))) # Возвращает стандартное исключение без какой-либо внешней операции.
|
||||
else: self._enqueue(request, socket) # Внешний запрос остаётся единственным, даже когда входной TCP разбит или склеен.
|
||||
Reference in New Issue
Block a user