소스 검색

feat: add LED debug page and Modbus RTU support

1. add LED debug page with 24 LED control and card reading功能
2. add full Modbus RTU protocol implementation
3. add serial port raw send and wait response methods
4. add backend Modbus API endpoints
5. fix websocket namespace and response parsing
6. refactor status message variable naming consistency
wenhongquan 5 시간 전
부모
커밋
71376f17f3

+ 16 - 0
.claude/settings.local.json

@@ -0,0 +1,16 @@
+{
+  "permissions": {
+    "allow": [
+      "mcp__mcp-doc__open_document",
+      "mcp__mcp-doc__get_document_info",
+      "mcp__mcp-doc__search_text",
+      "mcp__mcp-doc__add_paragraph",
+      "mcp__MCP_DOCKER__search_nodes",
+      "mcp__MCP_DOCKER__create_entities",
+      "mcp__MCP_DOCKER__add_observations",
+      "Bash(python3 -m py_compile app.py modules/modbus_rtu.py)",
+      "Bash(python3 *)",
+      "Bash(xxd)"
+    ]
+  }
+}

BIN
DTU和节点之间协议 V2.docx


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


+ 522 - 7
backend/app.py

@@ -44,6 +44,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
 
 app = Flask(__name__)
 
@@ -66,6 +67,8 @@ socketio = SocketIO(
 # 初始化串口和MQTT客户端
 serial_client = SerialPort()
 mqtt_client = MQTTClient()
+modbus_client = ModbusRTUClient(serial_client)
+address_config = AddressConfigProtocol(serial_client)
 
 # 转发标志
 forward_serial_to_mqtt = DEFAULT_FORWARD_SERIAL_TO_MQTT
@@ -270,7 +273,7 @@ def handle_serial_send(data):
             })
             return
         
-        if not serial_client.get_status():
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
             emit('serial_send_response', {
                 'success': False,
                 'message': '串口未连接',
@@ -438,6 +441,78 @@ serial_client.set_status_callback(serial_status_handler)
 mqtt_client.set_data_callback(mqtt_data_handler)
 mqtt_client.set_status_callback(mqtt_status_handler)
 
+# 设备配置文件路径
+DEVICE_CONFIG_FILE = '/root/dzxj_dtu/devices.json'
+confirm_loop_running = False
+
+
+def save_device_config():
+    """保存设备配置到文件"""
+    try:
+        filepath = DEVICE_CONFIG_FILE
+        success = address_config.save_config(filepath)
+        if success:
+            logger.info(f"设备配置已保存到: {filepath}")
+    except Exception as e:
+        logger.error(f"保存设备配置失败: {str(e)}")
+
+
+def load_device_config():
+    """加载设备配置"""
+    try:
+        filepath = DEVICE_CONFIG_FILE
+        if os.path.exists(filepath):
+            success = address_config.load_config(filepath)
+            if success:
+                devices = address_config.get_stored_devices()
+                logger.info(f"已加载 {len(devices)} 个设备配置")
+                return devices
+    except Exception as e:
+        logger.error(f"加载设备配置失败: {str(e)}")
+    return {}
+
+
+def confirm_loop():
+    """后台确认线程:每10秒对所有已存储设备发送 confirm_address"""
+    global confirm_loop_running
+    confirm_loop_running = True
+    logger.info("启动设备确认线程 (间隔10秒)")
+
+    while confirm_loop_running:
+        try:
+            devices = address_config.get_stored_devices()
+            if not devices:
+                time.sleep(10)
+                continue
+
+            if not serial_client.get_status():
+                time.sleep(10)
+                continue
+
+            _st = serial_client.get_status()
+            if not (isinstance(_st, dict) and _st.get('connected', False)):
+                time.sleep(10)
+                continue
+
+            for uid_hex, addr in devices.items():
+                try:
+                    uid_bytes = bytes.fromhex(uid_hex)
+                    cmd = build_confirm_address(addr, uid_bytes)
+                    success, msg = serial_client.send_raw(cmd)
+                    if success:
+                        logger.debug(f"确认设备: 地址={addr}, UID={uid_hex[:16]}...")
+                    else:
+                        logger.warning(f"确认失败 地址={addr}: {msg}")
+                except Exception as e:
+                    logger.error(f"确认异常 地址={addr}: {str(e)}")
+                time.sleep(0.1)
+
+        except Exception as e:
+            logger.error(f"确认线程异常: {str(e)}")
+
+        time.sleep(10)
+
+
 # API路由
 # 移除静态文件服务,前端由nginx提供服务
 
@@ -491,13 +566,15 @@ def serial_connect():
             }), 400
         
         # 先断开之前的连接
-        if serial_client.get_status():
-            logger.info(f"断开现有串口连接: {serial_client.port}")
+        _status = serial_client.get_status()
+        if isinstance(_status, dict) and _status.get('connected', False):
+            _port = serial_client.current_config.port if serial_client.current_config else 'unknown'
+            logger.info(f"断开现有串口连接: {_port}")
             serial_client.disconnect()
         
         # 连接新的串口
         logger.info(f"尝试连接串口: {port}, 波特率: {baudrate}")
-        success, message = serial_client.connect(port, baudrate, bytesize, parity, stopbits, timeout)
+        success, message = serial_client.connect(port, baudrate=baudrate, timeout=timeout, bytesize=bytesize, parity=parity, stopbits=stopbits)
         
         status_code = 200 if success else 400
         error_code = ERROR_CODES['SUCCESS'] if success else ERROR_CODES['SERIAL_CONNECTION_ERROR']
@@ -535,8 +612,9 @@ def serial_disconnect():
 @app.route('/api/serial/status', methods=['GET'])
 def serial_get_status():
     """获取串口状态"""
+    _st = serial_client.get_status()
     return jsonify({
-        'connected': serial_client.get_status()
+        'connected': _st.get('connected', False) if isinstance(_st, dict) else bool(_st)
     })
 
 @app.route('/api/serial/send', methods=['POST'])
@@ -839,6 +917,437 @@ def restart_network_service():
         logger.error(f'重启网络服务失败: {str(e)}')
         return jsonify({'success': False, 'message': f'重启网络服务失败: {str(e)}'}), 500
 
+# Modbus RTU API
+@app.route('/api/modbus/antenna_addresses', methods=['GET'])
+def get_antenna_addresses():
+    """获取天线地址映射表"""
+    return jsonify({
+        'success': True,
+        'antennas': {str(k): f"0x{v:04x}" for k, v in ANTENNA_ADDRESSES.items()}
+    })
+
+
+@app.route('/api/modbus/read_antenna', methods=['POST'])
+def modbus_read_antenna():
+    """读取指定天线的卡号
+
+    请求参数:
+    {
+        "device_address": 1,      // 设备地址 (1-247)
+        "antenna": 1,             // 天线编号 (1-24)
+        "timeout": 1.0            // 可选,超时时间(秒)
+    }
+    """
+    try:
+        data = request.json
+        device_address = data.get('device_address', 1)
+        antenna = data.get('antenna', 1)
+        timeout = data.get('timeout')
+
+        if antenna < 1 or antenna > 24:
+            return jsonify({
+                'success': False,
+                'message': f'无效的天线编号: {antenna}, 必须是1-24'
+            }), 400
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({
+                'success': False,
+                'message': '串口未连接'
+            }), 400
+
+        result = modbus_client.read_antenna_card(
+            device_address=device_address,
+            antenna_num=antenna,
+            timeout=timeout
+        )
+
+        if 'error' in result:
+            return jsonify({
+                'success': False,
+                'message': result['error'],
+                'raw_data': result.get('raw_data', '')
+            }), 400
+
+        return jsonify({
+            'success': True,
+            'data': result
+        })
+
+    except Exception as e:
+        logger.error(f'读取天线数据失败: {str(e)}')
+        return jsonify({
+            'success': False,
+            'message': str(e)
+        }), 500
+
+
+@app.route('/api/modbus/read_registers', methods=['POST'])
+def modbus_read_registers():
+    """读取保持寄存器
+
+    请求参数:
+    {
+        "device_address": 1,
+        "start_address": 2,       // 起始地址 (十六进制如0x0002或十进制如2)
+        "quantity": 4,            // 寄存器数量
+        "timeout": 1.0
+    }
+    """
+    try:
+        data = request.json
+        device_address = data.get('device_address', 1)
+        start_address = data.get('start_address', 0)
+        quantity = data.get('quantity', 1)
+        timeout = data.get('timeout')
+
+        # 支持十六进制字符串
+        if isinstance(start_address, str):
+            start_address = int(start_address, 16)
+        if isinstance(device_address, str):
+            device_address = int(device_address, 16)
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({
+                'success': False,
+                'message': '串口未连接'
+            }), 400
+
+        result = modbus_client.read_holding_registers(
+            device_address=device_address,
+            start_address=start_address,
+            quantity=quantity,
+            timeout=timeout
+        )
+
+        if 'error' in result:
+            return jsonify({
+                'success': False,
+                'message': result['error'],
+                'raw_data': result.get('raw_data', '')
+            }), 400
+
+        return jsonify({
+            'success': True,
+            'data': result
+        })
+
+    except Exception as e:
+        logger.error(f'读取寄存器失败: {str(e)}')
+        return jsonify({
+            'success': False,
+            'message': str(e)
+        }), 500
+
+
+@app.route('/api/modbus/write_register', methods=['POST'])
+def modbus_write_register():
+    """写单个寄存器
+
+    请求参数:
+    {
+        "device_address": 1,
+        "register_address": 1,    // 寄存器地址
+        "value": 256,             // 写入的值
+        "timeout": 1.0
+    }
+    """
+    try:
+        data = request.json
+        device_address = data.get('device_address', 1)
+        register_address = data.get('register_address', 1)
+        value = data.get('value', 0)
+        timeout = data.get('timeout')
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({
+                'success': False,
+                'message':'串口未连接'
+            }), 400
+
+        result = modbus_client.write_single_register(
+            device_address=device_address,
+            register_address=register_address,
+            value=value,
+            timeout=timeout
+        )
+
+        if 'error' in result:
+            return jsonify({
+                'success': False,
+                'message': result['error'],
+                'raw_data': result.get('raw_data', '')
+            }), 400
+
+        return jsonify({
+            'success': True,
+            'data': result
+        })
+
+    except Exception as e:
+        logger.error(f'写寄存器失败: {str(e)}')
+        return jsonify({
+            'success': False,
+            'message': str(e)
+        }), 500
+
+
+@app.route('/api/modbus/set_rgb_led', methods=['POST'])
+def modbus_set_rgb_led():
+    """设置RGB灯状态
+
+    请求参数:
+    {
+        "device_address": 1,
+        "led_number": 1,          // 灯编号 (1-24)
+        "color": 1,               // 颜色: 0=灭, 1=红灯, 2=绿灯, 3=蓝灯
+        "timeout": 1.0
+    }
+    """
+    try:
+        data = request.json
+        device_address = data.get('device_address', 1)
+        led_number = data.get('led_number', 1)
+        color = data.get('color', 0)
+        timeout = data.get('timeout')
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({
+                'success': False,
+                'message': '串口未连接'
+            }), 400
+
+        result = modbus_client.set_rgb_led(
+            device_address=device_address,
+            led_number=led_number,
+            color=color,
+            timeout=timeout
+        )
+
+        if 'error' in result:
+            return jsonify({
+                'success': False,
+                'message': result['error'],
+                'raw_data': result.get('raw_data', '')
+            }), 400
+
+        # 更新 LED 状态追踪
+        led_states[led_number] = color
+
+        return jsonify({
+            'success': True,
+            'data': result
+        })
+
+    except Exception as e:
+        logger.error(f'设置RGB灯失败: {str(e)}')
+        return jsonify({
+            'success': False,
+            'message': str(e)
+        }), 500
+
+
+@app.route('/api/modbus/scan', methods=['POST'])
+def modbus_scan_devices():
+    """扫描在线设备
+
+    请求参数:
+    {
+        "max_address": 247,       // 最大设备地址
+        "timeout": 0.2            // 单个设备超时时间
+    }
+    """
+    try:
+        data = request.json or {}
+        max_address = data.get('max_address', 247)
+        timeout = data.get('timeout', 0.2)
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({
+                'success': False,
+                'message': '串口未连接'
+            }), 400
+
+        # 异步扫描可能更好,但这里先同步实现
+        result = modbus_client.scan_devices(max_address)
+
+        return jsonify({
+            'success': True,
+            'devices': result
+        })
+
+    except Exception as e:
+        logger.error(f'扫描设备失败: {str(e)}')
+        return jsonify({
+            'success': False,
+            'message': str(e)
+        }), 500
+
+
+# ========== 地址配置协议 API ==========
+
+@app.route('/api/modbus/broadcast_query', methods=['POST'])
+def modbus_broadcast_query():
+    """发送广播查询指令"""
+    try:
+        data = request.json or {}
+        timeout = data.get('timeout')
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({'success': False, 'message': '串口未连接'}), 400
+
+        responses = address_config.broadcast_query(timeout)
+        return jsonify({'success': True, 'responses': responses, 'count': len(responses)})
+
+    except Exception as e:
+        logger.error(f'广播查询失败: {str(e)}')
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+@app.route('/api/modbus/auto_configure', methods=['POST'])
+def modbus_auto_configure():
+    """自动配置设备地址"""
+    try:
+        data = request.json or {}
+        timeout = data.get('timeout')
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({'success': False, 'message': '串口未连接'}), 400
+
+        result = address_config.auto_configure(timeout)
+        if result.get('success') and (result.get('discovered', 0) > 0 or result.get('confirmed', 0) > 0 or result.get('assigned', 0) > 0):
+            save_device_config()
+        return jsonify(result)
+
+    except Exception as e:
+        logger.error(f'自动配置失败: {str(e)}')
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+@app.route('/api/modbus/stored_devices', methods=['GET'])
+def get_stored_devices():
+    """获取已存储的设备列表"""
+    return jsonify({'success': True, 'devices': address_config.get_stored_devices()})
+
+
+@app.route('/api/modbus/stored_devices', methods=['POST'])
+def add_stored_device():
+    """添加已存储的设备"""
+    try:
+        data = request.json
+        uid = data.get('uid', '').lower()
+        address = data.get('address', 1)
+
+        if len(uid) != 24:
+            return jsonify({'success': False, 'message': 'UID长度必须是24个十六进制字符'}), 400
+
+        address_config.add_stored_device(uid, address)
+        save_device_config()
+        return jsonify({'success': True, 'message': f'已添加设备: UID={uid}, 地址={address}'})
+
+    except Exception as e:
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+@app.route('/api/modbus/load_config', methods=['POST'])
+def modbus_load_config():
+    """从文件加载设备配置"""
+    try:
+        data = request.json
+        filepath = data.get('filepath', '/tmp/modbus_devices.json')
+        success = address_config.load_config(filepath)
+        return jsonify({'success': success, 'devices': address_config.get_stored_devices()})
+    except Exception as e:
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+@app.route('/api/modbus/save_config', methods=['POST'])
+def modbus_save_config():
+    """保存设备配置到文件"""
+    try:
+        data = request.json
+        filepath = data.get('filepath', '/tmp/modbus_devices.json')
+        success = address_config.save_config(filepath)
+        return jsonify({'success': success, 'message': f'配置已保存到: {filepath}' if success else '保存失败'})
+    except Exception as e:
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+@app.route('/api/modbus/confirm_devices', methods=['POST'])
+def confirm_devices():
+    """手动触发对所有已存储设备的确认"""
+    try:
+        devices = address_config.get_stored_devices()
+        if not devices:
+            return jsonify({'success': False, 'message': '没有已存储的设备'}), 400
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({'success': False, 'message': '串口未连接'}), 400
+
+        results = []
+        for uid_hex, addr in devices.items():
+            try:
+                uid_bytes = bytes.fromhex(uid_hex)
+                cmd = build_confirm_address(addr, uid_bytes)
+                success, msg = serial_client.send_raw(cmd)
+                logger.info(f"确认设备 地址={addr}: {'成功' if success else '失败 ' + msg}")
+                results.append({'address': addr, 'uid': uid_hex, 'success': success, 'message': msg})
+            except Exception as e:
+                results.append({'address': addr, 'uid': uid_hex, 'success': False, 'message': str(e)})
+            time.sleep(0.1)
+
+        return jsonify({'success': True, 'results': results, 'count': len(results)})
+    except Exception as e:
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+# LED 状态追踪 (内存中跟踪每个灯的最后设置状态)
+led_states = {i: 0 for i in range(1, 25)}  # 0=off, 1=red, 2=green, 3=blue
+
+
+@app.route('/api/modbus/led_status', methods=['GET'])
+def get_led_status():
+    """获取所有 LED 状态"""
+    return jsonify({'success': True, 'leds': led_states})
+
+
+@app.route('/api/modbus/set_all_leds', methods=['POST'])
+def set_all_leds():
+    """批量设置 LED
+    请求: {"device_address": 1, "color": 1}
+    设置所有 24 个 LED 到指定颜色
+    """
+    try:
+        data = request.json
+        device_address = data.get('device_address', 1)
+        color = data.get('color', 0)
+        timeout = data.get('timeout')
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({'success': False, 'message': '串口未连接'}), 400
+
+        results = []
+        for led_num in range(1, 25):
+            result = modbus_client.set_rgb_led(
+                device_address=device_address,
+                led_number=led_num,
+                color=color,
+                timeout=timeout
+            )
+            if 'error' in result:
+                results.append({'led': led_num, 'success': False, 'error': result['error']})
+            else:
+                results.append({'led': led_num, 'success': True})
+                led_states[led_num] = color
+
+        return jsonify({'success': True, 'results': results})
+
+    except Exception as e:
+        logger.error(f'批量设置LED失败: {str(e)}')
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+# 在 set_rgb_led 中更新 LED 状态追踪
 # 不再需要静态文件目录,前端由nginx提供服务
 
 if __name__ == '__main__':
@@ -846,6 +1355,11 @@ if __name__ == '__main__':
         # 启动前的初始化工作
         logger.info('启动串口-MQTT网关服务...')
         logger.info(f"配置信息: 主机={FLASK_HOST}, 端口={FLASK_PORT}, 调试模式={FLASK_DEBUG}")
+
+        # 加载设备配置
+        loaded = load_device_config()
+        logger.info(f"已加载 {len(loaded)} 个设备配置")
+        logger.info("设备确认线程已禁用(确认命令干扰运行中设备的Modbus通信)")
         
         # 启动服务
         socketio.run(
@@ -854,13 +1368,14 @@ if __name__ == '__main__':
             port=FLASK_PORT, 
             debug=FLASK_DEBUG,
             use_reloader=False,  # 禁用重载器以避免重复初始化问题
-            log_output=False  # 禁用Flask的日志输出,使用我们自己的日志配置
+            log_output=False,  # 禁用Flask的日志输出,使用我们自己的日志配置
+            allow_unsafe_werkzeug=True
         )
     except KeyboardInterrupt:
         # 优雅退出
         logger.info('正在关闭应用...')
         try:
-            if serial_client.get_status():
+            if isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False):
                 serial_client.disconnect()
                 logger.info('串口连接已断开')
             if mqtt_client.get_status():

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


+ 372 - 0
backend/modules/modbus_rtu.py

@@ -0,0 +1,372 @@
+from typing import Optional
+
+
+# ========== 地址配置协议 ==========
+
+def build_broadcast_query() -> bytes:
+    """构建广播查询指令
+
+    DTU定时发送广播指令查询未配置地址的从机
+    格式: 00 40 00 40 00 (无CRC, 固定5字节)
+    """
+    return bytes([0x00, 0x40, 0x00, 0x40, 0x00])
+
+
+def parse_device_response(data: bytes) -> 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
+    """
+    if len(data) < 16:
+        return {'error': '数据长度不足', 'raw_data': data.hex()}
+
+    if data[1] != 0x41:
+        return {'error': f"非预期功能码: {data[1]:#x}", 'raw_data': data.hex()}
+
+    uid_bytes = data[2:14]
+    uid_hex = uid_bytes.hex()
+
+    if not verify_crc16(data[:14]):
+        logger.warning(f"CRC校验失败: {data.hex()}")
+
+    return {
+        'function_code': 0x41,
+        'uid': uid_hex,
+        'uid_readable': ':'.join(f'{b:02x}' for b in uid_bytes),
+        'raw_data': data.hex()
+    }
+
+
+def build_confirm_address(device_address: int, uid: bytes) -> bytes:
+    """构建确认地址指令
+
+    DTU遍历已存储的UID和地址,如果存在匹配的UID则发送确认指令
+    格式: add + 44 + 00 + 12个UID + CRC_L + CRC_H
+    """
+    if len(uid) != 12:
+        raise ValueError(f"UID长度必须是12字节,当前: {len(uid)}")
+
+    data = bytes([device_address, 0x44, 0x00]) + uid
+    return data + calculate_crc16(data)
+
+
+def build_assign_address(uid: bytes, address: int) -> bytes:
+    """构建分配地址指令
+
+    如果DTU中没有该UID的记录,则分配新地址
+    格式: 00 + 42 + 12个UID + add + CRC_L + CRC_H
+    """
+    if len(uid) != 12:
+        raise ValueError(f"UID长度必须是12字节,当前: {len(uid)}")
+    if address < 1 or address > 247:
+        raise ValueError(f"设备地址必须在1-247之间,当前: {address}")
+
+    data = bytes([0x00, 0x42]) + uid + bytes([address])
+    return data + calculate_crc16(data)
+
+
+def parse_address_assignment_response(data: bytes) -> dict:
+    """解析地址分配响应
+
+    模块应答格式: add + 43 + 00 + CRC_L + CRC_H
+    例如: 01 43 00 11 30
+    """
+    if len(data) < 5:
+        return {'error': '数据长度不足', 'raw_data': data.hex()}
+
+    device_address = data[0]
+
+    if data[1] != 0x43:
+        return {'error': f"非预期功能码: {data[1]:#x}", 'raw_data': data.hex()}
+
+    if not verify_crc16(data):
+        logger.warning(f"CRC校验失败: {data.hex()}")
+
+    return {
+        'device_address': device_address,
+        'function_code': 0x43,
+        'raw_data': data.hex()
+    }
+
+
+class AddressConfigProtocol:
+    """地址配置协议处理器"""
+
+    def __init__(self, serial_port):
+        self.serial = serial_port
+        self.default_timeout = 1.0
+        self.stored_devices = {}
+
+    def add_stored_device(self, uid: str, address: int):
+        """添加已存储的设备"""
+        self.stored_devices[uid] = address
+
+    def get_stored_devices(self) -> dict:
+        """获取已存储的设备列表"""
+        return self.stored_devices.copy()
+
+    def broadcast_query(self, timeout: float = None) -> list:
+        """send broadcast query and collect responses"""
+        import time
+
+        request = build_broadcast_query()
+        logger.info(f"send broadcast: {request.hex()}")
+
+        timeout = timeout or self.default_timeout
+        response = self.serial.send_and_wait(request, timeout=timeout, min_response_bytes=16)
+
+        responses = []
+        if response and len(response) >= 16:
+            logger.info(f"got response: {response.hex()}")
+            parsed = parse_device_response(response)
+            if "error" not in parsed:
+                responses.append(parsed)
+        else:
+            logger.info(f"no response or too short: {len(response) if response else 0}B")
+
+        logger.info(f"broadcast done, got {len(responses)} responses")
+        return responses
+
+    def process_responses(self, responses: list) -> list:
+        """处理广播查询响应,发送确认或分配地址指令"""
+        import time
+
+        results = []
+
+        for resp in responses:
+            if 'error' in resp:
+                results.append({'status': 'error', 'message': resp['error']})
+                continue
+
+            uid = resp['uid']
+            device_address = self.stored_devices.get(uid)
+
+            if device_address:
+                logger.info(f"UID={uid} 已配置地址={device_address},发送确认指令")
+                request = build_confirm_address(device_address, bytes.fromhex(uid))
+
+                self.serial.flush_input()
+                success, msg = self.serial.send_raw(request)
+                if success:
+                    results.append({
+                        'status': 'confirmed',
+                        'uid': uid,
+                        'address': device_address,
+                        'request': request.hex()
+                    })
+            else:
+                new_address = len(self.stored_devices) + 1
+                if new_address > 247:
+                    new_address = 1
+
+                logger.info(f"UID={uid} 未配置,分配新地址={new_address}")
+                request = build_assign_address(bytes.fromhex(uid), new_address)
+
+                self.serial.flush_input()
+                time.sleep(0.05)
+
+                success, msg = self.serial.send_raw(request)
+                if success:
+                    self.stored_devices[uid] = new_address
+                    results.append({
+                        'status': 'assigned',
+                        'uid': uid,
+                        'address': new_address,
+                        'request': request.hex()
+                    })
+
+        return results
+
+    def auto_configure(self, timeout: float = None) -> dict:
+        """自动配置所有未配置的设备"""
+        logger.info("开始自动配置设备...")
+
+        responses = self.broadcast_query(timeout)
+
+        if not responses:
+            return {
+                'success': True,
+                'message': '未发现任何从机设备',
+                'discovered': 0,
+                'configured': 0,
+                'results': []
+            }
+
+        results = self.process_responses(responses)
+
+        confirmed = sum(1 for r in results if r.get('status') == 'confirmed')
+        assigned = sum(1 for r in results if r.get('status') == 'assigned')
+        errors = sum(1 for r in results if r.get('status') == 'error')
+
+        return {
+            'success': errors == 0,
+            'discovered': len(responses),
+            'confirmed': confirmed,
+            'assigned': assigned,
+           'errors': errors,
+            'results': results,
+            'stored_devices': self.get_stored_devices()
+        }
+
+    def save_config(self, filepath: str) -> bool:
+        """保存配置到文件"""
+        import json
+        try:
+            with open(filepath, 'w') as f:
+                json.dump(self.stored_devices, f, indent=2)
+            return True
+        except Exception as e:
+            logger.error(f"保存配置失败: {e}")
+            return False
+
+    def load_config(self, filepath: str) -> bool:
+        """从文件加载配置"""
+        import json
+        try:
+            with open(filepath, 'r') as f:
+                self.stored_devices = json.load(f)
+            return True
+        except Exception as e:
+            logger.error(f"加载配置失败: {e}")
+            return False
+
+# ========== Modbus RTU Client ==========
+
+# 天线地址映射表(天线编号 -> Modbus寄存器地址)
+ANTENNA_ADDRESSES = {i: 0x0001 + (i - 1) for i in range(1, 25)}
+
+import struct
+import logging
+
+logger = logging.getLogger('serial_mqtt_gateway')
+
+
+def calculate_crc16(data: bytes) -> bytes:
+    """计算Modbus CRC16校验"""
+    crc = 0xFFFF
+    for byte in data:
+        crc ^= byte
+        for _ in range(8):
+            if crc & 0x0001:
+                crc = (crc >> 1) ^ 0xA001
+            else:
+                crc >>= 1
+    return struct.pack('<H', crc)
+
+
+def verify_crc16(data: bytes) -> bool:
+    """验证Modbus CRC16校验"""
+    if len(data) < 2:
+        return False
+    received_crc = struct.unpack('<H', data[-2:])[0]
+    calculated_crc = struct.unpack('<H', calculate_crc16(data[:-2]))[0]
+    return received_crc == calculated_crc
+
+
+class ModbusRTUClient:
+    """Modbus RTU 客户端"""
+
+    def __init__(self, serial_port):
+        self.serial = serial_port
+        self.default_timeout = 1.0
+
+    def _build_request(self, device_address: int, function_code: int, data: bytes = b'') -> bytes:
+        """构建Modbus请求帧"""
+        pdu = bytes([device_address, function_code]) + data
+        return pdu + calculate_crc16(pdu)
+
+    def _send_receive(self, request: bytes, expected_min_len: int = 5, timeout: float = None) -> dict:
+        """send request and receive response using sync method"""
+        timeout = timeout or self.default_timeout
+
+        if not self.serial.ser:
+            return {"error": "serial not connected"}
+
+        response = self.serial.send_and_wait(request, timeout=timeout, min_response_bytes=expected_min_len)
+
+        if not response or len(response) < expected_min_len:
+            return {"error": "response timeout or too short", "raw_data": response.hex() if response else ""}
+
+        if not verify_crc16(response):
+            logger.warning(f"CRC check failed: {response.hex()}")
+            return {"error": "CRC check failed", "raw_data": response.hex()}
+
+        return {
+            "success": True,
+            "device_address": response[0],
+            "function_code": response[1],
+            "data": response[2:-2].hex(),
+            "raw_data": response.hex()
+        }
+
+    def read_antenna_card(self, device_address: int, antenna_num: int, timeout: float = None) -> dict:
+        """读取天线卡号
+        协议: 读寄存器 0x0002+(ant-1)*4, 共4个寄存器(8字节卡号)
+        """
+        reg_addr = 0x0002 + (antenna_num - 1) * 4
+        request_data = struct.pack('>HH', reg_addr, 4)  # 读取4个寄存器
+        request = self._build_request(device_address, 0x03, request_data)
+        result = self._send_receive(request, timeout=timeout)
+
+        if 'error' in result:
+            return result
+
+        data_str = result.get('data', '')
+        data_bytes = bytes.fromhex(data_str) if isinstance(data_str, str) and data_str else b''
+        if len(data_bytes) >= 8:
+            # 8字节卡号, big-endian
+            card_number = int.from_bytes(data_bytes[:8], 'big')
+            card_str = data_bytes[:8].hex()
+            return {
+                'card_number': card_number,
+                'card_number_hex': f'0x{card_number:016x}',
+                'card_number_str': card_str,
+                'antenna': antenna_num,
+                'uid': ':'.join(data_bytes[:8].hex()[i:i+2] for i in range(0, 16, 2)),
+                **result
+            }
+        return {'error': f'数据长度不足({len(data_bytes)}B,需要8B)', **result}
+
+    def read_holding_registers(self, device_address: int, start_address: int, quantity: int = 1, timeout: float = None) -> dict:
+        """读取保持寄存器"""
+        request_data = struct.pack('>HH', start_address, quantity)
+        request = self._build_request(device_address, 0x03, request_data)
+        result = self._send_receive(request, timeout=timeout)
+
+        if 'error' in result:
+            return result
+
+        data_str = result.get('data', '')
+        data_bytes = bytes.fromhex(data_str) if isinstance(data_str, str) and data_str else b''
+        registers = []
+        for i in range(0, len(data_bytes), 2):
+            if i + 1 < len(data_bytes):
+                registers.append(struct.unpack('>H', data_bytes[i:i+2])[0])
+
+        return {'registers': registers, **result}
+
+    def write_single_register(self, device_address: int, register_address: int, value: int, timeout: float = None) -> dict:
+        """写单个寄存器"""
+        request_data = struct.pack('>HH', register_address, value)
+        request = self._build_request(device_address, 0x06, request_data)
+        return self._send_receive(request, timeout=timeout)
+
+    def set_rgb_led(self, device_address: int, led_number: int, color: int, timeout: float = None) -> dict:
+        """设置RGB灯
+        Register 0x0001, value = (led_number << 8) | color
+        color: 0=off, 1=flashing red, 2=flashing green, 3=flashing blue
+        """
+        value = (led_number << 8) | (color & 0xFF)
+        return self.write_single_register(device_address, 0x0001, value, timeout)
+
+    def scan_devices(self, max_address: int = 247) -> list:
+        """scan online devices using Modbus 03"""
+        import time
+        devices = []
+        for addr in range(1, min(max_address + 1, 248)):
+            request = self._build_request(addr, 0x03, struct.pack('>HH', 0x0000, 0x0001))
+            response = self.serial.send_and_wait(request, timeout=0.3, min_response_bytes=5)
+            if response and len(response) >= 5 and verify_crc16(response):
+                devices.append(addr)
+        return devices

+ 76 - 18
backend/modules/serial_port.py

@@ -42,6 +42,7 @@ class SerialPort:
         self.current_config = None
         self.reconnect_attempts = 0
         self.max_reconnect_attempts = 3
+        self.raw_response_buffer = []
     
     def list_ports(self):
         """列出系统中可用的串口"""
@@ -197,17 +198,19 @@ class SerialPort:
                     # 使用in_waiting提高效率
                     if self.ser.in_waiting > 0:
                         data = self.ser.read(self.ser.in_waiting)
-                        # 尝试解码,如果失败则返回原始数据
+                        # 默认将原始数据以十六进制存入缓冲区
+                        hex_data = data.hex()
+                        with self.lock:
+                            self.raw_response_buffer.append(hex_data)
+                        if self.data_callback:
+                            self.data_callback(hex_data)
+                        # 如果能解码为文本,也通知回调
                         try:
-                            decoded_data = data.decode('utf-8', errors='replace').strip()
+                            decoded_data = data.decode('utf-8').strip()
                             if decoded_data and self.data_callback:
                                 self.data_callback(decoded_data)
-                        except Exception as e:
-                            # 对于无法解码的二进制数据,以十六进制形式返回
-                            hex_data = data.hex()
-                            if hex_data and self.data_callback:
-                                self.data_callback(hex_data)
-                            logger.warning(f"收到无法解码的二进制数据,长度: {len(data)}字节")
+                        except:
+                            pass
                     time.sleep(0.001)
             except Exception as e:
                 error_msg = f"读取串口数据错误: {str(e)}"
@@ -236,16 +239,18 @@ class SerialPort:
         
         # 线程结束时清理资源
         logger.info("串口读取线程结束")
-        with self.lock:
-            self.is_connected = False
-            if self.ser:
-                try:
-                    self.ser.close()
-                except:
-                    pass
-                self.ser = None
-            if self.status_callback:
-                self.status_callback(False)
+        # 只有错误退出才断开连接,stop_event触发的暂停不重置
+        if not self.stop_event.is_set():
+            with self.lock:
+                self.is_connected = False
+                if self.ser:
+                    try:
+                        self.ser.close()
+                    except:
+                        pass
+                    self.ser = None
+                if self.status_callback:
+                    self.status_callback(False)
     
     def send_data(self, data, encoding='utf-8'):
         """发送数据到串口"""
@@ -305,6 +310,24 @@ class SerialPort:
         self.reconnect_attempts += 1
         return self.reconnect_attempts <= self.max_reconnect_attempts
     
+
+
+    def send_raw(self, data: bytes):
+        """send raw binary data without adding newline"""
+        try:
+            with self.lock:
+                if not self.is_connected or not self.ser or not self.ser.is_open:
+                    return False, "serial port not connected"
+                bytes_sent = self.ser.write(data)
+                self.ser.flush()
+                return True, "send ok"
+        except Exception as e:
+            error_msg = "send raw failed: " + str(e)
+            logger.error(error_msg)
+            if self.error_callback:
+                self.error_callback(error_msg)
+            return False, error_msg
+
     def flush_input(self):
         """清空输入缓冲区"""
         try:
@@ -331,5 +354,40 @@ class SerialPort:
             logger.error(error_msg)
             return False, error_msg
 
+    def send_and_wait(self, data: bytes, timeout: float = 2.0, min_response_bytes: int = 1) -> bytes:
+        """sync send and wait (read from buffer, thread continues running)"""
+        import time
+        
+        # Clear buffer and send
+        self.raw_response_buffer.clear()
+        
+        with self.lock:
+            if not self.ser or not self.ser.is_open:
+                return b''
+            self.ser.reset_input_buffer()
+            self.ser.write(data)
+            self.ser.flush()
+        
+        response = b''
+        start = time.time()
+        while time.time() - start < timeout:
+            while self.raw_response_buffer:
+                    hex_str = self.raw_response_buffer.pop(0)
+                    try:
+                        chunk = bytes.fromhex(hex_str)
+                        response += chunk
+                        if len(response) >= min_response_bytes:
+                            time.sleep(0.3)
+                            while self.raw_response_buffer:
+                                try:
+                                    response += bytes.fromhex(self.raw_response_buffer.pop(0))
+                                except:
+                                    pass
+                            return response
+                    except:
+                        pass
+            time.sleep(0.01)
+        return response
+
 # 创建全局串口实例
 global_serial = SerialPort()

+ 3 - 0
frontend/src/App.vue

@@ -44,6 +44,9 @@
             <a-menu-item key="/system-config" icon="<setting />">
               <router-link to="/system-config">系统配置</router-link>
             </a-menu-item>
+            <a-menu-item key="/led-debug" icon="<bulb />">
+              <router-link to="/led-debug">LED调试</router-link>
+            </a-menu-item>
             <a-menu-item key="/network-config" icon="<wifi />">
               <router-link to="/network-config">网络配置</router-link>
             </a-menu-item>

+ 16 - 0
frontend/src/api/apiService.js

@@ -56,6 +56,22 @@ const apiService = {
     getMQTTData: () => apiClient.get('/data/mqtt')
   },
   
+  // Modbus API
+  modbus: {
+    getAntennaAddresses: () => apiClient.get('/modbus/antenna_addresses'),
+    readAntenna: (params) => apiClient.post('/modbus/read_antenna', params),
+    readRegisters: (params) => apiClient.post('/modbus/read_registers', params),
+    writeRegister: (params) => apiClient.post('/modbus/write_register', params),
+    setRgbLed: (params) => apiClient.post('/modbus/set_rgb_led', params),
+    setAllLeds: (params) => apiClient.post('/modbus/set_all_leds', params),
+    getLedStatus: () => apiClient.get('/modbus/led_status'),
+    scan: (params) => apiClient.post('/modbus/scan', params),
+    broadcastQuery: (params) => apiClient.post('/modbus/broadcast_query', params),
+    autoConfigure: (params) => apiClient.post('/modbus/auto_configure', params),
+    getStoredDevices: () => apiClient.get('/modbus/stored_devices'),
+    addStoredDevice: (params) => apiClient.post('/modbus/stored_devices', params),
+  },
+  
   // 转发配置API
   forward: {
     // 设置转发配置

+ 6 - 6
frontend/src/components/ForwardConfig.vue

@@ -138,23 +138,23 @@ const loadCurrentConfig = async () => {
 }
 
 // 显示状态消息
-const showStatus = (message, type = 'info') => {
-  statusMessage.value = message
+const showStatus = (msg, type = 'info') => {
+  statusMessage.value = msg
   statusType.value = type
   
   // 使用Ant Design消息组件显示临时通知
   switch (type) {
     case 'success':
-      message.success(message)
+      message.success(msg)
       break
     case 'error':
-      message.error(message)
+      message.error(msg)
       break
     case 'warning':
-      message.warning(message)
+      message.warning(msg)
       break
     default:
-      message.info(message)
+      message.info(msg)
   }
 }
 

+ 6 - 6
frontend/src/components/SerialConfig.vue

@@ -112,23 +112,23 @@ const statusMessage = ref('')
 const statusType = ref('info')
 
 // 显示状态消息
-const showStatus = (message, type = 'info', duration = 3000) => {
-  statusMessage.value = message
+const showStatus = (msg, type = 'info', duration = 3000) => {
+  statusMessage.value = msg
   statusType.value = type
   
   // 使用Ant Design消息组件
   switch (type) {
     case 'success':
-      message.success(message, duration / 1000)
+      message.success(msg, duration / 1000)
       break
     case 'error':
-      message.error(message, duration / 1000)
+      message.error(msg, duration / 1000)
       break
     case 'warning':
-      message.warning(message, duration / 1000)
+      message.warning(msg, duration / 1000)
       break
     default:
-      message.info(message, duration / 1000)
+      message.info(msg, duration / 1000)
   }
   
   // 自动清除消息

+ 6 - 0
frontend/src/router/index.js

@@ -25,6 +25,12 @@ const routes = [
     meta: { title: '网络配置' }
   },
   {
+    path: '/led-debug',
+    name: 'LedDebug',
+    component: () => import('../views/LedDebug.vue'),
+    meta: { title: 'LED调试' }
+  },
+  {
     path: '/about',
     name: 'About',
     component: () => import('../views/About.vue'),

+ 15 - 19
frontend/src/utils/websocket.js

@@ -26,8 +26,8 @@ export function initWebSocket(dataStore) {
 
   console.log('初始化Socket.IO连接:', socketUrl)
 
-  // 创建数据命名空间的连接
-  dataSocket = io(socketUrl, {
+  // 创建数据命名空间的连接 (/data)
+  dataSocket = io(socketUrl + '/data', {
     path: '/socket.io',
     transports: ['websocket'],
     reconnectionAttempts: 10,
@@ -51,8 +51,8 @@ export function initWebSocket(dataStore) {
     dataStore.updateMQTTData(data)
   })
 
-  // 创建状态命名空间的连接
-  statusSocket = io(socketUrl, {
+  // 创建状态命名空间的连接 (/status)
+  statusSocket = io(socketUrl + '/status', {
     path: '/socket.io',
     transports: ['websocket'],
     reconnectionAttempts: 10,
@@ -64,16 +64,16 @@ export function initWebSocket(dataStore) {
     console.log('Socket.IO状态连接已建立')
   })
 
-  // 监听串口状态事件
-  statusSocket.on('serial_status', (status) => {
-    console.log('收到串口状态:', status)
-    dataStore.setSerialConnected(status)
+  // 监听串口状态事件 (后端发送 {connected: true/false})
+  statusSocket.on('serial_status', (data) => {
+    console.log('收到串口状态:', data)
+    dataStore.setSerialConnected(data ? data.connected : false)
   })
 
-  // 监听MQTT状态事件
-  statusSocket.on('mqtt_status', (status) => {
-    console.log('收到MQTT状态:', status)
-    dataStore.setMQTTConnected(status)
+  // 监听MQTT状态事件 (后端发送 {connected: true/false})
+  statusSocket.on('mqtt_status', (data) => {
+    console.log('收到MQTT状态:', data)
+    dataStore.setMQTTConnected(data ? data.connected : false)
   })
 
   // 处理连接错误
@@ -105,13 +105,9 @@ async function fetchInitialConfig(dataStore) {
     const portsResponse = await fetch('/api/serial/ports')
     if (portsResponse.ok) {
       const portsData = await portsResponse.json()
-      if (portsData.data && Array.isArray(portsData.data.ports)) {
-        dataStore.setAvailablePorts(portsData.data.ports)
-        console.log('获取可用串口成功:', portsData.data.ports)
-      } else if (portsData.data && Array.isArray(portsData.data)) {
-        // 兼容数据格式
-        dataStore.setAvailablePorts(portsData.data)
-        console.log('获取可用串口成功(兼容格式):', portsData.data)
+      if (portsData.ports && Array.isArray(portsData.ports)) {
+        dataStore.setAvailablePorts(portsData.ports)
+        console.log('获取可用串口成功:', portsData.ports)
       }
     }
   } catch (error) {

+ 341 - 0
frontend/src/views/LedDebug.vue

@@ -0,0 +1,341 @@
+<template>
+  <div class="led-debug">
+    <a-row :gutter="[24, 24]">
+      <a-col :xs="24" :lg="10">
+        <a-card title="LED 控制面板">
+          <a-form layout="vertical">
+            <a-form-item label="选择终端">
+              <a-select
+                v-model:value="selectedDevice"
+                placeholder="请选择终端"
+                style="width: 100%"
+              >
+                <a-select-option
+                  v-for="t in terminals"
+                  :key="t.address"
+                  :value="t.address"
+                >
+                  地址 {{ t.address }} ({{ t.uid_readable || t.uid }})
+                </a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="选择 LED">
+              <a-select
+                v-model:value="selectedLed"
+                style="width: 100%"
+              >
+                <a-select-option :value="0">全部 24 灯</a-select-option>
+                <a-select-option v-for="n in 24" :key="n" :value="n">LED {{ n }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="操作">
+              <a-space wrap>
+                <a-button
+                  danger
+                  type="primary"
+                  :disabled="!canControl"
+                  :loading="loading === 1"
+                  @click="setColor(1)"
+                >
+                  🔴 红灯闪烁
+                </a-button>
+                <a-button
+                  :disabled="!canControl"
+                  :loading="loading === 2"
+                  style="background:#52c41a;border-color:#52c41a;color:#fff"
+                  @click="setColor(2)"
+                >
+                  🟢 绿灯闪烁
+                </a-button>
+                <a-button
+                  type="primary"
+                  :disabled="!canControl"
+                  :loading="loading === 3"
+                  @click="setColor(3)"
+                >
+                  🔵 蓝灯闪烁
+                </a-button>
+                <a-button
+                  :disabled="!canControl"
+                  :loading="loading === 0"
+                  @click="setColor(0)"
+                >
+                  ⚫ 关闭
+                </a-button>
+              </a-space>
+            </a-form-item>
+          </a-form>
+
+          <a-alert
+            v-if="feedbackMsg"
+            :type="feedbackType"
+            :message="feedbackMsg"
+            show-icon
+            closable
+            style="margin-top: 12px"
+            @close="feedbackMsg = ''"
+          />
+        </a-card>
+
+        <a-card title="读卡号" style="margin-top: 16px">
+          <a-form layout="vertical">
+            <a-form-item label="选择天线 (1-24)">
+              <a-space>
+                <a-input-number v-model:value="cardAntenna" :min="1" :max="24" style="width: 120px" />
+                <a-button type="primary" @click="readCardNumber" :loading="readingCard" :disabled="!canControl">
+                  读取卡号
+                </a-button>
+              </a-space>
+            </a-form-item>
+          </a-form>
+
+          <div v-if="cardResult" class="card-result">
+            <a-descriptions bordered size="small" :column="1">
+              <a-descriptions-item label="天线位置">{{ cardResult.antenna }}</a-descriptions-item>
+              <a-descriptions-item label="卡号 (HEX)">{{ cardResult.card_number_hex }}</a-descriptions-item>
+              <a-descriptions-item label="卡号 (DEC)">{{ cardResult.card_number }}</a-descriptions-item>
+              <a-descriptions-item label="卡号 (UID)">{{ cardResult.uid }}</a-descriptions-item>
+              <a-descriptions-item label="卡号字符串">{{ cardResult.card_number_str }}</a-descriptions-item>
+              <a-descriptions-item label="原始数据">{{ cardResult.raw_data }}</a-descriptions-item>
+            </a-descriptions>
+          </div>
+          <a-empty v-else-if="!readingCard" description="点击「读取卡号」查看结果" />
+        </a-card>
+      </a-col>
+
+      <a-col :xs="24" :lg="14">
+        <a-card title="24 路 LED 实时状态">
+          <a-alert
+            v-if="!serialConnected"
+            message="串口未连接"
+            type="warning"
+            show-icon
+            style="margin-bottom: 16px"
+          />
+          <div class="led-grid">
+            <div
+              v-for="n in 24"
+              :key="n"
+              class="led-cell"
+              :class="{ active: selectedLed === n || selectedLed === 0 }"
+              @click="selectedLed = n; cardAntenna = n"
+            >
+              <div
+                class="led-dot"
+                :style="{ background: ledColors[ledStates[n] ?? 0] }"
+                :title="`LED ${n}: ${statusText[ledStates[n] ?? 0]}`"
+              >
+                <span class="led-label">{{ n }}</span>
+              </div>
+            </div>
+          </div>
+          <div class="legend">
+            <span v-for="(c, i) in ['关闭','红色闪烁','绿色闪烁','蓝色闪烁']" :key="i" class="legend-item">
+              <span class="legend-dot" :style="{ background: ledColors[i] }"></span> {{ c }}
+            </span>
+          </div>
+        </a-card>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import apiService from '../api/apiService'
+
+const selectedDevice = ref(1)
+const selectedLed = ref(1)
+const terminals = ref([])
+const serialConnected = ref(false)
+const loading = ref(-1)
+const discovering = ref(false)
+const feedbackMsg = ref('')
+const feedbackType = ref('success')
+const ledStates = ref({})
+const cardAntenna = ref(1)
+const readingCard = ref(false)
+const cardResult = ref(null)
+let pollTimer = null
+
+const ledColors = { 0: '#d9d9d9', 1: '#ff4d4f', 2: '#52c41a', 3: '#1890ff' }
+const statusText = { 0: '关闭', 1: '红色闪烁', 2: '绿色闪烁', 3: '蓝色闪烁' }
+const colorNames = { 0: '关闭', 1: '红灯闪烁', 2: '绿灯闪烁', 3: '蓝灯闪烁' }
+
+const canControl = computed(() => terminals.value.length > 0)
+
+async function setColor(color) {
+  loading.value = color
+  feedbackMsg.value = ''
+  const params = { device_address: selectedDevice.value, color }
+  try {
+    let res
+    if (selectedLed.value === 0) {
+      res = await apiService.modbus.setAllLeds(params)
+    } else {
+      res = await apiService.modbus.setRgbLed({ ...params, led_number: selectedLed.value })
+    }
+    if (res.data.success) {
+      const target = selectedLed.value === 0 ? '全部灯' : `LED ${selectedLed.value}`
+      feedbackMsg.value = `${target} → ${colorNames[color]}`
+      feedbackType.value = 'success'
+      // update local state
+      if (selectedLed.value === 0) {
+        for (let i = 1; i <= 24; i++) ledStates.value[i] = color
+      } else {
+        ledStates.value[selectedLed.value] = color
+      }
+      // force reactivity
+      ledStates.value = { ...ledStates.value }
+    } else {
+      feedbackMsg.value = res.data.message || '失败'
+      feedbackType.value = 'error'
+    }
+  } catch (e) {
+    feedbackMsg.value = '错误: ' + (e.response?.data?.message || e.message)
+    feedbackType.value = 'error'
+  } finally {
+    loading.value = -1
+    setTimeout(() => { feedbackMsg.value = '' }, 3000)
+  }
+}
+
+async function discoverTerminals() {
+  discovering.value = true
+  try {
+    const res = await apiService.modbus.broadcastQuery({ timeout: 3 })
+    if (res.data.success && res.data.responses) {
+      terminals.value = res.data.responses.map((r, i) => ({
+        uid: r.uid, uid_readable: r.uid_readable, address: i + 1
+      }))
+      if (terminals.value.length) selectedDevice.value = terminals.value[0].address
+    }
+  } finally {
+    discovering.value = false
+  }
+}
+
+async function loadTerminals() {
+  try {
+    const res = await apiService.modbus.getStoredDevices()
+    if (res.data.success && res.data.devices) {
+      const entries = Object.entries(res.data.devices)
+      terminals.value = entries.map(([uid, addr]) => ({
+        uid, uid_readable: uid.match(/.{1,2}/g)?.join(':') || uid, address: addr
+      }))
+      if (terminals.value.length) selectedDevice.value = terminals.value[0].address
+    }
+  } catch (e) { console.error(e) }
+}
+
+async function readCardNumber() {
+  readingCard.value = true
+  cardResult.value = null
+  try {
+    const res = await apiService.modbus.readAntenna({
+      device_address: selectedDevice.value,
+      antenna: cardAntenna.value
+    })
+    if (res.data.success && res.data.data) {
+      cardResult.value = res.data.data
+    } else {
+      feedbackMsg.value = '读卡失败: ' + (res.data.message || '未知错误')
+      feedbackType.value = 'error'
+    }
+  } catch (e) {
+    feedbackMsg.value = '读卡错误: ' + (e.response?.data?.message || e.message)
+    feedbackType.value = 'error'
+  } finally {
+    readingCard.value = false
+    setTimeout(() => { if (!cardResult.value) feedbackMsg.value = '' }, 3000)
+  }
+}
+
+async function refreshStatus() {
+  try {
+    const [ledRes, serRes] = await Promise.all([
+      apiService.modbus.getLedStatus(),
+      apiService.serial.getStatus()
+    ])
+    if (ledRes.data.success && ledRes.data.leds) ledStates.value = { ...ledRes.data.leds }
+    serialConnected.value = serRes.data.connected === true
+  } catch (e) { /* ignore */ }
+}
+
+onMounted(() => {
+  loadTerminals()
+  refreshStatus()
+  pollTimer = setInterval(refreshStatus, 3000)
+})
+
+onUnmounted(() => {
+  if (pollTimer) clearInterval(pollTimer)
+})
+</script>
+
+<style scoped>
+.led-debug {
+  width: 100%;
+}
+.led-grid {
+  display: grid;
+  grid-template-columns: repeat(6, 1fr);
+  gap: 12px;
+}
+@media (max-width: 768px) {
+  .led-grid { grid-template-columns: repeat(4, 1fr); }
+}
+@media (max-width: 480px) {
+  .led-grid { grid-template-columns: repeat(2, 1fr); }
+}
+.led-cell {
+  display: flex;
+  justify-content: center;
+  cursor: pointer;
+  padding: 4px;
+  border-radius: 12px;
+  transition: box-shadow 0.2s;
+}
+.led-cell.active {
+  box-shadow: 0 0 0 3px #1890ff;
+}
+.led-dot {
+  width: 64px;
+  height: 64px;
+  border-radius: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 2px solid rgba(0,0,0,0.1);
+  transition: background 0.3s;
+}
+.led-dot:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(0,0,0,0.2);
+}
+.led-label {
+  font-size: 18px;
+  font-weight: 700;
+  color: #fff;
+  text-shadow: 0 1px 3px rgba(0,0,0,0.4);
+}
+.legend {
+  display: flex;
+  gap: 20px;
+  margin-top: 16px;
+  flex-wrap: wrap;
+  font-size: 13px;
+  color: #666;
+}
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.legend-dot {
+  width: 14px;
+  height: 14px;
+  border-radius: 50%;
+  border: 1px solid rgba(0,0,0,0.1);
+}
+</style>

+ 204 - 569
frontend/src/views/RealTimeStatus.vue

@@ -1,633 +1,268 @@
 <template>
   <div class="real-time-status">
-    <a-card title="485连接多个配线架状态监控">
-      <div class="rack-system-info">
-        <div class="system-summary">
-          <a-tag color="blue">总配线架数: {{ racks.length }}</a-tag>
-          <a-tag color="blue">总端口数: {{ totalPorts }}</a-tag>
-          <a-tag :color="systemHealthColor">系统状态: {{ systemHealthStatus }}</a-tag>
-          <span class="update-time">更新时间: {{ updateTime }}</span>
-          
-          <!-- 状态颜色说明图例 -->
-          <div class="status-legend">
-            <div class="legend-item">
-              <div class="legend-color error"></div>
-              <span>连接错误</span>
+    <a-row :gutter="[24, 24]">
+      <!-- 顶部概览 -->
+      <a-col :span="24">
+        <a-card>
+          <div class="overview-bar">
+            <div class="overview-item">
+              <span class="overview-label">串口状态</span>
+              <a-tag :color="serialConnected ? 'green' : 'red'">
+                {{ serialConnected ? '已连接' : '未连接' }}
+              </a-tag>
             </div>
-            <div class="legend-item">
-              <div class="legend-color waiting"></div>
-              <span>等待连接</span>
+            <div class="overview-item">
+              <span class="overview-label">终端数量</span>
+              <a-tag color="blue">{{ terminalCount }} 个</a-tag>
             </div>
-            <div class="legend-item">
-              <div class="legend-color normal"></div>
-              <span>连接正常</span>
+            <div class="overview-item">
+              <a-button type="primary" size="small" @click="discoverTerminals" :loading="discovering">
+                发现终端
+              </a-button>
             </div>
-            <div class="legend-item">
-              <div class="legend-color idle"></div>
-              <span>空闲</span>
+            <div class="overview-item" v-if="lastUpdate">
+              <span class="overview-label">更新</span>
+              <span class="overview-value">{{ lastUpdate }}</span>
             </div>
           </div>
-        </div>
-      </div>
-      
-      <!-- 多个配线架容器 -->
-      <div class="racks-container">
-        <a-row :gutter="[24, 24]">
-          <a-col v-for="rack in racks" :key="rack.id" :xs="24" :sm="24" :md="12" :lg="8">
-            <div class="rack-card">
-              <div class="rack-card-header">
-                <h3>配线架 {{ rack.id }}</h3>
-                <a-tag :color="rackHealthColor(rack)">
-                  {{ rackHealthStatus(rack) }}
-                </a-tag>
-              </div>
-              
-              <!-- 配线架可视化组件 - 行排列网口 -->
-              <div class="rack-visualization">
-                <div class="rack-body-row">
-                  <div class="rack-header">{{ rack.id }}</div>
-                  <div class="ports-row">
-                    <div 
-                      v-for="port in rack.ports" 
-                      :key="port.id"
-                      class="port"
-                      :class="`port-status-${port.status}`"
-                      @click="handlePortClick(rack.id, port.id)"
-                      :title="`端口 ${port.id}: ${getPortStatusText(port)}`"
-                    >
-                      <!-- 网口图标 -->
-                      <div class="port-icon">
-                        <div class="port-pins"></div>
-                      </div>
-                      <span class="port-number">{{ port.id }}</span>
-                    </div>
-                  </div>
-                </div>
+        </a-card>
+      </a-col>
+
+      <!-- 终端列表 -->
+      <a-col :xs="24" :lg="8">
+        <a-card title="已发现终端">
+          <a-empty v-if="terminals.length === 0" description="暂无终端" />
+          <a-list v-else size="small" :data-source="terminals">
+            <template #renderItem="{ item }">
+              <a-list-item>
+                <a-list-item-meta>
+                  <template #title>
+                    <span>地址 {{ item.address }}</span>
+                  </template>
+                  <template #description>
+                    <code class="uid-text">{{ item.uid_readable || item.uid }}</code>
+                  </template>
+                </a-list-item-meta>
+              </a-list-item>
+            </template>
+          </a-list>
+        </a-card>
+      </a-col>
+
+      <!-- 24 LED 面板 -->
+      <a-col :xs="24" :lg="16">
+        <a-card title="24 路 LED 状态">
+          <div v-if="!serialConnected" class="disconnected-overlay">
+            <a-alert message="串口未连接,无法获取 LED 状态" type="warning" show-icon />
+          </div>
+          <div class="led-grid">
+            <div
+              v-for="led in 24"
+              :key="led"
+              class="led-cell"
+              :title="`LED ${led}: ${ledStatusText[ledStates[led] || 0]}`"
+            >
+              <div class="led-indicator" :style="{ backgroundColor: ledColors[ledStates[led] || 0] }">
+                <span class="led-number">{{ led }}</span>
               </div>
             </div>
-          </a-col>
-        </a-row>
-      </div>
-    </a-card>
-    
-    <!-- 连接详情模态框 -->
-    <a-modal
-      v-model:open="modalVisible"
-      :title="`配线架 ${selectedRackId} - 端口 ${selectedPortId} 详情`"
-      @ok="handleModalOk"
-      @cancel="handleModalCancel"
-    >
-      <a-descriptions column="1" bordered>
-        <a-descriptions-item label="配线架号">{{ selectedRackId }}</a-descriptions-item>
-        <a-descriptions-item label="端口号">{{ selectedPortId }}</a-descriptions-item>
-        <a-descriptions-item label="连接状态">
-          <a-tag :color="getPortStatusColor(selectedPort)">
-            {{ getPortStatusText(selectedPort) }}
-          </a-tag>
-        </a-descriptions-item>
-        <a-descriptions-item label="连接设备">{{ selectedPort?.device || '无' }}</a-descriptions-item>
-        <a-descriptions-item label="IP地址">{{ selectedPort?.ip || '无' }}</a-descriptions-item>
-        <a-descriptions-item label="连接时间">{{ selectedPort?.connectTime || '无' }}</a-descriptions-item>
-        <a-descriptions-item label="数据速率">{{ selectedPort?.dataRate || '0' }} Mbps</a-descriptions-item>
-      </a-descriptions>
-    </a-modal>
+          </div>
+          <!-- 图例 -->
+          <div class="led-legend">
+            <div class="legend-item"><span class="dot" :style="{ background: ledColors[0] }"></span> 关闭</div>
+            <div class="legend-item"><span class="dot" :style="{ background: ledColors[1] }"></span> 红色闪烁</div>
+            <div class="legend-item"><span class="dot" :style="{ background: ledColors[2] }"></span> 绿色闪烁</div>
+            <div class="legend-item"><span class="dot" :style="{ background: ledColors[3] }"></span> 蓝色闪烁</div>
+          </div>
+        </a-card>
+      </a-col>
+    </a-row>
   </div>
 </template>
 
 <script setup>
 import { ref, onMounted, onUnmounted, computed } from 'vue'
-import { message } from 'ant-design-vue'
-
-const racks = ref([])
-const updateTime = ref('')
-const modalVisible = ref(false)
-const selectedRackId = ref(0)
-const selectedPortId = ref(0)
-const selectedPort = ref(null)
-const updateInterval = ref(null)
-
-// 端口状态定义
-const PORT_STATUS = {
-  ERROR: 'error',      // 红色 - 连接错误
-  WAITING: 'waiting',  // 黄色 - 等待连接
-  NORMAL: 'normal',    // 绿色 - 连接正常
-  IDLE: 'idle'         // 灰色 - 等待连接
-}
-
-// 计算总端口数
-const totalPorts = computed(() => {
-  return racks.value.reduce((total, rack) => total + rack.ports.length, 0)
-})
-
-// 计算系统健康状态
-const systemHealthColor = computed(() => {
-  const hasError = racks.value.some(rack => 
-    rack.ports.some(port => port.status === PORT_STATUS.ERROR)
-  )
-  const hasWaiting = racks.value.some(rack => 
-    rack.ports.some(port => port.status === PORT_STATUS.WAITING)
-  )
-  
-  if (hasError) return 'red'
-  if (hasWaiting) return 'orange'
-  return 'green'
-})
-
-// 计算系统健康状态文本
-const systemHealthStatus = computed(() => {
-  const hasError = racks.value.some(rack => 
-    rack.ports.some(port => port.status === PORT_STATUS.ERROR)
-  )
-  const hasWaiting = racks.value.some(rack => 
-    rack.ports.some(port => port.status === PORT_STATUS.WAITING)
-  )
-  const allNormal = racks.value.every(rack => 
-    rack.ports.every(port => port.status === PORT_STATUS.NORMAL)
-  )
-  
-  if (hasError) return '有错误'
-  if (hasWaiting) return '部分等待'
-  if (allNormal) return '全部正常'
-  return '部分空闲'
-})
-
-// 获取配线架健康状态颜色
-const rackHealthColor = (rack) => {
-  const hasError = rack.ports.some(port => port.status === PORT_STATUS.ERROR)
-  const hasWaiting = rack.ports.some(port => port.status === PORT_STATUS.WAITING)
-  const allNormal = rack.ports.every(port => port.status === PORT_STATUS.NORMAL)
-  
-  if (hasError) return 'red'
-  if (hasWaiting) return 'orange'
-  if (allNormal) return 'green'
-  return 'default'
-}
-
-// 获取配线架健康状态文本
-const rackHealthStatus = (rack) => {
-  const errorCount = rack.ports.filter(port => port.status === PORT_STATUS.ERROR).length
-  const waitingCount = rack.ports.filter(port => port.status === PORT_STATUS.WAITING).length
-  const normalCount = rack.ports.filter(port => port.status === PORT_STATUS.NORMAL).length
-  
-  if (errorCount > 0) return `${errorCount}个错误`
-  if (waitingCount > 0) return `${waitingCount}个等待`
-  return `${normalCount}个正常`
-}
-
-// 获取端口状态颜色
-const getPortStatusColor = (port) => {
-  if (!port) return 'default'
-  switch (port.status) {
-    case PORT_STATUS.ERROR:
-      return 'red'
-    case PORT_STATUS.WAITING:
-      return 'orange'
-    case PORT_STATUS.NORMAL:
-      return 'green'
-    case PORT_STATUS.IDLE:
-      return 'default'
-    default:
-      return 'default'
-  }
-}
-
-// 获取端口状态文本
-const getPortStatusText = (port) => {
-  if (!port) return '未知'
-  switch (port.status) {
-    case PORT_STATUS.ERROR:
-      return '连接错误'
-    case PORT_STATUS.WAITING:
-      return '等待连接'
-    case PORT_STATUS.NORMAL:
-      return '连接正常'
-    case PORT_STATUS.IDLE:
-      return '空闲'
-    default:
-      return '未知'
+import apiService from '../api/apiService'
+import { useDataStore } from '../stores/dataStore'
+
+const dataStore = useDataStore()
+
+const serialConnected = ref(false)
+const terminals = ref([])
+const discovering = ref(false)
+const lastUpdate = ref('')
+const ledStates = ref({})
+const ledColors = {
+  0: '#d9d9d9',
+  1: '#ff4d4f',
+  2: '#52c41a',
+  3: '#1890ff'
+}
+const ledStatusText = {
+  0: '关闭',
+  1: '红色闪烁',
+  2: '绿色闪烁',
+  3: '蓝色闪烁'
+}
+
+let pollTimer = null
+
+const terminalCount = computed(() => terminals.value.length)
+
+async function discoverTerminals() {
+  discovering.value = true
+  try {
+    const res = await apiService.modbus.broadcastQuery({ timeout: 3 })
+    if (res.data.success && res.data.responses) {
+      terminals.value = res.data.responses.map((r, i) => ({
+        uid: r.uid,
+        uid_readable: r.uid_readable,
+        address: i + 1
+      }))
+    }
+  } catch (e) {
+    console.error('discover error:', e)
+  } finally {
+    discovering.value = false
+    lastUpdate.value = new Date().toLocaleTimeString()
   }
 }
 
-// 初始化多个配线架数据
-const initRacks = () => {
-  // 模拟3个配线架
-  const initialRacks = []
-  for (let rackId = 1; rackId <= 3; rackId++) {
-    const rackPorts = []
-    for (let portId = 1; portId <= 24; portId++) {
-      // 随机生成端口状态
-      const statusOptions = [PORT_STATUS.ERROR, PORT_STATUS.WAITING, PORT_STATUS.NORMAL, PORT_STATUS.IDLE]
-      const statusWeights = [0.1, 0.2, 0.5, 0.2] // 权重分布
-      const status = weightedRandom(statusOptions, statusWeights)
-      
-      rackPorts.push({
-        id: portId,
-        status: status,
-        device: status === PORT_STATUS.NORMAL ? `设备-${rackId}-${Math.floor(Math.random() * 1000)}` : '',
-        ip: status === PORT_STATUS.NORMAL ? `192.168.${rackId}.${Math.floor(Math.random() * 254) + 1}` : '',
-        connectTime: status === PORT_STATUS.NORMAL ? new Date(Date.now() - Math.random() * 86400000).toLocaleString() : '',
-        dataRate: status === PORT_STATUS.NORMAL ? Math.floor(Math.random() * 1000) : 0
-      })
+async function loadStoredDevices() {
+  try {
+    const res = await apiService.modbus.getStoredDevices()
+    if (res.data.success && res.data.devices) {
+      const entries = Object.entries(res.data.devices)
+      terminals.value = entries.map(([uid, addr]) => ({
+        uid,
+        uid_readable: uid.match(/.{1,2}/g)?.join(':') || uid,
+        address: addr
+      }))
     }
-    
-    initialRacks.push({
-      id: rackId,
-      ports: rackPorts
-    })
+  } catch (e) {
+    console.error('load stored devices error:', e)
   }
-  
-  racks.value = initialRacks
-  updateTime.value = new Date().toLocaleString()
 }
 
-// 加权随机选择函数
-const weightedRandom = (options, weights) => {
-  const totalWeight = weights.reduce((sum, weight) => sum + weight, 0)
-  let random = Math.random() * totalWeight
-  
-  for (let i = 0; i < weights.length; i++) {
-    random -= weights[i]
-    if (random <= 0) {
-      return options[i]
+async function pollLedStatus() {
+  try {
+    const res = await apiService.modbus.getLedStatus()
+    if (res.data.success && res.data.leds) {
+      ledStates.value = { ...res.data.leds }
     }
+  } catch (e) {
+    // ignore polling errors
   }
-  
-  return options[options.length - 1]
-}
-
-// 处理端口点击事件
-const handlePortClick = (rackId, portId) => {
-  selectedRackId.value = rackId
-  selectedPortId.value = portId
-  const rack = racks.value.find(r => r.id === rackId)
-  selectedPort.value = rack?.ports.find(p => p.id === portId)
-  modalVisible.value = true
-}
-
-// 处理模态框确定按钮
-const handleModalOk = () => {
-  modalVisible.value = false
-}
-
-// 处理模态框取消按钮
-const handleModalCancel = () => {
-  modalVisible.value = false
-}
-
-// 模拟数据更新
-const updateData = () => {
-  racks.value.forEach(rack => {
-    rack.ports.forEach(port => {
-      // 随机更新部分端口状态
-      if (Math.random() > 0.85) {
-        const statusOptions = [PORT_STATUS.ERROR, PORT_STATUS.WAITING, PORT_STATUS.NORMAL, PORT_STATUS.IDLE]
-        const statusWeights = [0.1, 0.2, 0.5, 0.2]
-        port.status = weightedRandom(statusOptions, statusWeights)
-        
-        if (port.status === PORT_STATUS.NORMAL) {
-          port.device = `设备-${rack.id}-${Math.floor(Math.random() * 1000)}`
-          port.ip = `192.168.${rack.id}.${Math.floor(Math.random() * 254) + 1}`
-          port.connectTime = new Date().toLocaleString()
-          port.dataRate = Math.floor(Math.random() * 1000)
-        } else if (port.status !== PORT_STATUS.WAITING) {
-          port.device = ''
-          port.ip = ''
-          port.connectTime = ''
-          port.dataRate = 0
-        }
-      }
-      // 随机更新数据速率(仅对正常状态的端口)
-      if (port.status === PORT_STATUS.NORMAL && Math.random() > 0.5) {
-        port.dataRate = Math.floor(Math.random() * 1000)
-      }
-    })
-  })
-  
-  updateTime.value = new Date().toLocaleString()
 }
 
-// 模拟从WebSocket接收数据更新
-const simulateWebSocketUpdates = () => {
-  updateInterval.value = setInterval(() => {
-    updateData()
-  }, 10000) // 每10秒更新一次
+async function checkSerialStatus() {
+  try {
+    const res = await apiService.serial.getStatus()
+    serialConnected.value = res.data.connected === true
+  } catch (e) {
+    serialConnected.value = false
+  }
 }
 
-onMounted(() => {
-  initRacks()
-  simulateWebSocketUpdates()
-  message.success('实时状态页面已加载')
+onMounted(async () => {
+  await checkSerialStatus()
+  await loadStoredDevices()
+  await pollLedStatus()
+  pollTimer = setInterval(pollLedStatus, 3000)
 })
 
 onUnmounted(() => {
-  if (updateInterval.value) {
-    clearInterval(updateInterval.value)
-  }
+  if (pollTimer) clearInterval(pollTimer)
 })
 </script>
 
 <style scoped>
-.real-time-status {
-  width: 100%;
-}
-
-.rack-system-info {
-  margin-bottom: 24px;
-}
-
-.system-summary {
+.overview-bar {
   display: flex;
   align-items: center;
-  gap: 16px;
+  gap: 24px;
   flex-wrap: wrap;
 }
-
-.update-time {
-  font-size: 14px;
-  color: #666;
-}
-
-/* 状态颜色说明图例 */
-.status-legend {
-  margin-left: auto;
+.overview-item {
   display: flex;
-  gap: 16px;
   align-items: center;
+  gap: 8px;
 }
-
-.legend-item {
-  display: flex;
-  align-items: center;
-  gap: 6px;
+.overview-label {
   font-size: 14px;
+  color: #666;
 }
-
-.legend-color {
-  width: 16px;
-  height: 16px;
-  border-radius: 4px;
-  border: 1px solid #ddd;
-}
-
-.legend-color.error {
-  background-color: #ff4d4f;
-}
-
-.legend-color.waiting {
-  background-color: #faad14;
-}
-
-.legend-color.normal {
-  background-color: #52c41a;
-}
-
-.legend-color.idle {
-  background-color: #d9d9d9;
-}
-
-.racks-container {
-  margin-top: 16px;
-}
-
-.rack-card {
-  border: 1px solid #f0f0f0;
-  border-radius: 8px;
-  padding: 16px;
-  background-color: #ffffff;
-  transition: all 0.3s ease;
+.overview-value {
+  font-size: 14px;
+  color: #333;
 }
-
-.rack-card:hover {
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+.uid-text {
+  font-size: 12px;
+  word-break: break-all;
 }
-
-.rack-card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
+.disconnected-overlay {
   margin-bottom: 16px;
-  padding-bottom: 12px;
-  border-bottom: 1px solid #f0f0f0;
-}
-
-.rack-card-header h3 {
-  margin: 0;
-  font-size: 18px;
-  font-weight: 600;
-  color: #333;
 }
-
-.rack-visualization {
-  width: 100%;
+.led-grid {
+  display: grid;
+  grid-template-columns: repeat(6, 1fr);
+  gap: 12px;
 }
-
-/* 行排列网口样式 */
-.rack-body-row {
-  background-color: #f5f5f5;
-  border: 1px solid #e8e8e8;
-  border-radius: 8px;
-  padding: 16px;
-  margin-bottom: 16px;
+@media (max-width: 768px) {
+  .led-grid {
+    grid-template-columns: repeat(4, 1fr);
+    gap: 8px;
+  }
 }
-
-.rack-header {
-  font-size: 16px;
-  font-weight: 600;
-  color: #333;
-  margin-bottom: 12px;
-  padding-bottom: 8px;
-  border-bottom: 2px solid #e8e8e8;
-  background-color: #fff;
-  padding: 8px 12px;
-  border-radius: 4px;
-  display: inline-block;
+@media (max-width: 480px) {
+  .led-grid {
+    grid-template-columns: repeat(2, 1fr);
+    gap: 6px;
+  }
 }
-
-.ports-row {
+.led-cell {
   display: flex;
-  flex-wrap: wrap;
-  gap: 12px;
+  justify-content: center;
 }
-
-.port {
-  position: relative;
-  background-color: #fff;
-  border: 2px solid #d9d9d9;
-  border-radius: 6px;
-  width: 70px;
-  height: 60px;
+.led-indicator {
+  width: 64px;
+  height: 64px;
+  border-radius: 12px;
   display: flex;
-  flex-direction: column;
   align-items: center;
   justify-content: center;
-  cursor: pointer;
-  transition: all 0.2s ease;
-  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  border: 2px solid rgba(0, 0, 0, 0.1);
+  transition: background-color 0.3s ease, box-shadow 0.3s ease;
+  cursor: default;
 }
-
-.port:hover {
+.led-indicator:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
   transform: translateY(-2px);
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
 }
-
-/* 网口图标样式 */
-.port-icon {
-  width: 30px;
-  height: 20px;
-  background-color: #f0f0f0;
-  border: 1px solid #d9d9d9;
-  border-radius: 4px;
-  position: relative;
-  margin-bottom: 4px;
-  overflow: hidden;
+.led-number {
+  font-size: 18px;
+  font-weight: 700;
+  color: #fff;
+  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
 }
-
-.port-pins {
-  width: 100%;
-  height: 100%;
+.led-legend {
   display: flex;
-  justify-content: space-around;
-  padding: 3px;
-}
-
-.port-pins::before {
-  content: '';
-  display: block;
-  width: 100%;
-  height: 2px;
-  background-color: #d9d9d9;
-  position: absolute;
-  top: 50%;
-  left: 0;
-  transform: translateY(-50%);
-}
-
-.port-number {
-  font-size: 12px;
-  font-weight: bold;
-  color: #333;
-}
-
-
-/* 端口状态样式 */
-.port-status-error {
-  border-color: #ff4d4f;
-}
-
-.port-status-error .port-icon {
-  background-color: rgba(255, 77, 79, 0.2);
-  border-color: #ff4d4f;
-}
-
-.port-status-error .port-icon::after {
-  content: '';
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  width: 80%;
-  height: 4px;
-  background-color: #ff4d4f;
-  transform: translate(-50%, -50%) rotate(45deg);
-}
-
-.port-status-waiting {
-  border-color: #faad14;
+  gap: 20px;
+  margin-top: 16px;
+  flex-wrap: wrap;
 }
-
-.port-status-waiting .port-icon {
-  background-color: rgba(250, 173, 20, 0.2);
-  border-color: #faad14;
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+  color: #666;
 }
-
-.port-status-waiting .port-icon::after {
-  content: '';
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  width: 60%;
-  height: 60%;
-  border: 2px solid #faad14;
+.dot {
+  display: inline-block;
+  width: 14px;
+  height: 14px;
   border-radius: 50%;
-  transform: translate(-50%, -50%);
-  animation: pulse 2s infinite;
-}
-
-@keyframes pulse {
-  0% {
-    transform: translate(-50%, -50%) scale(0.8);
-    opacity: 1;
-  }
-  100% {
-    transform: translate(-50%, -50%) scale(1.2);
-    opacity: 0;
-  }
-}
-
-.port-status-normal {
-  border-color: #52c41a;
-}
-
-.port-status-normal .port-icon {
-  background-color: rgba(82, 196, 26, 0.2);
-  border-color: #52c41a;
-}
-
-.port-status-normal .port-pins::before {
-  background-color: #52c41a;
-}
-
-.port-status-normal .port-icon::after {
-  content: '';
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  width: 6px;
-  height: 10px;
-  border-right: 2px solid #52c41a;
-  border-bottom: 2px solid #52c41a;
-  transform: translate(-50%, -60%) rotate(45deg);
-}
-
-.port-status-idle {
-  border-color: #d9d9d9;
-  opacity: 0.8;
-}
-
-/* 响应式设计 */
-@media (max-width: 768px) {
-  .system-summary {
-    flex-direction: column;
-    align-items: flex-start;
-    gap: 12px;
-  }
-  
-  .status-legend {
-    margin-left: 0;
-    width: 100%;
-    flex-wrap: wrap;
-    gap: 8px;
-  }
-  
-  .legend-item {
-    font-size: 12px;
-  }
-  
-  .port {
-    width: 60px;
-    height: 50px;
-  }
-  
-  .port-icon {
-    width: 24px;
-    height: 16px;
-  }
-  
-  .port-number {
-    font-size: 10px;
-  }
-  
-  .ports-row {
-    gap: 8px;
-  }
+  border: 1px solid rgba(0, 0, 0, 0.1);
 }
-</style>
+</style>