From bae784f8496d0d3c13ccdcf9a63f619c0bdac97c Mon Sep 17 00:00:00 2001 From: YuanHongbin <975559679@qq.com> Date: Sat, 10 Oct 2026 15:18:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=8D=87=E7=BA=A7=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E5=B8=83=E5=B1=80=E4=B8=8E=E6=B3=A2=E7=89=B9=E7=8E=87?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 窗口标题去除"(串口/Cat1 TCP)"后缀 - 浏览按钮固定窄宽(70px), 文件地址框随布局加宽 - 波特率下拉改为可编辑(setEditable), 候选项与主界面串口连接区一致(115200~1000000) - 文件编码修正: 修复编辑过程产生的GBK污染, 统一UTF-8 --- gateway_config.py | 342 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 278 insertions(+), 64 deletions(-) diff --git a/gateway_config.py b/gateway_config.py index 4289eec..75d350e 100644 --- a/gateway_config.py +++ b/gateway_config.py @@ -130,7 +130,9 @@ class SerialThread(QThread): class TcpServerThread(QThread): """TCP服务线程: 监听端口, 等待网关Cat1模组(AT+LIPOPEN)主动连入后透传升级帧。""" client_connected = pyqtSignal(str) - client_disconnected = pyqtSignal() + 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) @@ -138,83 +140,157 @@ class TcpServerThread(QThread): super().__init__() self.port = port self.running = True - self._hexbuf = b"" # 设备上行ASCII hex文本的奇数尾巴缓冲 - self.sock = None # 监听socket - self.conn = None # 已连接的客户端(网关模组) + 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(1) - self.sock.settimeout(0.5) + 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: - continue + pass except OSError: break - conn.settimeout(0.5) - try: - conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - except Exception: - pass + # 2) poll every client for data with self.conn_lock: - self.conn = conn - self.client_connected.emit(addr[0]) - while self.running: + 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: - break + 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: - break - self.data_received_raw.emit(self._decode_hex(data)) - with self.conn_lock: + 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.conn = None - self._hexbuf = b"" - self.client_disconnected.emit() + 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 _decode_hex(self, data): - """设备上行是ASCII hex文本(LIPSEND hex-ascii), 流式解码为二进制(保留奇数尾巴)。""" - buf = self._hexbuf + data + 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._hexbuf = buf[n:] + self._hexbufs[key] = buf[n:] try: return bytes.fromhex(buf[:n].decode('ascii')) except ValueError: - self._hexbuf = b"" # 非法字符: 丢弃重同步 + self._hexbufs[key] = b"" # invalid chars: drop and resync return b"" def send_data(self, data): + """握手阶段(目标未定)广播所有连接; 目标选定后只发目标IP的连接。""" with self.conn_lock: - if not self.conn: - return False + 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: - self.conn.sendall(data) - return True + conn.sendall(data) + ok = True except Exception: - return False + 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 self.conn is not None + return len(self._conns) > 0 def stop(self): self.running = False @@ -706,7 +782,7 @@ class UpgradeDialog(QDialog): def __init__(self, main_window): super().__init__(main_window) self.main = main_window - self.setWindowTitle("设备升级 - 通用升级协议V1.1 (串口/Cat1 TCP)") + self.setWindowTitle("设备升级 - 通用升级协议V1.1") self.setModal(False) self.resize(720, 520) @@ -740,6 +816,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) @@ -768,19 +845,45 @@ class UpgradeDialog(QDialog): 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) + 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() - cfg_layout.addLayout(tcp_row, 2, 2) + 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, 3, 0, 1, 3) @@ -823,6 +926,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 @@ -836,23 +953,68 @@ class UpgradeDialog(QDialog): 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_lbl.setEnabled(tcp_on) - self.tcp_port_edit.setEnabled(tcp_on) - self.btn_tcp_listen.setEnabled(tcp_on) + 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("开始监听") - self.tcp_status_lbl.setText("未监听") - self.tcp_status_lbl.setStyleSheet("color:#909090;") return try: port = int(self.tcp_port_edit.text().strip()) @@ -863,27 +1025,35 @@ class UpgradeDialog(QDialog): 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("停止监听") - self.tcp_status_lbl.setText(f"监听中(0.0.0.0:{port})") - self.tcp_status_lbl.setStyleSheet("color:#1565C0;") + + 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): - self.tcp_status_lbl.setText(f"已连接: {ip}") - self.tcp_status_lbl.setStyleSheet("color:#2E7D32;") + pass - 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;") + def _on_session_resumed(self, ip): if self.running and self.channel == 'tcp': - self._log("TCP连接断开,等待网关模组重连...", "err") + 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.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() @@ -897,7 +1067,15 @@ class UpgradeDialog(QDialog): self._log("TCP未连接,发送失败", "err") return False return self.tcp_thread.send_data(frame.hex().upper().encode('ascii')) - return self.main.send_raw(frame) + 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( @@ -918,8 +1096,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()) @@ -933,7 +1119,7 @@ class UpgradeDialog(QDialog): 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: + elif not self.ser and self.ser.is_open: QMessageBox.warning(self, "提示", "请先打开串口") return try: @@ -950,6 +1136,9 @@ class UpgradeDialog(QDialog): 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 @@ -957,7 +1146,7 @@ class UpgradeDialog(QDialog): 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 @@ -974,8 +1163,7 @@ class UpgradeDialog(QDialog): 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() @@ -1009,6 +1197,10 @@ class UpgradeDialog(QDialog): return None def _handle(self, cmd, payload): + 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: @@ -1022,6 +1214,10 @@ class UpgradeDialog(QDialog): 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") @@ -1032,6 +1228,13 @@ class UpgradeDialog(QDialog): self._log("从机请求包帧格式错误", "err") return idx, total = struct.unpack('= self._pkg_num: self._log(f"从机请求包序号越界({idx}),视为完成", "ok") self._finish() @@ -1055,6 +1258,8 @@ class UpgradeDialog(QDialog): 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} 包") @@ -1072,6 +1277,8 @@ 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 @@ -1095,11 +1302,18 @@ class UpgradeDialog(QDialog): 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() + if self.ser and self.ser.is_open: + self._toggle_serial() self._reset_ui() event.accept() - class DeviceDialog(QDialog): """设备管理对话框:通过 culs 查询已注册通讯单元,cudel 删除设备。"""