此文章对CJT188协议简单价绍,默认串口格式在物理层上设置处理,本文只提供实现业务逻辑的关键代码。欢迎提出不足与其他方案。
CJT188协议简介
CJT188协议是物联网水表、气表、电表数的通用协议,基于RS485总线的多点通讯方式,支持数据传输的双向通讯,数据格式简洁且使用二进制编码。采用半双工通信,可主从模式交互数据。
CJT188协议采用RS485串口传输,8位数据位数,偶检验方式。
串口字节格式
字节格式为每字节含 8 位二进制码,传输时加上 一个起始位(0)、一个偶校验位(E)、一个停止位(1),共 11 位。
默认RS485串口格式在物理层上设置处理。
如下所示:
0 | X X X X X X X X | E | 1 |
起始位 | 8位数据 | 偶校验位 | 停止位 |
CJT188协议格式
请求报文
FE FE FE 68 10 37 29 41 84 00 00 00 01 03 90 1F 01 51 16
FE FE FE | 68 | 10 | 37 29 41 84 00 00 00 | 01 | 03 | 90 1F 01 | 51 | 16 |
前导字节 | 起始 | 表类型 | 表地址 | 控制码 | 数据长度 | 数据标识 | 校验码 | 结束 |
响应报文
FE FE FE 68 10 37 29 41 84 00 00 00 81 16 90 1F 01 30 84 91 02 2C 00 43 18 29 2C 00 00 00 00 00 00 00 00 FF 06 16
FE FE FE | 68 | 10 | 37 29 41 84 00 00 00 | 81 | 16 | 90 1F 01 | 30 84 91 02 | 2C | 00 43 18 29 | 2C | 00 00 00 00 00 00 00 | 00 FF | 06 | 16 |
前导字节 | 起始 | 表类型 | 表地址 | 控制码 | 数据长度 | 数据标识 | 累计流量 | 单位(t) | 剩余流量 | 单位(t) | 实时时间 | 状态 | 校验码 | 结束 |
具体协议字节
前导字节:发送消息命令之前,先发送 2-4 字节 0xFE
表类型:10(1X)为水表,30气表,40电表
表地址:水表号后8位,采用BCD码高地址在后。如表号:ABC230884412937,则表地址:37294184000000
BCD码(Binary-Coded Decimal)是用二进制编码的十进制,每一位分别用二进制来保存,这种编码形式利用了四个位元来储存一个十进制的数码码
控制码:采用位图(bitmap)结构存储,如下图,剩余中间两位与最后三位组合:
00×001——读数据
00×011——读地址
X | X | X | X | X | X | X | X |
0-请求 | 0-正常 | 0-明文 | |||||
1-应答 | 1-异常 | 0-密文 |
数据标识:0x901F 读数据,0x810A 读地址
校验码:和校验,即算术累加不计FFH溢出值
流量:采用BCD码高地址在后,30 84 91 02——029184.30,00 43 18 29 ——291843.00
状态:0x00 前位采用位图(bitmap)结构存储,如下图,0xFF 后位厂商定义保留字节
X(阀门) | X(阀门) | X(电压) | X | X | X | X | X |
0-开 | 0-正常 | 0-正常 | |||||
1-关 | 1-异常 | 1-欠压 |
Java实现CJT188的数据解析
实体类model
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
public class DataPackage {
// 前导字符
private String leadByte;
// 帧起始符
private String frameStart;
// 校验码
private String checkCode;
// 帧结束符
private String frameEnd;
}
/**
* 请求实体
*/
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class Cjt188PushDataPackage extends DataPackage {
// 表计类型代码
private String meterType;
// 表计地址
private String meterAddress;
// 控制码 CTR
private String ctr;
// 数据域长度
private String dataLength;
// 数据标识
private String dataMarker;
// 序列号(01h)
private String number;
}
/**
* 响应实体
*/
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class Cjt188ResDataPackage extends Cjt188PushDataPackage {
// 当前累积流量
private String traffic;
// 累计流量单位
private String tUnit;
// 当前剩余流量
private String residueTraffic;
// 剩余流量单位
private String rtUnit;
// 实时时间
private String time;
// 状态码
private String stateCode;
// 解析数据
private Cjt188Analysis cjt188Analysis;
/**
* 响应实体内部类
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class Cjt188Analysis {
// 表号
private String meterNum;
// 当前累积流量
private String traffic;
// 当前剩余流量
private String trafficResidue;
// 数据状态
private String dataState;
// 表计状态
private String meterState;
}
}
协议解析逻辑类
public class Cjt188Protocol {
// 重要字节索引
int[] indices = {0, 6, 8, 10, 24, 26, 28, 32, 34, 42, 44, 52, 54, 68, 72, 74, 76};
// 解析响应报文
public DataPackage analysis(byte[] msgByte) {
StringBuilder msb = new StringBuilder(HexUtil.hex(msgByte));
while (!msb.substring(0, 6).equalsIgnoreCase("fefefe")) {
msb.insert(0, "fe");
}
String[] parts = new String[indices.length - 1];
// 复杂度 o(n+k) n 为字符串长度 k 为字符串分割次数
for (int i = 0; i < indices.length - 1; ) {
StringBuilder sb = new StringBuilder(indices[i + 1] - indices[i]);
for (int j = indices[i]; j < indices[i + 1]; j++) {
sb.append(msb.charAt(j));
}
parts[i++] = sb.toString();
}
Cjt188ResDataPackage dataPackage = Cjt188ResDataPackage.builder()
.leadByte(parts[0])
.frameStart(parts[1])
.meterType(parts[2])
.meterAddress(parts[3])
.ctr(parts[4])
.dataLength(parts[5])
.dataMarker(parts[6])
.number(parts[7])
.traffic(parts[8])
.tUnit(parts[9])
.residueTraffic(parts[10])
.rtUnit(parts[11])
.time(parts[12])
.stateCode(parts[13])
.checkCode(parts[14])
.frameEnd(parts[15])
.build();
Cjt188ResDataPackage.Cjt188Analysis cjt188Analysis = dataPackage.new Cjt188Analysis();
cjt188Analysis.setMeterNum(BcdCodeSix(dataPackage.getMeterAddress().substring(0, 8), false));
cjt188Analysis.setTraffic(BcdCodeSix(dataPackage.getTraffic(), true));
cjt188Analysis.setTrafficResidue(BcdCodeSix(dataPackage.getResidueTraffic(), true));
int ctr = Integer.parseInt(dataPackage.getCtr(), 16);
cjt188Analysis.setDataState(Cjt188Enum.enumValueOf("r" + ((ctr >> 7) & 1)).getValue() +
Cjt188Enum.enumValueOf("d" + ((ctr >> 6) & 1)).getValue() +
Cjt188Enum.enumValueOf("t" + ((ctr >> 3) & 1)).getValue() +
Cjt188Enum.enumValueOf(String.format("%02d", ((ctr >> 4) & 3)) + "x" +
String.format("%03d", (ctr & 7))).getValue());
char ms = dataPackage.getStateCode().charAt(0);
cjt188Analysis.setMeterState(Cjt188Enum.enumValueOf("v" + ((ms - '0') >> 3 & 1)).getValue() +
Cjt188Enum.enumValueOf("s" + ((ms - '0') >> 2 & 1)).getValue() +
Cjt188Enum.enumValueOf("e" + ((ms - '0') >> 1 & 1)).getValue());
dataPackage.setCjt188Analysis(cjt188Analysis);
return dataPackage;
}
// 封装请求报文
public byte[] pushMsg(String address) {
Cjt188PushDataPackage resPushEntity = Cjt188PushDataPackage.builder()
.leadByte(MsgCode.CJT188_LEAD_BYTE)
.frameStart(MsgCode.CJT188_FRAME_START)
.meterType("10")
.meterAddress(address)
.ctr(MsgCode.CJT188_CTR_0)
.dataLength(MsgCode.CJT188_DATA_LENGTH_PUSH)
.dataMarker(MsgCode.CJT188_DATA_MARKER)
.number(MsgCode.CJT188_NUMBER)
.frameEnd(MsgCode.CJT188_FRAME_END)
.build();
String meterAddress = resPushEntity.getMeterAddress();
int checkCodeInt = Integer.parseInt("2c", 16);
// checksum校验不计溢出值
for (int i = 0; i < meterAddress.length(); i += 2) {
checkCodeInt += (Character.digit(meterAddress.charAt(i), 16) << 4) +
Character.digit(meterAddress.charAt(i + 1), 16);
}
String checkCode = Integer.toHexString(checkCodeInt & 0xFF);
if (checkCode.length() < 2) {
checkCode = "0" + checkCode;
}
resPushEntity.setCheckCode(checkCode);
byte[] resMsg = HexUtil.hexToBytes(resPushEntity.getLeadByte() + resPushEntity.getFrameStart() +
resPushEntity.getMeterType() + resPushEntity.getMeterAddress() + resPushEntity.getCtr() +
resPushEntity.getDataLength() + resPushEntity.getDataMarker() + resPushEntity.getNumber() +
resPushEntity.getCheckCode() + resPushEntity.getFrameEnd());
return resMsg;
}
private String BcdCodeSix(String s, boolean isDot) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); i += 2) {
sb.insert(0, s.charAt(i));
sb.insert(1, s.charAt(i + 1));
if(i == 0 && isDot) {
sb.insert(0, ".");
}
}
return sb.toString();
}
}
其他类
public class MsgCode {
/**
* CJT188
*/
//前导字节 FE FE FE
public static final String CJT188_LEAD_BYTE = "fefefe";
//控制码 CTR_0 01h
public static final String CJT188_CTR_0 = "01";
//控制码 CTR_3 03h
public static final String CJT188_CTR_3 = "03";
//控制码 CTR_1 81h
public static final String CJT188_CTR_1 = "81";
//数据域长度L 03h
public static final String CJT188_DATA_LENGTH_PUSH = "03";
//数据域长度L 09h
public static final String CJT188_DATA_LENGTH_RES = "09";
//数据标识DI0-DI1 901Fh
public static final String CJT188_DATA_MARKER = "901f";
public static final String CJT188_DATA_MARKER1 = "810a";
//序列号(01h)
public static final String CJT188_NUMBER = "01";
public static final String CJT188_NUMBER1 = "00";
//帧起始符
public static final String CJT188_FRAME_START = "68";
//帧结束符
public static final String CJT188_FRAME_END = "16";
}
public enum Cjt188Enum {
UNIT("2c", "吨(立方米)"),
REQ("r0", "请求-"),
RES("r1", "应答-"),
DATANORMAL("d0", "正常-"),
DATAERROR("d1", "异常-"),
PLAINTEXT("t0", "明文-"),
CIPHERTEXT("t1", "密文-"),
READDATA("00x001", "读数据"),
READADDRESS("00x011", "读地址"),
VALVEOPEN("v0", "阀门开-"),
VALVECLOSE("v1", "阀门关-"),
VALVENORMAL("s0", "阀门正常-"),
VALVEERROR("s1", "阀门异常-"),
ELENORMAL("e0", "电压正常"),
ELEERROR("e1", "电压欠压"),
DefaultEnum("FFFF", "未找到");
private String value;
private String key;
private Cjt188Enum(String key,String value) {
this.value = value;
this.key = key;
}
public String getValue() {
return value;
}
public String getKey() {
return key;
}
public static Cjt188Enum enumValueOf(String key) {
Cjt188Enum[] values = Cjt188Enum.values();
for(Cjt188Enum value : values){
if(value.getKey().equals(key)){
return value;
}
}
return Cjt188Enum.DefaultEnum;
}
}
public class HexUtil {
// 字节数组转16进制
public static String hex(byte[] bytes) {
StringBuilder result = new StringBuilder(bytes.length * 2);
for (byte aByte : bytes) {
result.append(String.format("%02x", aByte & 0xff));
}
return result.toString();
}
// 十六进制转字节数组
public static byte[] hexToBytes(String hex) {
int len = hex.length();
if (len % 2 != 0) {
throw new IllegalArgumentException("Hex string length must be even");
}
byte[] result = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
result[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return result;
}
}
Netty自定义响应报文粘包处理
CJT188协议数据包长度不定,Netty自带粘包处理无法很好解决,需要自定义粘包处理,也欢迎提出优化方案。
忽略不定长度的前导字节0xFE,通过起初字节0x68和结束字节0x16来确定报文完整。
public class CJT188Decoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
while (in.readableBytes() >= 37) { // 2个不定长0xFE + 1个0x68 + 33个数据字节 + 1个0x16
in.markReaderIndex();
// 跳过不定长度的0xFE字节
while (in.isReadable() && in.getByte(in.readerIndex()) == (byte) 0xFE) {
in.readByte();
}
// 检查首字节是否为0x68
if (in.readableBytes() < 1) {
in.resetReaderIndex();
return; // 如果数据不足,返回并等待更多数据
}
if (in.getByte(in.readerIndex()) != (byte) 0x68) {
in.resetReaderIndex();
in.skipBytes(1); // 跳过一个字节以避免死循环
continue; // 继续处理下一组数据
}
// 读取首字节0x68
in.readByte();
// 确保有至少33个数据字节和1个尾字节
if (in.readableBytes() < 34) {
in.resetReaderIndex();
return; // 如果数据不足,返回并等待更多数据
}
// 读取33个数据字节
ByteBuf dataBytes = in.readSlice(33);
// 检查尾字节是否为0x16
if (in.readableBytes() < 1) {
in.resetReaderIndex();
return; // 如果数据不足,返回并等待更多数据
}
if (in.readByte() != (byte) 0x16) {
in.resetReaderIndex();
in.skipBytes(1); // 跳过一个字节以避免死循环
continue; // 继续处理下一组数据
}
// 读取完整的帧数据
ByteBuf frame = ctx.alloc().buffer(35);
frame.writeByte(0x68);
frame.writeBytes(dataBytes);
frame.writeByte(0x16);
// 将帧数据添加到输出列表中
out.add(frame);
}
}
}
Netty自定义心跳方案
通常CJT188的水表无网络连接,网络连接采用其他RS485数据转发设备,若网络转发设备只提供RS485数据包转发和网络连接,无内置实现心跳,可采用CJT188水表号查询方式。发送询问表号,水表返回水表号报文。
查询表号报文
FE FE FE 68 AA AA AA AA AA AA AA AA 03 03 81 0A 00 49 16