feat(ota): 新增TCP(Cat1)升级通道(hex收发+窗口流控+断点续传)
- TCP服务端监听(默认8080)等待网关Cat1模组连入, 协议复用0x7D帧
- 升级帧转ASCII hex文本下发, 上行hex流式解码(兼容旧格式ACK)
- 窗口流控: 收到0x02请求后连发8包/批; 断点续传ACK扩展{Status,ResumedIdx}
- 握手阶段超时重发0x81(修复原重发数据批次无效问题)
- 版本V1.3.0(宽带调试版, 含Cat1远程升级)
This commit is contained in:
+259
-22
@@ -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('<H', self._pkg_num) + struct.pack('<I', len(fw)) + struct.pack('<I', app_crc)
|
||||
frame = build_frame(mac, CMD_ANNOUNCE, payload)
|
||||
self._log(f"下发APP信息: 包数={self._pkg_num}, 大小={len(fw)}字节, CRC32=0x{app_crc:08X}")
|
||||
self.main.send_raw(frame)
|
||||
self._announce_frame = frame
|
||||
self._send(frame)
|
||||
self._log("等待设备握手后接收数据请求...")
|
||||
self._timer.start(5000)
|
||||
self._timer.start(10000 if self.channel == 'tcp' else 5000)
|
||||
|
||||
def feed_data(self, data):
|
||||
if not self.running:
|
||||
@@ -810,9 +1009,22 @@ class UpgradeDialog(QDialog):
|
||||
return None
|
||||
|
||||
def _handle(self, cmd, payload):
|
||||
self._timer.start(5000)
|
||||
self._timer.start(3000 if self.channel == 'tcp' else 5000)
|
||||
self._retry_cnt = 0
|
||||
if cmd == CMD_ACK:
|
||||
self._log("收到从机应答(0x01)", "ok")
|
||||
self._got_ack = True
|
||||
# 兼容旧格式(1字节0x20)与新格式{Status, ResumedIdx}: 0x20=全新, 0x21=断点续传
|
||||
if len(payload) >= 3:
|
||||
status, resumed = struct.unpack('<BH', payload[0:3])
|
||||
if status == 0x21 and resumed > 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('<HHB', idx, self._pkg_num, len(data)) + data
|
||||
frame = build_frame(self._mac, CMD_SEND_PKG, p)
|
||||
self.main.send_raw(frame)
|
||||
self._sent.add(idx)
|
||||
pct = len(self._sent) * 100.0 / self._pkg_num
|
||||
self.progress.setValue(int(pct))
|
||||
self.status_lbl.setText(f"{len(self._sent)}/{self._pkg_num} 包")
|
||||
if len(self._sent) >= 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('<HHB', idx, self._pkg_num, len(data)) + data
|
||||
if not self._send(build_frame(self._mac, CMD_SEND_PKG, p)):
|
||||
self._log("发送失败, 通道异常", "err")
|
||||
return
|
||||
self._sent.add(idx)
|
||||
done = max(self._sent) + 1
|
||||
self.progress.setValue(min(int(done * 100.0 / self._pkg_num), 100))
|
||||
self.status_lbl.setText(f"{done}/{self._pkg_num} 包")
|
||||
if len(self._sent) >= 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):
|
||||
|
||||
Reference in New Issue
Block a user