【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)
17 KiB
IoT 设备实时点值显示 + 设备控制实施方案
编写时间: 2026-07-24 编写人: 猫娘工程师 幽浮喵 适用版本: SecMPS 仓库大屏 (warehouse) + 管理端 (web.vite) + IntegrationGateway 目标: 解决 MC4 IoT 设备(温度探头/湿度探头/空调控制器)在管理端和大屏两个前端的实时点值显示与控制问题
一、问题背景
1.1 当前痛点
- 管理端:base_device 列表中点击 IoT 设备的"实时数据"按钮,弹窗表格无任何数据显示(之前现场反馈过)
- 大屏端:warehouse 地图点击 IoT 设备标签,实时点值不显示,空调控制器无法下发制冷/制热/关机命令
- 数据正确性: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(),未做字段名映射(网关返回 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<PointValue>(PascalCase)[ {"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):
{ "deviceId": "1928", // 设备 sourceId(注意:是 deviceId 不是 sourceDeviceId) "pointIndex": 2, // 控制点索引 "value": 1 // 1=发射 } - 出参:
200 OK - 实现位置:
IntegrationGateway.Host/Program.cs:329-335路由 +Mc4Adapter.cs:167-175SetPointValueAsync
3.3 MC4 适配器 GetRealtimeValuesAsync 修复(V1.10.1)
改动文件:IntegrationGateway.Adapters.MC4/Mc4Adapter.cs:154
修改前:
var values = JsonSerializer.Deserialize<List<Mc4PointValue>>(json)!;
修改后:
// 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):
// 网关 B4 返回 List<PointValue>,字段 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
改动点:
- script 改用
/api/base_device/getPageData接口(line 524-582) - 优先用 MapModelId 查(地图点击场景),其次用 DeviceId
- 根据 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):
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)
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):
<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)
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)
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 用法
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 后端网关验证
dotnet build gateway— 0 错误 0 警告GetRealtimeValuesAsync改用DeserializeData<T>,处理{code, msg, data:[...]}包装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 适配器完全无影响,回滚风险极低
十、后续优化建议(非本次范围)
- 点位配置表:在 base_device 表加
pointIndexConfig字段(JSON),让管理员能自定义每个设备的"哪个 index 是什么含义",避免硬编码在代码里 - 告警阈值:在 base_device 加
minThreshold/maxThreshold字段,gateway 实时轮询时自动比对,超出阈值推送 iot_alarm - 历史曲线:仓库大屏的温度/湿度曲线当前已用 SVG 简单实现,可改为 ECharts 实时刷新(30 秒一次)
- 设备控制日志:所有通过
/api/gateway/realtime/{adapter}/control下发的命令记录到新表iot_command_log(含下发人、时间、命令、响应) - 批量控制:空调控制器分组管理时,支持"批量制冷"操作(一组设备同时下发 index=2 发射命令)
附录 A:网关 B4 响应示例(修复后)
请求:
GET http://192.168.3.108:5100/api/gateway/realtime/MC4:33ku/1928
响应(HTTP 200,application/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):
true
或空 body(minimal API Results.Ok())。
维护说明: 本文档作为 V1.10 IoT 设备实时点值 + 控制功能的实施记录,与
说明文档.md的"进度记录"段保持同步更新。