Добавить шлюзы CAN/RS485 и Modbus и уточнить обработку CAN

This commit is contained in:
2026-11-12 15:00:22 +03:00
parent 062f064c85
commit fa5ea166a9
18 changed files with 771 additions and 4 deletions
+5
View File
@@ -468,6 +468,11 @@ class Rs485PacketParser:
# Команды прошивки
# ==========================================================================
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:
"""Скорость шины CAN, кбит/с."""
return "S%s" % kbps
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
"""Единый неблокирующий порт 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 даже при отключённой плате.
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 isOpen(self): # Штатные кнопки BoardTab учитывают реальное состояние выбранного канала.
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._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._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): # Длина результата позволяет обнаружить непринятую целиком команду.
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): # Нижняя строка монитора показывает фактический канал.
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 с исходной диагностикой ошибок.
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -79,6 +79,7 @@ class SlcanAdapter(QObject):
frame_received = Signal(int, bytes)
capture_received = Signal(int, bytes, bool, bool)
connection_lost = Signal(str)
protocol_error = Signal(str) # Сообщает явный отказ BEL вне настройки; принятие байтов COM не подтверждает выполнение команды адаптером.
fd_received = Signal(int, bytes, bool, int, int) # Отдельный маршрут FD исключает ошибочный разбор кадра как Classic CAN.
def __init__(self, parent: QObject | None = None) -> None:
@@ -222,6 +223,8 @@ class SlcanAdapter(QObject):
if separator == 7: # ASCII BEL имеет код 7 и сообщает об отклонении последней команды.
if self._waiting_command and self._command_reply is None: # Принимаем только первый ответ текущего этапа настройки.
self._command_reply = False # Сохраняем отказ для проверки в последовательном обмене командами.
elif not self._waiting_command and self._query_prefix is None: # Отказ диагностического запроса не приписывается передаче CAN; установку режима обслуживает собственное ожидание.
self.protocol_error.emit("SLCAN: адаптер вернул отказ BEL; проверьте состояние CAN и очередь передачи") # BEL не содержит идентификатора кадра и причины, поэтому не выдаём его за доказательство отсутствия CAN ACK.
continue # BEL не должен приклеиваться к следующему CAN-кадру.
if self.options.fd and raw[:1] in (b"d", b"D", b"b", b"B") and self._receive_fd(raw): # Только корректная FD-строка забирается отдельным маршрутом; версия CANable может начинаться с HEX-буквы.
continue # Даже FD с данными до восьми байтов не меняет тип при передаче подписчикам.
@@ -286,7 +289,7 @@ class SlcanAdapter(QObject):
lines = [] # Ответы V и N читают сведения, F дополнительно сбрасывает индикатор ошибки согласно Lawicel.
canable = self.options.profile != "lawicel" # CANable возвращает V без буквенного префикса, а регистр E иногда без завершающего CR.
if canable: # Отсутствие подтверждения настроек сообщается независимо от успешного чтения версии.
lines.append(f"Профиль {self.options.profile}: команды конфигурации без ACK/NACK; применение настроек не подтверждается прошивкой.") # Диагностика не превращает ответ V в подтверждение предыдущих M/A/Y.
lines.append(f"Профиль {self.options.profile}: команды настройки по COM без ответов CR/BEL; это не аппаратное подтверждение ACK на шине CAN.") # Отделяет отсутствие ответов протокола SLCAN от аппаратного ACK, который активный CAN-контроллер формирует самостоятельно.
for command in (("V", "E") if canable else ("V", "N", "F")): # Набор фиксирован спецификацией выбранной прошивки.
self._read() # Сначала выдаём уже накопленные CAN-кадры, не смешивая их с ответом запроса.
self._query_prefix, self._query_reply = (b"" if canable else command.encode("ascii")), None # CANable не использует префикс V в строке версии.
+4 -3
View File
@@ -13,13 +13,14 @@ def decode_gas167(frame, node):
result.protocol = "УМП %d / ProtoCAN GAS 167/v1" % node
if not frame.ide or frame.rtr or frame.is_error:
return result
if result.id.msg_type not in (pc.MsgType.GENERAL_ADDRESS_SPACE,
pc.MsgType.MODBUS_HOLDING):
if result.id.msg_type not in (pc.MsgType.GENERAL_ADDRESS_SPACE, pc.MsgType.MODBUS): # Текущий ProtoCAN использует единый тип Modbus 0x5; отдельного MODBUS_HOLDING больше нет.
return result
if result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE:
start = result.id.body
else:
start, _count = pc.split_modbus(result.id.body)
start, _count, function = pc.split_modbus(result.id.body) # Современный Body возвращает также функцию, определяющую банк регистров.
if function != pc.ModbusFunction.HOLDING: # Ручная карта GAS 167/v1 относится к прежнему банку Holding, а не к дискретам или входным регистрам Modbus.
return result # Остальные банки сохраняют штатную расшифровку ProtoCAN без ошибочной подстановки адресов карты УМП.
words = result.registers
if words is None:
return result