本文总结自两年前我负责的一个小项目,完全自主架构,同时也是所开发的唯一一个非环保行业数据采集软件,我觉得很有必要记录下。
工作中经手的数采系统也不算少了,之前接触到的都是环境监测行业的数据采集,直到两年前接收了一个远程采集智能电表电能数据的项目。
由于行业不同,外加采集方式有所区别,此前数采系统的架构均不适合,需要重新设计一个结构。
当时我开发的这套电表数采,在这个项目中充当数据源的角色,仅需要实现智能电表的数据采集、统计和保存,展示和应用分析不在要求之内。也就是说,我只需要保证数据正常入库即可。
之前说过,智能电表的数据采集方式跟我之前接触过的环保行业有所区别。
环保行业(环境空气
和地表水
)现场都具备工控机,由工控机跟分析仪通过串口或网口直接通讯,采集完数据后,按照HJ212-2017
协议组包上传给中心服务器,大致的流程如下图:
对于智能电表而言,现场不配备工控机,直接通过DTU实现透传。每个电表有一个唯一地址,即中心服务器可直接通过DTU与其下挂载的电表直接通讯,大致如下图所示。
由于DTU数量是不确定的,后期可能会加装,联网状态也是不确定的,程序启动时无法预知有效客户端的数量。因此,设计时需要考虑动态挂载。
首先,DTU需要配置服务器的IP和端口号,并在上线后向服务器发送一个注册包,约定好注册包的内容为此DTU的ID。这个ID是人为定义的,且必须唯一,其作用是在服务器上能识别出是哪个DTU上线。
此外,单个DTU下可挂载多个电表,DTU和电表对的对应关系需要提前在数据库中配置好,一旦DTU上线后成功识别出注册包,根据配置创建出此DTU下所有挂载的电表,由服务器根据电表地址,主动轮询取数。
解析出数据后,每隔5分钟或者10分钟保存一组瞬时数据,直接入库。
DL/T645协议是针对电表通信而制定的通信协议,主要有两个版本,分别是DL/T645-97和DL/T645-07,目前最新版本是2007版,而项目中电表虽然有多个幸好,但采用的都是07版本。因此,仅需要兼容这一个版本的协议即可。
有点类似ModBus协议,格式如下:
据此设计如下的出解析算法。
public bool TryParse(byte[] data)
{
// 校验包头和包尾
if (data[0] != 0x68 && data[data.Length - 1] != 0x16)
{
return false;
}
// 校验和
byte[] dest = new byte[data.Length - 2];
Buffer.BlockCopy(data, 0, dest, 0, data.Length - 2);
if (CheckSum(dest) != data[data.Length - 2])
{
return false;
}
// 校验地址
bool isSameAddress = true;
for (int i = 0; i < m_Address.Length; i++)
{
if (m_Address[i] != dest[i + 1])
{
isSameAddress = false;
break;
}
}
if (!isSameAddress)
{
return false;
}
// 校验数据长度
int dataLen = dest[9];
int realLen = dest.Length - 10;
if (dataLen != realLen)
{
return false;
}
// 截取数据段
byte[] realData = new byte[dataLen];
Buffer.BlockCopy(dest, 10, realData, 0, dataLen);
// 数据包-0x33
List dataFlagMinus33 = new List();
for (int i = 0; i < realData.Length; i++)
{
realData[i] -= 0x33;
if (i < 4)
{
dataFlagMinus33.Add(realData[i]);
}
}
// DataFlag倒置回来
dataFlagMinus33.Reverse();
for (int i = 0; i < 4; i++)
{
realData[i] = dataFlagMinus33[i];
}
return Parse(realData);
}
///
/// 和校验
///
protected static short CheckSum(IEnumerable data)
{
int result = 0;
foreach (byte b in data)
{
result += b;
}
return (byte)(result % 256);
}
按照之前的开发习惯,将电表抽象为仪器Device,需要采集的数据抽象为因子Factor,DTU抽象为总线Bus。
public class Factor
{
///
/// 电表地址
///
public string TerminalID { get; private set; }
///
/// 电表内挂载因子的索引
///
public int IndexInDevice { get; private set; }
///
/// 名称
///
public string Name { get; private set; }
///
/// 单位
///
public string Unit { get; private set; }
///
/// 统计系数,
///
private float RealValuePara { get; set; }
///
/// 原始数据
///
public Data RawData { get; private set; }
///
/// 构造
///
public Factor(string terminalID, int indexInDevice, string name, string unit, float realValuePara)
{
TerminalID = terminalID;
IndexInDevice = indexInDevice;
Name = name;
Unit = unit;
RealValuePara = realValuePara;
RawData = new Data();
}
public void SetData(float rawValue)
{
RawData.DataTime = DateTime.Now;
RawData.RawValue = rawValue;
RawData.Value = rawValue * RealValuePara;
}
}
为了方便解析,定义可解析645协议的父类Base645Driver
。
public class Base645Driver
{
///
/// 电流互感器
///
public float CT { get; private set; }
///
/// 电压互感器
///
public float PT { get; private set; }
///
/// 已配置的数据
///
public List Factors
{
get { return this.m_Factors; }
}
///
/// 所有支持的数据
///
public virtual List AllSupportedChannels
{
get { return new List(); }
}
///
/// 构造
///
public Base645Driver(string address, float ct, float pt)
{
...
}
///
/// 初始化
///
public void Init()
{
...
}
///
/// 生成取数命令
///
public bool MakeCmd(out byte[] cmd)
{
...
}
///
/// 尝试解析收到的数据包
///
public bool TryParse(byte[] data)
{
...
}
}
以DTSD483智能电表为例,只需要继承此类,定义内部通道即可。
///
/// DTSD483电表驱动
///
public class DTSD483Driver : Base645Driver
{
public DTSD483Driver(string address, float ct, float pt)
: base(address, ct, pt)
{
}
public override List AllSupportedChannels
{
get
{
List channels = new List();
// tpe 组合有功总电能 kWh XXXXXX.XX 二次侧值,真实值=tpe*PT*CT
channels.Add(new ChannelConfig("组合有功总电能", "kWh", 0, new byte[] { 0x00, 0x00, 0x00, 0x00 }, 2, PT * CT));
// tqe 组合无功1总电能 kVarh XXXXXX.XX 二次侧值,真实值=tqe*PT*CT
channels.Add(new ChannelConfig("组合无功1总电能", "kVarh", 1, new byte[] { 0x00, 0x03, 0x00, 0x00 }, 2, PT * CT));
// tqe 组合无功2总电能 kVarh XXXXXX.XX 二次侧值,真实值=tqe*PT*CT
channels.Add(new ChannelConfig("组合无功2总电能", "kVarh", 2, new byte[] { 0x00, 0x04, 0x00, 0x00 }, 2, PT * CT));
// Ia A相电流值,单位A,二次侧值,真实值=Ia*CT
channels.Add(new ChannelConfig("A相电流", "A", 3, new byte[] { 0x02, 0x02, 0x01, 0x00 }, 3, CT));
// Ua A相电压值,单位V,二次侧值,真实值=Ua*PT
channels.Add(new ChannelConfig("A相电压", "V", 4, new byte[] { 0x02, 0x01, 0x01, 0x00 }, 1, PT));
// Pa A相有功功率值,单位kW,二次侧值,真实值=Pa*PT*CT
channels.Add(new ChannelConfig("A相有功功率", "kW", 5, new byte[] { 0x02, 0x03, 0x01, 0x00 }, 4, PT * CT));
// PFa A相功率因数
channels.Add(new ChannelConfig("A相功率因数", "", 6, new byte[] { 0x02, 0x06, 0x01, 0x00 }, 3, 1));
// Qa A相无功功率值,单位kVar,二次侧值,真实值=Qa*PT*CT
channels.Add(new ChannelConfig("A相无功功率", "kVar", 7, new byte[] { 0x02, 0x04, 0x01, 0x00 }, 4, PT * CT));
...
return channels;
}
}
protected override bool Parse(byte[] data)
{
int factorIndex = -1;
#region 电能
// 组合有功总电能 kWh XXXXXX.XX
if (data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x00 && data[3] == 0x00)
{
factorIndex = m_Factors[0].IndexInDevice;
}
// 组合无功1总电能
else if (data[0] == 0x00 && data[1] == 0x03 && data[2] == 0x00 && data[3] == 0x00)
{
factorIndex = m_Factors[1].IndexInDevice;
}
// 组合无功1总电能
else if (data[0] == 0x00 && data[1] == 0x04 && data[2] == 0x00 && data[3] == 0x00)
{
factorIndex = m_Factors[2].IndexInDevice;
}
#endregion
#region A相
// Ia A相电流值,单位A,二次侧值,真实值=Ia*CT
else if (data[0] == 0x02 && data[1] == 0x02 && data[2] == 0x01 && data[3] == 0x00)
{
factorIndex = m_Factors[3].IndexInDevice;
}
// A相电压值
else if (data[0] == 0x02 && data[1] == 0x01 && data[2] == 0x01 && data[3] == 0x00)
{
factorIndex = m_Factors[4].IndexInDevice;
}
// A相有功功率值
else if (data[0] == 0x02 && data[1] == 0x03 && data[2] == 0x01 && data[3] == 0x00)
{
factorIndex = m_Factors[5].IndexInDevice;
}
// A相功率因数
else if (data[0] == 0x02 && data[1] == 0x06 && data[2] == 0x01 && data[3] == 0x00)
{
factorIndex = m_Factors[6].IndexInDevice;
}
// A相无功功率值
else if (data[0] == 0x02 && data[1] == 0x04 && data[2] == 0x01 && data[3] == 0x00)
{
factorIndex = m_Factors[7].IndexInDevice;
}
#endregion
...
else
{
return base.Parse(data);
}
if (factorIndex < 0)
{
return false;
}
float value = Data.DEFAULT_VALUE;
if (!DoParseValue(data, m_AllSupportedChannels[factorIndex].PointLen, out value))
{
return false;
}
m_Factors[factorIndex].SetData(value);
return true;
}
}
最后就是总线Bus的设计,总线需要实现挂载/移除仪器、定时轮询取数、电表通讯状态通知等功能。
大致结构如下。
public delegate void CommMessageArrivedHandler(string terminalID, bool isSend, bool? parseResult, byte[] data);
public delegate List LoadDeviceHandler(string regpack);
public delegate void BusClosingHandler(string busGuid);
public class Bus : IDisposable
{
public event CommMessageArrivedHandler CommMessageArrived;
public event LoadDeviceHandler LoadDevice;
public event BusClosingHandler Closing;
public string BusGuid { get; set; }
public DateTime LastCommTime { get; set; }
public bool Enabled
{
get { return m_Enabled; }
set
{
this.m_SendTimer.Enabled = value;
StartSample(value);
}
}
private bool m_Connected = true;
public bool Connected
{
get
{
if (m_Socket != null)
{
return m_Connected && !((m_Socket.Poll(1000, SelectMode.SelectRead) && (m_Socket.Available == 0)) || !m_Socket.Connected);
}
else
{
return false;
}
}
}
///
/// 获取总线上启用的仪器个数
///
public int EnabledDeviceCount
{
get { return m_Device.Count(a => a.Enabled); }
}
///
/// 获取总线上启用的仪器个数
///
public Bus(Socket socket)
{
BusGuid = Guid.NewGuid().ToString();
m_Socket = socket;
LastCommTime = DateTime.Now;
m_SendTimer.Interval = 2000;
m_SendTimer.Elapsed += SendTimer_Elapsed;
m_SendTimer.Start();
}
public void Close()
{
if (m_Socket != null)
{
m_Socket.Close();
}
OnClosing();
}
public void RemoveDevice(Device device)
{
...
}
public void Dispose()
{
...
}
}
程序启动时开启Socket监听,当有新的客户端连入时,解析注册包,并生成总线,总线内部由定时器定时轮询,发送指令向电表取数。
由于是基于Socket的通讯,存在Socket.IsConnected
为True
,而实际上链路已经断开的情况。在总线中实现了重连机制,通过判断此连接最后一次通讯成功的时间距当前时间超过设定的超时时间,来强制掐断此链接,卸载Bus下挂载的Device和Factor,注销此Bus,等待客户端重连。
与采集类似,设计出统计模块的父类ProcSevice
。
///
/// 数据处理共用父类
///
public abstract class ProcService : BaseMySqlDA
{
///
/// 目标电表
///
protected List m_Devices = new List();
///
/// 保存周期(分钟)
///
public virtual int SaveInterval { get; protected set; } = 5;
///
/// 构造函数,挂载电表
///
///
public ProcService(List device)
: base(ConnectionStringManager.EmsDataDB)
{
m_Devices.AddRange(device);
}
///
/// 子类实现具体的计算逻辑
///
///
public abstract void Process(DateTime now);
}
不同型号的电表,采集的指标不同,有的电表只能出组合有功电能
,还有的电表可以出完整的三相数据。按照客户要求,需要保存到独立的表中。因此,具体的逻辑交给子类实现,主程序只需要根据不同的电表型号出初始化不同的保存模块即可。
本程序的侧重点在数据采集和保存,对界面的要求不高。但为了方便查看实时数据、电表在线状态和配置,我还是做了几个简单的界面。
实时监控界面如下,虽然朴素,但可以很清晰的看到每个电表的数据情况和在线状态。
左侧列出了所有的DTU,以及DTU下挂载的电表,可通过左侧的通讯状态指示灯判断电表的通讯状态。
主界面右上角可以打开/关闭选中电表的实时通讯报文以及解析结果。
主界面右下角可能清楚地看到电表在线状态的统计信息。
此界面可以配置电表信息及相应的DTU注册包。电表台账信息来自客户另一个数据库,因此本程序没有实现新增功能,读取现有的电表记录后,我方程序内配置并保存通讯参数。
双击行可配置通讯解析参数。
程序会记录注册包
、通讯故障/恢复
以及系统启动/退出
三类运行日志,并提供查询。
作为一个数采系统,数据采集和传输的稳定性尤其重要。为保存程序7*24无故障运行,对于一些可预见的异常,必须及时处理。
启动时检测到Socket端口被占用这种致命的问题,不应该直接将异常抛给用户,给出一个提示会友好很多。
Socket服务端通讯程序不允许同时启动多个实例,我们可以使用Mutex
来确保只启动一个进程。
// 单例模式启动系统
bool canCreateNew = true;
int retryCount = 0;
do
{
if (RunMutex == null)
{
RunMutex = new Mutex(true, "EmsGetway", out canCreateNew);
}
else
{
canCreateNew = RunMutex.WaitOne(100, true);
}
retryCount++;
}
while (!canCreateNew && retryCount <= 20);
if (!canCreateNew)
{
MessageUtilEx.ShowInfo(null, "程序已经在运行。");
Application.Exit();
return;
}
在main
方法中加入上面的代码,启动时检测是否已经存在同名的Mutex
,如果尝试20次之后依旧存在,则认为已经有一个实例正在运行,弹出消息框提示用户。
程序在运行过程中,还会有许多不可预见的异常,如某个组件被误删除,数据库服务挂了,等等。可以通过订阅下面的事件来保证异常发生后及时记录日志,方便后期排查故障。
// 未处理异常捕获
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
Application.ThreadException += new ThreadExceptionEventHandler(Application_ThreadException);
两个事件处理程序中都是调用了HandleException
方法来记录日志。
///
/// 未处理异常处理
///
/// 异常对象
private static void HandleException(object exceptionObj)
{
string logMsg = null, titleMsg = null, attachMsg = null;
if (exceptionObj is FileNotFoundException fileNotFoundException)
{
string fileName = fileNotFoundException.FileName;
logMsg = "无法找到以下文件,程序即将退出。\r\n文件名:" + fileName;
titleMsg = "缺失文件,程序即将退出。";
attachMsg = logMsg;
LogUtil.WriteLog(typeof(App).FullName, fileNotFoundException);
}
else if (exceptionObj is Exception exception)
{
logMsg = "发生错误,程序即将退出。\r\n异常信息:" + exception.Message + "\r\n堆栈:" + exception.StackTrace;
titleMsg = "发生错误,程序即将退出。";
attachMsg = "异常信息:" + exception.Message + "\r\n堆栈:" + exception.StackTrace;
}
LogUtil.WriteLog(logMsg);
MessageUtil.ShowError(titleMsg, attachMsg);
}
启动时向注册表写入启动项,保证开机后自动运行。
本程序运行在服务器上,由于端口号的限制,可接入客户端的数量是有上限的。同时,为了防止恶意攻击,有必要限制接入的最大客户端数量,存储为系统配置。
根据项目实际情况,电表数量不超过50台,目前只有13组DTU,因此限制了客户端数量为50。当接入客户端数量达到50个后,新来的连接会被强制关闭。
后期如果加装DTU,可以直接修改上限配置。
本文简要地描述了电表数据采集终端的设计思想,这只是个很简单的数采程序,有关数采核心模块没有过多描述,只贴了部分代码。
作为一个码农,只会用代码表达思想。
程序中还有不合理的地方值得优化。比如网络不通畅,应答延时就会出现串包现象。其实完全可以根据电表地址解析的,但由于总线按照简单的轮询规则收发报文,串包时就会认为解析失败。现在的做法是修改收发间隔来缓解此问题。
项目结束快两年了,运行地挺正常,没必要再花时间优化。