فهرست منبع

feat: 增强面板轮询机制,新增离线状态处理与地址重分配逻辑

wenhongquan 20 ساعت پیش
والد
کامیت
0655a0803b
2فایلهای تغییر یافته به همراه108 افزوده شده و 33 حذف شده
  1. BIN
      backend/__pycache__/app.cpython-310.pyc
  2. 108 33
      backend/app.py

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


+ 108 - 33
backend/app.py

@@ -279,7 +279,6 @@ def refresh_screen_panel(panel_id):
 
         # 当前 panel 对应的设备地址
         device_address = panel_config.get(panel_id, {}).get('address', 1)
-        panel_led_states = led_states.get(device_address, {})
 
         screen_display.send_cmd(f't2.txt="{NEXTION_PANEL_NAME_PREFIX}{idx + 1}"', delay_ms=delay_ms)
         screen_display.send_cmd(f't1.txt="{ip_address}"', delay_ms=delay_ms)
@@ -290,12 +289,11 @@ def refresh_screen_panel(panel_id):
         screen_display.send_cmd(f'g0.pco={0 if mqtt_connected else 63488}', delay_ms=delay_ms)
 
         for port_id in range(1, 25):
-            # 屏幕网口图片显示 LED 颜色状态
-            color = panel_led_states.get(port_id, 0)
-            pic = LED_COLOR_TO_PIC.get(color, 0)
+            # 屏幕网口图片显示 LED:由端口真实连接状态推导(连接/断开/非法/未知)
+            pic = _port_status_to_pic(port_state.get(panel_id, {}).get(port_id))
             cmd = f'p{port_id - 1}.pic={pic}'
             ok = screen_display.send_cmd(cmd, delay_ms=delay_ms)
-            logger.info(f"[Nextion] panel={panel_id} addr={device_address} port={port_id} led_color={color} pic={pic} cmd={cmd} ok={ok}")
+            logger.info(f"[Nextion] panel={panel_id} addr={device_address} port={port_id} pic={pic} cmd={cmd} ok={ok}")
 
         logger.info(f"屏幕已刷新: {panel_id} (终端{idx + 1})")
         return True, '屏幕已刷新'
@@ -344,10 +342,50 @@ def screen_button_event_handler(page_id, comp_id, event):
 # 面板配置(从地址配置模块加载)
 panel_config = {}  # {panel_id: {'address': int, 'position': int, 'panel_uid': str}}
 
-# 面板连续轮询失败计数(达到阈值后从 panel_config 移除,避免对离线设备持续发查询)
+# 面板连续轮询失败计数(达到阈值后标记离线、停止查询,避免对离线设备持续发查询)
 panel_fail_count = {}  # {panel_id: int}
+# 已对面板发送过「重新分配地址」指令(处于第二阶段观察)的标记
+panel_assign_pending = {}  # {panel_id: bool}
 PANEL_FAIL_THRESHOLD = 3
 
+
+def register_panel_poll_failure(panel_id, cfg, addr):
+    """记录一次面板轮询失败,按约定流程决定是否重发分配地址 / 标记离线。
+
+    流程: 连续 3 次查询无响应 -> 重新发送分配地址指令(把已保存地址再发一遍)
+          -> 再连续 3 次无响应 -> 标记离线(保留在 panel_config,停止查询)。
+    返回 True 表示本次已标记离线。
+    """
+    global panel_fail_count, panel_assign_pending
+    panel_fail_count[panel_id] = panel_fail_count.get(panel_id, 0) + 1
+    if panel_fail_count[panel_id] < PANEL_FAIL_THRESHOLD:
+        logger.warning(f"面板 {panel_id}(地址={addr}) 轮询无响应,连续失败 {panel_fail_count[panel_id]} 次")
+        return False
+    # 达到第一阶段阈值(3 次)
+    if not panel_assign_pending.get(panel_id):
+        # 第一阶段: 重新发送分配地址指令,再观察 3 次
+        uid = cfg.get('panel_uid', '') or ''
+        if len(uid) == 24:
+            try:
+                frame = build_assign_address(bytes.fromhex(uid), addr)
+                ok, msg = serial_client.send_raw(frame)
+                logger.warning(f"面板 {panel_id}(地址={addr}) 连续{PANEL_FAIL_THRESHOLD}次无响应,已重新发送分配地址帧 ok={ok} msg={msg}")
+            except Exception as e:
+                logger.error(f"面板 {panel_id}(地址={addr}) 发送分配地址帧失败: {e}")
+        else:
+            logger.warning(f"面板 {panel_id}(地址={addr}) 缺少有效 panel_uid,无法重发分配地址,直接标记离线")
+        panel_assign_pending[panel_id] = True
+        panel_fail_count[panel_id] = 0
+        return False
+    # 第二阶段: 重发分配地址后仍连续 3 次无响应 -> 标记离线
+    cfg['status'] = 'offline'
+    panel_fail_count.pop(panel_id, None)
+    panel_assign_pending.pop(panel_id, None)
+    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)
+    return True
+
 # 数据存储缓冲区
 serial_data_buffer = []
 mqtt_data_buffer = []
@@ -1028,11 +1066,16 @@ def dtu_publish_alarm(panel_id, port_id, alarm_type, expected_jumper_uid, actual
         return False
 
 
-def dtu_publish_panel_status(panel_id, address, ports_data):
-    """发送配线架状态消息 (7.7)"""
+def dtu_publish_panel_status(panel_id, address, ports_data, online=True):
+    """发送配线架状态消息 (7.7)
+
+    online=False 时表示面板已离线,ports 全部置为 UNKNOWN。
+    """
     if not mqtt_client.get_status() or not dtu_config.get('enabled'):
         return False
     try:
+        if not online:
+            ports_data = [{'port_id': p, 'status': 'UNKNOWN', 'jumper_uid': None} for p in range(1, 25)]
         topic = build_dtu_topic(dtu_config['customer_id'], 'patchpanel', dtu_config['dtu_id'], panel_id, 'status')
         payload = {
             'msg_id': f"pst_{int(time.time() * 1000)}",
@@ -1043,7 +1086,7 @@ def dtu_publish_panel_status(panel_id, address, ports_data):
                 'panel_id': panel_id,
                 'position': panel_config.get(panel_id, {}).get('position', 0),
                 'address': address,
-                'online': True,
+                'online': online,
                 'last_poll_time': int(time.time() * 1000),
                 'ports': ports_data
             }
@@ -1236,6 +1279,8 @@ def dtu_handle_control(topic, payload):
                 port_state[target][port_id] = {'last_uid': None, 'expected_uid': None, 'alarm_count': 0}
 
             port_state[target][port_id]['expected_uid'] = jumper_uid
+            # 持久化期望映射,保证离线恢复/重启后仍能判定非法连接
+            dtu_config.setdefault('port_mappings', {}).setdefault(target, {})[str(port_id)] = jumper_uid
             save_dtu_config()
 
         elif command == 'SYNC_ALL_MAPPING':
@@ -1260,6 +1305,8 @@ def dtu_handle_control(topic, payload):
                     if port_id not in port_state[panel_id]:
                         port_state[panel_id][port_id] = {'last_uid': None, 'expected_uid': None, 'alarm_count': 0}
                     port_state[panel_id][port_id]['expected_uid'] = jumper_uid
+                    # 持久化期望映射
+                    dtu_config.setdefault('port_mappings', {}).setdefault(panel_id, {})[str(port_id)] = jumper_uid
                 save_dtu_config()
             except Exception as e:
                 response_payload['payload']['success'] = False
@@ -1777,6 +1824,21 @@ def load_device_config():
     return {}
 
 
+def _merge_panel_config(new_config):
+    """合并面板配置:叠加新发现的在线设备,并保留已标记为离线的面板
+
+    离线设备保留在 panel_config 中(status='offline'),UI 可显示离线状态,
+    轮询循环会跳过它们不再发查询;广播发现仍会定期尝试重新上线。
+    """
+    global panel_config
+    merged = dict(new_config)
+    for panel_id, cfg in panel_config.items():
+        if cfg.get('status') == 'offline' and panel_id not in merged:
+            merged[panel_id] = cfg
+    panel_config = merged
+    return merged
+
+
 def sync_panel_config():
     """根据 address_config.stored_devices 同步 panel_config"""
     global panel_config
@@ -1793,9 +1855,10 @@ def sync_panel_config():
             new_config[panel_id] = {
                 'address': addr,
                 'position': addr,
-                'panel_uid': uid_hex
+                'panel_uid': uid_hex,
+                'status': 'online'
             }
-        panel_config = new_config
+        _merge_panel_config(new_config)
         logger.info(f"已同步面板配置: {len(panel_config)} 个面板")
     except Exception as e:
         logger.error(f"同步面板配置失败: {str(e)}")
@@ -1822,9 +1885,10 @@ def sync_panel_config_from_results(results):
             new_config[panel_id] = {
                 'address': addr,
                 'position': addr,
-                'panel_uid': uid_hex
+                'panel_uid': uid_hex,
+                'status': 'online'
             }
-        panel_config = new_config
+        _merge_panel_config(new_config)
         logger.info(f"已同步面板配置(发现模式): {len(panel_config)} 个面板")
     except Exception as e:
         logger.error(f"同步面板配置失败: {str(e)}")
@@ -1891,6 +1955,15 @@ def run_discovery_sync(min_interval=10):
 
         # 1. 先查询地址表中已有地址是否在线(已分配地址的设备不回复广播)
         stored_devices = address_config.get_stored_devices()
+        # 已分配地址的设备不回复广播,无法靠广播重新发现;
+        # 因此把当前已标记离线的面板(已知地址)也纳入按地址复查,
+        # 使其恢复后能在发现周期内重新上线(即使 device_config 未持久化)。
+        for pid, cfg in panel_config.items():
+            if cfg.get('status') == 'offline':
+                uid = cfg.get('panel_uid', '') or ''
+                addr = cfg.get('address')
+                if uid and addr and uid not in stored_devices:
+                    stored_devices[uid] = addr
         confirmed = check_assigned_devices_online(stored_devices, retries=3, timeout=0.5)
 
         # 2. 再广播发现未配置地址的新设备并分配地址
@@ -1916,10 +1989,10 @@ def run_discovery_sync(min_interval=10):
             logger.info(f"在线设备同步: 广播结果={discovered}, 已分配在线={len(confirmed)}, 合计={len(unique_results)}")
             sync_panel_config_from_results(unique_results)
         else:
-            panel_config.clear()
+            _merge_panel_config({})
             panel_fail_count.clear()
             port_state.clear()
-            logger.info("未发现任何在线设备,已清空轮询列表")
+            logger.info("未发现任何在线设备,已清空轮询列表(保留离线面板)")
 
         # 更新最近在线时间
         for r in unique_results:
@@ -3995,6 +4068,12 @@ if __name__ == '__main__':
                         for panel_id, cfg in panel_config.items():
                             addr = cfg.get('address', 1)
 
+                            # 已标记离线的面板不再主动发查询,
+                            # 交由广播发现(自动发现循环)定期尝试重新上线
+                            if cfg.get('status') == 'offline':
+                                offline += 1
+                                continue
+
                             # 先快速探测面板是否在线,不在线则跳过 24 个端口的详细轮询
                             try:
                                 probe = modbus_client.read_antenna_card(addr, 1, timeout=0.5)
@@ -4002,8 +4081,7 @@ if __name__ == '__main__':
                                 probe = {'error': 'exception'}
                             if 'error' in probe:
                                 offline += 1
-                                panel_fail_count[panel_id] = panel_fail_count.get(panel_id, 0) + 1
-                                logger.warning(f"面板 {panel_id}(地址={addr}) 快速探测无响应,连续失败 {panel_fail_count[panel_id]} 次")
+                                register_panel_poll_failure(panel_id, cfg, addr)
                                 continue
 
                             panel_ok = False
@@ -4022,7 +4100,15 @@ if __name__ == '__main__':
                                 if panel_id not in port_state:
                                     port_state[panel_id] = {}
                                 if port_id not in port_state[panel_id]:
-                                    port_state[panel_id][port_id] = {'last_uid': None, 'expected_uid': None, 'alarm_count': 0, 'last_polled_at': int(time.time() * 1000)}
+                                    # 重建端口状态时,从持久化的期望映射回填 expected_uid,
+                                    # 保证离线恢复/重启后仍能正确判定非法连接
+                                    exp_uid = dtu_config.get('port_mappings', {}).get(panel_id, {}).get(str(port_id))
+                                    port_state[panel_id][port_id] = {
+                                        'last_uid': None,
+                                        'expected_uid': exp_uid,
+                                        'alarm_count': 0,
+                                        'last_polled_at': int(time.time() * 1000)
+                                    }
                                 ps = port_state[panel_id][port_id]
                                 ps['last_polled_at'] = int(time.time() * 1000)
                                 last_uid = ps.get('last_uid')
@@ -4054,8 +4140,10 @@ if __name__ == '__main__':
 
                             if panel_ok:
                                 online += 1
-                                if panel_fail_count.get(panel_id, 0) > 0:
+                                if cfg.get('status') == 'offline' or panel_fail_count.get(panel_id, 0) > 0 or panel_assign_pending.get(panel_id):
+                                    cfg['status'] = 'online'
                                     panel_fail_count[panel_id] = 0
+                                    panel_assign_pending.pop(panel_id, None)
                                     logger.info(f"面板 {panel_id}(地址={addr}) 恢复在线")
                                 device_last_seen[panel_id] = time.time()
                                 device_last_seen[cfg.get('panel_uid', '')] = time.time()
@@ -4073,20 +4161,7 @@ if __name__ == '__main__':
                                         logger.error(f"刷新屏幕失败: {e}")
                             else:
                                 offline += 1
-                                panel_fail_count[panel_id] = panel_fail_count.get(panel_id, 0) + 1
-                                logger.warning(f"面板 {panel_id}(地址={addr}) 轮询无响应,连续失败 {panel_fail_count[panel_id]} 次")
-
-                        # 移除连续失败达到阈值的面板,停止无效查询;后续由自动发现重新分配地址
-                        panels_to_remove = [
-                            pid for pid, count in panel_fail_count.items()
-                            if count >= PANEL_FAIL_THRESHOLD and pid in panel_config
-                        ]
-                        for pid in panels_to_remove:
-                            removed_cfg = panel_config.pop(pid, {})
-                            panel_fail_count.pop(pid, None)
-                            port_state.pop(pid, None)
-                            device_last_seen.pop(pid, None)
-                            logger.info(f"面板 {pid}(地址={removed_cfg.get('address')}) 连续 {PANEL_FAIL_THRESHOLD} 次无响应,已从轮询列表移除,等待自动发现")
+                                register_panel_poll_failure(panel_id, cfg, addr)
 
                         dtu_publish_status(force=True)
                     finally: