要對某種協議進行編解碼操做,就必須知道協議的基本定義,首先咱們來看一下 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 |
請求幀: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 度。
前導字節並不是水/電錶協議強制規定的協議組,所謂前導字節是在數據幀的頭部增長 1-4 組 0xFE,例如如下數據幀就是增長了前導字節。
FE FE FE FE 68 10 44 33 22 11 00 33 78 01 03 1F 90 00 80 16
因此在處理的協議的時候,某些廠家可能會加入前導字節,在處理的時候必定要注意。
水/電錶協議的請求幀與響應幀其實結構一致,區別僅在於不一樣的響應,其具體的數據域值也不一樣,因此在處理的時候能夠用一個字典/列表來存儲數據域。
爲了方便咱們對協議的解析與組裝,咱們須要編寫一個工具類實現對字節組的某些特殊操做,例如校驗和、BCD 轉換、十六進制數據的校驗等。
首先咱們來實現累加和的計算,累加和就是一堆字節相加的結果,不過這個結果可能超過一個字節的大小,咱們須要對 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); }
首先咱們須要校驗一個字符串是不是一個規範合法的十六進制字符串。
/// <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(); }
關於 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(); }
協議分爲發送幀與響應幀,發送幀是經過傳入一系列參數構建一個 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(); } }
上述代碼實現均已打包爲壓縮文件,點擊我 便可直接下載。