From d325910bfe8976f8ba36db6467f3a716ad61a0f2 Mon Sep 17 00:00:00 2001 From: YuanHongbin <975559679@qq.com> Date: Thu, 24 Sep 2026 17:14:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(ota):=20=E6=96=B0=E5=A2=9ETCP(Cat1)?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E9=80=9A=E9=81=93(hex=E6=94=B6=E5=8F=91+?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=B5=81=E6=8E=A7+=E6=96=AD=E7=82=B9?= =?UTF-8?q?=E7=BB=AD=E4=BC=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TCP服务端监听(默认8080)等待网关Cat1模组连入, 协议复用0x7D帧 - 升级帧转ASCII hex文本下发, 上行hex流式解码(兼容旧格式ACK) - 窗口流控: 收到0x02请求后连发8包/批; 断点续传ACK扩展{Status,ResumedIdx} - 握手阶段超时重发0x81(修复原重发数据批次无效问题) - 版本V1.3.0(宽带调试版, 含Cat1远程升级) --- gateway_config.py | 281 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 259 insertions(+), 22 deletions(-) diff --git a/gateway_config.py b/gateway_config.py index 9e7abb0..4289eec 100644 --- a/gateway_config.py +++ b/gateway_config.py @@ -10,6 +10,7 @@ import time import struct import zlib import threading +import socket from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, @@ -126,6 +127,100 @@ class SerialThread(QThread): self.wait() +class TcpServerThread(QThread): + """TCP服务线程: 监听端口, 等待网关Cat1模组(AT+LIPOPEN)主动连入后透传升级帧。""" + client_connected = pyqtSignal(str) + client_disconnected = pyqtSignal() + data_received_raw = pyqtSignal(bytes) + listen_error = pyqtSignal(str) + + def __init__(self, port): + super().__init__() + self.port = port + self.running = True + self._hexbuf = b"" # 设备上行ASCII hex文本的奇数尾巴缓冲 + self.sock = None # 监听socket + self.conn = None # 已连接的客户端(网关模组) + self.conn_lock = threading.Lock() + + def run(self): + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind(('0.0.0.0', self.port)) + self.sock.listen(1) + self.sock.settimeout(0.5) + except Exception as e: + self.listen_error.emit(str(e)) + return + while self.running: + try: + conn, addr = self.sock.accept() + except socket.timeout: + continue + except OSError: + break + conn.settimeout(0.5) + try: + conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except Exception: + pass + with self.conn_lock: + self.conn = conn + self.client_connected.emit(addr[0]) + while self.running: + try: + data = conn.recv(4096) + except socket.timeout: + continue + except OSError: + break + if not data: + break + self.data_received_raw.emit(self._decode_hex(data)) + with self.conn_lock: + try: + conn.close() + except Exception: + pass + self.conn = None + self._hexbuf = b"" + self.client_disconnected.emit() + try: + self.sock.close() + except Exception: + pass + + def _decode_hex(self, data): + """设备上行是ASCII hex文本(LIPSEND hex-ascii), 流式解码为二进制(保留奇数尾巴)。""" + buf = self._hexbuf + data + n = len(buf) - (len(buf) % 2) + self._hexbuf = buf[n:] + try: + return bytes.fromhex(buf[:n].decode('ascii')) + except ValueError: + self._hexbuf = b"" # 非法字符: 丢弃重同步 + return b"" + + def send_data(self, data): + with self.conn_lock: + if not self.conn: + return False + try: + self.conn.sendall(data) + return True + except Exception: + return False + + def is_client_connected(self): + with self.conn_lock: + return self.conn is not None + + def stop(self): + self.running = False + self.wait(3000) + + class GatewayConfigTool(QMainWindow): def __init__(self): super().__init__() @@ -144,7 +239,7 @@ class GatewayConfigTool(QMainWindow): self.refresh_ports() def init_ui(self): - self.setWindowTitle("网关配置工具(宽带版 V1.2.2)") + self.setWindowTitle("网关配置工具(宽带版 V1.3.0)") self.setWindowIcon(QIcon(resource_path("物联网网关.png"))) self.setMinimumSize(940, 640) self.resize(1000, 700) @@ -611,7 +706,7 @@ class UpgradeDialog(QDialog): def __init__(self, main_window): super().__init__(main_window) self.main = main_window - self.setWindowTitle("设备升级 - 通用串口升级协议V1.1") + self.setWindowTitle("设备升级 - 通用升级协议V1.1 (串口/Cat1 TCP)") self.setModal(False) self.resize(720, 520) @@ -622,6 +717,12 @@ class UpgradeDialog(QDialog): self._sent = set() self._buf = b"" self._timer = QTimer() + self.channel = 'serial' # 升级通道: serial / tcp + self.tcp_thread = None + self._window = 8 # TCP模式每批包数(窗口流控) + self._window_start = 0 # 当前批次首包序号(超时重发用) + self._retry_cnt = 0 # TCP模式超时重发计数 + self._resume_base = 0 # 断点续传基数(设备已收包数) self._timer.setSingleShot(True) self._timer.timeout.connect(self._on_timeout) @@ -651,6 +752,28 @@ class UpgradeDialog(QDialog): mac_hint.setStyleSheet("color:#909090;") cfg_layout.addWidget(mac_hint, 1, 2) + # 通道选择 + TCP监听 + cfg_layout.addWidget(QLabel("通道:"), 2, 0) + self.combo_channel = QComboBox() + self.combo_channel.addItems(["串口", "TCP(Cat1)"]) + self.combo_channel.currentIndexChanged.connect(self._on_channel_changed) + cfg_layout.addWidget(self.combo_channel, 2, 1) + tcp_row = QHBoxLayout() + self.tcp_lbl = QLabel("TCP端口:") + tcp_row.addWidget(self.tcp_lbl) + self.tcp_port_edit = QLineEdit("8080") + self.tcp_port_edit.setFixedWidth(80) + tcp_row.addWidget(self.tcp_port_edit) + self.btn_tcp_listen = QPushButton("开始监听") + self.btn_tcp_listen.setFixedWidth(90) + self.btn_tcp_listen.clicked.connect(self._toggle_tcp_listen) + tcp_row.addWidget(self.btn_tcp_listen) + self.tcp_status_lbl = QLabel("未监听") + self.tcp_status_lbl.setStyleSheet("color:#909090;") + tcp_row.addWidget(self.tcp_status_lbl) + tcp_row.addStretch() + cfg_layout.addLayout(tcp_row, 2, 2) + btn_row = QHBoxLayout() self.start_btn = QPushButton("开始升级") self.start_btn.clicked.connect(self._start) @@ -659,7 +782,7 @@ class UpgradeDialog(QDialog): btn_close.clicked.connect(self.close) btn_row.addWidget(btn_close) btn_row.addStretch() - cfg_layout.addLayout(btn_row, 2, 0, 1, 3) + cfg_layout.addLayout(btn_row, 3, 0, 1, 3) self.progress = QProgressBar() self.progress.setRange(0, 100) @@ -676,9 +799,9 @@ class UpgradeDialog(QDialog): background-color: #1976D2; } """) - cfg_layout.addWidget(self.progress, 3, 0, 1, 2) + cfg_layout.addWidget(self.progress, 4, 0, 1, 2) self.status_lbl = QLabel("就绪") - cfg_layout.addWidget(self.status_lbl, 3, 2) + cfg_layout.addWidget(self.status_lbl, 4, 2) root.addWidget(cfg) @@ -709,10 +832,73 @@ class UpgradeDialog(QDialog): self._fw_data = b"" self._mac = None self._pkg_num = 0 + self._window_start = 0 + self._retry_cnt = 0 + self._resume_base = 0 self.start_btn.setEnabled(True) self.progress.setValue(0) self.status_lbl.setText("就绪") + def _on_channel_changed(self, idx): + self.channel = 'serial' if idx == 0 else 'tcp' + tcp_on = (self.channel == 'tcp') + self.tcp_lbl.setEnabled(tcp_on) + self.tcp_port_edit.setEnabled(tcp_on) + self.btn_tcp_listen.setEnabled(tcp_on) + + def _toggle_tcp_listen(self): + if self.tcp_thread and self.tcp_thread.isRunning(): + self.tcp_thread.stop() + self.tcp_thread = None + self.btn_tcp_listen.setText("开始监听") + self.tcp_status_lbl.setText("未监听") + self.tcp_status_lbl.setStyleSheet("color:#909090;") + return + try: + port = int(self.tcp_port_edit.text().strip()) + assert 1 <= port <= 65535 + except Exception: + QMessageBox.warning(self, "提示", "TCP端口无效") + return + self.tcp_thread = TcpServerThread(port) + self.tcp_thread.client_connected.connect(self._on_tcp_client) + self.tcp_thread.client_disconnected.connect(self._on_tcp_disconnect) + self.tcp_thread.data_received_raw.connect(self.feed_data) + self.tcp_thread.listen_error.connect(self._on_tcp_error) + self.tcp_thread.start() + self.btn_tcp_listen.setText("停止监听") + self.tcp_status_lbl.setText(f"监听中(0.0.0.0:{port})") + self.tcp_status_lbl.setStyleSheet("color:#1565C0;") + + def _on_tcp_client(self, ip): + self.tcp_status_lbl.setText(f"已连接: {ip}") + self.tcp_status_lbl.setStyleSheet("color:#2E7D32;") + + def _on_tcp_disconnect(self): + if self.tcp_thread and self.tcp_thread.isRunning(): + self.tcp_status_lbl.setText("客户端断开,等待重连...") + self.tcp_status_lbl.setStyleSheet("color:#D32F2F;") + if self.running and self.channel == 'tcp': + self._log("TCP连接断开,等待网关模组重连...", "err") + + def _on_tcp_error(self, msg): + self.tcp_status_lbl.setText("监听失败") + self.tcp_status_lbl.setStyleSheet("color:#D32F2F;") + self._log(f"TCP监听失败: {msg}", "err") + if self.tcp_thread: + self.tcp_thread.stop() + self.tcp_thread = None + self.btn_tcp_listen.setText("开始监听") + + def _send(self, frame): + """按当前通道发送升级帧(TCP通道按协议约定发ASCII hex文本, 设备端AsciiToHex还原)。""" + if self.channel == 'tcp': + if not (self.tcp_thread and self.tcp_thread.is_client_connected()): + self._log("TCP未连接,发送失败", "err") + return False + return self.tcp_thread.send_data(frame.hex().upper().encode('ascii')) + return self.main.send_raw(frame) + def _choose_file(self): path, _ = QFileDialog.getOpenFileName( self, "选择固件文件", "", "固件文件 (*.bin);;所有文件 (*)" @@ -743,7 +929,11 @@ class UpgradeDialog(QDialog): if mac is None: QMessageBox.warning(self, "提示", "设备MAC格式错误,应为 81-00-00-06-00-00") return - if not self.main.serial_port or not self.main.serial_port.is_open: + if self.channel == 'tcp': + if not (self.tcp_thread and self.tcp_thread.is_client_connected()): + QMessageBox.warning(self, "提示", "TCP未监听或网关未连接, 请先监听并等待网关接入") + return + elif not self.main.serial_port or not self.main.serial_port.is_open: QMessageBox.warning(self, "提示", "请先打开串口") return try: @@ -755,24 +945,33 @@ class UpgradeDialog(QDialog): if not fw: QMessageBox.warning(self, "提示", "固件文件为空") return + if len(fw) > 232 * 1024: + QMessageBox.warning(self, "提示", f"固件大小{len(fw)}字节超出A区上限(237568字节), 无法升级") + return self._mac = mac self._fw_data = fw + self._got_ack = False + self._announce_frame = None self._pkg_num = (len(fw) + UPG_PACKET_DATA - 1) // UPG_PACKET_DATA self._sent.clear() self._buf = b"" self.running = True self.start_btn.setEnabled(False) self.progress.setValue(0) + self._window_start = 0 + self._retry_cnt = 0 + self._resume_base = 0 self.status_lbl.setText("等待握手...") app_crc = zlib.crc32(fw) & 0xFFFFFFFF payload = struct.pack('= 3: + status, resumed = struct.unpack(' 0: + self._resume_base = resumed + self._log(f"设备断点续传: 已接收 {resumed} 包, 从断点继续", "ok") + else: + self._resume_base = 0 + self._log("收到从机应答(0x01), 全新升级", "ok") + else: + self._resume_base = 0 + self._log("收到从机应答(0x01)", "ok") self.status_lbl.setText("设备就绪") return if cmd == CMD_REQ_PKG: @@ -824,20 +1036,31 @@ class UpgradeDialog(QDialog): self._log(f"从机请求包序号越界({idx}),视为完成", "ok") self._finish() return - start = idx * UPG_PACKET_DATA - data = self._fw_data[start:start + UPG_PACKET_DATA] - p = struct.pack('= self._pkg_num: - self._finish() + batch = self._window if self.channel == 'tcp' else 1 + self._window_start = idx + self._send_window(idx, batch) return self._log(f"收到其他帧 Cmd={cmd:#04x}", "err") + def _send_window(self, start, batch): + """从start包起连发batch包(TCP窗口流控; 串口停等batch=1)。""" + for k in range(batch): + idx = start + k + if idx >= self._pkg_num: + break + s = idx * UPG_PACKET_DATA + data = self._fw_data[s:s + UPG_PACKET_DATA] + p = struct.pack('= self._pkg_num: + self._finish() + def _finish(self): self.running = False self._timer.stop() @@ -852,10 +1075,24 @@ class UpgradeDialog(QDialog): def _on_timeout(self): if not self.running: return + if self.channel == 'tcp' and not self._got_ack and self._retry_cnt < 5: + # 握手阶段(未收到ACK): 重发0x81升级请求而非数据批次 + self._retry_cnt += 1 + self._log(f"握手超时, 重发升级请求({self._retry_cnt}/5)", "err") + self._timer.start(10000) + self._send(self._announce_frame) + return + if self.channel == 'tcp' and self._retry_cnt < 5: + # Cat1链路抖动: 重发当前批次(设备按期望包号丢弃重复包) + self._retry_cnt += 1 + self._log(f"超时, 从第{self._window_start}包起重发批次({self._retry_cnt}/5)", "err") + self._timer.start(3000) + self._send_window(self._window_start, self._window) + return self.running = False self._timer.stop() self.start_btn.setEnabled(True) - self._log("升级超时(5秒无响应)", "err") + self._log("升级超时(无响应)", "err") self.status_lbl.setText("超时") def closeEvent(self, event):