使用 C# 实现 CJ-T188 水表协议和 DL-T645 电表协议的解析与编码
一、协议的定义
要对某种协议进行编解码操作,就必须知道协议的基本定义,首先我们来看一下 CJ/T188 的数据帧定义(协议定义),了解请求数据与响应数据的基本结构。
1.1 CJ/T188 水表通讯协议
请求帧:
字节 | 值 | 描述 |
---|---|---|
0 | 0x68 | 数据帧开始标识。 |
1 | T | 表计类型代码,详细信息请参考 表计类型表 。 |
2-8 | A0-A6 | 表计地址,水表设备的具体地址,这里是 BCD 形式。 |
9 | CTR_01 | 协议控制码,例如 0x1 就是读表数据。 |
10 | 0x3 | 数据域长度。 |
11-12 | 0x1F,0x90 | 数据标识 DI0-DI1。 |
13 | 0x00 | 序列号,一般为 0x00,序列号也被作为整个数据域的长度。 |
14 | CS | 表示校验和数据,即 0-13 位置的所有字节的累加和。 |
15 | 0x16 | 数据帧的结束标识。 |
例如有以下请求帧数据(读取水表数据):
68 10 01 00 00 05 08 00 00 01 03 1F 90 00 39 16
对应的解释如下。
顺序 | 0 | 1 | 2-8 | 9 | 10 | 11-12 | 13 | 14 | 15 |
---|---|---|---|---|---|---|---|---|---|
说明 | 帧头 | 类型 | 地址 | CTR_0 | 长度 | 数据标识 | 序列号 | 校验和 | 帧尾 |
实例 | 68 | 10 | 01 00 00 05 08 00 00 | 01 | 03 | 1F 90 | 00 | 39 | 16 |
表计类型表:
值 | 含义 |
---|---|
10 | 冷水水表 |
11 | 生活热水水表 |
12 | 直饮水水表 |
13 | 中水水表 |
20 | 热量表 (记热量) |
21 | 热量表 (记冷量) |
30 | 燃气表 |
40 | 电度表 |
响应帧(读表操作):
字节 | 值 | 描述 |
---|---|---|
0 | 0x68 | 数据帧开始标识。 |
1 | T | 表计类型代码,详细信息请参考 表计类型表 。 |
2-8 | A0-A6 | 表计地址,水表设备的具体地址,这里是 BCD 形式。 |
9 | CTR_1 | 协议控制码,在返回帧含义即是请求帧的控制码加上 0x80。 |
10 | L | 数据域长度。 |
11-12 | 0x1F,0x90 | 数据标识 DI0-DI1。 |
13 | 0x00 | 序列号,一般为 0x00。 |
14-17 | ALL DATA | 累计用量,以 BCD 形式进行存储。 |
18 | 单位 | 计量单位,具体含义可以参考 计量单位表 。 |
19-22 | MONTH DATA | 本月用量,以 BCD 形式进行存储。 |
23 | 单位 | 计量单位,具体含义可以参考 计量单位表 。 |
24-30 | 时间 | 表示实际时间,以 BCD 形式存储,格式为 ss mm HH dd MM yy yy。 |
31 | 状态 1 | 状态字段。 |
32 | 状态 2 | 保留字节,一般置为 0xFF。 |
33 | CS | 表示校验和数据,即 0-32 位置的所有字节的累加和。 |
34 | 0x16 | 数据帧的结束标识。 |
例如有以下响应帧数据:
68 10 44 33 22 11 00 33 78 81 16 1F 90 00 00 77 66 55 2C 00 77 66 55 2C 31 01 22 11 05 15 20 21 84 6D 16
对应的解释如下:
顺序 | 0 | 1 | 2-8 | 9 | 10 | 11-12 | 13 |
---|---|---|---|---|---|---|---|
说明 | 帧头 | 类型 | 地址 | 控制码 | 长度 | 标识 | 序列号 |
实例 | 68 | 10 | 44 33 22 11 00 33 78 | 81 | 16 | 1F 90 | 00 |
顺序 | 14-17 | 18 | 19-22 | 23 | 24-30 |
---|---|---|---|---|---|
说明 | 累计用量 | 单位 | 本月用量 | 单位 | 时间 |
实例 | 00 77 66 55 | 2C | 00 77 66 55 | 2C | 31 01 22 11 05 15 20 |
顺序 | 31 | 32 | 33 | 34 |
---|---|---|---|---|
说明 | 状态 1 | 状态 2 | 校验和 | 帧尾 |
实例 | 00 | FF | 6D | 16 |
计量单位表:
单位 | 值 |
---|---|
Wh | 0x2 |
KWh | 0x5 |
MWh | 0x8 |
MWh * 100 | 0xA |
J | 0x1 |
KJ | 0xB |
MJ | 0xE |
GJ | 0x11 |
GJ * 100 | 0x13 |
W | 0x14 |
KW | 0x17 |
MW | 0x1A |
L | 0x29 |
$$m^3$$ | 0x2C |
$$ L/h $$ | 0x32 |
$$m^3/h$$ | 0x35 |
2.2 DL/T645 多功能电能表通信协议
请求帧:
字节 | 值 | 描述 |
---|---|---|
0 | 0x68 | 数据帧开始标识。 |
1-6 | A0-A5 | 电表设备地址,以 BCD 码形式存储。 |
7 | 0x68 | 帧起始符。 |
8 | C | 控制码。 |
9 | L | 数据域长度。 |
10 | DATA | 数据域。 |
11 | CS | 校验码,从 0-10 字节的累加和。 |
12 | 0x16 | 数据帧结束标识。 |
读取电表的当前正向有功总电量,表号为 12345678。
68 78 56 34 12 00 00 68 11 04 33 33 34 33 C6 16
顺序 | 0 | 1-6 | 7 | 8 | 9 | 10-13 |
---|---|---|---|---|---|---|
说明 | 帧头 | 地址 | 帧头 | 控制码 | 长度 | 数据域 |
实例 | 68 | 78 56 34 12 00 00 | 68 | 11 | 04 |
顺序 | 14 | 15 |
---|---|---|
说明 | 累加和 | 帧尾 |
实例 | C6 | 16 |
这里需要注意的是,33 33 34 33 是 00 01 00 00 加上 0x33 之后的值,因为传输的时候是低位在前,高位在后,所以就是 00 00 01 00 每字节加上 0x33,00 01 00 00 即代表要读取当前正向有功总电能,也有其他的标识,这里不再叙述。
响应帧(读表操作):
68 78 56 34 12 00 00 68 91 08 33 33 34 33 A4 56 79 38 F5 16
顺序 | 0 | 1-6 | 7 | 8 | 9 |
---|---|---|---|---|---|
说明 | 帧头 | 地址 | 帧头 | 控制码,这里即 0x11 + 0x80 | 长度 |
实例 | 68 | 78 56 34 12 00 00 | 68 | 91 | 08 |
顺序 | 10-17 | 18 | 19 |
---|---|---|---|
说明 | 数据域 | 累加和 | 帧尾 |
实例 | 33 33 34 33 A4 56 79 38 | F5 | 16 |
这里只说明一下数据域,在这里 33 33 34 33 可以理解成寄存器地址,而 A4 56 79 38 则是具体的电量数据,在这里就是分别减去 0x33,即 71 23 46 5,因为其精度是两位,且是 BCD 码的形式,最后的结果就是 54623.71 度。
2.3 前导字节
前导字节并非水/电表协议强制规定的协议组,所谓前导字节是在数据帧的头部增加 1-4 组 0xFE,例如以下数据帧就是增加了前导字节。
FE FE FE FE 68 10 44 33 22 11 00 33 78 01 03 1F 90 00 80 16
所以在处理的协议的时候,某些厂家可能会加入前导字节,在处理的时候一定要注意。
2.4 小结
水/电表协议的请求帧与响应帧其实结构一致,区别仅在于不同的响应,其具体的数据域值也不同,所以在处理的时候可以用一个字典/列表来存储数据域。
二、代码的实现
2.1 工具类的编码
为了方便我们对协议的解析与组装,我们需要编写一个工具类实现对字节组的某些特殊操作,例如校验和、BCD 转换、十六进制数据的校验等。
2.1.1 累加和计算功能
首先我们来实现累加和的计算,累加和就是一堆字节相加的结果,不过这个结果可能超过一个字节的大小,我们需要对 256 取模,使其结果刚好能被 1 个字节存储。
/// <summary>
/// 计算一组二进制数据的累加和。
/// </summary>
/// <param name="waitCalcBytes">等待计算的二进制数据。</param>
public static byte CalculateAccumulateSum(byte[] waitCalcBytes)
{
int ck = 0;
foreach (var @byte in waitCalcBytes) ck = (ck + @byte);
// 对 256 取余,获得 1 个字节的数据。
return (byte)(ck % 0x100);
}
2.1.2 十六进制字符串转字节数组
首先我们需要校验一个字符串是否是一个规范合法的十六进制字符串。
/// <summary>
/// 判断输入的字符串是否是有效的十六进制数据。
/// </summary>
/// <param name="hexStr">等待判断的十六进制数据。</param>
/// <returns>符合规范则返回 True,不符合则返回 False。</returns>
public static bool IsIllegalHexadecimal(string hexStr)
{
var validStr = hexStr.Replace("-", string.Empty).Replace(" ", string.Empty);
if (validStr.Length % 2 != 0) return false;
if (string.IsNullOrEmpty(hexStr) || string.IsNullOrWhiteSpace(hexStr)) return false;
return new Regex(@"[A-Fa-f0-9]+$").IsMatch(hexStr);
}
校验之后我们才能够将这个字符串用于转换。
/// <summary>
/// 将 16 进制的字符串转换为字节数组。
/// </summary>
/// <param name="hexStr">等待转换的 16 进制字符串。</param>
/// <returns>转换成功的字节数组。</returns>
public static byte[] HexStringToBytes(string hexStr)
{
// 处理干扰,例如空格和 '-' 符号。
var str = hexStr.Replace("-", string.Empty).Replace(" ", string.Empty);
return Enumerable.Range(0, str.Length)
.Where(x => x % 2 == 0)
.Select(x => Convert.ToByte(str.Substring(x, 2), 16))
.ToArray();
}
2.1.3 BCD 数据的转换
关于 BCD 码的介绍,网上有诸多解释,这里不再赘述,这里只讲一下编码实现。
/// <summary>
/// BCD 码转换成 <see cref="double"/> 类型。
/// </summary>
/// <param name="sourceBytes">等待转换的 BCD 码数据。</param>
/// <param name="precisionIndex">精度位置,用于指示小数点所在的索引。</param>
/// <returns>转换成功的值。</returns>
public static double BCDToDouble(byte[] sourceBytes, int precisionIndex)
{
var sb = new StringBuilder();
var reverseBytes = sourceBytes.Reverse().ToArray();
for (int index = 0; index < reverseBytes.Length; index++)
{
sb.Append(reverseBytes[index] >> 4 & 0xF);
sb.Append(reverseBytes[index] & 0xF);
if (index == precisionIndex - 1) sb.Append('.');
}
return Convert.ToDouble(sb.ToString());
}
/// <summary>
/// BCD 码转换成 <see cref="string"/> 类型。
/// </summary>
/// <param name="sourceBytes">等待转换的 BCD 码数据。</param>
/// <returns>转换成功的值。</returns>
public static string BCDToString(byte[] sourceBytes)
{
var sb = new StringBuilder();
var reverseBytes = sourceBytes.Reverse().ToArray();
for (int index = 0; index < reverseBytes.Length; index++)
{
sb.Append(reverseBytes[index] >> 4 & 0xF);
sb.Append(reverseBytes[index] & 0xF);
}
return sb.ToString();
}
2.2 协议的实现
协议分为发送帧与响应帧,发送帧是通过传入一系列参数构建一个 byte
数组,而响应帧则需要我们从一个 byte
数组转换为方便读写的对象。
根据以上特点,我们编写一个 IProtocol
接口,该接口拥有两个方法,即编码 (Encode) 和解码 (Decode) 方法。
public interface IProtocol
{
byte[] Encode();
IProtocol Decode(byte[] sourceBytes);
List<DataDefine> DataDefines { get;}
}
接着我们可以使用一个类型来表示每个数据域的数据,这里我定义了一个 DataDefine
类型。
public class DataDefine
{
public string Name { get; set; }
public byte[] Data { get; set; }
public int Length { get; set; }
}
这里我以水表的读表操作为例,定义了一个抽象基类,在抽象基类里面定义了数据帧的基本接口,并且实现了编码/解码方法。在这里 DataDefines
的作用就体现了,他主要是用于
public abstract class CJT188Protocol : IProtocol
{
protected const byte FrameHead = 0x68;
public byte DeviceType { get; protected set; }
public byte[] Address { get; protected set; }
public byte ControlCode { get; protected set; }
public int DataLength { get; protected set; }
public byte[] DataArea { get; private set; }
public List<DataDefine> DataDefines { get;}
public byte AccumulateSum { get; protected set; }
protected const byte FrameEnd = 0x16;
public CJT188Protocol()
{
DataDefines = new List<DataDefine>();
}
public DataDefine this[string key]
{
get
{
return DataDefines.FirstOrDefault(x => x.Name == key);
}
}
public virtual byte[] Encode()
{
// 校验协议数据。
if(Address.Length != 7) throw new ArgumentException($"水表地址 {BitConverter.ToString(Address)} 的长度不正确,长度不等于 7 个字节。");
BuildDataArea();
using (var mem = new MemoryStream())
{
mem.WriteByte(FrameHead);
mem.WriteByte(DeviceType);
mem.Write(Address);
mem.WriteByte(ControlCode);
mem.WriteByte((byte)DataLength);
mem.Write(DataArea);
AccumulateSum = ByteUtils.CalculateAccumulateSum(mem.ToArray());
mem.WriteByte(AccumulateSum);
mem.WriteByte(FrameEnd);
return mem.ToArray();
}
}
public virtual IProtocol Decode(byte[] sourceBytes)
{
using (var mem = new MemoryStream(sourceBytes))
{
using (var reader = new BinaryReader(mem))
{
reader.ReadByte();
DeviceType = reader.ReadByte();
Address = reader.ReadBytes(7);
ControlCode = reader.ReadByte();
DataLength = reader.ReadByte();
foreach (var dataDefine in DataDefines)
{
dataDefine.Data = reader.ReadBytes(dataDefine.Length);
}
AccumulateSum = reader.ReadByte();
}
}
return this;
}
protected virtual void BuildDataArea()
{
// 构建数据域。
using (var dataMemory = new MemoryStream())
{
foreach (var data in DataDefines)
{
if(data==null) continue;
dataMemory.Write(data.Data);
}
DataArea = dataMemory.ToArray();
DataLength = DataArea.Length;
}
}
}
最后我们定义了两个具体的协议类,分别是读表的请求帧和读表的响应帧,在其构造方法分别定义了具体的数据域。
public class CJT188_Read_Request : CJT188Protocol
{
public CJT188_Read_Request(string address,byte type)
{
Address = ByteUtils.HexStringToBytes(address).Reverse().ToArray();
ControlCode = 0x1;
DeviceType = type;
DataDefines.Add(new DataDefine{Name = "Default",Length = 2});
DataDefines.Add(new DataDefine{Name = "Seq",Length = 1});
}
}
public class CJT188_Read_Response : CJT188Protocol
{
public CJT188_Read_Response()
{
DataDefines.Add(new DataDefine{Name = "Default",Length = 2});
DataDefines.Add(new DataDefine{Name = "Seq",Length = 1});
DataDefines.Add(new DataDefine{Name = "AllData",Length = 4});
DataDefines.Add(new DataDefine{Name = "AllDataUnit",Length = 1});
DataDefines.Add(new DataDefine{Name = "MonthData",Length = 4});
DataDefines.Add(new DataDefine{Name = "MonthDataUnit",Length = 1});
DataDefines.Add(new DataDefine{Name = "DateTime",Length = 7});
DataDefines.Add(new DataDefine{Name = "Status1",Length = 1});
DataDefines.Add(new DataDefine{Name = "Status2",Length = 1});
}
}
测试代码:
class Program
{
static void Main(string[] args)
{
// 发送水表读表数据。
var sendProtocol = new CJT188_Read_Request("00000805000001",0x10);
sendProtocol["Default"].Data = new byte[] {0x1F, 0x90};
sendProtocol["Seq"].Data = new byte[] {0x00};
Console.WriteLine(BitConverter.ToString(sendProtocol.Encode()));
// 解析水表响应数据。
var receiveProtocol = new CJT188_Read_Response().Decode(ByteUtils.HexStringToBytes("68 10 78 06 12 18 20 00 00 81 16 90 1F 00 00 01 00 00 2C 00 01 00 00 2C 00 00 00 00 00 00 00 01 FF E0 16"));
Console.ReadLine();
}
}
2.3 代码打包下载
上述代码实现均已打包为压缩文件,点击我 即可直接下载。