Explorar el Código

feat: 实现MQTT与硬件卡号适配,优化gpiod兼容与测试用例

1.  在modbus_rtu模块添加注释说明硬件卡号与MQTT的适配逻辑
2.  重构dht11_sensor的gpiod版本兼容逻辑,适配1.x/2.x不同API
3.  新增MQTT jumper_uid与12位硬件卡号的转换工具函数,实现前后端统一适配
4.  更新测试用例与脚本,适配新的卡号长度与适配规则
5.  新增MQTT配置保存加载与自动连接功能
6.  新增网络IP监测线程,自动更新串口屏显示IP
wenhongquan hace 1 día
padre
commit
036557b48d

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


+ 268 - 21
backend/app.py

@@ -6,11 +6,13 @@ import queue
 import time
 import json
 import os
+import re
 import logging
 import uuid
 
 # 配置文件路径
 SERIAL_CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'serial_config.json')
+MQTT_CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mqtt_config.json')
 
 # 静态文件目录(前端构建产物或由 nginx 提供)
 STATIC_FOLDER = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend', 'dist')
@@ -75,7 +77,7 @@ logger = logging.getLogger('serial_mqtt_gateway')
 from modules.serial_port import SerialPort
 from modules.mqtt_client import MQTTClient
 from modules.network_config import network_manager
-from modules.modbus_rtu import ModbusRTUClient, ANTENNA_ADDRESSES, AddressConfigProtocol, build_broadcast_query, build_confirm_address, build_assign_address
+from modules.modbus_rtu import ModbusRTUClient, ANTENNA_ADDRESSES, AddressConfigProtocol, build_broadcast_query, build_confirm_address, build_assign_address, calculate_crc16
 from modules.dht11_sensor import DHT11Sensor
 from modules.nextion_display import NextionDisplay
 
@@ -160,6 +162,112 @@ def save_dtu_config():
 # 端口状态追踪(用于事件检测)
 port_state = {}  # {panel_id: {port_id: {'last_uid': str, 'expected_uid': str, 'alarm_count': int}}}
 
+# jumper_uid 十六进制字符集(大写)
+_JUMPER_HEX = set('0123456789ABCDEF')
+
+
+_mac_uid_prefix_cache = None  # 缓存 MAC 派生的前缀(稳定,避免每次发布都读 MAC)
+
+
+def _read_mac_bytes():
+    """读取本机真实 MAC 地址(6 字节),失败返回 None。
+
+    优先用 uuid.getnode();其 multicast 位为 1 时是随机生成而非真实硬件 MAC,
+    改读 /sys/class/net(Linux)兜底。
+    """
+    try:
+        node = uuid.getnode()
+        # 第 9 位(0x010000000000)为 1 表示 multicast/随机 MAC,非真实硬件地址
+        if node and not (node & 0x010000000000):
+            return node.to_bytes(6, 'big')
+    except Exception:
+        pass
+    try:
+        net_dir = '/sys/class/net'
+        if os.path.isdir(net_dir):
+            for iface in sorted(os.listdir(net_dir)):
+                if iface == 'lo':
+                    continue
+                path = os.path.join(net_dir, iface, 'address')
+                if os.path.exists(path):
+                    with open(path) as f:
+                        mac_str = f.read().strip()
+                    if re.match(r'^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$', mac_str):
+                        return bytes(int(b, 16) for b in mac_str.split(':'))
+    except Exception:
+        pass
+    return None
+
+
+def _dtu_uid_prefix():
+    """jumper_uid 前 4 位 DTU 号(4 位大写十六进制),从 MAC 派生保证多 DTU 唯一。
+
+    适配规则: MQTT jumper_uid = 前 4 位 DTU 号 + 后 12 位硬件卡号(共 16 位)。
+    优先级:
+      1. dtu_config['uid_prefix'] 显式配置(4 位大写十六进制)
+      2. 本机 MAC 地址的 CRC16(稳定、各 DTU 不同)
+      3. 兜底: dtu_id 末尾数字
+    """
+    global _mac_uid_prefix_cache
+    p = (dtu_config.get('uid_prefix') or '').strip().upper()
+    if len(p) == 4 and all(c in _JUMPER_HEX for c in p):
+        return p
+    if _mac_uid_prefix_cache is None:
+        mac = _read_mac_bytes()
+        if mac:
+            _mac_uid_prefix_cache = calculate_crc16(mac).hex().upper()
+        else:
+            m = re.search(r'(\d+)$', str(dtu_config.get('dtu_id') or ''))
+            n = int(m.group(1)) if m else 0
+            _mac_uid_prefix_cache = f'{n & 0xFFFF:04X}'
+    return _mac_uid_prefix_cache
+
+
+def _to_mqtt_jumper_uid(card_uid):
+    """内部 12 位硬件卡号 -> MQTT 16 位 jumper_uid(前 4 位 DTU 号 + 后 12 位卡号)。
+
+    None/空 -> None。硬件通信固定 6 字节(12 位),仅在 MQTT 边界拼成 16 位。
+    """
+    if not card_uid:
+        return None
+    return _dtu_uid_prefix() + card_uid.upper()
+
+
+def _from_mqtt_jumper_uid(value):
+    """MQTT 16 位 jumper_uid -> 内部 12 位硬件卡号(剥前 4 位 DTU 号)。
+
+    None/空 -> None;非 16 位大写十六进制 -> 抛 ValueError(由 SYNC 调用方拒绝)。
+    """
+    if value is None:
+        return None
+    if not isinstance(value, str):
+        raise ValueError(f"jumper_uid 必须是字符串或 null,当前类型: {type(value).__name__}")
+    v = value.strip().upper()
+    if v == '':
+        return None
+    if len(v) != 16 or any(c not in _JUMPER_HEX for c in v):
+        raise ValueError(f"jumper_uid 必须是 16 位大写十六进制或 null,当前: {value!r}")
+    return v[4:]
+
+
+def _to_internal_uid_lenient(value):
+    """重建端口状态用:兼容历史持久化数据。
+
+    16 位 -> 剥前 4 位得 12 位;12 位 -> 原样;None/空 -> None;非法 -> None。
+    """
+    if value is None:
+        return None
+    if not isinstance(value, str):
+        return None
+    v = value.strip().upper()
+    if v == '':
+        return None
+    if len(v) == 16 and all(c in _JUMPER_HEX for c in v):
+        return v[4:]
+    if len(v) == 12 and all(c in _JUMPER_HEX for c in v):
+        return v
+    return None
+
 
 def get_sorted_panels():
     """按 position 排序返回 panel 列表。"""
@@ -244,6 +352,34 @@ def _get_display_ip():
     return ''
 
 
+_last_display_ip = None  # 上次写入屏幕的 IP,用于检测变化
+
+
+def _network_ip_monitor_loop():
+    """后台监测本机 IP 变化。
+
+    插网线获取到 IP(或 IP 发生任何变化)时,及时刷新串口屏显示。
+    复用 screen_refresh_queue 串行化下发,避免与其它刷新竞争。
+    """
+    global _last_display_ip
+    while True:
+        try:
+            current_ip = _get_display_ip()
+            if current_ip != _last_display_ip:
+                logger.info(f"显示 IP 变化: {_last_display_ip!r} -> {current_ip!r}")
+                _last_display_ip = current_ip
+                if screen_display.get_status().get('connected'):
+                    panels = get_sorted_panels()
+                    if panels:
+                        with screen_lock:
+                            idx = screen_current_panel_index
+                        if 0 <= idx < len(panels):
+                            screen_refresh_queue.put((panels[idx][0],))
+        except Exception as e:
+            logger.error(f"网络 IP 监测异常: {e}")
+        time.sleep(3)
+
+
 def _screen_refresh_worker():
     """在后台线程中异步刷新 Nextion 屏幕。"""
     while True:
@@ -469,6 +605,69 @@ def load_serial_config():
             logger.error(f"加载串口配置失败: {e}")
     return None
 
+def save_mqtt_config(broker, port=1883, client_id='', username='', password='', keepalive=60, topics=None):
+    """保存MQTT配置"""
+    config = {
+        'broker': broker,
+        'port': port,
+        'client_id': client_id,
+        'username': username,
+        'password': password,
+        'keepalive': keepalive,
+        'topics': topics or []
+    }
+    try:
+        with open(MQTT_CONFIG_FILE, 'w') as f:
+            json.dump(config, f, indent=2)
+        logger.info(f"MQTT配置已保存: {broker}:{port}")
+    except Exception as e:
+        logger.error(f"保存MQTT配置失败: {e}")
+
+def load_mqtt_config():
+    """加载MQTT配置"""
+    if os.path.exists(MQTT_CONFIG_FILE):
+        try:
+            with open(MQTT_CONFIG_FILE, 'r') as f:
+                config = json.load(f)
+            logger.info(f"已加载MQTT配置: {config.get('broker')}:{config.get('port')}")
+            return config
+        except Exception as e:
+            logger.error(f"加载MQTT配置失败: {e}")
+    return None
+
+def auto_connect_mqtt():
+    """自动连接上次使用的MQTT服务器"""
+    config = load_mqtt_config()
+    if config and config.get('broker'):
+        broker = config.get('broker')
+        port = config.get('port', 1883)
+        client_id = config.get('client_id', '')
+        username = config.get('username', '')
+        password = config.get('password', '')
+        keepalive = config.get('keepalive', 60)
+        topics = config.get('topics', [])
+        
+        logger.info(f"尝试自动连接MQTT: {broker}:{port}")
+        will_topic = build_dtu_topic(dtu_config['customer_id'], 'dtu', dtu_config['dtu_id'], 'status')
+        will_payload = json.dumps({
+            'dtu_id': dtu_config['dtu_id'], 'type': 'STATUS',
+            'payload': {'online': False, 'reason': 'CONNECTION_LOST'}
+        })
+        success, message = mqtt_client.connect(
+            broker=broker, port=port, client_id=client_id,
+            username=username or "", password=password or "",
+            keepalive=keepalive,
+            will_topic=will_topic, will_payload=will_payload, will_qos=1, will_retain=False
+        )
+        if success:
+            logger.info(f"自动连接MQTT成功: {broker}:{port}")
+            if topics:
+                mqtt_client.subscribe(topics)
+        else:
+            logger.warning(f"自动连接MQTT失败: {message}")
+        return success
+    return False
+
 def auto_connect_serial():
     """自动连接上次使用的串口"""
     config = load_serial_config()
@@ -1008,11 +1207,17 @@ def dtu_publish_status(force=False):
 
 
 def dtu_publish_event(panel_id, port_id, event_type, jumper_uid, previous_jumper_uid=None):
-    """发送端口事件消息"""
+    """发送端口事件消息。
+
+    入参 jumper_uid / previous_jumper_uid 为内部 12 位硬件卡号;
+    MQTT 上行拼成 16 位(前 4 位 DTU 号 + 后 12 位卡号)。
+    """
     if not _mqtt_connected() or not dtu_config.get('enabled'):
         return False
 
     try:
+        mqtt_jumper = _to_mqtt_jumper_uid(jumper_uid)
+        mqtt_prev = _to_mqtt_jumper_uid(previous_jumper_uid)
         payload = {
             'msg_id': f"evt_{int(time.time() * 1000)}",
             'timestamp': int(time.time() * 1000),
@@ -1023,8 +1228,8 @@ def dtu_publish_event(panel_id, port_id, event_type, jumper_uid, previous_jumper
                 'port_id': port_id,
                 'event_type': event_type,
                 'event_id': f"evt_{uuid.uuid4().hex[:8]}",
-                'jumper_uid': jumper_uid,
-                'previous_jumper_uid': previous_jumper_uid
+                'jumper_uid': mqtt_jumper,
+                'previous_jumper_uid': mqtt_prev
             }
         }
 
@@ -1033,7 +1238,7 @@ def dtu_publish_event(panel_id, port_id, event_type, jumper_uid, previous_jumper
 
         if success:
             logger.info(f"端口事件已发送: {panel_id}:{port_id} - {event_type}")
-            # 记录到事件历史
+            # 记录到事件历史(保留内部 12 位,供本地前端/调试)
             port_event_history.append({
                 'timestamp': int(time.time() * 1000),
                 'panel_id': panel_id,
@@ -1058,12 +1263,18 @@ def dtu_publish_event(panel_id, port_id, event_type, jumper_uid, previous_jumper
 
 
 def dtu_publish_alarm(panel_id, port_id, alarm_type, expected_jumper_uid, actual_jumper_uid, severity='WARNING'):
-    """发送非法告警消息"""
+    """发送非法告警消息。
+
+    入参 expected_jumper_uid / actual_jumper_uid 为内部 12 位硬件卡号;
+    MQTT 上行拼成 16 位(前 4 位 DTU 号 + 后 12 位卡号)。
+    """
     if not _mqtt_connected() or not dtu_config.get('enabled'):
         return False
 
     try:
-        description = f"端口{port_id}期望跳线{expected_jumper_uid},实际{'未读到' if not actual_jumper_uid else actual_jumper_uid}"
+        mqtt_exp = _to_mqtt_jumper_uid(expected_jumper_uid)
+        mqtt_act = _to_mqtt_jumper_uid(actual_jumper_uid)
+        description = f"端口{port_id}期望跳线{mqtt_exp or '无'},实际{'未读到' if not actual_jumper_uid else mqtt_act}"
 
         payload = {
             'msg_id': f"alm_{int(time.time() * 1000)}",
@@ -1075,8 +1286,8 @@ def dtu_publish_alarm(panel_id, port_id, alarm_type, expected_jumper_uid, actual
                 'port_id': port_id,
                 'alarm_type': alarm_type,
                 'severity': severity,
-                'expected_jumper_uid': expected_jumper_uid,
-                'actual_jumper_uid': actual_jumper_uid,
+                'expected_jumper_uid': mqtt_exp,
+                'actual_jumper_uid': mqtt_act,
                 'description': description
             }
         }
@@ -1086,7 +1297,7 @@ def dtu_publish_alarm(panel_id, port_id, alarm_type, expected_jumper_uid, actual
 
         if success:
             logger.warning(f"非法告警已发送: {panel_id}:{port_id} - {alarm_type}")
-            # 记录到事件历史
+            # 记录到事件历史(保留内部 12 位,供本地前端/调试)
             port_event_history.append({
                 'timestamp': int(time.time() * 1000),
                 'panel_id': panel_id,
@@ -1121,6 +1332,11 @@ def dtu_publish_panel_status(panel_id, address, ports_data, online=True):
     try:
         if not online:
             ports_data = [{'port_id': p, 'status': 'UNKNOWN', 'jumper_uid': None} for p in range(1, 25)]
+        # MQTT 上行 jumper_uid 拼成 16 位(前 4 位 DTU 号 + 后 12 位卡号)
+        mqtt_ports = [
+            {**p, 'jumper_uid': _to_mqtt_jumper_uid(p.get('jumper_uid'))}
+            for p in ports_data
+        ]
         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)}",
@@ -1133,7 +1349,7 @@ def dtu_publish_panel_status(panel_id, address, ports_data, online=True):
                 'address': address,
                 'online': online,
                 'last_poll_time': int(time.time() * 1000),
-                'ports': ports_data
+                'ports': mqtt_ports
             }
         }
         mqtt_client.publish(topic, json.dumps(payload), qos=0)
@@ -1170,7 +1386,8 @@ def dtu_publish_jumper_status():
                 ports_arr.append({
                     'port_id': port_id,
                     'status': status,
-                    'jumper_uid': last_uid
+                    # MQTT 上行拼成 16 位(前 4 位 DTU 号 + 后 12 位卡号)
+                    'jumper_uid': _to_mqtt_jumper_uid(last_uid)
                 })
             panels_arr.append({
                 'panel_id': panel_id,
@@ -1352,14 +1569,24 @@ def dtu_handle_control(topic, payload):
                 mqtt_client.publish(topic_response_dtu, json.dumps(response_payload), qos=1)
                 return
 
+            try:
+                # MQTT 16 位 -> 内部 12 位硬件卡号(剥前 4 位 DTU 号)
+                expected_uid = _from_mqtt_jumper_uid(jumper_uid)
+            except ValueError as e:
+                response_payload['payload']['success'] = False
+                response_payload['payload']['error_code'] = 1006
+                response_payload['payload']['error_message'] = str(e)
+                mqtt_client.publish(topic_response_dtu, json.dumps(response_payload), qos=1)
+                return
+
             if panel_target not in port_state:
                 port_state[panel_target] = {}
             if port_id not in port_state[panel_target]:
                 port_state[panel_target][port_id] = {'last_uid': None, 'expected_uid': None, 'alarm_count': 0}
 
-            port_state[panel_target][port_id]['expected_uid'] = jumper_uid
-            # 持久化期望映射,保证离线恢复/重启后仍能判定非法连接
-            dtu_config.setdefault('port_mappings', {}).setdefault(panel_target, {})[str(port_id)] = jumper_uid
+            port_state[panel_target][port_id]['expected_uid'] = expected_uid
+            # 持久化期望映射(内部 12 位),保证离线恢复/重启后仍能判定非法连接
+            dtu_config.setdefault('port_mappings', {}).setdefault(panel_target, {})[str(port_id)] = expected_uid
             save_dtu_config()
 
         elif command == 'SYNC_ALL_MAPPING':
@@ -1376,6 +1603,8 @@ def dtu_handle_control(topic, payload):
                     panel_id = _resolve_panel_id(mapping.get('panel_id'))
                     port_id = mapping.get('port_id')
                     jumper_uid = mapping.get('jumper_uid')
+                    # MQTT 16 位 -> 内部 12 位硬件卡号(剥前 4 位 DTU 号),非法抛 ValueError
+                    expected_uid = _from_mqtt_jumper_uid(jumper_uid)
 
                     if panel_id not in panel_config:
                         raise ValueError(f"目标面板不存在: {mapping.get('panel_id')}")
@@ -1383,9 +1612,9 @@ def dtu_handle_control(topic, payload):
                         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}
-                    port_state[panel_id][port_id]['expected_uid'] = jumper_uid
-                    # 持久化期望映射
-                    dtu_config.setdefault('port_mappings', {}).setdefault(panel_id, {})[str(port_id)] = jumper_uid
+                    port_state[panel_id][port_id]['expected_uid'] = expected_uid
+                    # 持久化期望映射(内部 12 位)
+                    dtu_config.setdefault('port_mappings', {}).setdefault(panel_id, {})[str(port_id)] = expected_uid
                 save_dtu_config()
             except Exception as e:
                 response_payload['payload']['success'] = False
@@ -2315,6 +2544,12 @@ def mqtt_connect():
         
         if success:
             logger.info(f"MQTT服务器连接成功: {host}:{port}")
+            save_mqtt_config(
+                broker=host, port=port, client_id=client_id,
+                username=username or "", password=password or "",
+                keepalive=keepalive,
+                topics=data.get('topics', [])
+            )
         else:
             logger.error(f"MQTT服务器连接失败: {message}")
         
@@ -4198,6 +4433,10 @@ if __name__ == '__main__':
         logger.info("尝试自动连接串口...")
         auto_connect_serial()
 
+        # 自动连接上次使用的MQTT服务器
+        logger.info("尝试自动连接MQTT...")
+        auto_connect_mqtt()
+
         # 串口连接成功后会通过 status_callback 触发 run_discovery_sync(),
         # 先查询地址表中已有地址是否在线,再广播发现新设备并分配地址。
         # 这里只做兜底:如果串口未连接则清空轮询列表,避免轮询离线设备。
@@ -4284,12 +4523,15 @@ if __name__ == '__main__':
                                 if panel_id not in port_state:
                                     port_state[panel_id] = {}
                                 if port_id not in port_state[panel_id]:
-                                    # 重建端口状态时,从持久化的期望映射回填 expected_uid
-                                    # 保证离线恢复/重启后仍能正确判定非法连接
+                                    # 重建端口状态时,从持久化的期望映射回填 expected_uid(内部 12 位)。
+                                    # 兼容历史脏数据:16 位剥前 4 位得 12 位;12 位原样;非法丢弃并告警。
                                     exp_uid = dtu_config.get('port_mappings', {}).get(panel_id, {}).get(str(port_id))
+                                    norm = _to_internal_uid_lenient(exp_uid)
+                                    if exp_uid and norm is None:
+                                        logger.warning(f"面板{panel_id}端口{port_id} 持久化的 expected_uid 非法已丢弃: {exp_uid!r}")
                                     port_state[panel_id][port_id] = {
                                         'last_uid': None,
-                                        'expected_uid': exp_uid,
+                                        'expected_uid': norm,
                                         'alarm_count': 0,
                                         'last_polled_at': int(time.time() * 1000)
                                     }
@@ -4406,6 +4648,11 @@ if __name__ == '__main__':
         screen_refresh_thread.start()
         logger.info("启动 Nextion 屏幕异步刷新线程")
 
+        # 启动网络 IP 监测线程:插网线获取到 IP 后及时写入串口屏
+        network_ip_monitor_thread = threading.Thread(target=_network_ip_monitor_loop, daemon=True)
+        network_ip_monitor_thread.start()
+        logger.info("启动网络 IP 监测线程")
+
         # 启动服务
         socketio.run(
             app, 

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


+ 35 - 6
backend/modules/dht11_sensor.py

@@ -18,16 +18,45 @@ logger = logging.getLogger('dht11_sensor')
 try:
     import gpiod
     _GPIOD_AVAILABLE = True
-    # 简单判断 gpiod 1.x/2.x:1.x 有 Chip/get_line,2.x 没有 get_line
-    _GPIOD_V1 = hasattr(gpiod, 'Chip') and hasattr(gpiod.Chip, 'get_line')
-    _GPIOD_V2 = hasattr(gpiod, 'Chip') and not _GPIOD_V1
+    # 兼容不同版本的 gpiod API:
+    #   - 系统 apt 安装的 1.4.x:使用大写 Chip
+    #   - pip 安装的 1.5.x:使用小写 chip
+    if hasattr(gpiod, 'Chip'):
+        _gpiod_chip_class = gpiod.Chip
+    elif hasattr(gpiod, 'chip'):
+        _gpiod_chip_class = gpiod.chip
+    else:
+        _gpiod_chip_class = None
+    _GPIOD_V1 = _gpiod_chip_class is not None and hasattr(_gpiod_chip_class, 'get_line')
+    _GPIOD_V2 = _gpiod_chip_class is None
     if _GPIOD_V2:
-        logger.warning("检测到 gpiod 2.x,当前 DHT11 驱动使用 gpiod 1.x API;"
-                       "请在目标设备使用系统包管理器安装 python3-libgpiod (1.x)")
+        logger.warning("检测到不兼容的 gpiod 版本,当前 DHT11 驱动需要 gpiod 1.x with Chip/chip API;"
+                       "请在目标设备安装 python3-libgpiod (1.x)")
         _GPIOD_AVAILABLE = False
+
+    # 兼容不同 gpiod 1.x 版本的常量命名
+    if _GPIOD_AVAILABLE:
+        _GPIOD_CONST_MAP = {}
+        # 方向常量
+        for _attr in ('LINE_REQ_DIR_OUT', 'LINE_REQ_DIR_IN'):
+            if hasattr(gpiod, _attr):
+                _GPIOD_CONST_MAP[_attr] = getattr(gpiod, _attr)
+        if hasattr(gpiod, 'line_request'):
+            _lr = gpiod.line_request
+            _GPIOD_CONST_MAP.setdefault('LINE_REQ_DIR_OUT', getattr(_lr, 'DIRECTION_OUTPUT', None))
+            _GPIOD_CONST_MAP.setdefault('LINE_REQ_DIR_IN', getattr(_lr, 'DIRECTION_INPUT', None))
+            _GPIOD_CONST_MAP.setdefault('LINE_REQ_FLAG_BIAS_PULL_UP', getattr(_lr, 'FLAG_BIAS_PULL_UP', None))
+        # 写回模块,方便后续直接使用 gpiod.LINE_REQ_*
+        for _k, _v in _GPIOD_CONST_MAP.items():
+            if _v is not None and not hasattr(gpiod, _k):
+                try:
+                    setattr(gpiod, _k, _v)
+                except Exception:
+                    pass
 except Exception as _e:  # pragma: no cover
     gpiod = None
     _GPIOD_AVAILABLE = False
+    _gpiod_chip_class = None
     logger.warning(f"gpiod 库未安装或加载失败,DHT11 真实读取不可用: {_e}")
 
 
@@ -228,7 +257,7 @@ class DHT11Sensor:
         except Exception:
             pass
 
-        chip = gpiod.Chip(chip_name)
+        chip = _gpiod_chip_class(chip_name)
         line = None
         try:
             line = chip.get_line(pin)

+ 11 - 8
backend/modules/modbus_rtu.py

@@ -313,6 +313,8 @@ class AddressConfigProtocol:
 
 # ========== V4 批量读卡协议常量 ==========
 # V4: 一次性批量读 24 路卡号;每张卡 6 字节;无卡标记 6×0xFF。
+# 注: 硬件通信固定 6 字节卡号;对 MQTT 的 8 字节(16 位)适配在 app.py 边界完成
+#     (前 4 位 DTU 号 + 后 12 位卡号)。
 CARD_REG_ADDR = 0x0002          # 批量卡号读起始寄存器
 CARD_REG_QUANTITY = 0x48        # 72 寄存器 = 144 字节
 CARD_DATA_BYTES = 144           # 24 天线 × 6 字节
@@ -395,15 +397,16 @@ class ModbusRTUClient:
     def read_all_antenna_cards(self, device_address: int, timeout: float = None) -> dict:
         """V4: 一次批量读取全部 24 路天线卡号。
 
-        寄存器 0x0002,数量 0x48(72 寄存器 = 144 字节)。
-        响应: [addr][0x03][0x90][144 数据字节][CRC_L][CRC_H] = 149 字节。
-        每张卡 6 字节;无卡标记 = FF FF FF FF FF FF。
+    寄存器 0x0002,数量 0x48(72 寄存器 = 144 字节)。
+    响应: [addr][0x03][0x90][144 数据字节][CRC_L][CRC_H] = 149 字节。
+    每张卡 6 字节(硬件卡号,12 位十六进制);无卡标记 = 6×0xFF。
+    MQTT 侧的 8 字节(16 位) jumper_uid 适配在 app.py 边界完成。
 
-        返回:
-            {'success': True, 'device_address', 'function_code': 0x03,
-             'cards': [{'antenna', 'card_str', 'present', 'uid'}, ...24],
-             'raw_data'}
-        """
+    返回:
+        {'success': True, 'device_address', 'function_code': 0x03,
+         'cards': [{'antenna', 'card_str', 'present', 'uid'}, ...24],
+         'raw_data'}
+    """
         # 测试注入: test_cards = {addr: {port_id: 'hex12'|None, ...}, ...}
         # 命中时绕过串口,返回构造数据(触发告警/同步流程测试)
         if self.test_cards is not None:

+ 16 - 7
backend/scripts/test_alarm_flow.py

@@ -1,9 +1,11 @@
 """同步映射 + 非法插接告警端到端测试
 
 场景(PANEL_dtu_001_2, 地址=2):
+  硬件通信固定 6 字节(12 位)卡号;MQTT 侧 jumper_uid = 前 4 位 DTU 号 + 后 12 位卡号(16 位)。
+  - 注入(模拟硬件读卡)用 12 位卡号;SYNC 下发与 MQTT 上行断言用 16 位。
   1. 订阅 event / alarm / patchpanel status / response 主题
   2. 清空注入 & 现有 port_state
-  3. SYNC_PORT_MAPPING: port 1 期望 AABBCCDDEEFF, port 5 期望 112233445566
+  3. SYNC_PORT_MAPPING: port 1 期望 0000AABBCCDDEEFF, port 5 期望 0000112233445566
   4. 注入正确卡(port 1 = AABBCCDDEEFF, port 5 = 112233445566)-> 预期 CONNECT, 无告警
   5. 注入错误卡(port 1 = DEADBEEF0001)-> 预期 MOVE + ILLEGAL_CONNECT
   6. 注入空(port 5 拔出)-> 预期 DISCONNECT + ILLEGAL_DISCONNECT
@@ -113,16 +115,22 @@ def main():
     client.loop_start()
     time.sleep(2)
 
-    UID_A = "aabbccddeeff"
+    # 12 位硬件卡号(注入用,模拟硬件读卡)
+    UID_A = "AABBCCDDEEFF"
     UID_B = "112233445566"
-    UID_WRONG = "deadbeef0001"
+    UID_WRONG = "DEADBEEF0001"
+    # SYNC 下发的 16 位 jumper_uid: 前 4 位 DTU 号 + 后 12 位卡号。
+    # DTU 侧会剥掉前 4 位只用后 12 位比对,故前缀任意填(DTU 实际前缀由其 MAC 派生)。
+    UID_A_MQTT = "0000" + UID_A
+    UID_B_MQTT = "0000" + UID_B
+    UID_WRONG_MQTT = "0000" + UID_WRONG
 
     # -------- 步骤 1: 清零 & 同步期望映射 --------
     print("\n===== 步骤 1: 同步期望映射(port1=A, port5=B)=====")
     events.clear(); alarms.clear(); responses.clear()
     send_ctrl(client, "SYNC_ALL_MAPPING", "all", {"mappings": [
-        {"panel_id": PANEL_ID, "port_id": 1, "jumper_uid": UID_A.upper()},
-        {"panel_id": PANEL_ID, "port_id": 5, "jumper_uid": UID_B.upper()},
+        {"panel_id": PANEL_ID, "port_id": 1, "jumper_uid": UID_A_MQTT},
+        {"panel_id": PANEL_ID, "port_id": 5, "jumper_uid": UID_B_MQTT},
     ]})
     wait(3, "等 SYNC 生效")
     # 清空所有假数据(无卡状态)
@@ -153,9 +161,10 @@ def main():
     assert move_events, "预期 port1 MOVE 事件"
     assert illegal_conn, "预期 port1 ILLEGAL_CONNECT 告警"
     a0 = illegal_conn[0]
-    assert a0.get("expected_jumper_uid", "").lower() == UID_A, \
+    # MQTT 上行 16 位 = DTU 前缀(由 MAC 派生) + 卡号(12 位);只比对后 12 位卡号
+    assert a0.get("expected_jumper_uid", "").lower()[-12:] == UID_A.lower(), \
         f"expected_jumper_uid 错: {a0.get('expected_jumper_uid')}"
-    assert a0.get("actual_jumper_uid", "").lower() == UID_WRONG, \
+    assert a0.get("actual_jumper_uid", "").lower()[-12:] == UID_WRONG.lower(), \
         f"actual_jumper_uid 错: {a0.get('actual_jumper_uid')}"
     print(f"✅ 通过:ILLEGAL_CONNECT expected={a0.get('expected_jumper_uid')} actual={a0.get('actual_jumper_uid')} sev={a0.get('severity')}")
 

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


+ 8 - 5
backend/tests/test_modbus_rtu.py

@@ -3,7 +3,10 @@
 协议 V4: 一次性批量读 24 路卡号。
   请求: [addr][0x03][0x00][0x02][0x00][0x48][CRC_L][CRC_H]  -> 01 03 00 02 00 48 E4 3C
   响应: [addr][0x03][0x90][144 数据字节][CRC_L][CRC_H] = 149 字节
-  每张卡 6 字节; 无卡标记 = FF FF FF FF FF FF
+  每张卡 6 字节(硬件卡号,12 位十六进制); 无卡标记 = FF FF FF FF FF FF
+
+注: 硬件通信固定 6 字节卡号;对 MQTT 的 8 字节(16 位) jumper_uid 适配在 app.py 边界完成
+    (前 4 位 DTU 号 + 后 12 位卡号),本测试只覆盖硬件通信层。
 """
 import sys
 import os
@@ -69,7 +72,7 @@ class TestReadAllAntennaCards:
         assert kwargs.get('min_response_bytes') == BULK_RESPONSE_LEN
 
     def test_no_tag_response(self):
-        """24 路全无卡 (FF*144)。CRC 动态计算,不用文档笔误的 c9a2。"""
+        """24 路全无卡 (FF*144)。"""
         tag_data = NO_TAG_BYTES * ANTENNA_COUNT  # 144 字节 0xFF
         self._mock_response(build_bulk_response(0x01, tag_data))
 
@@ -87,7 +90,7 @@ class TestReadAllAntennaCards:
             assert card['uid'] == ''
 
     def test_tag_on_antenna_24(self):
-        """文档示例: 24 号天线有卡 ED9A57F95001,其余无卡。"""
+        """24 号天线有卡 ED9A57F95001,其余无卡。"""
         tag = bytes([0xED, 0x9A, 0x57, 0xF9, 0x50, 0x01])
         tag_data = NO_TAG_BYTES * 23 + tag  # 138 + 6 = 144
         self._mock_response(build_bulk_response(0x03, tag_data))
@@ -212,7 +215,7 @@ class TestReadAntennaCardSingle:
         assert result['card_number_hex'] == f'0x{int.from_bytes(tag, "big"):012x}'
 
     def test_extract_antenna_no_tag(self):
-        """无卡时 card_number_str 为空串 (V2 是 16 个 0)。"""
+        """无卡时 card_number_str 为空串(V2 是 16 个 0)。"""
         self.client.serial.send_and_wait.return_value = build_bulk_response(
             0x01, NO_TAG_BYTES * 24)
 
@@ -281,7 +284,7 @@ class TestCrcHelpers:
     """CRC 辅助函数 (回归)。"""
 
     def test_request_crc_matches_doc(self):
-        """请求帧 CRC = E4 3C (文档已核实)。"""
+        """文档示例请求帧 (01 03 00 02 00 48) CRC = E4 3C。"""
         req = bytes([0x01, 0x03, 0x00, 0x02, 0x00, 0x48])
         assert calculate_crc16(req) == bytes([0xE4, 0x3C])