Răsfoiți Sursa

feat: add multi-device LED status support and real-time LED display

1. 重构后端LED状态存储,改为按设备地址组织的结构
2. 改造getLedStatus API,支持按设备地址查询LED状态
3. 为LedDebug页面添加多设备LED状态管理,支持按选中设备展示LED状态
4. 在RealTimeStatus页面新增24路LED实时状态展示面板
5. 优化前后端LED状态更新和显示逻辑,修复状态异常问题
wenhongquan 1 zi în urmă
părinte
comite
52410aebb9

+ 48 - 9
backend/app.py

@@ -277,6 +277,10 @@ def refresh_screen_panel(panel_id):
         # 获取本机 IP 地址显示在 t1.txt
         ip_address = _get_display_ip()
 
+        # 当前 panel 对应的设备地址
+        device_address = panel_config.get(panel_id, {}).get('address', 1)
+        panel_led_states = led_states.get(device_address, {})
+
         screen_display.send_cmd(f't2.txt="{NEXTION_PANEL_NAME_PREFIX}{idx + 1}"', delay_ms=delay_ms)
         screen_display.send_cmd(f't1.txt="{ip_address}"', delay_ms=delay_ms)
         screen_display.send_cmd(f't4.txt="{dtu_config.get("firmware_version", "v1.0.0")}"', delay_ms=delay_ms)
@@ -287,9 +291,11 @@ def refresh_screen_panel(panel_id):
 
         for port_id in range(1, 25):
             # 屏幕网口图片显示 LED 颜色状态
-            color = led_states.get(port_id, 0)
+            color = panel_led_states.get(port_id, 0)
             pic = LED_COLOR_TO_PIC.get(color, 0)
-            screen_display.send_cmd(f'p{port_id - 1}.pic={pic}', delay_ms=delay_ms)
+            cmd = f'p{port_id - 1}.pic={pic}'
+            ok = screen_display.send_cmd(cmd, delay_ms=delay_ms)
+            logger.info(f"[Nextion] panel={panel_id} addr={device_address} port={port_id} led_color={color} pic={pic} cmd={cmd} ok={ok}")
 
         logger.info(f"屏幕已刷新: {panel_id} (终端{idx + 1})")
         return True, '屏幕已刷新'
@@ -1203,7 +1209,9 @@ def dtu_handle_control(topic, payload):
                 response_payload['payload']['error_code'] = 1005
                 response_payload['payload']['error_message'] = result['error']
             else:
-                led_states[int(port_id)] = color
+                if device_address not in led_states:
+                    led_states[device_address] = {}
+                led_states[device_address][int(port_id)] = color
                 _refresh_current_screen_panel()
 
         elif command == 'QUERY_DTU_STATUS':
@@ -2458,6 +2466,25 @@ def refresh_screen():
     return jsonify({'success': False, 'message': message}), 400
 
 
+@app.route('/api/screen/test_cmd', methods=['POST'])
+def screen_test_cmd():
+    """向 Nextion 屏幕发送原始命令(调试用)。"""
+    try:
+        data = request.json
+        cmd = data.get('cmd')
+        if not cmd:
+            return jsonify({'success': False, 'message': '缺少 cmd 参数'}), 400
+        status = screen_display.get_status()
+        if not status.get('connected'):
+            return jsonify({'success': False, 'message': '屏幕串口未连接'}), 400
+        ok = screen_display.send_cmd(cmd, delay_ms=data.get('delay_ms', 50))
+        logger.info(f"[Nextion TEST] cmd={cmd} ok={ok}")
+        return jsonify({'success': ok, 'cmd': cmd, 'screen_connected': True})
+    except Exception as e:
+        logger.error(f"屏幕测试命令失败: {e}")
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
 # 网络配置相关API
 @app.route('/api/network/config', methods=['GET'])
 def get_network_config():
@@ -2720,8 +2747,10 @@ def modbus_set_rgb_led():
                 'raw_data': result.get('raw_data', '')
             }), 400
 
-        # 更新 LED 状态追踪
-        led_states[led_number] = color
+        # 按设备地址更新 LED 状态追踪
+        if device_address not in led_states:
+            led_states[device_address] = {}
+        led_states[device_address][led_number] = color
         _refresh_current_screen_panel()
 
         return jsonify({
@@ -3018,8 +3047,9 @@ def get_panel_status():
     return jsonify({'success': True, 'panels': panel_status})
 
 
-# LED 状态追踪 (内存中跟踪每个灯的最后设置状态)
-led_states = {i: 0 for i in range(1, 25)}  # 0=off, 1=red, 2=green, 3=blue
+# LED 状态追踪 (按设备地址跟踪每个灯的最后设置状态)
+# 结构: {device_address: {port_id: color}}, color: 0=off, 1=red, 2=green, 3=blue
+led_states = {}
 
 # Nextion 串口屏网口图片与 LED 颜色的映射关系
 # 屏幕图片定义: 0=灰(灭), 1=蓝, 2=红, 3=绿
@@ -3029,7 +3059,14 @@ LED_COLOR_TO_PIC = {0: 0, 1: 2, 2: 3, 3: 1}
 
 @app.route('/api/modbus/led_status', methods=['GET'])
 def get_led_status():
-    """获取所有 LED 状态"""
+    """获取 LED 状态,支持按设备地址过滤"""
+    device_address = request.args.get('device_address', type=int)
+    if device_address is not None:
+        return jsonify({
+            'success': True,
+            'device_address': device_address,
+            'leds': led_states.get(device_address, {})
+        })
     return jsonify({'success': True, 'leds': led_states})
 
 
@@ -3049,6 +3086,8 @@ def set_all_leds():
             return jsonify({'success': False, 'message': '串口未连接'}), 400
 
         results = []
+        if device_address not in led_states:
+            led_states[device_address] = {}
         for led_num in range(1, 25):
             result = modbus_client.set_rgb_led(
                 device_address=device_address,
@@ -3060,7 +3099,7 @@ def set_all_leds():
                 results.append({'led': led_num, 'success': False, 'error': result['error']})
             else:
                 results.append({'led': led_num, 'success': True})
-                led_states[led_num] = color
+                led_states[device_address][led_num] = color
 
         _refresh_current_screen_panel()
         return jsonify({'success': True, 'results': results})

+ 4 - 1
frontend/src/api/apiService.js

@@ -70,7 +70,10 @@ const apiService = {
     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'),
+    getLedStatus: (deviceAddress) => {
+      const params = deviceAddress !== undefined ? { device_address: deviceAddress } : {}
+      return apiClient.get('/modbus/led_status', { params })
+    },
     scan: (params) => apiClient.post('/modbus/scan', params),
     broadcastQuery: (params) => apiClient.post('/modbus/broadcast_query', params),
     autoConfigure: (params) => apiClient.post('/modbus/auto_configure', params),

+ 33 - 8
frontend/src/views/LedDebug.vue

@@ -122,8 +122,8 @@
             >
               <div
                 class="led-dot"
-                :style="{ background: ledColors[ledStates[n] ?? 0] }"
-                :title="`LED ${n}: ${statusText[ledStates[n] ?? 0]}`"
+                :style="{ background: ledColors[currentLedStates[n] ?? 0] }"
+                :title="`LED ${n}: ${statusText[currentLedStates[n] ?? 0]}`"
               >
                 <span class="led-label">{{ n }}</span>
               </div>
@@ -147,7 +147,7 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
 import apiService from '../api/apiService'
 import { useDataStore } from '../stores/dataStore'
 import SerialDataDisplay from '../components/SerialDataDisplay.vue'
@@ -161,6 +161,7 @@ const loading = ref(-1)
 const discovering = ref(false)
 const feedbackMsg = ref('')
 const feedbackType = ref('success')
+// LED 状态按设备地址组织: { deviceAddress: { portId: color } }
 const ledStates = ref({})
 const cardAntenna = ref(1)
 const readingCard = ref(false)
@@ -171,6 +172,19 @@ const ledColors = { 0: '#d9d9d9', 1: '#ff4d4f', 2: '#52c41a', 3: '#1890ff' }
 const statusText = { 0: '关闭', 1: '红色闪烁', 2: '绿色闪烁', 3: '蓝色闪烁' }
 const colorNames = { 0: '关闭', 1: '红灯闪烁', 2: '绿灯闪烁', 3: '蓝灯闪烁' }
 
+const currentLedStates = computed(() => {
+  const addr = selectedDevice.value
+  if (addr === undefined) return {}
+  const raw = ledStates.value[addr] || {}
+  // 统一转换为数字,避免 undefined/字符串导致样式异常
+  const normalized = {}
+  for (let i = 1; i <= 24; i++) {
+    const v = raw[i]
+    normalized[i] = (v === undefined || v === null || v === '') ? 0 : Math.max(0, Math.min(3, Number(v) || 0))
+  }
+  return normalized
+})
+
 const canControl = computed(() => terminals.value.length > 0)
 
 async function setColor(color) {
@@ -188,11 +202,13 @@ async function setColor(color) {
       const target = selectedLed.value === 0 ? '全部灯' : `LED ${selectedLed.value}`
       feedbackMsg.value = `${target} → ${colorNames[color]}`
       feedbackType.value = 'success'
-      // update local state
+      // 按设备地址更新本地状态
+      const addr = selectedDevice.value
+      if (!ledStates.value[addr]) ledStates.value[addr] = {}
       if (selectedLed.value === 0) {
-        for (let i = 1; i <= 24; i++) ledStates.value[i] = color
+        for (let i = 1; i <= 24; i++) ledStates.value[addr][i] = color
       } else {
-        ledStates.value[selectedLed.value] = color
+        ledStates.value[addr][selectedLed.value] = color
       }
       // force reactivity
       ledStates.value = { ...ledStates.value }
@@ -264,11 +280,20 @@ async function readCardNumber() {
 
 async function refreshStatus() {
   try {
-    const ledRes = await apiService.modbus.getLedStatus()
-    if (ledRes.data.success && ledRes.data.leds) ledStates.value = { ...ledRes.data.leds }
+    const addr = selectedDevice.value
+    if (addr === undefined) return
+    const ledRes = await apiService.modbus.getLedStatus(addr)
+    if (ledRes.data.success && ledRes.data.leds) {
+      ledStates.value[addr] = { ...ledRes.data.leds }
+      ledStates.value = { ...ledStates.value }
+    }
   } catch (e) { /* ignore */ }
 }
 
+watch(selectedDevice, (newAddr) => {
+  if (newAddr !== undefined) refreshStatus()
+})
+
 onMounted(() => {
   loadTerminals()
   refreshStatus()

+ 101 - 2
frontend/src/views/RealTimeStatus.vue

@@ -82,6 +82,28 @@
               <div class="legend-item"><span class="dot" :style="{ background: portStatusColors.ILLEGAL }"></span> 非法</div>
               <div class="legend-item"><span class="dot" :style="{ background: portStatusColors.UNKNOWN }"></span> 未知</div>
             </div>
+
+            <!-- 24 路 LED 状态 -->
+            <a-divider style="margin: 16px 0" />
+            <h4 class="led-section-title">24 路 LED 状态</h4>
+            <div class="led-grid">
+              <div
+                v-for="port in 24"
+                :key="`led-${port}`"
+                class="led-cell"
+                :title="`端口 ${port} LED: ${ledStatusText[ledStates[port] ?? 0]}`"
+              >
+                <div class="led-dot" :style="{ backgroundColor: ledColors[ledStates[port] ?? 0] }">
+                  <span class="led-number">{{ port }}</span>
+                </div>
+              </div>
+            </div>
+            <div class="led-legend">
+              <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 class="legend-item"><span class="dot" :style="{ background: ledColors[0] }"></span> 关闭</div>
+            </div>
           </div>
         </a-card>
       </a-col>
@@ -97,7 +119,7 @@
 </template>
 
 <script setup>
-import { ref, onMounted, onUnmounted, computed } from 'vue'
+import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
 import apiService from '../api/apiService'
 import { useDataStore } from '../stores/dataStore'
 import SerialDataDisplay from '../components/SerialDataDisplay.vue'
@@ -123,6 +145,11 @@ const portStatusText = {
   UNKNOWN: '未知'
 }
 
+// LED 状态显示
+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)
@@ -135,7 +162,7 @@ const portStates = computed(() => {
   if (!panel || !panel.ports) return {}
   const states = {}
   for (let i = 1; i <= 24; i++) {
-    states[i] = panel.ports[i] || 'UNKNOWN'
+    states[i] = panel.ports[i]?.status || 'UNKNOWN'
   }
   return states
 })
@@ -184,6 +211,25 @@ function selectTerminal(item) {
   selectedTerminal.value = item
 }
 
+async function refreshLedStatus() {
+  try {
+    const address = selectedTerminal.value?.address
+    if (address === undefined) return
+    const res = await apiService.modbus.getLedStatus(address)
+    if (res.data.success && res.data.leds) {
+      const raw = res.data.leds
+      const normalized = {}
+      for (let i = 1; i <= 24; i++) {
+        const v = raw[i]
+        normalized[i] = (v === undefined || v === null || v === '') ? 0 : Math.max(0, Math.min(3, Number(v) || 0))
+      }
+      ledStates.value = normalized
+    }
+  } catch (e) {
+    // ignore polling errors
+  }
+}
+
 async function pollPanelStatus() {
   try {
     const res = await apiService.panel.getStatus()
@@ -195,14 +241,20 @@ async function pollPanelStatus() {
   }
 }
 
+watch(selectedTerminal, (newTerminal) => {
+  if (newTerminal) refreshLedStatus()
+})
+
 onMounted(async () => {
   if (dataStore.serialConnected) {
     await loadStoredDevices()
   }
   await pollPanelStatus()
+  await refreshLedStatus()
   pollTimer = setInterval(async () => {
     await loadStoredDevices()
     await pollPanelStatus()
+    await refreshLedStatus()
   }, 3000)
   // 默认选中第一个在线终端
   if (terminals.value.length > 0 && !selectedTerminal.value) {
@@ -315,4 +367,51 @@ onUnmounted(() => {
   border-radius: 50%;
   border: 1px solid rgba(0, 0, 0, 0.1);
 }
+.led-section-title {
+  margin: 0 0 12px 0;
+  font-size: 16px;
+  font-weight: 500;
+  color: #333;
+}
+.led-grid {
+  display: grid;
+  grid-template-columns: repeat(6, 1fr);
+  gap: 12px;
+}
+@media (max-width: 768px) {
+  .led-grid {
+    grid-template-columns: repeat(4, 1fr);
+    gap: 8px;
+  }
+}
+@media (max-width: 480px) {
+  .led-grid {
+    grid-template-columns: repeat(2, 1fr);
+    gap: 6px;
+  }
+}
+.led-cell {
+  display: flex;
+  justify-content: center;
+}
+.led-dot {
+  width: 48px;
+  height: 48px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 2px solid rgba(0, 0, 0, 0.1);
+  transition: background-color 0.3s ease, box-shadow 0.3s ease;
+}
+.led-dot:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+  transform: translateY(-2px);
+}
+.led-number {
+  font-size: 14px;
+  font-weight: 700;
+  color: #fff;
+  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
+}
 </style>