使用 C# 实现 CJ-T188 水表协议和 DL-T645 电表协议的解析与编码

1、协议的定义

要对某种协议进行编解码操做,就必须知道协议的基本定义,首先咱们来看一下 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

对应的解释以下。ui

顺序 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

表计类型表:this

含义
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 数据帧的结束标识。

例若有如下响应帧数据:spa

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

对应的解释以下:code

顺序 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 多功能电能表通讯协议

请求帧:blog

字节 描述
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、代码的实现

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 代码打包下载

上述代码实现均已打包为压缩文件,点击我 便可直接下载。

相关文章
相关标签/搜索