wenhongquan 10 часов назад
Родитель
Сommit
6f96e2ce9d

+ 8 - 1
.claude/settings.local.json

@@ -47,7 +47,14 @@
       "Bash(curl -s http://192.168.199.149:5001/api/panel/status)",
       "Bash(curl -s -m 8 -X POST http://192.168.199.149:5001/api/modbus/read_all_cards -H 'Content-Type: application/json' -d '{\"device_address\":3}')",
       "Bash(/Users/wenhongquan/miniforge3/bin/python -c \"import ast; ast.parse\\(open\\('backend/app.py'\\).read\\(\\)\\); print\\('语法 OK'\\)\")",
-      "Bash(ping *)"
+      "Bash(ping *)",
+      "Bash(node *)",
+      "Bash(textutil -convert txt \"DTU和节点之间协议 V4.docx\" -output /tmp/v4_proto.txt)",
+      "Read(//tmp/**)",
+      "Bash(textutil -convert txt -encoding UTF-8 \"DTU和节点之间协议 V4.docx\" -output /tmp/v4.txt)",
+      "Bash(echo \"scp退出码=$?\")",
+      "Bash(rsync -avz --delete -e \"sshpass -p 123456 ssh -o StrictHostKeyChecking=no\" --exclude='venv/' --exclude='__pycache__/' --exclude='*.pyc' --exclude='*.log' --exclude='.pytest_cache/' --exclude='._*' --exclude='tmp_update/' --exclude='dtu_config.json' --exclude='serial_config.json' backend/ root@192.168.199.149:/root/dzxj_dtu/backend/)",
+      "Bash(echo \"rsync退出码=$?\")"
     ]
   }
 }

BIN
backend/__pycache__/app.cpython-310.pyc


+ 23 - 4
backend/app.py

@@ -312,15 +312,32 @@ def refresh_screen_panel(panel_id):
 
 
 def switch_screen_panel(direction):
-    """切换当前显示的 panel。"""
+    """切换当前显示的 panel,跳过已标记离线的终端。
+
+    离线终端不再占用屏幕:按"上一/下一终端"时自动落到下一台在线的设备,
+    避免屏上长时间停留在空白面板上。若全部终端都离线,则退化为相邻切换,
+    方便用户仍能浏览各终端的离线状态。
+    """
     global screen_current_panel_index
     with screen_lock:
         panels = get_sorted_panels()
         if not panels:
             return False, '没有可用的 panel'
-        screen_current_panel_index = (screen_current_panel_index + direction) % len(panels)
-        panel_id = panels[screen_current_panel_index][0]
-        logger.info(f'屏幕切换: 终端{screen_current_panel_index + 1} -> {panel_id}')
+        n = len(panels)
+        start = screen_current_panel_index
+        idx = (start + direction) % n
+        found = False
+        while idx != start:
+            if panels[idx][1].get('status') != 'offline':
+                found = True
+                break
+            idx = (idx + direction) % n
+        if not found:
+            # 全部离线:仍然切换到相邻面板,让用户能浏览各终端
+            idx = (start + direction) % n
+        screen_current_panel_index = idx
+        panel_id = panels[idx][0]
+        logger.info(f'屏幕切换: 终端{idx + 1} -> {panel_id}')
     return refresh_screen_panel(panel_id)
 
 
@@ -396,6 +413,8 @@ def register_panel_poll_failure(panel_id, cfg, addr):
     port_state.pop(panel_id, None)
     logger.info(f"面板 {panel_id}(地址={addr}) 重发分配地址后仍连续{PANEL_FAIL_THRESHOLD}次无响应,已标记为离线")
     dtu_publish_panel_status(panel_id, addr, None, online=False)
+    # 若该面板正显示在串口屏上,触发刷新使其显示离线(全灰)状态,避免残留旧画面
+    _refresh_current_screen_panel()
     return True
 
 # 数据存储缓冲区

BIN
backend/modules/__pycache__/modbus_rtu.cpython-310.pyc


BIN
backend/modules/__pycache__/modbus_rtu.cpython-313.pyc


BIN
backend/modules/__pycache__/mqtt_client.cpython-310.pyc


BIN
backend/modules/__pycache__/serial_port.cpython-310.pyc


BIN
backend/modules/__pycache__/serial_port.cpython-313.pyc


+ 31 - 14
backend/modules/modbus_rtu.py

@@ -13,11 +13,14 @@ def build_broadcast_query() -> bytes:
     return bytes([0x00, 0x40, 0x00, 0x40, 0x00])
 
 
-def parse_device_response(data: bytes) -> Optional[dict]:
+def parse_device_response(data: bytes, fix_crc: bool = True) -> Optional[dict]:
     """解析从机应答
 
     从机应答格式: 00 + 41 + 12个UID + CRC_L + CRC_H
     例如: 00 41 18 00 40 00 14 00 00 59 59 54 30 56 AE 34
+
+    fix_crc=True: 当 CRC 不通过但帧头 + UID 有效时,重新计算正确 CRC 补帧,
+    而不是直接拒绝。用于兼容部分从机固件 CRC 计算 bug。
     """
     if len(data) < 16:
         return {'error': '数据长度不足', 'raw_data': data.hex()}
@@ -28,20 +31,33 @@ def parse_device_response(data: bytes) -> Optional[dict]:
     uid_bytes = data[2:14]
     uid_hex = uid_bytes.hex()
 
-    # CRC 校验失败则拒绝(传入完整 16 字节帧,最后两字节为 CRC)
-    if not verify_crc16(data):
-        return {'error': 'CRC校验失败', 'raw_data': data.hex()}
-
-    # UID 有效性检查:拒绝明显是噪声或碰撞产生的数据
-    zero_count = uid_bytes.count(0)
-    if zero_count > 6:
-        return {'error': f"UID中零字节过多({zero_count}/12)", 'raw_data': data.hex()}
-
-    # 检查碰撞:UID 中不应有大量重复字节
+    # UID 有效性检查(CRC 之前先做,以便 CRC 失败时也能判断是否补帧)
     from collections import Counter
+    zero_count = uid_bytes.count(0)
     counts = Counter(uid_bytes)
-    if counts.most_common(1)[0][1] > 8:
-        return {'error': f"UID中单字节重复过多({counts.most_common(1)})", 'raw_data': data.hex()}
+    most_common_count = counts.most_common(1)[0][1]
+
+    uid_looks_valid = (zero_count <= 6) and (most_common_count <= 8)
+
+    crc_ok = verify_crc16(data)
+
+    if not crc_ok and fix_crc and uid_looks_valid:
+        # 补帧:重新计算正确 CRC 替换从机发出的错误 CRC
+        original_crc = data[14:16]
+        corrected_crc = calculate_crc16(data[:14])
+        data = data[:14] + corrected_crc
+        logger.warning(
+            f"广播响应 CRC 错误,已补帧修正: 原CRC={original_crc.hex()} "
+            f"→ 正确CRC={corrected_crc.hex()} (数据={data[:14].hex()})"
+        )
+    elif not crc_ok:
+        return {'error': 'CRC校验失败', 'raw_data': data.hex()}
+
+    if not uid_looks_valid:
+        if zero_count > 6:
+            return {'error': f"UID中零字节过多({zero_count}/12)", 'raw_data': data.hex()}
+        else:
+            return {'error': f"UID中单字节重复过多({counts.most_common(1)})", 'raw_data': data.hex()}
 
     return {
         'function_code': 0x41,
@@ -204,8 +220,9 @@ class AddressConfigProtocol:
                     continue
 
                 # 等待并解析从机的 43 确认响应
+                # 用 wait_response 只读等待:不发空数据(避免前端空JSON)、不清空接收缓冲(避免冲掉43)
                 time.sleep(0.1)
-                ack = self.serial.send_and_wait(b'', timeout=0.5, min_response_bytes=5)
+                ack = self.serial.wait_response(timeout=0.8, min_response_bytes=5)
                 ack_parsed = None
                 if ack and len(ack) >= 5:
                     # 过滤回显/噪声,找到 43 响应帧

+ 87 - 2
backend/modules/serial_port.py

@@ -80,6 +80,11 @@ class SerialPort:
                             timeout=item.get('timeout', 2.0),
                             min_response_bytes=item.get('min_response_bytes', 1)
                         )
+                    elif cmd_type == 'wait_response':
+                        result = self._do_wait_response(
+                            timeout=item.get('timeout', 2.0),
+                            min_response_bytes=item.get('min_response_bytes', 1)
+                        )
                     elif cmd_type == 'send_raw':
                         result = self._do_send_raw(item['data'])
                     elif cmd_type == 'send_data':
@@ -108,7 +113,7 @@ class SerialPort:
                     return False, "serial port not connected"
                 bytes_sent = self.ser.write(data)
                 self.ser.flush()
-            if self.send_callback:
+            if self.send_callback and data:
                 self.send_callback(data.hex())
             return True, "send ok"
         except Exception as e:
@@ -181,7 +186,7 @@ class SerialPort:
                 # 485 半双工:等最后字节发完并给收发器一点切换方向的时间
                 time.sleep(0.03)
 
-                if self.send_callback:
+                if self.send_callback and data:
                     self.send_callback(data.hex())
 
             # 阶段 2:等待响应。由于后台读线程已暂停,这里独占串口读取。
@@ -227,6 +232,59 @@ class SerialPort:
             with self._sync_lock:
                 self._sync_receive_active = False
 
+    def _do_wait_response(self, timeout: float = 2.0, min_response_bytes: int = 1) -> bytes:
+        """实际执行 wait_response(在队列工作线程中调用)。
+
+        与 _do_send_and_wait 的区别:
+        - 不发送任何数据(不 write),因此不触发 send_callback,不会在前端产生空 JSON;
+        - 不清空接收缓冲(不 reset_input_buffer),避免把从机已回的应答冲掉。
+
+        用于发送完指令(send_raw)后专门等待从机应答。
+        收到 min_response_bytes 字节后,继续等到总线空闲(100ms 无新数据)再返回,
+        以便把回显与真实应答一并收齐后再交由调用方解析。
+        """
+        # 进入同步接收模式,暂停后台读取线程,独占串口读取
+        with self._sync_lock:
+            self._sync_receive_active = True
+
+        try:
+            response = b''
+            start = time.time()
+            last_data_time = start
+
+            while time.time() - start < timeout:
+                # 从后台读线程缓冲 + 串口硬件缓冲两处收集
+                chunk = b''
+                with self.lock:
+                    while self.raw_response_buffer:
+                        try:
+                            chunk += bytes.fromhex(self.raw_response_buffer.pop(0))
+                        except Exception:
+                            pass
+                try:
+                    if self.ser and self.ser.is_open and self.ser.in_waiting > 0:
+                        chunk += self.ser.read(self.ser.in_waiting)
+                except Exception:
+                    pass
+
+                if chunk:
+                    response += chunk
+                    last_data_time = time.time()
+
+                # 收够最小长度后,等总线空闲再返回,避免回显导致提前返回而漏掉真实应答
+                if len(response) >= min_response_bytes:
+                    if time.time() - last_data_time > 0.1:
+                        self._emit_received_data(response)
+                        return response
+
+                time.sleep(0.01)
+
+            self._emit_received_data(response)
+            return response
+        finally:
+            with self._sync_lock:
+                self._sync_receive_active = False
+
     def list_ports(self):
         """列出系统中可用的串口"""
         ports = []
@@ -618,3 +676,30 @@ class SerialPort:
             return b''
         return result_container.get('result', b'')
 
+    def wait_response(self, timeout: float = 2.0, min_response_bytes: int = 1) -> bytes:
+        """只读取从机应答,不发送任何数据(入队,由队列工作线程统一执行)。
+
+        与 send_and_wait 的区别:不 write 数据、不触发 send_callback、不清空接收缓冲。
+        用于发送完指令(send_raw)后等待从机应答,避免发空数据污染前端显示、
+        避免清缓冲冲掉已到达的应答。
+        """
+        result_container = {}
+        event = threading.Event()
+
+        def callback(result):
+            result_container['result'] = result
+            event.set()
+
+        self._cmd_queue.put({
+            'type': 'wait_response',
+            'timeout': timeout,
+            'min_response_bytes': min_response_bytes,
+            'callback': callback
+        })
+
+        # 最多等待 timeout + 队列处理余量
+        wait_time = timeout + 5
+        if not event.wait(wait_time):
+            return b''
+        return result_container.get('result', b'')
+

BIN
backend/scripts/__pycache__/mqtt_business_test.cpython-310-pytest-9.0.2.pyc


BIN
backend/scripts/__pycache__/test_alarm_flow.cpython-310-pytest-9.0.2.pyc


BIN
backend/tests/__pycache__/test_app.cpython-310-pytest-9.0.2.pyc


+ 80 - 0
backend/tests/test_app.py

@@ -310,5 +310,85 @@ class TestApp:
         self.socketio_client.disconnect(namespace='/status')
 
 
+    def test_switch_screen_skips_offline_panels(self):
+        """测试屏幕切换跳过离线终端:只落到在线面板"""
+        import app as app_module
+        from app import switch_screen_panel, panel_config, screen_lock
+
+        orig_config = dict(panel_config)
+        orig_index = app_module.screen_current_panel_index
+
+        def _current_addr():
+            with screen_lock:
+                panels = app_module.get_sorted_panels()
+                idx = app_module.screen_current_panel_index
+                return panels[idx][1]['address'] if panels else None
+
+        try:
+            panel_config.clear()
+            panel_config.update({
+                'PANEL_1': {'address': 1, 'position': 1, 'status': 'offline'},
+                'PANEL_2': {'address': 2, 'position': 2, 'status': 'online'},
+                'PANEL_3': {'address': 3, 'position': 3, 'status': 'offline'},
+                'PANEL_4': {'address': 4, 'position': 4, 'status': 'online'},
+            })
+
+            with patch.object(app_module, 'refresh_screen_panel', return_value=(True, 'ok')):
+                # 当前显示 PANEL_1(离线),下一终端 -> 跳过 -> PANEL_2(在线)
+                with screen_lock:
+                    app_module.screen_current_panel_index = 0
+                assert switch_screen_panel(1)[0]
+                assert _current_addr() == 2
+
+                # PANEL_2(在线) -> 下一终端 -> 跳过 PANEL_3(离线) -> PANEL_4(在线)
+                assert switch_screen_panel(1)[0]
+                assert _current_addr() == 4
+
+                # PANEL_4(在线) -> 下一终端 -> 环绕跳过 PANEL_1(离线) -> PANEL_2(在线)
+                assert switch_screen_panel(1)[0]
+                assert _current_addr() == 2
+
+                # PANEL_2(在线) -> 上一终端 -> 环绕跳过 PANEL_1(离线) -> PANEL_4(在线)
+                assert switch_screen_panel(-1)[0]
+                assert _current_addr() == 4
+        finally:
+            panel_config.clear()
+            panel_config.update(orig_config)
+            with screen_lock:
+                app_module.screen_current_panel_index = orig_index
+
+    def test_switch_screen_all_offline_falls_back(self):
+        """测试全部终端离线时切换退化为相邻切换,不卡死"""
+        import app as app_module
+        from app import switch_screen_panel, panel_config, screen_lock
+
+        orig_config = dict(panel_config)
+        orig_index = app_module.screen_current_panel_index
+
+        def _current_panel():
+            with screen_lock:
+                panels = app_module.get_sorted_panels()
+                idx = app_module.screen_current_panel_index
+                return panels[idx][0] if panels else None
+
+        try:
+            panel_config.clear()
+            panel_config.update({
+                'PANEL_1': {'address': 1, 'position': 1, 'status': 'offline'},
+                'PANEL_2': {'address': 2, 'position': 2, 'status': 'offline'},
+                'PANEL_3': {'address': 3, 'position': 3, 'status': 'offline'},
+            })
+            with patch.object(app_module, 'refresh_screen_panel', return_value=(True, 'ok')):
+                with screen_lock:
+                    app_module.screen_current_panel_index = 0
+                assert switch_screen_panel(1)[0]
+                assert _current_panel() == 'PANEL_2'
+        finally:
+            panel_config.clear()
+            panel_config.update(orig_config)
+            with screen_lock:
+                app_module.screen_current_panel_index = orig_index
+
+
 if __name__ == "__main__":
     pytest.main(["-v", __file__])

+ 3 - 1
frontend/src/components/SerialDataDisplay.vue

@@ -54,7 +54,9 @@ const formattedSerialData = computed(() => {
       `
     } else {
       const dir = item.direction === 'in' ? '接收' : item.direction === 'out' ? '发送' : ''
-      const text = item.data || item.message || JSON.stringify(item)
+      // 空数据(空串)不再回退到 JSON.stringify(item),避免显示大写 JSON;直接跳过该项
+      const text = item.data ?? item.message ?? ''
+      if (!text) return ''
       const formatted = text.length > 20 ? text.replace(/(.{2})/g, '$1 ').trim().toUpperCase() : text
       const dirHtml = dir ? ` : <span class="direction">${dir}</span>` : ''
       return `<div class="message-item"><span class="timestamp">${formatTime(item.timestamp || new Date())}</span>${dirHtml} : <span class="msg-data">${escapeHtml(formatted)}</span></div>`