一、協議的定義
要對某種協議進行編解碼操作,就必須知道協議的基本定義,首先我們來看一下 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 代碼打包下載
上述代碼實現均已打包為壓縮文件,點擊我 即可直接下載。
