Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 577a15c400 | |||
| bae784f849 | |||
| d325910bfe | |||
| 52435a21dd | |||
| e2f1fa1206 | |||
| 62f4032eca | |||
| 455d083c50 | |||
| 489c34e16a | |||
| 8382fe6f36 | |||
| 03474850d6 | |||
| ea029ca6b7 | |||
| dc598d8912 |
+557
-56
@@ -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,201 @@ class SerialThread(QThread):
|
||||
self.wait()
|
||||
|
||||
|
||||
class TcpServerThread(QThread):
|
||||
"""TCP服务线程: 监听端口, 等待网关Cat1模组(AT+LIPOPEN)主动连入后透传升级帧。"""
|
||||
client_connected = pyqtSignal(str)
|
||||
client_disconnected = pyqtSignal(str, bool) # (client_ip, was_upgrade_session)
|
||||
session_resumed = pyqtSignal(str) # (client_ip) upgrade session (re)locked
|
||||
ip_list_changed = pyqtSignal(list) # 当前所有已连接客户端IP
|
||||
data_received_raw = pyqtSignal(bytes)
|
||||
listen_error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, port):
|
||||
super().__init__()
|
||||
self.port = port
|
||||
self.running = True
|
||||
self._hexbufs = {} # per-conn hex tail buffer (device uplink is ASCII hex text)
|
||||
self.sock = None # listen socket
|
||||
self.conn = None # upgrade session client (first valid ACK), None = not locked
|
||||
self.conn_lock = threading.Lock()
|
||||
self._conns = {} # {sock: ip} all connected clients
|
||||
self.expect_mac = None # target DevMac for ACK sniff (set by upgrade dialog); None = accept first valid ACK
|
||||
self.target_ip = None # 握手成功的目标IP(升级包只发它)
|
||||
self._errsince = {} # conn -> first OSError time (5s grace before declaring dead)
|
||||
|
||||
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(5)
|
||||
self.sock.settimeout(0.1)
|
||||
except Exception as e:
|
||||
self.listen_error.emit(str(e))
|
||||
return
|
||||
while self.running:
|
||||
# 1) accept new clients (non-blocking poll)
|
||||
try:
|
||||
conn, addr = self.sock.accept()
|
||||
conn.settimeout(0.05)
|
||||
try:
|
||||
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
except Exception:
|
||||
pass
|
||||
with self.conn_lock:
|
||||
self._conns[conn] = addr[0]
|
||||
self.client_connected.emit(addr[0])
|
||||
self._push_ip_list()
|
||||
except socket.timeout:
|
||||
pass
|
||||
except OSError:
|
||||
break
|
||||
# 2) poll every client for data
|
||||
with self.conn_lock:
|
||||
items = list(self._conns.items())
|
||||
dead = []
|
||||
for conn, ip in items:
|
||||
try:
|
||||
data = conn.recv(4096)
|
||||
self._errsince.pop(conn, None)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
t0 = self._errsince.get(conn)
|
||||
if t0 is None:
|
||||
self._errsince[conn] = time.monotonic()
|
||||
continue
|
||||
if time.monotonic() - t0 < 5.0:
|
||||
continue
|
||||
dead.append((conn, self.target_ip == ip))
|
||||
continue
|
||||
if not data:
|
||||
dead.append((conn, self.target_ip == ip))
|
||||
continue
|
||||
if self.target_ip is not None and ip != self.target_ip:
|
||||
continue # session locked: only locked client's data is processed
|
||||
self._sniff_ack_lock(conn, ip, data)
|
||||
self.data_received_raw.emit(self._decode_hex_conn(conn, data))
|
||||
# 3) clean up disconnected clients
|
||||
for conn, was_session in dead:
|
||||
with self.conn_lock:
|
||||
ip = self._conns.pop(conn, None)
|
||||
if self.conn is conn:
|
||||
self.conn = None
|
||||
self._hexbufs.pop(id(conn), None)
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.client_disconnected.emit(ip if ip else '', was_session)
|
||||
if was_session:
|
||||
self.target_ip = None
|
||||
self._push_ip_list()
|
||||
try:
|
||||
self.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sniff_ack_lock(self, conn, ip, data):
|
||||
"""握手阶段(目标未定): 嗅探首个合法 ACK(0x01,len3,总14) 或 REQ(0x02,len4,总15) 帧(0x7D+CRC),
|
||||
且帧内MAC与目标设备MAC一致时, 选定该IP为升级目标。"""
|
||||
if self.target_ip is not None:
|
||||
return
|
||||
for i in range(len(data) - 13):
|
||||
if data[i] != 0x7D:
|
||||
continue
|
||||
cmd = data[i + 7]
|
||||
plen = data[i + 8]
|
||||
is_ack = (cmd == 0x01 and plen == 0x03)
|
||||
is_req = (cmd == 0x02 and plen == 0x04)
|
||||
if not (is_ack or is_req):
|
||||
continue
|
||||
flen = 14 if is_ack else 15
|
||||
if i + flen > len(data):
|
||||
break
|
||||
frame = data[i:i + flen]
|
||||
if crc16_modbus(frame[:-2]) != (frame[-2] | (frame[-1] << 8)):
|
||||
continue
|
||||
if self.expect_mac is not None and bytes(frame[1:7]) != bytes(self.expect_mac):
|
||||
continue # 非目标设备的握手应答(旧固件无MAC过滤): 不选定
|
||||
with self.conn_lock:
|
||||
if self.target_ip is None:
|
||||
self.target_ip = ip
|
||||
self.session_resumed.emit(ip)
|
||||
return
|
||||
|
||||
def _decode_hex_conn(self, conn, data):
|
||||
"""Device uplink is ASCII hex text: stream-decode to binary (keep odd tail), per connection."""
|
||||
key = id(conn)
|
||||
buf = self._hexbufs.get(key, b"") + data
|
||||
n = len(buf) - (len(buf) % 2)
|
||||
self._hexbufs[key] = buf[n:]
|
||||
try:
|
||||
return bytes.fromhex(buf[:n].decode('ascii'))
|
||||
except ValueError:
|
||||
self._hexbufs[key] = b"" # invalid chars: drop and resync
|
||||
return b""
|
||||
|
||||
def send_data(self, data):
|
||||
"""握手阶段(目标未定)广播所有连接; 目标选定后只发目标IP的连接。"""
|
||||
with self.conn_lock:
|
||||
if self.target_ip is not None:
|
||||
targets = [c for c, ip in self._conns.items() if ip == self.target_ip]
|
||||
else:
|
||||
targets = list(self._conns.keys())
|
||||
ok = False
|
||||
for conn in targets:
|
||||
try:
|
||||
conn.sendall(data)
|
||||
ok = True
|
||||
except Exception:
|
||||
pass
|
||||
return ok
|
||||
|
||||
def _push_ip_list(self):
|
||||
with self.conn_lock:
|
||||
ips = sorted(set(self._conns.values()))
|
||||
self.ip_list_changed.emit(ips)
|
||||
|
||||
def release_session(self):
|
||||
"""升级结束(完成/取消/关闭): 清除目标IP, 恢复广播模式。"""
|
||||
with self.conn_lock:
|
||||
self.target_ip = None
|
||||
|
||||
def is_client_connected(self):
|
||||
with self.conn_lock:
|
||||
return len(self._conns) > 0
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
try:
|
||||
if self.sock:
|
||||
self.sock.close() # 打断accept轮询, run循环立即退出
|
||||
except Exception:
|
||||
pass
|
||||
self._close_all_clients()
|
||||
self.wait(3000)
|
||||
|
||||
def _close_all_clients(self):
|
||||
"""主动断开所有已连接客户端(网关模组的TCP链路随之释放, 对端能立刻感知断开)。"""
|
||||
with self.conn_lock:
|
||||
conns = list(self._conns.keys())
|
||||
self._conns.clear()
|
||||
self.conn = None
|
||||
self.target_ip = None
|
||||
for conn in conns:
|
||||
try:
|
||||
conn.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._hexbufs.clear()
|
||||
self._push_ip_list()
|
||||
|
||||
|
||||
class GatewayConfigTool(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -144,7 +340,7 @@ class GatewayConfigTool(QMainWindow):
|
||||
self.refresh_ports()
|
||||
|
||||
def init_ui(self):
|
||||
self.setWindowTitle("网关配置工具")
|
||||
self.setWindowTitle("网关配置工具(宽带版 V1.3.1)")
|
||||
self.setWindowIcon(QIcon(resource_path("物联网网关.png")))
|
||||
self.setMinimumSize(940, 640)
|
||||
self.resize(1000, 700)
|
||||
@@ -204,8 +400,9 @@ class GatewayConfigTool(QMainWindow):
|
||||
lora_layout.addWidget(QLabel("信道频率(fc):"))
|
||||
self.combo_lora_fc = QComboBox()
|
||||
self.combo_lora_fc.setFixedWidth(200)
|
||||
# 宽带 Lora 信道:433.1MHz ~ 492.6MHz,步进0.5MHz,共120信道(信道0起始)
|
||||
lora_freqs = []
|
||||
fc_hz = 470100000
|
||||
fc_hz = 433100000
|
||||
ch_idx = 0
|
||||
while fc_hz <= 492600000:
|
||||
mhz = fc_hz / 1000000
|
||||
@@ -610,7 +807,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")
|
||||
self.setModal(False)
|
||||
self.resize(720, 520)
|
||||
|
||||
@@ -621,6 +818,14 @@ class UpgradeDialog(QDialog):
|
||||
self._sent = set()
|
||||
self._buf = b""
|
||||
self._timer = QTimer()
|
||||
self.channel = 'serial' # 升级通道: serial / tcp
|
||||
self.tcp_thread = None
|
||||
self.ser = None # 升级通道串口对象(独立于主界面串口)
|
||||
self._serial_rx = 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)
|
||||
|
||||
@@ -638,6 +843,7 @@ class UpgradeDialog(QDialog):
|
||||
self.fw_edit.setPlaceholderText("选择固件文件...")
|
||||
cfg_layout.addWidget(self.fw_edit, 0, 1)
|
||||
btn_browse = QPushButton("浏览")
|
||||
btn_browse.setFixedWidth(70)
|
||||
btn_browse.clicked.connect(self._choose_file)
|
||||
cfg_layout.addWidget(btn_browse, 0, 2)
|
||||
|
||||
@@ -650,15 +856,63 @@ 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)", "TCP(ETH)"])
|
||||
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.ip_lbl = QLabel("客户端:")
|
||||
tcp_row.addWidget(self.ip_lbl)
|
||||
self.ip_cmb = QComboBox()
|
||||
self.ip_cmb.setFixedWidth(140)
|
||||
tcp_row.addWidget(self.ip_cmb)
|
||||
tcp_row.addStretch()
|
||||
self.tcp_row_w = QWidget()
|
||||
self.tcp_row_w.setLayout(tcp_row)
|
||||
cfg_layout.addWidget(self.tcp_row_w, 2, 2)
|
||||
|
||||
# 串口参数行(串口通道): 串口选择/波特率/打开
|
||||
serial_row = QHBoxLayout()
|
||||
self.serial_cmb = QComboBox()
|
||||
self.serial_cmb.setFixedWidth(150)
|
||||
serial_row.addWidget(self.serial_cmb)
|
||||
self.baud_cmb = QComboBox()
|
||||
self.baud_cmb.setEditable(True)
|
||||
self.baud_cmb.addItems(["115200", "230400", "460800", "500000", "921600", "1000000"])
|
||||
self.baud_cmb.setCurrentText("500000")
|
||||
self.baud_cmb.setFixedWidth(90)
|
||||
serial_row.addWidget(self.baud_cmb)
|
||||
self.btn_serial_open = QPushButton("打开串口")
|
||||
self.btn_serial_open.setFixedWidth(90)
|
||||
self.btn_serial_open.clicked.connect(self._toggle_serial)
|
||||
serial_row.addWidget(self.btn_serial_open)
|
||||
self.serial_status_lbl = QLabel("未打开")
|
||||
self.serial_status_lbl.setStyleSheet("color:#909090;")
|
||||
serial_row.addWidget(self.serial_status_lbl)
|
||||
serial_row.addStretch()
|
||||
self.serial_row_w = QWidget()
|
||||
self.serial_row_w.setLayout(serial_row)
|
||||
cfg_layout.addWidget(self.serial_row_w, 2, 2)
|
||||
self.tcp_row_w.setVisible(False)
|
||||
self._refresh_serial_ports()
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
self.start_btn = QPushButton("开始升级")
|
||||
self.start_btn.clicked.connect(self._start)
|
||||
self.start_btn.clicked.connect(self._on_start_btn)
|
||||
btn_row.addWidget(self.start_btn)
|
||||
btn_close = QPushButton("关闭")
|
||||
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)
|
||||
@@ -675,9 +929,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)
|
||||
|
||||
@@ -699,6 +953,20 @@ class UpgradeDialog(QDialog):
|
||||
def isUpgrading(self):
|
||||
return self.running
|
||||
|
||||
def _release_session(self):
|
||||
if self.tcp_thread:
|
||||
self.tcp_thread.release_session()
|
||||
|
||||
def _cancel_upgrade(self):
|
||||
"""升级中取消: 停止收发并释放会话锁定。"""
|
||||
self.running = False
|
||||
self._timer.stop()
|
||||
self._log("升级已取消", "err")
|
||||
self.status_lbl.setText("已取消")
|
||||
self._release_session()
|
||||
self.start_btn.setText("开始升级")
|
||||
self.start_btn.setEnabled(True)
|
||||
|
||||
def _reset_ui(self):
|
||||
"""重置弹窗 UI 到初始状态,避免关闭后重新打开时界面卡住。"""
|
||||
self.running = False
|
||||
@@ -708,10 +976,134 @@ 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._release_session()
|
||||
self.start_btn.setText("开始升级")
|
||||
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_row_w.setVisible(tcp_on)
|
||||
self.serial_row_w.setVisible(not tcp_on)
|
||||
|
||||
def _refresh_serial_ports(self):
|
||||
try:
|
||||
ports = [pt.device for pt in serial.tools.list_ports.comports()]
|
||||
except Exception:
|
||||
ports = []
|
||||
self.serial_cmb.clear()
|
||||
if ports:
|
||||
self.serial_cmb.addItems(ports)
|
||||
else:
|
||||
self.serial_cmb.addItem("无可用串口")
|
||||
|
||||
def _toggle_serial(self):
|
||||
try:
|
||||
if self.ser and self.ser.is_open:
|
||||
if self._serial_rx:
|
||||
self._serial_rx.stop()
|
||||
self._serial_rx = None
|
||||
self.ser.close()
|
||||
self.ser = None
|
||||
self.btn_serial_open.setText("打开串口")
|
||||
self.serial_status_lbl.setText("未打开")
|
||||
self.serial_status_lbl.setStyleSheet("color:#909090;")
|
||||
return
|
||||
port = self.serial_cmb.currentText()
|
||||
if not port or port == "无可用串口":
|
||||
QMessageBox.warning(self, "提示", "未扫描到串口, 请重新打开升级页面")
|
||||
return
|
||||
self.ser = serial.Serial(port, int(self.baud_cmb.currentText()), timeout=0.02)
|
||||
self._serial_rx = SerialThread(self.ser)
|
||||
self._serial_rx.data_received_raw.connect(self.feed_data)
|
||||
self._serial_rx.start()
|
||||
self.btn_serial_open.setText("关闭串口")
|
||||
self.serial_status_lbl.setText("已打开")
|
||||
self.serial_status_lbl.setStyleSheet("color:#2E7D32;")
|
||||
except Exception as e:
|
||||
self._log(f"串口操作异常: {e}", "err")
|
||||
QMessageBox.warning(self, "提示", f"串口操作异常: {e}")
|
||||
try:
|
||||
if self.ser and self.ser.is_open:
|
||||
self.ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ser = None
|
||||
self.btn_serial_open.setText("打开串口")
|
||||
self.serial_status_lbl.setText("未打开")
|
||||
|
||||
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("开始监听")
|
||||
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.session_resumed.connect(self._on_session_resumed)
|
||||
self.tcp_thread.ip_list_changed.connect(self._on_ip_list)
|
||||
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("停止监听")
|
||||
|
||||
def _on_ip_list(self, ips):
|
||||
self.ip_cmb.clear()
|
||||
if ips:
|
||||
self.ip_cmb.addItems(ips)
|
||||
else:
|
||||
self.ip_cmb.addItem("无客户端")
|
||||
|
||||
def _on_tcp_client(self, ip):
|
||||
pass
|
||||
|
||||
def _on_session_resumed(self, ip):
|
||||
if self.running and self.channel == 'tcp':
|
||||
self._log(f"握手成功, 升级目标: {ip}", "ok")
|
||||
idx = self.ip_cmb.findText(ip)
|
||||
if idx >= 0:
|
||||
self.ip_cmb.setCurrentIndex(idx)
|
||||
|
||||
def _on_tcp_disconnect(self, ip, was_session):
|
||||
if was_session and self.running and self.channel == 'tcp':
|
||||
self._log(f"升级会话TCP断开(IP={ip}),等待网关模组重连后自动恢复...", "err")
|
||||
|
||||
def _on_tcp_error(self, msg):
|
||||
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'))
|
||||
if not (self.ser and self.ser.is_open):
|
||||
self._log("串口未打开,发送失败", "err")
|
||||
return False
|
||||
try:
|
||||
self.ser.write(frame)
|
||||
return True
|
||||
except Exception:
|
||||
self._log("串口发送失败", "err")
|
||||
return False
|
||||
|
||||
def _choose_file(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "选择固件文件", "", "固件文件 (*.bin);;所有文件 (*)"
|
||||
@@ -731,8 +1123,16 @@ class UpgradeDialog(QDialog):
|
||||
if text:
|
||||
self._log(f"[设备] {text}")
|
||||
|
||||
def _on_start_btn(self):
|
||||
"""升级中=取消; 空闲=开始升级。"""
|
||||
if self.running:
|
||||
self._cancel_upgrade()
|
||||
else:
|
||||
self._start()
|
||||
|
||||
def _start(self):
|
||||
if self.running:
|
||||
self._cancel_upgrade()
|
||||
return
|
||||
path = self.fw_edit.text().strip()
|
||||
mac = parse_mac(self.mac_edit.text())
|
||||
@@ -742,7 +1142,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.ser and self.ser.is_open:
|
||||
QMessageBox.warning(self, "提示", "请先打开串口")
|
||||
return
|
||||
try:
|
||||
@@ -754,28 +1158,39 @@ 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
|
||||
if getattr(self, 'tcp_thread', None) is not None:
|
||||
self.tcp_thread.expect_mac = mac
|
||||
self.tcp_thread.release_session()
|
||||
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.start_btn.setText("取消")
|
||||
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:
|
||||
return
|
||||
# keep parsing after finish: device sends REQ(0) to restart download on CRC fail, gate moved to _handle
|
||||
self._buf += data
|
||||
while True:
|
||||
frame = self._try_next_frame()
|
||||
@@ -809,9 +1224,30 @@ class UpgradeDialog(QDialog):
|
||||
return None
|
||||
|
||||
def _handle(self, cmd, payload):
|
||||
self._timer.start(5000)
|
||||
if not self.running and cmd != CMD_REQ_PKG:
|
||||
return # finished: only REQ(0) restart passes, other frames dropped
|
||||
if not self.running:
|
||||
return
|
||||
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")
|
||||
if self.channel == 'tcp':
|
||||
self._window_start = 0
|
||||
self._send_window(0, self._window)
|
||||
return
|
||||
else:
|
||||
self._resume_base = 0
|
||||
self._log("收到从机应答(0x01)", "ok")
|
||||
self.status_lbl.setText("设备就绪")
|
||||
return
|
||||
if cmd == CMD_REQ_PKG:
|
||||
@@ -819,24 +1255,45 @@ class UpgradeDialog(QDialog):
|
||||
self._log("从机请求包帧格式错误", "err")
|
||||
return
|
||||
idx, total = struct.unpack('<HH', payload[0:4])
|
||||
if idx == 0 and not self.running:
|
||||
# device CRC failed, full re-download: reset progress and resend from pkt 0
|
||||
self.running = True
|
||||
self._sent.clear()
|
||||
self.progress.setValue(0)
|
||||
self.start_btn.setEnabled(False)
|
||||
self._log("设备CRC校验失败, 已重置进度重新下载", "warn")
|
||||
if idx >= self._pkg_num:
|
||||
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)
|
||||
if k < batch - 1:
|
||||
time.sleep(0.005) # Cat1模组URC输出限流: 防LIPURC截断丢包
|
||||
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} 包")
|
||||
self._timer.start(3000) # 批次发完重置超时: 防止上次定时器虚假触发(REQ已处理仍判超时)
|
||||
if len(self._sent) >= self._pkg_num:
|
||||
self._finish()
|
||||
|
||||
def _finish(self):
|
||||
self.running = False
|
||||
self._timer.stop()
|
||||
@@ -848,20 +1305,60 @@ class UpgradeDialog(QDialog):
|
||||
else:
|
||||
self._log(f"升级未完成,已发送 {len(self._sent)}/{self._pkg_num} 包", "err")
|
||||
|
||||
self._release_session()
|
||||
self.start_btn.setText("开始升级")
|
||||
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("超时")
|
||||
|
||||
self._release_session()
|
||||
self.start_btn.setText("开始升级")
|
||||
def closeEvent(self, event):
|
||||
if self.running:
|
||||
self._cancel_upgrade()
|
||||
else:
|
||||
self._release_session()
|
||||
# 升级TCP通道: 停止监听并主动断开已连接的客户端, 释放网关模组建立的TCP链路
|
||||
if self.tcp_thread is not None:
|
||||
if self.tcp_thread.isRunning():
|
||||
self.tcp_thread.stop()
|
||||
self.tcp_thread = None
|
||||
self.btn_tcp_listen.setText("开始监听")
|
||||
# 串口通道: 关闭升级页面打开的串口
|
||||
if self.ser is not None:
|
||||
if self._serial_rx is not None:
|
||||
self._serial_rx.stop()
|
||||
self._serial_rx = None
|
||||
try:
|
||||
self.ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ser = None
|
||||
self.btn_serial_open.setText("打开串口")
|
||||
self.serial_status_lbl.setText("未打开")
|
||||
self.serial_status_lbl.setStyleSheet("color:#909090;")
|
||||
self._reset_ui()
|
||||
event.accept()
|
||||
|
||||
|
||||
class DeviceDialog(QDialog):
|
||||
"""设备管理对话框:通过 culs 查询已注册通讯单元,cudel 删除设备。"""
|
||||
|
||||
@@ -873,7 +1370,7 @@ class DeviceDialog(QDialog):
|
||||
self.resize(860, 520)
|
||||
|
||||
self._buf = ""
|
||||
self._pending_del = None # 等待删除结果的MAC,"all"表示删除全部
|
||||
self._parsed = False # 本次查询是否已完成解析(防止数据流中途误触发)
|
||||
self._quiet = QTimer(self)
|
||||
self._quiet.setSingleShot(True)
|
||||
self._quiet.timeout.connect(self._on_quiet)
|
||||
@@ -920,30 +1417,35 @@ class DeviceDialog(QDialog):
|
||||
if not self._serial_ok():
|
||||
return
|
||||
self._buf = ""
|
||||
self._pending_del = None
|
||||
self._parsed = False
|
||||
self._quiet.stop()
|
||||
self.table.setRowCount(0) # 清空旧数据,避免新旧混淆
|
||||
self.status_lbl.setText("查询中...")
|
||||
self.main.send_command("culs")
|
||||
self._quiet.start(800)
|
||||
self._quiet.start(1200) # 首次等待稍长,给设备响应时间
|
||||
|
||||
def feed_text(self, text):
|
||||
# 已完成本次解析后,忽略后续到来的残留数据,直到下一次手动查询
|
||||
if self._parsed:
|
||||
return
|
||||
self._buf += text
|
||||
self._quiet.start(600)
|
||||
self._quiet.start(600) # 数据流仍在到达,重置静默定时器
|
||||
|
||||
def _remove_row_by_mac(self, mac):
|
||||
"""从表格中移除指定MAC的设备行, 返回是否移除成功。"""
|
||||
mac_u = mac.upper()
|
||||
for row in range(self.table.rowCount()):
|
||||
item = self.table.item(row, 1)
|
||||
if item and mac_u in item.text():
|
||||
self.table.removeRow(row)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _on_quiet(self):
|
||||
if self._pending_del is not None:
|
||||
buf = self._buf
|
||||
if "deleted" in buf.lower():
|
||||
self.status_lbl.setText(f"删除成功 ({self._pending_del})")
|
||||
elif "not registered" in buf:
|
||||
self.status_lbl.setText("删除失败:设备未注册")
|
||||
elif "Para Error" in buf:
|
||||
self.status_lbl.setText("删除失败:指令参数错误")
|
||||
else:
|
||||
self.status_lbl.setText("删除失败:设备无响应")
|
||||
self._pending_del = None
|
||||
self.query()
|
||||
# 仅解析一次:避免数据流间歇触发的中途解析把表格清零
|
||||
if self._parsed:
|
||||
return
|
||||
self._parsed = True
|
||||
self._parse_devices()
|
||||
|
||||
def _parse_devices(self):
|
||||
@@ -1001,7 +1503,6 @@ class DeviceDialog(QDialog):
|
||||
if m:
|
||||
cur["online"] = m.group(1)
|
||||
self._fill_table(devices)
|
||||
self._buf = ""
|
||||
|
||||
def _fill_table(self, devices):
|
||||
self.table.setRowCount(0)
|
||||
@@ -1042,11 +1543,12 @@ class DeviceDialog(QDialog):
|
||||
QMessageBox.Yes | QMessageBox.No)
|
||||
if ret != QMessageBox.Yes:
|
||||
return
|
||||
self._buf = ""
|
||||
self._pending_del = mac
|
||||
self.status_lbl.setText(f"正在删除 {mac} ...")
|
||||
# 网关cudel无应答, 直接本地移除该行
|
||||
self.main.send_command(f"cudel {mac}")
|
||||
self._quiet.start(800)
|
||||
if self._remove_row_by_mac(mac):
|
||||
self.status_lbl.setText(f"已删除 {mac} (共 {self.table.rowCount()} 个设备)")
|
||||
else:
|
||||
self.status_lbl.setText(f"删除命令已发送 ({mac})")
|
||||
|
||||
def _del_all(self):
|
||||
if not self._serial_ok():
|
||||
@@ -1055,11 +1557,10 @@ class DeviceDialog(QDialog):
|
||||
QMessageBox.Yes | QMessageBox.No)
|
||||
if ret != QMessageBox.Yes:
|
||||
return
|
||||
self._buf = ""
|
||||
self._pending_del = "all"
|
||||
self.status_lbl.setText("正在删除全部设备 ...")
|
||||
# 网关cudel无应答, 直接清空表格
|
||||
self.main.send_command("cudel all")
|
||||
self._quiet.start(800)
|
||||
self.table.setRowCount(0)
|
||||
self.status_lbl.setText("已删除全部设备")
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._quiet.stop()
|
||||
|
||||
+33
-7
@@ -147,10 +147,10 @@ story.append(Spacer(1, 10 * mm))
|
||||
|
||||
cover_info = [
|
||||
["软件名称", "网关配置工具"],
|
||||
["适用设备", "物联网网关(HC32F460 + 4G/Cat1 + Ethernet)"],
|
||||
["适用设备", "物联网网关"],
|
||||
["通信接口", "串口(UART),波特率默认500000可修改"],
|
||||
["运行环境", "Windows 7/10/11"],
|
||||
["文档版本", "V1.0"],
|
||||
["文档版本", "V1.1"],
|
||||
]
|
||||
cover_table = Table(cover_info, colWidths=[40 * mm, 90 * mm])
|
||||
cover_table.setStyle(TableStyle([
|
||||
@@ -187,6 +187,7 @@ toc_items = [
|
||||
(" 3.3", "Lora信道配置"),
|
||||
(" 3.4", "自定义指令"),
|
||||
(" 3.5", "通讯日志"),
|
||||
(" 3.6", "设备管理"),
|
||||
("4", "指令参考"),
|
||||
("5", "常见问题(FAQ)"),
|
||||
("6", "注意事项"),
|
||||
@@ -210,9 +211,10 @@ features = [
|
||||
["功能模块", "说明"],
|
||||
["串口通信", "通过USB转串口连接网关设备,波特率默认500000可自行修改,实时收发指令"],
|
||||
["配置读取", "一键读取网关全部参数,包括软件/硬件版本、MAC地址、电池状态、注册状态等"],
|
||||
["Lora信道配置", "选择并设置Lora通信信道频率(470.1MHz ~ 492.6MHz)"],
|
||||
["Lora信道配置", "选择并设置Lora通信信道频率(433.1MHz ~ 492.6MHz)"],
|
||||
["自定义指令", "支持手动输入任意指令与网关交互"],
|
||||
["通讯日志", "实时显示发送和接收的通讯数据,便于调试"],
|
||||
["设备管理", "查询/删除网关已注册的通讯单元设备,删除后即时更新列表"],
|
||||
]
|
||||
story.append(make_table(features[0], features[1:], col_widths=[35 * mm, 115 * mm]))
|
||||
story.append(Spacer(1, 3 * mm))
|
||||
@@ -304,8 +306,8 @@ story.append(make_table(["显示项", "指令关键字", "说明"], para_rows, c
|
||||
# 3.3
|
||||
story.append(heading2("3.3 Lora信道配置"))
|
||||
story.append(body(
|
||||
"Lora信道配置用于设置网关的Lora无线通信频率。信道列表覆盖470.1MHz至492.6MHz范围,"
|
||||
"频率间隔0.5MHz,共46个信道可供选择。"
|
||||
"Lora信道配置用于设置网关的Lora无线通信频率。信道列表覆盖433.1MHz至492.6MHz范围,"
|
||||
"频率间隔0.5MHz,共120个信道可供选择。"
|
||||
))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
|
||||
@@ -319,8 +321,8 @@ for i, s in enumerate(lora_steps, 1):
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
|
||||
lora_range = [
|
||||
["起始频率", "470.1 MHz(信道0)"],
|
||||
["终止频率", "492.6 MHz(信道45)"],
|
||||
["起始频率", "433.1 MHz(信道0)"],
|
||||
["终止频率", "492.6 MHz(信道119)"],
|
||||
["频率间隔", "0.5 MHz(500 kHz)"],
|
||||
["指令格式", "lora fc [频率值,单位Hz]"],
|
||||
]
|
||||
@@ -352,6 +354,19 @@ story.append(make_table(["颜色", "类型", "说明"], log_rows, col_widths=[25
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
story.append(note(f"点击{LQ}清空日志{RQ}按钮可清除当前日志内容。日志数据仅保留在内存中,关闭软件后自动清除。"))
|
||||
|
||||
story.append(heading2("3.6 设备管理"))
|
||||
story.append(body(
|
||||
f"点击{LQ}设备管理{RQ}按钮打开设备管理对话框,可查询与删除网关已注册的通讯单元设备:"
|
||||
))
|
||||
mgmt_rows = [
|
||||
[f"{LQ}刷新查询{RQ}", "向网关发送查询指令,列出全部已注册设备(类型、MAC、传感器类型、电量、RSSI/SNR、在线状态等)"],
|
||||
[f"{LQ}删除{RQ}", "点击设备行右侧的删除按钮并确认后,立即从列表中移除该设备(列表即时更新,无需重新查询)"],
|
||||
[f"{LQ}删除全部设备{RQ}", "删除网关上全部已注册设备,列表清空"],
|
||||
]
|
||||
story.append(make_table(["操作", "说明"], mgmt_rows, col_widths=[35 * mm, 115 * mm]))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
story.append(note("删除操作直接生效,若需确认设备列表最新状态,可再次点击刷新查询。"))
|
||||
|
||||
story.append(PageBreak())
|
||||
|
||||
# ==================== 4. 指令参考 ====================
|
||||
@@ -363,6 +378,9 @@ story.append(Spacer(1, 2 * mm))
|
||||
cmd_rows = [
|
||||
["para", "读取网关全部配置参数", "查看配置(para)"],
|
||||
["lora fc <频率>", "设置Lora信道频率(Hz)", "Lora信道配置"],
|
||||
["culs", "查询已注册通讯单元设备列表", "设备管理-刷新查询"],
|
||||
["cudel <MAC>", "删除指定MAC的通讯单元设备", "设备管理-删除"],
|
||||
["cudel all", "删除全部已注册通讯单元设备", "设备管理-删除全部设备"],
|
||||
]
|
||||
story.append(make_table(
|
||||
["指令格式", "功能说明", "对应操作"],
|
||||
@@ -387,6 +405,12 @@ faqs = [
|
||||
("Q3:Lora信道列表很长,如何快速选择?",
|
||||
"Lora信道下拉框支持鼠标滚轮滚动选择,也可以点击下拉后直接滚动浏览。"
|
||||
"信道编号和频率一一对应,选择后点击发送即可。"),
|
||||
("Q4:设备升级失败或超时怎么办?",
|
||||
f"1)确认升级过程中串口连接稳定,未出现断开或断电;2)确认{LQ}设备MAC{RQ}与目标网关一致;"
|
||||
"3)确认固件文件为与设备匹配的.bin文件且未损坏;4)重新点击开始升级再试一次。"),
|
||||
("Q5:设备管理查询不到设备或列表为空?",
|
||||
"1)确认网关上已注册过通讯单元设备;2)点击刷新查询重试;"
|
||||
"3)确认串口通讯正常(可通过查看配置验证);4)若刚删除设备,列表已实时更新属正常现象。"),
|
||||
]
|
||||
for q, a in faqs:
|
||||
story.append(Paragraph(f"<b>{q}</b>", style_body))
|
||||
@@ -405,6 +429,8 @@ warnings = [
|
||||
f"<b>数据备份:</b>修改配置前建议先通过{LQ}查看配置{RQ}记录当前参数,以便需要时恢复。",
|
||||
"<b>断电保护:</b>配置修改后请确认网关已保存参数,避免在参数写入过程中断电。",
|
||||
"<b>驱动安装:</b>首次使用需安装USB转串口芯片驱动(如CH340、CP2102等),具体型号取决于使用的转接线。",
|
||||
"<b>升级安全:</b>固件升级过程中严禁断开串口、关闭软件或给网关断电,否则可能导致网关固件损坏而无法启动。",
|
||||
"<b>固件匹配:</b>请使用与网关硬件和项目匹配的固件文件(.bin),错误的固件可能导致设备功能异常。",
|
||||
]
|
||||
for w in warnings:
|
||||
story.append(Paragraph(f"\u26a0 {w}", style_body))
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user