# IoT 设备实时点值显示 + 设备控制实施方案 > **编写时间**: 2026-07-24 > **编写人**: 猫娘工程师 幽浮喵 > **适用版本**: SecMPS 仓库大屏 (warehouse) + 管理端 (web.vite) + IntegrationGateway > **目标**: 解决 MC4 IoT 设备(温度探头/湿度探头/空调控制器)在管理端和大屏两个前端的实时点值显示与控制问题 --- ## 一、问题背景 ### 1.1 当前痛点 1. **管理端**:base_device 列表中点击 IoT 设备的"实时数据"按钮,弹窗表格**无任何数据显示**(之前现场反馈过) 2. **大屏端**:warehouse 地图点击 IoT 设备标签,**实时点值不显示**,空调控制器无法下发制冷/制热/关机命令 3. **数据正确性**:MC4 设备点位序号 1 是自动添加的"在线点",真实数据从序号 2 开始;空调控制器有多个有效点位(5=湿度、6=温度、2=制冷发射、3=制热发射、4=关机发射) ### 1.2 根因汇总 | # | 根因 | 文件 | |---|---|---| | ① | `Mc4Adapter.GetRealtimeValuesAsync` 用 `JsonSerializer.Deserialize>(json)!` 反序列化实时点值响应,**没有传 `JsonOpts`**,MC4 返回的 `{code, msg, data:[...]}` 包装结构无法被 List 直接反序列化 | `IntegrationGateway.Adapters.MC4/Mc4Adapter.cs:154` | | ② | `base_device.vue` 的 `fetchRealtime` 直接 `realtimeValues.value = await r.json()`,**未做字段名映射**(网关返回 PascalCase:PointIndex/Value/UpdateTime/Interval,前端 table 列名是 camelCase:pointIndex/value/updateTime/interval) | `web.vite/src/views/warehouse/device_manager/base_device.vue:164-170` | | ③ | `RealtimeDataPanel.vue` 同样的问题,`values.value = await r.json()` 没做字段映射 | `web.vite/src/views/warehouse/device_manager/base_device/components/RealtimeDataPanel.vue:25` | | ④ | 仓库 `DeviceInfo.vue` 调用的后端接口是老的(之前基于 warehouse_devicepoint 表),**未切换到新的 `/api/base_device/getPageData` 接口** | `warehouse/src/view/DeviceInfo.vue` | | ⑤ | 仓库空调控制按钮的下发参数 `sourceDeviceId` 与后端 `ControlRequest` 期望的 `deviceId` **字段名不一致** | `warehouse/src/view/DeviceInfo.vue:618` | | ⑥ | MC4 设备点位索引语义**仅在主人本地知道**(index=2 是真实数据/探头,空调 index=5=湿度/6=温度/2=制冷/3=制热/4=关机),未在代码/文档中固化,新人接手成本高 | 全局 | --- ## 二、设备分类与点位映射表 ### 2.1 IoT 设备三分类 | 设备分类 | DeviceCategory 字典值 | 真实数据点位 | 控制点位 | |---|---|---|---| | 温度探头 | `温度探头` | `index=2`(温度值) | 无 | | 湿度探头 | `湿度探头` | `index=2`(湿度值) | 无 | | 空调控制器 | `空调控制器` | `index=5`(湿度)、`index=6`(温度) | `index=2`(制冷发射)、`index=3`(制热发射)、`index=4`(关机发射) | > 说明:MC4 平台固定在每个设备的 `index=1` 位置自动添加"在线点"(bool 类型,1=在线),从 `index=2` 开始才是业务点位。 ### 2.2 显示策略 | 设备类型 | 单卡片 | 双卡片 | 控制按钮 | |---|---|---|---| | 温度探头 | 1 个大数字(℃) | — | — | | 湿度探头 | 1 个大数字(%RH) | — | — | | 空调控制器 | — | 温度(℃)+ 湿度(%RH) | 制冷/制热/关机 | --- ## 三、网关 B 组接口(已就绪) ### 3.1 实时点位值查询 - **路径**:`GET /api/gateway/realtime/{adapter}/{deviceId}` - **入参**:`adapter=MC4:33ku`、`deviceId=1928`(MC4 设备 id 字符串) - **出参**:`List`(PascalCase) ```json [ {"sourceDeviceId":"1928","pointIndex":1,"value":1,"updateTime":"2026-07-24T10:00:00","interval":10}, {"sourceDeviceId":"1928","pointIndex":2,"value":25.6,"updateTime":"2026-07-24T10:00:00","interval":10} ] ``` - **实现位置**:`IntegrationGateway.Host/Program.cs:302-307` 路由 + `Mc4Adapter.cs:145-164` 调用 ### 3.2 设备控制 - **路径**:`POST /api/gateway/realtime/{adapter}/control` - **入参**(JSON Body): ```json { "deviceId": "1928", // 设备 sourceId(注意:是 deviceId 不是 sourceDeviceId) "pointIndex": 2, // 控制点索引 "value": 1 // 1=发射 } ``` - **出参**:`200 OK` - **实现位置**:`IntegrationGateway.Host/Program.cs:329-335` 路由 + `Mc4Adapter.cs:167-175` SetPointValueAsync ### 3.3 MC4 适配器 GetRealtimeValuesAsync 修复(V1.10.1) **改动文件**:`IntegrationGateway.Adapters.MC4/Mc4Adapter.cs:154` **修改前**: ```csharp var values = JsonSerializer.Deserialize>(json)!; ``` **修改后**: ```csharp // MC4 响应是 {code, msg, data:[...]} 包装结构,data 段才是真正的点位列表 var values = DeserializeData>(json, "/api/central/device/point/value/get", new List()); ``` `DeserializeData` 在 `Mc4Adapter.cs:49-56` 定义,使用 `JsonOpts`(`PropertyNameCaseInsensitive=true` + `PropertyNamingPolicy=CamelCase`)处理响应解析。 --- ## 四、web.vite 管理端(已修复) ### 4.1 base_device.vue 实时数据弹窗 **文件**:`web.vite/src/views/warehouse/device_manager/base_device.vue` **改动点**(`fetchRealtime` 函数,line 163-179): ```javascript // 网关 B4 返回 List,字段 PascalCase:SourceDeviceId, PointIndex, Value, UpdateTime, Interval // 前端 table 字段是 camelCase,做一层字段映射 const normalizePoint = (v) => ({ pointIndex: v.PointIndex ?? v.pointIndex ?? v.index ?? 0, value: v.Value ?? v.value ?? 0, updateTime: v.UpdateTime ?? v.updateTime ?? v.Time ?? v.time ?? '', interval: v.Interval ?? v.interval ?? 0 }); const fetchRealtime = async () => { if (!curDev.value) return; realtimeLoading.value = true; try { const r = await fetch(`${GW}/api/gateway/realtime/${(curDev.value.AdapterCode || curDev.value.adapterCode)}/${curDev.value.SourceId || curDev.value.sourceId}`); const list = await r.json(); realtimeValues.value = Array.isArray(list) ? list.map(normalizePoint) : []; } catch {} finally { realtimeLoading.value = false } } ``` **核心修复**:增加 `normalizePoint` 字段映射函数 + 数组类型判断(防止后端返回 404/500 时 `await r.json()` 解析失败)。 ### 4.2 RealtimeDataPanel.vue 组件 **文件**:`web.vite/src/views/warehouse/device_manager/base_device/components/RealtimeDataPanel.vue` **改动点**:与 `base_device.vue` 完全一致的 `normalizePoint` 字段映射逻辑(line 19-26、line 35-37)。 ### 4.3 IoT 设备操作列 **文件**:`web.vite/src/views/warehouse/device_manager/base_device.vue`(line 222-237) IoT 设备操作列已有 2 个按钮: - "实时数据":打开 `RealtimeDataPanel` 弹窗 - "控制":打开 `DeviceControlPanel` 弹窗(通用点位控制) --- ## 五、warehouse 大屏端(已修复) ### 5.1 DeviceInfo.vue 接入新接口 **文件**:`warehouse/src/view/DeviceInfo.vue` **改动点**: 1. **script 改用 `/api/base_device/getPageData` 接口**(line 524-582) 2. **优先用 MapModelId 查**(地图点击场景),其次用 DeviceId 3. **根据 DeviceCategory 字段判断设备分类**:温度探头/湿度探头/空调控制器 ### 5.2 实时数据展示(按分类) **文件**:`warehouse/src/view/DeviceInfo.vue` | 设备分类 | 展示模板 | 关键计算属性 | |---|---|---| | 温度探头 | `
` + 大数字 + ℃ | `primaryPointValue` 取 `index=2` | | 湿度探头 | `
` + 大数字 + %RH | `primaryPointValue` 取 `index=2` | | 空调控制器 | `
` + 温度+湿度双卡片 | `acTempValue` 取 `index=6`,`acHumValue` 取 `index=5` | **计算属性定义**(line 478-510): ```typescript const primaryPointValue = computed(() => { const p = realtimePoints.value.find(v => v.index === 2); return p ? p.value.toFixed(1) : '--'; }); const acTempValue = computed(() => { const p = realtimePoints.value.find(v => v.index === 6); return p ? p.value.toFixed(1) : '--'; }); const acHumValue = computed(() => { const p = realtimePoints.value.find(v => v.index === 5); return p ? p.value.toFixed(1) : '--'; }); ``` ### 5.3 空调控制 **文件**:`warehouse/src/view/DeviceInfo.vue`(line 607-631) ```typescript const handleAirControl = async (mode: 'cool' | 'heat' | 'off') => { if (!deviceInfo.value.adapterCode || !deviceInfo.value.sourceId) { ElMessage.error('设备信息不完整,无法控制'); return; } const pointIndex = mode === 'cool' ? 2 : mode === 'heat' ? 3 : 4; controlLoading.value = mode; try { await gwPost(`/api/gateway/realtime/${deviceInfo.value.adapterCode}/control`, { deviceId: deviceInfo.value.sourceId, // ← 关键修复:原来是 sourceDeviceId,与后端 ControlRequest.DeviceId 不匹配 pointIndex, value: 1 // 1 = 发射 }); ElMessage.success(`已下发${mode === 'cool' ? '制冷' : mode === 'heat' ? '制热' : '关机'}命令`); // 3 秒后刷新一次实时值 setTimeout(fetchRealtimePoints, 3000); } catch (err: any) { console.error('控制失败:', err); ElMessage.error(`控制失败: ${err?.message || err}`); } finally { controlLoading.value = ''; } }; ``` **控制按钮 UI**(template line 115-125): ```vue
制冷 制热 关机
通过网关下发到 MC4 设备的 index=2/3/4 控制点
``` ### 5.4 30 秒轮询实时点值 **文件**:`warehouse/src/view/DeviceInfo.vue`(line 647-655) ```typescript onMounted(() => { setInterval(() => { if (isIotDevice.value && deviceInfo.value.sourceId && deviceInfo.value.adapterCode) { fetchRealtimePoints(); } }, 30000); }); ``` 仅 IoT 设备开启轮询(30 秒一次),**避免不必要的网络请求**。 ### 5.5 实时点值解析(兼容多种命名风格) **文件**:`warehouse/src/view/DeviceInfo.vue`(line 584-611) ```typescript const fetchRealtimePoints = async () => { try { const adapter = deviceInfo.value.adapterCode; // 例如 MC4:33ku const devId = deviceInfo.value.sourceId; // MC4 设备 id(字符串) const data: any = await gwGet(`/api/gateway/realtime/${adapter}/${devId}`); // 网关 B4 返回 List,字段:SourceDeviceId, PointIndex, Value, UpdateTime, Interval // 同时兼容历史字段(items 包装 / 全小写 / 驼峰) const list: any[] = Array.isArray(data) ? data : (data?.items || data?.data?.items || data?.data || []); realtimePoints.value = list.map((v: any) => { const updateTime = v.updateTime || v.UpdateTime || v.Time || v.time; return { index: Number(v.pointIndex ?? v.PointIndex ?? v.index ?? v.Index ?? 0), value: Number(v.value ?? v.Value ?? 0), name: v.name || v.Name, unit: v.unit || v.Unit, updateTime: updateTime ? new Date(updateTime).toLocaleString('zh-CN') : '' }; }); console.log(`[DeviceInfo] 实时点值 ${realtimePoints.value.length} 条`, realtimePoints.value); } catch (err: any) { console.warn('获取实时点值失败:', err); realtimePoints.value = []; } }; ``` --- ## 六、调试探针(可选工具) ### 6.1 目的 连真实 MC4 设备(192.168.3.92)抓取设备和点位数据,验证点位索引偏移假设。 ### 6.2 工具位置 `tools/mc4_probe/Mc4Probe/Program.cs`(独立 .NET 控制台程序) ### 6.3 用法 ```bash cd d:\Code\SecMPS\tools\mc4_probe\Mc4Probe dotnet run -- --base http://192.168.3.92:3000 --user admin --pwd admin ``` ### 6.4 输出示例 ``` [MC4] 登录成功, token=eyJhbGciOiJIUzI1NiJ9... [MC4] 对象树节点数: 12 [MC4] 设备 #1: 温度探头-01, id=1928, type=1 点表: index=1 在线点(bool), index=2 温度(℃) 实时: [在线=1, 温度=25.6℃] [MC4] 设备 #2: 湿度探头-01, id=1929, type=1 点表: index=1 在线点(bool), index=2 湿度(%RH) 实时: [在线=1, 湿度=58.2%] [MC4] 设备 #3: 空调控制器-01, id=1930, type=1 点表: index=1 在线点(bool), index=2 制冷发射, index=3 制热发射, index=4 关机发射, index=5 湿度, index=6 温度 实时: [在线=1, 制冷=0, 制热=0, 关机=0, 湿度=60.0, 温度=22.5] ``` --- ## 七、验证清单 ### 7.1 后端网关验证 - [x] `dotnet build gateway` — 0 错误 0 警告 - [x] `GetRealtimeValuesAsync` 改用 `DeserializeData`,处理 `{code, msg, data:[...]}` 包装 - [x] `JsonOpts` 大小写不敏感,能正确反序列化 MC4 全小写字段 ### 7.2 web.vite 管理端验证 - [ ] `npm run build web.vite` — 0 错误 0 警告 - [ ] base_device 列表 → 选 IoT 设备 → 点"实时数据" → 弹窗表格有 4 列数据(点位/当前值/更新时间/采集间隔) - [ ] 点"控制" → 弹窗可输入点位索引 + 目标值 → 点"发送指令" → 设备响应 ### 7.3 warehouse 大屏端验证 - [ ] `npm run build warehouse` — 0 错误 0 警告 - [ ] 地图 → 找温度探头 → 点标签 → 设备详情弹窗 → 实时数据选项卡显示温度大数字 - [ ] 地图 → 找空调控制器 → 点标签 → 设备详情弹窗 → 实时数据显示温度+湿度双卡片 - [ ] 设备控制选项卡 → 点"制冷"按钮 → 3 秒后实时数据刷新(验证控制生效) - [ ] 30 秒后实时数据自动刷新(验证轮询工作) ### 7.4 端到端联调验证 - [ ] 主人用 192.168.3.92 实机测试:温度探头/湿度探头/空调控制器三分类全部正常 - [ ] 空调制冷/制热/关机三个命令都能成功下发并反映到实时数据 --- ## 八、关键文件改动清单 | # | 文件 | 改动 | 验证 | |---|---|---|---| | 1 | `gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs` | `GetRealtimeValuesAsync` 改用 `DeserializeData` | dotnet build ✓ | | 2 | `gateway/src/IntegrationGateway.Adapters.MC4/Mc4AuthHelper.cs` | 新增 `JsonOpts` + `EmptyJsonBody`(V1.10 前序修复) | dotnet build ✓ | | 3 | `web.vite/src/views/warehouse/device_manager/base_device.vue` | `fetchRealtime` 增加 `normalizePoint` 字段映射 | 需 build | | 4 | `web.vite/src/views/warehouse/device_manager/base_device/components/RealtimeDataPanel.vue` | 同样增加 `normalizePoint` 字段映射 | 需 build | | 5 | `warehouse/src/view/DeviceInfo.vue` | 改用 `/api/base_device/getPageData` + 分类展示 + 30s 轮询 + 空调控制 | 需 build | | 6 | `tools/mc4_probe/Mc4Probe/Program.cs` | 新增(调试探针,独立工具) | 独立运行 | --- ## 九、风险与回滚 ### 9.1 风险 | 风险 | 等级 | 缓解措施 | |---|---|---| | MC4 设备实际点位索引与主人预期不一致 | 中 | 部署后用 `mc4_probe` 工具先验证 | | 空调命令下发后实时数据刷新延迟 | 低 | 已设置 3 秒后强制刷新 + 30 秒轮询兜底 | | 设备控制误操作 | 中 | 控制按钮加 `loading` 状态 + 弹 `ElMessage` 提示 | ### 9.2 回滚方案 - 仓库 `DeviceInfo.vue` 改动可通过 `git revert` 回滚到上一版本 - web.vite `base_device.vue` / `RealtimeDataPanel.vue` 改动可通过 `git revert` 回滚 - 网关 `Mc4Adapter.cs` 改动只影响 MC4 设备,对 Owl/KMS 适配器**完全无影响**,**回滚风险极低** --- ## 十、后续优化建议(非本次范围) 1. **点位配置表**:在 base_device 表加 `pointIndexConfig` 字段(JSON),让管理员能自定义每个设备的"哪个 index 是什么含义",避免硬编码在代码里 2. **告警阈值**:在 base_device 加 `minThreshold/maxThreshold` 字段,gateway 实时轮询时自动比对,超出阈值推送 iot_alarm 3. **历史曲线**:仓库大屏的温度/湿度曲线当前已用 SVG 简单实现,可改为 ECharts 实时刷新(30 秒一次) 4. **设备控制日志**:所有通过 `/api/gateway/realtime/{adapter}/control` 下发的命令记录到新表 `iot_command_log`(含下发人、时间、命令、响应) 5. **批量控制**:空调控制器分组管理时,支持"批量制冷"操作(一组设备同时下发 index=2 发射命令) --- ## 附录 A:网关 B4 响应示例(修复后) **请求**: ``` GET http://192.168.3.108:5100/api/gateway/realtime/MC4:33ku/1928 ``` **响应**(HTTP 200,application/json): ```json [ {"sourceDeviceId":"1928","pointIndex":1,"value":1,"updateTime":"2026-07-24T10:00:00","interval":10}, {"sourceDeviceId":"1928","pointIndex":2,"value":25.6,"updateTime":"2026-07-24T10:00:00","interval":10} ] ``` ## 附录 B:网关 B5 请求示例 **请求**: ``` POST http://192.168.3.108:5100/api/gateway/realtime/MC4:33ku/control Content-Type: application/json { "deviceId": "1930", "pointIndex": 2, "value": 1 } ``` **响应**(HTTP 200,application/json): ```json true ``` 或空 body(minimal API `Results.Ok()`)。 --- > **维护说明**: 本文档作为 V1.10 IoT 设备实时点值 + 控制功能的实施记录,与 `说明文档.md` 的"进度记录"段保持同步更新。