搞完OPC搞ModBus,最近是和自控系统杠上了,自己的业务系统要和一堆现场设备对接,各种协议都有,上次刚写了一篇关于融合SignalR的OPCClient,这次就换成ModBus了。解决思路基本类似,具体实现稍有不同,详见下文。
严谨的讲,OPC和ModBus完全不是一个层次的东西,并不存在可比性。OPC是纯粹软件层面的协议,而ModBus是硬件之间的通讯协议,使用范围不同。ModBus是常见的工业控制通信协议之一,其他的还有CANBus、ProfiBus等,而OPC协议正是为了解决多种协议并存时软件驱动混乱的问题而诞生的。ModBus更接近底层硬件,OPC更接近上层软件。
鉴于实际项目需要,我们开发的业务系统需要从现场设备读取数据,而设备由不同的施工单位负责,有的设备厂商在安装调试后提供了OPCServer,直接用之前开发的OPCClient连接就好了;有的设备厂商不提供OPCServer,就要自己想办法了。(不提供OPCServer的工控系统集成商不是好的施工单位)
解决方案
总体思路沿用之前开发OPCClient的方案,使用融合SignalR的服务端做数据转发。对于不提供OPCServer又需要读取数据的设备,有两种方案可以解决这个问题:
方案一:安装带ModBus驱动的OPCServer,连接设备,然后再用OPCClient连接OPCServer就行了。
方案二:开发基于ModBus-TCP协议的客户端,直接连接设备读取数据。
方案一和方案二的区别如下方左图和右图所示。
本次采用的是方案二,方案一后面会单独写文说明。方案二是基于ModBus协议直接从设备控制器中读取写入数据,而ModBus协议同时提供了TCP、RTU和ASCII三种编码格式,本次选用的是TCP编码格式。
ModBus-TCP编码格式
协议就是标准、规范,ModBus的TCP实现就是通过Socket发送接收字节流,按照ModBus的TCP编码格式发送消息就能得到特定格式的反馈。这种格式是Requset/Response形式的,即发送一个请求(Request)得到一个反馈(Response)。ModBus规定的两者格式如下:
序号 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
请求 | - | - | - | - | - | - | 设备地址 | 功能码 | 起始地址 | 起始地址 | 读取长度 | 读取长度 |
反馈 | - | - | - | - | - | - | 设备地址 | 功能码 | 数据长度 | 数据 | 数据 | … |
请求和反馈的第0-5位相对固定,第5位存放从第6位开始之后所有数据的长度值;第6位均为设备地址;第7位为功能码,标识要执行的操作或返回的数据类型,常见功能码有数字量输入DI、数字量输出DO、模拟量输入AI、模拟量输出AO等。对于请求,第7位的功能码直接决定了8、9位开始地址的取值范围。同时由于请求中10、11位的读取长度,决定了单次请求可以获取多个测点的数据,这点在反馈的8位也得到了体现。反馈的8位存放从9位开始的所有数据的长度值。
有了以上对ModBus-TCP编码格式的理解,下一步编写代码实现功能的思路就更清晰了。
服务端实现
为了保证数据的通用性,使用的仍是上篇文章中的服务端。
ModBus客户端实现
数据库
数据库仍旧使用的是SQLite,用来存储测点信息。根据ModBus协议的格式,发送一次请求获得的一次反馈中可以包含多个测点的数据,所以数据库表结构与之前OPCClient的表结构略有不同。
连接/断开Socket
全局变量
private string _controllerGuid; //控制器ID
private string _controllerIp; //控制器地址
private readonly int _controllerPort = 502; //控制器端口号
private string _deviceGuid; //设备ID
private string _deviceAddress; //设备地址
private static readonly int _updateRate = int.Parse(ConfigurationManager.AppSettings["UpdateRate"]); //刷新频率
private bool isConnect; //是否连接ModBus
private DataTable _dtItems = new DataTable();
private DataTable _dtDevices=new DataTable();
private DispatherTimer _time;
连接、断开ModBus
#region 连接ModBus
private void btnStart_Click(object sender, RoutedEventArgs e)
{
byte[] data = new byte[1024];
IPEndPoint endPoint = new IPEndPoint(IPAddress.Parse(_controllerIp), _controllerPort);
_socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
_socket.Connect(endPoint);
isConnect = true;
WriteToStatus("控制器" + _controllerIp + "已连接!");
btnStart.IsEnabled = false;
btnStop.IsEnabled = true;
}
catch (Exception ex)
{
WriteToStatus(ex.Message);
return;
}
ThreadStart threadStart = ReceiveMessage;
ThreadMessage = new Thread(threadStart);
ThreadMessage.Start();
_timer.Start();
}
#endregion
#region 断开ModBus
private void btnStop_Click(object sender, RoutedEventArgs e)
{
ThreadMessage.Abort();
_timer.Stop();
_socket.Close();
isConnect = false;
WriteToStatus("控制器" + _controllerIp + "已断开!");
}
#endregion
发送/接收数据
#region 发送数据
private void Send(byte[] dataBytes)
{
_socket.Send(dataBytes);
}
#endregion
#region 接收数据
private void ReceiveMessage()
{
while (true)
{
try
{
byte[] data = new byte[512];
_socket.Receive(data);
Dispatcher.InvokeAsync(() =>
{
RebuildData(data);
});
}
catch (Exception e)
{
WriteToStatus(e.Message);
}
}
}
反馈数据解析
由于反馈的数据中包含了多个测点数据,所以要将这些数据分别解析出来。对于DI、DO、AI、AO的反馈数据格式是不同的,需要区分对待,放上DI、DO的解析方法。
private void RebuildData(byte[] dataBytes)
{
byte deviceAddress = dataBytes[6];
byte functionCode = dataBytes[7];
int dataLength = Convert.ToInt32(dataBytes[8]);
byte[] datas=new byte[dataLength];
Buffer.BlockCopy(dataBytes,9,datas,0,dataLength);
switch (functionCode)
{
case 0x01:
ConvertByteToDIO(ByteToStringHex(deviceAddress), ByteToStringHex(functionCode), dataLength,datas);
break;
case 0x02:
ConvertByteToDIO(ByteToStringHex(deviceAddress), ByteToStringHex(functionCode), dataLength, datas);
break;
case 0x03:
break;
case 0x04:
break;
}
WriteToStatus(BitConverter.ToString(datas));
}
internal void ConvertByteToDIO(string deviceAddress,string functionCode,int dataLength,byte[] data)
{
string tmp = string.Empty;
for (int i=0;i<dataLength;i++)
{
string s = ("00000000" + Convert.ToString(data[dataLength - i - 1], 2));
tmp += s.Substring(s.Length - 8, 8);
}
int length = tmp.Length;
string[] arry=new string[length];
for (int i = 0; i < length; i++)
{
arry[i] = tmp.Substring(length - i - 1, 1);
}
DataRow[] drs = _dtItems.Select("DeviceAddress='"+ deviceAddress + "' and FunctionCode='"+ functionCode + "'");
foreach (DataRow dr in drs)
{
int index = int.Parse(dr["ItemIndex"].ToString());
OPCValueModel model = new OPCValueModel()
{
ServerId = dr["ControllerGuid"].ToString(),
ServerName = dr["ControllerIP"].ToString(),
GroupName = dr["DeviceAddress"].ToString(),
ItemHandle = dr["ItemAddress"].ToString(),
ItemId = dr["ItemGuid"].ToString(),
ItemName = dr["ItemName"].ToString(),
ItemRemark = dr["ItemRemark"].ToString(),
AlarmType = dr["AlarmType"].ToString(),
AlarmLevel = dr["AlarmLevel"].ToString(),
MaxValue = dr["MaxValue"].ToString(),
MinValue = dr["MinValue"].ToString(),
ItemValue = arry[index]
};
if (isStart)
{
Sender("RefreshOPCData", model);
}
}
}
internal string ByteToStringHex(byte hex)
{
string tmp = string.Empty;
switch (hex)
{
case 0x01:
tmp = "01";
break;
case 0x02:
tmp = "02";
break;
case 0x03:
tmp = "03";
break;
case 0x04:
tmp = "04";
break;
case 0x05:
tmp = "05";
break;
}
return tmp;
}
定时发送请求获取数据
因为只有发送请求才能获取反馈,为了保证数据的实时性,需要创建一个定时器不断发送请求获取数据。
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
_timer = new DispatcherTimer();
_timer.Interval = new TimeSpan(0, 0, 0, _updateRate);
_timer.Tick += TimeCycle;
}
//从数据库获取所有设备地址、功能码、起始地址和读取长度,并拼接成字节数组。
private void GetAllDevices()
{
OperationFun operation = new OperationFun();
_dtDevices = operation.GetDevice(_controllerGuid);
}
private void TimeCycle(object sender, EventArgs e)
{
foreach (DataRow dr in _dtDevices.Rows)
{
byte[] dataBytes = new byte[] { 0x00, 0x01, 0x00, 0x00, 0x00, 0x06,Convert.ToByte(dr["DeviceAddress"]),Convert.ToByte(dr["FunctionCode"]),0x00, Convert.ToByte(dr["StartAddress"]) ,0x00, Convert.ToByte(dr["DataLength"]) };
Send(dataBytes);
}
}
实时数据传输
实时数据传输依然是利用的SignalR,将解析出来的数据转换成特定格式发送给服务端并由服务端转发至PC客户端、Web客户端。代码和上篇文章一模一样。
客户端
PC客户端和Web客户端的代码也都没变,代码还是看上一篇了。
写完这个ModBusClient之后,整个系统的结构就变成下面这样了:
OPCClient和ModBusClient相互独立,不会影响彼此运行,至此预期目的已达成。
条条大路通罗马,罗马虽已到,但选的这条路不一定是最快最便捷的,下次再试试其他的路。