SCADA数据接入实战:如何处理工业协议数据并呈现给前端
一、从一个真实场景说起
去年接手储能电站监控平台时,我面临一个棘手问题:后端丢过来一堆 Modbus 寄存器地址列表,每个地址对应一个 16 位整数,让我把这些数据变成前端能用的 JSON,再画成实时曲线。我当时的第一反应是------Modbus 是什么?寄存器地址 40001 又是什么?为什么一个简单的"电压值"需要两个寄存器拼成一个 float32?
如果你也在做工业物联网、储能、电力等行业的 Web 应用,大概率也会遇到同样的困惑。这篇文章从实战角度,讲清楚工业协议数据如何一步步变成前端图表上的点和线。
二、先搞懂三种工业协议
工业现场不像 Web 开发那样统一用 HTTP + JSON。设备层跑的是各种工业协议,各有各的用武之地。
Modbus:最老的,但到处都是
Modbus 诞生于 1979 年,至今仍是工业设备的标配。它的数据模型很简单------线圈(Coil)、离散输入、保持寄存器(Holding Register)、输入寄存器。你看到的 40001 就是保持寄存器的起始地址。
javascript
// Modbus TCP 读取保持寄存器的简化版解析
// 假设后端已通过 Modbus TCP 读到原始 Buffer
function parseModbusHoldingRegisters(buffer, startAddress, count) {
const registers = [];
for (let i = 0; i < count; i++) {
// 每个寄存器 2 字节,大端序
registers.push({
address: startAddress + i,
value: buffer.readUInt16BE(i * 2),
});
}
return registers;
}
// 两个寄存器拼一个 float32(IEEE 754)
function registersToFloat32(regHigh, regLow) {
const buffer = Buffer.alloc(4);
buffer.writeUInt16BE(regHigh, 0);
buffer.writeUInt16BE(regLow, 2);
return buffer.readFloatBE(0);
}
// 示例:地址 40001-40002 存储 A 相电压,两个寄存器拼 float32
const raw = Buffer.from([0x43, 0xD2, 0x00, 0x00]); // 420.0V
const voltage = registersToFloat32(
raw.readUInt16BE(0), // 17362
raw.readUInt16BE(2) // 0
);
console.log(voltage); // 420.0
Modbus 的问题也很明显:没有时间戳、没有数据类型描述、没有单位。所有语义信息都需要手动映射,这是前端接入时最容易踩的坑。
OPC UA:工业界的 GraphQL
OPC UA 解决了 Modbus 最痛的点------它自带语义。每个数据点不仅有值,还有名称、类型、单位、时间戳、质量戳(Good/Bad/Uncertain)。
javascript
// OPC UA 客户端读取节点的简化示例
// 使用 node-opcua 库
const { OPCUAClient, AttributeIds } = require('node-opcua');
async function readOPCUANode(endpointUrl, nodeId) {
const client = OPCUAClient.create({ endpointMustExist: false });
await client.connect(endpointUrl);
const session = await client.createSession();
const dataValue = await session.read({
nodeId,
attributeId: AttributeIds.Value,
});
// OPC UA 自带元数据,不需要手动映射
console.log({
value: dataValue.value.value, // 实际值
dataType: dataValue.value.dataType, // 数据类型
sourceTimestamp: dataValue.sourceTimestamp, // 数据源时间戳
statusCode: dataValue.statusCode.name, // Good / Bad / Uncertain
});
await session.close();
await client.disconnect();
}
对于前端团队来说,OPC UA 是更友好的选择------它提供的结构化数据可以直接映射到 TypeScript 类型,不需要像 Modbus 那样维护一张巨大的"寄存器地址→含义"映射表。
MQTT:轻量级实时推送
MQTT 不是工业协议,但在工业物联网场景中几乎无处不在。它的发布-订阅模型非常适合把设备数据从边缘网关推到云端,再由 WebSocket 桥接到前端。
scss
架构示意:
设备(Modbus) → 边缘网关(协议转换) → MQTT Broker → 后端服务 → WebSocket → 前端
↓
ECharts/图表
三种协议的关系可以这样理解:Modbus 负责从设备把数据读出来,OPC UA 负责给数据加上语义标签,MQTT 负责把数据实时推出去。
三、数据管道的设计:从原始字节到前端 JSON
搞清楚协议之后,核心问题变成:如何设计一条可靠的数据管道,把工业数据稳定地喂给前端?
三层管道架构
在储能电站监控平台中,我们设计了这样一个数据流:
css
[采集层] [转换层] [分发层]
Modbus TCP ─┐
OPC UA ──┤──→ 数据归一化引擎 ──→ Redis/InfluxDB ──→ WebSocket ──→ 前端
IEC 104 ─┘ │
├── 寄存器映射 → 物理量
├── 单位转换
├── 质量校验
└── 死区过滤
关键设计是中间的转换层。它负责把所有协议的数据统一成一种中间格式:
typescript
// 归一化后的数据点定义
interface NormalizedDataPoint {
deviceId: string; // 设备唯一标识
pointName: string; // 数据点名称,如 "A相电压"
value: number; // 物理量数值
unit: string; // 单位,如 "V", "kW", "℃"
quality: 'good' | 'bad' | 'uncertain';
timestamp: number; // Unix 毫秒时间戳
protocolSource: string; // 来源协议,便于追溯
}
// 转换层伪代码
function normalize(rawData: RawModbusData, mapping: RegisterMapping[]): NormalizedDataPoint[] {
return mapping.map(m => {
let value: number;
switch (m.dataType) {
case 'int16':
value = rawData.registers[m.address];
break;
case 'float32':
value = registersToFloat32(
rawData.registers[m.address],
rawData.registers[m.address + 1]
);
break;
case 'int32':
value = (rawData.registers[m.address] << 16) | rawData.registers[m.address + 1];
break;
}
return {
deviceId: rawData.deviceId,
pointName: m.name,
value: value * (m.scale ?? 1),
unit: m.unit,
quality: 'good',
timestamp: Date.now(),
protocolSource: 'modbus',
};
});
}
这套管道设计让我们在一个平台里同时接入 PCS(储能变流器)、BMS(电池管理系统)、电表、温控设备等几十种设备,前端只需要关心 NormalizedDataPoint 一种数据结构。
四、前端如何高效消费实时数据
数据到了前端之后,新的挑战来了:一个储能电站可能有 500+ 数据点,每个点每秒更新一次,前端如何不吃内存、不卡顿?
WebSocket + 分层缓存
typescript
// 前端数据管理层
class SCADADataManager {
// 分层缓存:按设备 → 数据点组织
private cache = new Map<string, Map<string, NormalizedDataPoint>>();
// 数据时效窗口(秒),超过则标记为 stale
private ttlMs = 5000;
// 从 WebSocket 接收归一化数据
update(dataPoint: NormalizedDataPoint): void {
if (!this.cache.has(dataPoint.deviceId)) {
this.cache.set(dataPoint.deviceId, new Map());
}
this.cache.get(dataPoint.deviceId)!.set(dataPoint.pointName, dataPoint);
}
// 获取某个设备的所有实时数据
getDeviceSnapshot(deviceId: string): NormalizedDataPoint[] {
const points = this.cache.get(deviceId);
if (!points) return [];
const now = Date.now();
return Array.from(points.values()).map(p => ({
...p,
quality: (now - p.timestamp > this.ttlMs) ? 'bad' : p.quality,
}));
}
// 获取历史数据做趋势图(从 InfluxDB 查询)
async queryHistory(
deviceId: string,
pointName: string,
range: { start: number; end: number }
): Promise<{ time: number; value: number }[]> {
// 通过 HTTP API 查询时序数据库
const res = await fetch(`/api/history?device=${deviceId}&point=${pointName}&start=${range.start}&end=${range.end}`);
return res.json();
}
}
与 ECharts 集成
拿到实时数据后,渲染曲线就很简单了:
typescript
// 实时曲线组件
class RealtimeChart {
private chart: echarts.ECharts;
private dataQueue: [number, number][] = []; // [time, value]
private maxPoints = 300; // 最多显示 300 个点(5 分钟)
init(dom: HTMLElement, title: string, unit: string): void {
this.chart = echarts.init(dom);
this.chart.setOption({
title: { text: title },
xAxis: { type: 'time' },
yAxis: { name: unit },
series: [{
type: 'line',
smooth: true,
showSymbol: false,
data: [],
markLine: {
silent: true,
data: [
{ yAxis: 800, label: { formatter: '上限' }, lineStyle: { color: 'red' } },
],
},
}],
});
}
pushData(time: number, value: number): void {
this.dataQueue.push([time, value]);
if (this.dataQueue.length > this.maxPoints) {
this.dataQueue.shift();
}
this.chart.setOption({ series: [{ data: this.dataQueue }] });
}
}
性能优化两招
-
节流更新:500 个数据点每秒更新一次意味着每秒 500 次 setOption 调用,ECharts 扛不住。我们按设备分组,每组 100ms 内合并成一次批量更新。
-
虚拟化表格 :设备列表用
vue-virtual-scroller或react-window,只渲染可视区域内的设备行,减少 DOM 节点。
五、总结
工业数据接入前端这件事,核心就三点:
-
协议是门槛,但不是高墙。Modbus 的核心就是读寄存器 + 拼 float32,OPC UA 自带语义更友好,MQTT 负责推送。花半天看懂协议文档,后面全是工程问题。
-
中间归一化层是灵魂 。不要让前端直接面对原始协议数据。设计一个统一的数据模型(
NormalizedDataPoint),把所有协议差异挡在转换层,前端只消费一种格式。 -
实时数据的更新策略决定体验上限。分层缓存 + 批量更新 + 虚拟化渲染,三个手段组合使用,500 点/秒的数据量完全在可控范围内。
-
工业数据的价值不在"显示",在"判断"。前端不只是画曲线,更要基于数据做告警联动(上限红线、变化率异常)、趋势预测(用历史数据拟合)、远程控制(指令下行)。这一步才是从"可视化"到"监控系统"的质变。
对于前端工程师来说,理解工业协议不是让你去写 PLC 程序,而是让你在和后端/嵌入式团队沟通时,能听懂他们在说什么,能判断数据到前端的链路哪里断了,能把需求翻译成前端团队能实现的方案。这是做工业 Web 应用的基本功,也是你区别于"纯前端"的竞争力。