Добавить многоклиентный CAN и общий транспорт FTDI

Выделить переносимое C-ядро CAN485 и Linux-порт SocketCAN/RS485 с наблюдателями и одним владельцем передачи.
Подключить Qt CAN к общему менеджеру консоли, сохранив независимое закрытие каналов.
This commit is contained in:
2026-11-13 20:33:18 +03:00
parent 41f5bf5e94
commit b724b40b84
9 changed files with 504 additions and 1 deletions
+6 -1
View File
@@ -468,7 +468,12 @@ class Rs485PacketParser:
# Команды прошивки
# ==========================================================================
def command_bitrate(kbps: str | int) -> str:
def command_can_interface(name: str) -> str: # Расширение общего контракта выбирает PS CAN Linux, сохраняя protocol wire в templates.
if name not in ('can0', 'can1'): raise ValueError('CAN-интерфейс должен быть can0 или can1') # Закрытый набор не допускает произвольных имён и внедрения дополнительных команд.
return 'N' + name # Выбор интерфейса не включает драйвер и не передаёт кадры автоматически.
def command_bitrate(kbps: str | int) -> str: # Общая команда битрейта используется ESP и Zynq без локальных копий.
"""Скорость шины CAN, кбит/с."""
return "S%s" % kbps
+107
View File
@@ -0,0 +1,107 @@
"""Единый неблокирующий порт CAN485 для USB CDC/UART и Ethernet TCP.""" # Протокол кадров остаётся в can485_board и переносимом C-ядре templates.
from .qt_compat import QObject, Signal, QTimer, QSerialPort, QElapsedTimer # Qt elapsed timer ограничивает также короткое подтверждение восстановления UART-консоли.
try: # Выбирает тот же Qt binding, который использует остальное приложение.
from PySide6.QtNetwork import QTcpSocket, QAbstractSocket # TCP передаёт тот же поток строк, что физический USB-порт.
except ImportError: # Старые системы используют доступный PySide2.
from PySide2.QtNetwork import QTcpSocket, QAbstractSocket # Публичный контракт адаптера не зависит от версии Qt.
class Can485Port(QObject): # Совместимая с BoardTab оболочка исключает копирование протокольных и экранных обработчиков.
readyRead = Signal() # Уведомляет монитор о произвольном фрагменте, включая неполную строку.
errorOccurred = Signal(object) # Использует существующий обработчик ошибок последовательного порта панели.
connected = Signal() # TCP-подключение подтверждается асинхронно после фактического установления соединения.
def __init__(self, parent=None): # Создаёт закрытые endpoints без аппаратного ввода-вывода при импорте или открытии вкладки.
super().__init__(parent) # QObject связывает жизненный цикл сокетов с владельцем панели.
self.serial = QSerialPort(self); self.tcp = QTcpSocket(self); self.kind = 'USB'; self._closing = False; self._error = ''; self._endpoint = ''; self._console = False; self._console_ready = False; self._console_rx = bytearray(); self.console_restored=True # Состояние восстановления консоли проверяется аппаратным USB-тестом после закрытия.
self.serial.readyRead.connect(self._serial_ready); self.tcp.readyRead.connect(self.readyRead) # USB-консоль ожидает подтверждение службы, затем передаёт bytes штатному parser строк.
self.serial.errorOccurred.connect(self._serial_error); self.tcp.errorOccurred.connect(self._tcp_error); self.tcp.disconnected.connect(self._disconnected) # Потеря соединения останавливает циклическую отправку через обработчик панели.
self.tcp.connected.connect(self._connected) # Начальная настройка выполняется только после подтверждённого TCP handshake.
self.timeout = QTimer(self); self.timeout.setSingleShot(True); self.timeout.timeout.connect(self._timed_out) # Конечный срок TCP не блокирует GUI даже при отключённой плате.
self._shared=None # Единая сборка позволяет CAN и Modbus одновременно использовать один физический FTDI.
def _connected(self): # Завершает фазу ожидания и разрешает запрос версии.
self.timeout.stop(); self.connected.emit() # Запоздалый timeout больше не может закрыть действующую сессию.
def _tcp_error(self, error): # Преобразует ошибку TCP в общий контракт порта.
if not self._closing: self._error = self.tcp.errorString(); self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # GUI получает исходную диагностическую строку сокета.
def _serial_error(self, error): # Сохраняет штатные коды QSerialPort, включая отсутствие ошибки.
if not self._closing: self._error = self.serial.errorString(); self.errorOccurred.emit(error) # USB-обрыв проходит тот же cleanup, что сетевой.
def _disconnected(self): # FIN от платы завершает сессию даже без отдельного socket error.
if not self._closing: self._error = 'Ethernet: соединение закрыто платой'; self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Панель прекращает периодическую передачу и очищает подтверждённые настройки.
def _timed_out(self): # Срабатывает только для незавершённого TCP-подключения.
if not self.isOpen(): self._error = 'Нет ответа CAN-службы Zynq за 5 секунд. Для USB-консоли нужны root shell и установленная zynq-can485d.'; self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Ожидание отсутствующего сервиса либо login prompt не блокирует GUI.
def _serial_ready(self): # В режиме консоли отделяет готовность туннеля от простого открытия COM-порта.
if self._closing: return # Во время короткого ожидания shell prompt данные читает cleanup, не вызывая повторные GUI callbacks.
if not self._console: self.readyRead.emit(); return # Прямой USB CDC/UART сохраняет исходные bytes без буферной копии.
self._console_rx.extend(bytes(self.serial.readAll())) # Shell и nc могут выдавать произвольные фрагменты строк.
if not self._console_ready and b'#INFO Zynq CAN485 / templates' in self._console_rx: self._console_ready=True; self.timeout.stop(); self.connected.emit() # Команды CAN разрешаются только после реального ответа общего daemon.
if self._console_ready: self.readyRead.emit() # После подтверждения весь поток, включая сохранённый заголовок, поступает штатному монитору.
elif len(self._console_rx)>8192: del self._console_rx[:-4096] # До handshake хранит ограниченный хвост консольного вывода.
def start_console_tunnel(self, port=37579): # Использует уже открытую root UART-консоль как USB-канал к тому же TCP-порту службы.
port=int(port) # Числовой аргумент проверяется до формирования команды shell.
if not 1<=port<=65535: raise ValueError('TCP-порт CAN должен быть 1..65535') # Исключает неверный endpoint и внедрение shell-команд из параметра.
self._console=True; self._console_ready=False; self._console_rx.clear(); self.console_restored=False; self.timeout.start(5000) # Открытый tty не выдаётся за подтверждённый CAN-сеанс.
self.serial.write(f'\rstty -echo -icanon min 1 time 0; nc 127.0.0.1 {port}; stty sane\r'.encode('ascii')) # Штатный nc передаёт общий протокол без копии daemon и без перенастройки USB/Device Tree.
def connect_tcp(self, host, port): # Endpoint уже проверен UI, DNS и синхронное ожидание не требуются.
self.close(); self.kind = 'Ethernet'; self._endpoint = f'{host}:{port}'; self._error = ''; self.timeout.start(5000); self.tcp.connectToHost(host, port) # Пять секунд ограничивают ожидание недоступного TCP-порта 37579.
def connect_console(self,name,port=37579): # Общий FTDI канал открывает отдельную CAN TCP сессию без захвата COM второй вкладкой.
from .console_link_port import ConsoleLink # Общий менеджер делится с Modbus и сохраняет действующий USB host HDMI.
self.close(); self.kind='USB'; self._endpoint=name; self._error=''; self.console_restored=False; self._shared=ConsoleLink.acquire(name,port,self); channel=self._shared; self.timeout.start(5000) # Начальное открытие остаётся отменяемым до подтверждения backend.
channel.readyRead.connect(self.readyRead); channel.opened.connect(self._connected); channel.errorOccurred.connect(lambda error:self._shared_error(channel,error)) # Прикладные CAN строки и ошибки принадлежат только своему логическому каналу.
def _shared_error(self,channel,error): # Ошибка локального CAN backend не должна выдавать сообщение физического QtSerialPort другого протокола.
if not self._closing: self._error=channel.errorString(); self.errorOccurred.emit(error) # Стандартный BoardTab cleanup завершает только CAN сессию.
def isOpen(self): # Штатные кнопки BoardTab учитывают реальное состояние выбранного канала.
if self._shared is not None: return self._shared.active and self._shared.isOpen() # Общий COM не считается готовым CAN до OPEN конкретного loopback backend.
return self.tcp.state() == QAbstractSocket.SocketState.ConnectedState if self.kind == 'Ethernet' else self.serial.isOpen() and (not self._console or self._console_ready) # Открытый USB tty с незавершённым nc handshake тоже не считается CAN-подключением.
def isConnecting(self): # Позволяет отменить незавершённую попытку подключения повторным нажатием кнопки.
return self.timeout.isActive() # Таймер активен только в фазе TCP handshake.
def close(self): # Закрытие идемпотентно и не порождает рекурсивную ошибку панели.
self._closing = True; self.timeout.stop(); self.tcp.abort() # Сначала отменяет deadline и сетевые сигналы старой сессии.
if self._shared is not None: channel=self._shared; self._shared=None; channel.close(); self.console_restored=channel.link.console_restored; channel.deleteLater() # Освобождается логический CAN канал, тогда как Modbus продолжает использовать тот же FTDI.
if self._console and self.serial.isOpen(): # Дожидается реального ответа UART, чтобы close не отбросил ещё не переданный Ctrl-C в драйвере Windows.
self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # Удаляет старый поток статуса и немедленно передаёт сигнал завершения собственного foreground nc.
if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Ограничивает ожидание освобождения локальной выходной очереди.
elapsed=QElapsedTimer(); elapsed.start(); response=bytearray() # Подтверждение восстановления shell имеет конечный срок 400 мс.
while elapsed.elapsed()<400 and not self.console_restored: # Нельзя закрыть FTDI раньше передачи управляющего байта и возврата root prompt.
self.serial.waitForReadyRead(40); response.extend(bytes(self.serial.readAll())); self.console_restored=response.rstrip(b'\r\n').endswith((b'# ',b'$ ')) # Настоящий prompt подтверждает завершение nc и выполнение stty sane.
if not self.console_restored: self._error='Закрыт USB, но возврат Linux-консоли за 400 мс не подтверждён' # Не выдаёт простое закрытие COM за подтверждённое восстановление терминала.
if self.serial.isOpen(): self.serial.close() # Не вызывает NotOpenError при повторном cleanup уже закрытого USB-порта.
self._console=False; self._console_ready=False; self._console_rx.clear(); self._closing = False # Не оставляет старые данные handshake для следующего COM-порта.
def readAll(self): # Возвращает сырой QByteArray для существующего LineAssembler templates.
if self._shared is not None: return self._shared.readAll() # Общий физический поток уже разделён на независимые CAN/Modbus сессии.
if self._console: data=bytes(self._console_rx); self._console_rx.clear(); return data # Консольные bytes прочитаны единожды при поиске подтверждённого заголовка.
return self.tcp.readAll() if self.kind == 'Ethernet' else self.serial.readAll() # Сегментация TCP/USB не влияет на протокол.
def write(self, data): # Длина результата позволяет обнаружить непринятую целиком команду.
if self._shared is not None: return self._shared.write(data) # Канал кодирует только транспортное обрамление, не меняя исходные CAN команды.
return self.tcp.write(data) if self.kind == 'Ethernet' else self.serial.write(data) # Порт не добавляет заголовков либо переводов строк к готовой команде.
def errorString(self): # Единая диагностика сохраняется до следующего соединения.
return self._error or (self.tcp.errorString() if self.kind == 'Ethernet' else self.serial.errorString()) # Сообщение Qt не подменяется общим «нет связи».
def portName(self): # Нижняя строка монитора показывает фактический канал.
if self._shared is not None: return self._shared.portName() # Общий COM остаётся видимым пользователю при одновременной работе двух протоколов.
return self._endpoint if self.kind == 'Ethernet' else self.serial.portName() # Сетевое соединение отличается от COM-порта платы.
def setPortName(self, name): # Штатный USB-путь BoardTab выбирает физический порт без изменения своего кода.
self.kind = 'USB'; self.serial.setPortName(name) # Следующие настройки применяются только к закрытому USB-порту.
def setBaudRate(self, value): return self.serial.setBaudRate(value) # Физический USB-UART требует согласованной скорости, USB CDC принимает line coding.
def setDataBits(self, value): return self.serial.setDataBits(value) # Использует 8 бит данных штатной панели.
def setParity(self, value): return self.serial.setParity(value) # Сохраняет отсутствие parity в общем контракте 8N1.
def setStopBits(self, value): return self.serial.setStopBits(value) # Сохраняет один стоповый бит.
def setFlowControl(self, value): return self.serial.setFlowControl(value) # RTS/CTS не задействуются для управления платой.
def open(self, mode): return self.serial.open(mode) # Открытие USB выполняется штатным QSerialPort с исходной диагностикой ошибок.