Files
SecMPS/doc/整合方案/IoT设备实时点值显示与控制实施方案_v1.0.md
T
g82tt 6c76007249 V1.10+V1.10.1+V1.11: MC4 JSON反序列化大小写+IoT设备实时点值显示+空调控制
【V1.10 MC4 适配器 JSON 反序列化大小写修复】
- Mc4AuthHelper.cs: 新增 JsonOpts(PropertyNameCaseInsensitive+CamelCase) + EmptyJsonBody
- Mc4Adapter.cs: 新增统一 DeserializeData<T> 处理 {code,msg,data} 包装
- 所有 MC4 业务接口改用 DeserializeData<T> 解决 List 不能直接反序列化包装结构
- 解决 POST 无参 body=null 触发 400 的问题

【V1.10.1 GetObjectTreeAsync 修复】
- Mc4Adapter.cs: GetObjectTreeAsync 改用 EmptyJsonBody + DeserializeData<List<Mc4TreeNode>>
- 修复 A3 同步时报 400 和 List 反序列化错误

【V1.11 IoT 设备实时点值 + 空调控制】
- Mc4Adapter.cs: GetRealtimeValuesAsync 改用 DeserializeData(V1.10 漏改)
- web.vite/base_device.vue: fetchRealtime 增加 normalizePoint 字段映射(PascalCase→camelCase)
- web.vite/RealtimeDataPanel.vue: 组件层同样字段映射
- warehouse/DeviceInfo.vue: 改用 /api/base_device/getPageData 新接口
  + 按 DeviceCategory 分类展示(温度探头/湿度探头单卡片+空调控制器双卡片)
  + 30秒轮询实时点值(仅IoT设备)
  + 空调控制按钮 sourceDeviceId→deviceId 字段名修复
- tools/mc4_probe: 新增 MC4 设备探针工具(连接192.168.3.92抓取设备+点表+实时值)
- doc/整合方案/IoT设备实时点值显示与控制实施方案_v1.0.md: 详细方案文档
  + 设备三分类+点位映射表(温度探头/湿度探头index=2;空调index=5=湿度/6=温度/2-3-4=控制)
- 说明文档.md: 进度记录 V1.10/V1.10.1/V1.11 三条更新

【关键修复】
1. MC4 返回 JSON 字段全小写 → C# Model 大小写不匹配 (V1.10)
2. List 不能反序列化 {code,msg,data:[...]} 包装 (V1.10)
3. POST 无参 body=null 触发 400 (V1.10)
4. A3 同步 GetObjectTreeAsync 报 400+反序列化错误 (V1.10.1)
5. GetRealtimeValuesAsync 漏改 (V1.11)
6. 管理端实时数据弹窗 PascalCase 字段→camelCase 列名不匹配 (V1.11)
7. 仓库空调控制 sourceDeviceId 字段名与后端 ControlRequest.DeviceId 不匹配 (V1.11)
2026-07-24 05:08:37 +08:00

399 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<List<Mc4PointValue>>(json)!` 反序列化实时点值响应,**没有传 `JsonOpts`**MC4 返回的 `{code, msg, data:[...]}` 包装结构无法被 List 直接反序列化 | `IntegrationGateway.Adapters.MC4/Mc4Adapter.cs:154` |
| ② | `base_device.vue``fetchRealtime` 直接 `realtimeValues.value = await r.json()`,**未做字段名映射**(网关返回 PascalCasePointIndex/Value/UpdateTime/Interval,前端 table 列名是 camelCasepointIndex/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<PointValue>`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<List<Mc4PointValue>>(json)!;
```
**修改后**
```csharp
// MC4 响应是 {code, msg, data:[...]} 包装结构,data 段才是真正的点位列表
var values = DeserializeData<List<Mc4PointValue>>(json, "/api/central/device/point/value/get", new List<Mc4PointValue>());
```
`DeserializeData<T>` 在 `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<PointValue>,字段 PascalCaseSourceDeviceId, 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`
| 设备分类 | 展示模板 | 关键计算属性 |
|---|---|---|
| 温度探头 | `<div class="single-value-card">` + 大数字 + ℃ | `primaryPointValue` 取 `index=2` |
| 湿度探头 | `<div class="single-value-card">` + 大数字 + %RH | `primaryPointValue` 取 `index=2` |
| 空调控制器 | `<div class="dual-value-card">` + 温度+湿度双卡片 | `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
<el-tab-pane v-if="isAirConditionerController" label="设备控制">
<div class="tab-content">
<div class="control-buttons">
<el-button type="primary" :loading="controlLoading === 'cool'" @click="handleAirControl('cool')">制冷</el-button>
<el-button type="warning" :loading="controlLoading === 'heat'" @click="handleAirControl('heat')">制热</el-button>
<el-button type="danger" :loading="controlLoading === 'off'" @click="handleAirControl('off')">关机</el-button>
</div>
<div class="control-tip">通过网关下发到 MC4 设备的 index=2/3/4 控制点</div>
</div>
</el-tab-pane>
```
### 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<PointValue>,字段: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<T>`,处理 `{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 200application/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 200application/json):
```json
true
```
或空 bodyminimal API `Results.Ok()`)。
---
> **维护说明**: 本文档作为 V1.10 IoT 设备实时点值 + 控制功能的实施记录,与 `说明文档.md` 的"进度记录"段保持同步更新。