chore: initial import of lingniu-vehicle-ingest
Multi-module Spring Boot ingest service for vehicle telemetry. Modules: - ingest-api / ingest-core / ingest-codec-common: shared SPI, dispatcher, Disruptor event bus, BCC/BCD codec helpers - protocol-gb32960: GB/T 32960.3 inbound (Netty + per-version parser packages v2016/v2025), platform login auth, VIN whitelist, idle handler - protocol-jt808 / protocol-jt1078 / protocol-jsatl12: JT/T inbound - inbound-mqtt / inbound-xinda-push: alternative ingest channels - session-core: per-channel session state - sink-archive / sink-mq: persistence sinks (local file / Kafka) - command-gateway: terminal control command gateway - bootstrap-all: aggregator Spring Boot app - observability: Micrometer / Actuator wiring Includes hex-dump golden samples under protocol-gb32960/src/test/resources and the GB/T 32960.3-2016 / 2025 reference PDFs under reference/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
protocol-jt808/pom.xml
Normal file
54
protocol-jt808/pom.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>protocol-jt808</artifactId>
|
||||
<name>protocol-jt808</name>
|
||||
<description>JT/T 808 协议接入(Netty Server,默认 TCP 10808)。</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-codec-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>session-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-all</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 单一消息体解析器 SPI。每个消息 ID 一个实现,替代旧代码里的巨型 switch。
|
||||
*
|
||||
* <p>约定:{@code parse} 调用时 buffer.position 指向 body 第 0 字节,
|
||||
* 解析完成后 position 停在 body 末尾(不消费校验码)。
|
||||
*/
|
||||
public interface BodyParser {
|
||||
|
||||
int messageId();
|
||||
|
||||
Jt808Body parse(Jt808Header header, ByteBuffer body);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JT808 消息体解析器注册中心。
|
||||
*/
|
||||
public final class BodyParserRegistry {
|
||||
|
||||
private final Map<Integer, BodyParser> parsers = new HashMap<>();
|
||||
|
||||
public BodyParserRegistry(List<BodyParser> list) {
|
||||
for (BodyParser p : list) parsers.put(p.messageId(), p);
|
||||
}
|
||||
|
||||
public BodyParser find(int messageId) {
|
||||
return parsers.get(messageId);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return parsers.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
/**
|
||||
* JT/T 808 转义规则:
|
||||
* <pre>
|
||||
* 0x7e → 0x7d 0x02
|
||||
* 0x7d → 0x7d 0x01
|
||||
* </pre>
|
||||
* 转义仅作用于 <b>消息头 + 消息体 + 校验码</b>,不包含首尾的 0x7e 标识位。
|
||||
*/
|
||||
public final class Jt808Escape {
|
||||
|
||||
private Jt808Escape() {}
|
||||
|
||||
/** 反转义:从 framed 字节数组中剥除 0x7e 首尾后,恢复原始字节。 */
|
||||
public static byte[] unescape(byte[] framed, int offset, int length) {
|
||||
byte[] out = new byte[length];
|
||||
int outLen = 0;
|
||||
for (int i = offset; i < offset + length; i++) {
|
||||
byte b = framed[i];
|
||||
if (b == 0x7d && i + 1 < offset + length) {
|
||||
byte next = framed[i + 1];
|
||||
if (next == 0x02) {
|
||||
out[outLen++] = 0x7e;
|
||||
i++;
|
||||
continue;
|
||||
} else if (next == 0x01) {
|
||||
out[outLen++] = 0x7d;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out[outLen++] = b;
|
||||
}
|
||||
byte[] trimmed = new byte[outLen];
|
||||
System.arraycopy(out, 0, trimmed, 0, outLen);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 转义:在原始字节上应用 7d/7e 替换,结果不含首尾 0x7e。 */
|
||||
public static byte[] escape(byte[] raw) {
|
||||
byte[] out = new byte[raw.length * 2];
|
||||
int outLen = 0;
|
||||
for (byte b : raw) {
|
||||
if (b == 0x7e) {
|
||||
out[outLen++] = 0x7d;
|
||||
out[outLen++] = 0x02;
|
||||
} else if (b == 0x7d) {
|
||||
out[outLen++] = 0x7d;
|
||||
out[outLen++] = 0x01;
|
||||
} else {
|
||||
out[outLen++] = b;
|
||||
}
|
||||
}
|
||||
byte[] trimmed = new byte[outLen];
|
||||
System.arraycopy(out, 0, trimmed, 0, outLen);
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.ByteToMessageDecoder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Netty 层 JT808 分帧 + 反转义。
|
||||
*
|
||||
* <p>寻找配对的 {@code 0x7e ... 0x7e},提取中间字节并调用 {@link Jt808Escape#unescape} 还原,
|
||||
* 输出到 pipeline 的是 {@code byte[]}(反转义后的原始数据,不含首尾 0x7e)。
|
||||
*/
|
||||
public class Jt808FrameDecoder extends ByteToMessageDecoder {
|
||||
|
||||
private static final int MAX_FRAME = 16 * 1024;
|
||||
|
||||
@Override
|
||||
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
|
||||
while (in.readableBytes() >= 2) {
|
||||
int startIdx = in.indexOf(in.readerIndex(), in.writerIndex(), (byte) 0x7e);
|
||||
if (startIdx < 0) {
|
||||
in.skipBytes(in.readableBytes());
|
||||
return;
|
||||
}
|
||||
if (startIdx != in.readerIndex()) {
|
||||
in.skipBytes(startIdx - in.readerIndex());
|
||||
}
|
||||
int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e);
|
||||
if (endIdx < 0) {
|
||||
if (in.readableBytes() > MAX_FRAME) {
|
||||
in.skipBytes(in.readableBytes() - 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
int rawLen = endIdx - (in.readerIndex() + 1);
|
||||
if (rawLen == 0) {
|
||||
// 两个相邻 0x7e:视作空帧,跳过一个
|
||||
in.skipBytes(1);
|
||||
continue;
|
||||
}
|
||||
byte[] raw = new byte[rawLen];
|
||||
in.skipBytes(1);
|
||||
in.readBytes(raw);
|
||||
in.skipBytes(1); // 结束 0x7e
|
||||
byte[] unescaped = Jt808Escape.unescape(raw, 0, raw.length);
|
||||
out.add(unescaped);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.codec.BcdCodec;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
/**
|
||||
* JT/T 808 下行帧编码器:把 header + body 字节拼装 → 加 XOR 校验 → 转义 → 外包 0x7e。
|
||||
*
|
||||
* <p>只支持 2013 版(6 字节 BCD phone)。分包暂不支持(PoC 阶段下行多数是短命令)。
|
||||
*/
|
||||
public final class Jt808FrameEncoder {
|
||||
|
||||
private Jt808FrameEncoder() {}
|
||||
|
||||
public static byte[] encode(int messageId, String phone, int serialNo, byte[] body) {
|
||||
return encode(messageId, phone, serialNo, body, Jt808Header.ProtocolVersion.V2013);
|
||||
}
|
||||
|
||||
public static byte[] encode(int messageId, String phone, int serialNo, byte[] body,
|
||||
Jt808Header.ProtocolVersion version) {
|
||||
byte[] raw = buildRaw(messageId, phone, serialNo, body, version);
|
||||
byte bcc = BccChecksum.compute(raw, 0, raw.length);
|
||||
|
||||
ByteArrayOutputStream almost = new ByteArrayOutputStream(raw.length + 1);
|
||||
almost.write(raw, 0, raw.length);
|
||||
almost.write(bcc & 0xFF);
|
||||
|
||||
byte[] escaped = Jt808Escape.escape(almost.toByteArray());
|
||||
byte[] out = new byte[escaped.length + 2];
|
||||
out[0] = 0x7e;
|
||||
System.arraycopy(escaped, 0, out, 1, escaped.length);
|
||||
out[out.length - 1] = 0x7e;
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] buildRaw(int messageId, String phone, int serialNo, byte[] body,
|
||||
Jt808Header.ProtocolVersion version) {
|
||||
boolean v2019 = version == Jt808Header.ProtocolVersion.V2019;
|
||||
int phoneLen = v2019 ? 10 : 6;
|
||||
int len = 2 /* msgId */ + 2 /* attrs */ + (v2019 ? 1 : 0) + phoneLen + 2 /* serial */ + body.length;
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream(len);
|
||||
|
||||
// msgId
|
||||
os.write((messageId >> 8) & 0xFF);
|
||||
os.write(messageId & 0xFF);
|
||||
|
||||
// attrs: 2019 版 bit14 置 1
|
||||
int attrs = body.length & 0x03FF;
|
||||
if (v2019) attrs |= 0x4000;
|
||||
os.write((attrs >> 8) & 0xFF);
|
||||
os.write(attrs & 0xFF);
|
||||
|
||||
// 2019 版多一个 protocolVersion 字节(值 1)
|
||||
if (v2019) os.write(0x01);
|
||||
|
||||
// phone BCD
|
||||
String padded = padLeft(phone, phoneLen * 2);
|
||||
byte[] bcd = BcdCodec.encode(padded);
|
||||
os.write(bcd, 0, phoneLen);
|
||||
|
||||
// serial
|
||||
os.write((serialNo >> 8) & 0xFF);
|
||||
os.write(serialNo & 0xFF);
|
||||
|
||||
// body
|
||||
os.write(body, 0, body.length);
|
||||
return os.toByteArray();
|
||||
}
|
||||
|
||||
private static String padLeft(String s, int len) {
|
||||
if (s == null) s = "";
|
||||
if (s.length() >= len) return s.substring(s.length() - len);
|
||||
StringBuilder sb = new StringBuilder(len);
|
||||
for (int i = s.length(); i < len; i++) sb.append('0');
|
||||
sb.append(s);
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import com.lingniu.ingest.api.spi.DecodeException;
|
||||
import com.lingniu.ingest.api.spi.FrameDecoder;
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.codec.BcdCodec;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* JT808 完整帧(已反转义、含校验码,不含首尾 0x7e) → {@link Jt808Message}。
|
||||
*
|
||||
* <p>入参 {@link ByteBuffer} 的生命周期由调用方管理,本类只读取。
|
||||
*/
|
||||
public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
|
||||
|
||||
private static final int HEADER_LEN_2013 = 12;
|
||||
private static final int HEADER_LEN_2019 = 16; // + phone 延长 4 字节
|
||||
|
||||
private final BodyParserRegistry registry;
|
||||
|
||||
public Jt808MessageDecoder(BodyParserRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Message decode(ByteBuffer buffer) {
|
||||
byte[] bytes = new byte[buffer.remaining()];
|
||||
buffer.get(bytes);
|
||||
|
||||
if (bytes.length < HEADER_LEN_2013 + 1) {
|
||||
throw new DecodeException("jt808 frame too short: " + bytes.length);
|
||||
}
|
||||
|
||||
// 校验码:从 msgId 开始到校验码前一字节
|
||||
byte expected = bytes[bytes.length - 1];
|
||||
if (!BccChecksum.verify(bytes, 0, bytes.length - 1, expected)) {
|
||||
throw new DecodeException("jt808 XOR checksum mismatch");
|
||||
}
|
||||
|
||||
int messageId = ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF);
|
||||
int attrs = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF);
|
||||
int bodyLength = attrs & 0x03FF;
|
||||
int encryptType = (attrs >> 10) & 0x07;
|
||||
boolean subpacket = (attrs & 0x2000) != 0;
|
||||
boolean v2019 = (attrs & 0x4000) != 0;
|
||||
|
||||
Jt808Header.ProtocolVersion version = v2019
|
||||
? Jt808Header.ProtocolVersion.V2019
|
||||
: Jt808Header.ProtocolVersion.V2013;
|
||||
int phoneLen = v2019 ? 10 : 6;
|
||||
int headerLen = 4 /* msgId+attrs */ + (v2019 ? 1 : 0) /* protocol version */ + phoneLen + 2 /* serial */;
|
||||
int phoneOffset = 4 + (v2019 ? 1 : 0);
|
||||
|
||||
String phone = BcdCodec.decode(Arrays.copyOfRange(bytes, phoneOffset, phoneOffset + phoneLen))
|
||||
.replaceAll("^0+", "");
|
||||
int serial = ((bytes[phoneOffset + phoneLen] & 0xFF) << 8)
|
||||
| (bytes[phoneOffset + phoneLen + 1] & 0xFF);
|
||||
|
||||
int totalPkgs = 0, pkgSeq = 0;
|
||||
int bodyStart = headerLen;
|
||||
if (subpacket) {
|
||||
totalPkgs = ((bytes[bodyStart] & 0xFF) << 8) | (bytes[bodyStart + 1] & 0xFF);
|
||||
pkgSeq = ((bytes[bodyStart + 2] & 0xFF) << 8) | (bytes[bodyStart + 3] & 0xFF);
|
||||
bodyStart += 4;
|
||||
}
|
||||
|
||||
int bodyEnd = bodyStart + bodyLength;
|
||||
if (bodyEnd > bytes.length - 1) {
|
||||
throw new DecodeException("jt808 body length " + bodyLength + " exceeds frame");
|
||||
}
|
||||
|
||||
Jt808Header header = new Jt808Header(
|
||||
messageId, bodyLength, encryptType, subpacket, version,
|
||||
phone, serial, totalPkgs, pkgSeq);
|
||||
|
||||
ByteBuffer bodyBuf = ByteBuffer.wrap(bytes, bodyStart, bodyLength);
|
||||
BodyParser parser = registry.find(messageId);
|
||||
Jt808Body body;
|
||||
if (parser == null) {
|
||||
byte[] raw = new byte[bodyLength];
|
||||
bodyBuf.get(raw);
|
||||
body = new Jt808Body.Raw(messageId, raw);
|
||||
} else {
|
||||
body = parser.parse(header, bodyBuf);
|
||||
}
|
||||
return new Jt808Message(header, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec.parser;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/** 0x0002 心跳,无 body。 */
|
||||
public final class HeartbeatBodyParser implements BodyParser {
|
||||
@Override public int messageId() { return Jt808MessageId.TERMINAL_HEARTBEAT; }
|
||||
@Override public Jt808Body parse(Jt808Header header, ByteBuffer body) { return new Jt808Body.Heartbeat(); }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec.parser;
|
||||
|
||||
import com.lingniu.ingest.codec.BcdCodec;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* 0x0200 位置信息汇报。
|
||||
*
|
||||
* <p>固定 28 字节 + 可选附加项(本 PoC 先忽略附加项)。
|
||||
* <pre>
|
||||
* alarmFlag(4)
|
||||
* statusFlag(4)
|
||||
* latitude(4) // 1/1000000 度
|
||||
* longitude(4) // 1/1000000 度
|
||||
* altitudeM(2)
|
||||
* speed(2) // 1/10 km/h
|
||||
* direction(2)
|
||||
* time(6 BCD, YYMMDDHHMMSS)
|
||||
* </pre>
|
||||
*/
|
||||
public final class LocationBodyParser implements BodyParser {
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_LOCATION;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer buf) {
|
||||
long alarmFlag = buf.getInt() & 0xFFFFFFFFL;
|
||||
long statusFlag = buf.getInt() & 0xFFFFFFFFL;
|
||||
long latRaw = buf.getInt() & 0xFFFFFFFFL;
|
||||
long lonRaw = buf.getInt() & 0xFFFFFFFFL;
|
||||
int altitudeM = buf.getShort();
|
||||
double speed = (buf.getShort() & 0xFFFF) * 0.1;
|
||||
int direction = buf.getShort() & 0xFFFF;
|
||||
|
||||
byte[] timeBcd = new byte[6];
|
||||
buf.get(timeBcd);
|
||||
String ts = BcdCodec.decode(timeBcd);
|
||||
|
||||
LocalDateTime ldt = LocalDateTime.of(
|
||||
2000 + Integer.parseInt(ts.substring(0, 2)),
|
||||
Integer.parseInt(ts.substring(2, 4)),
|
||||
Integer.parseInt(ts.substring(4, 6)),
|
||||
Integer.parseInt(ts.substring(6, 8)),
|
||||
Integer.parseInt(ts.substring(8, 10)),
|
||||
Integer.parseInt(ts.substring(10, 12)));
|
||||
|
||||
// 跳过剩余附加项
|
||||
if (buf.hasRemaining()) buf.position(buf.limit());
|
||||
|
||||
return new Jt808Body.Location(
|
||||
alarmFlag, statusFlag,
|
||||
lonRaw / 1_000_000.0, latRaw / 1_000_000.0,
|
||||
altitudeM, speed, direction,
|
||||
ldt.atZone(ZoneId.of("Asia/Shanghai")).toInstant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec.parser;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 0x0100 终端注册。
|
||||
*
|
||||
* <p>2013 body 格式:
|
||||
* <pre>
|
||||
* provinceId(2)
|
||||
* cityId(2)
|
||||
* makerId(5 ASCII)
|
||||
* deviceType(20 ASCII) -- 2011 版为 8
|
||||
* deviceId(7 ASCII)
|
||||
* plateColor(1)
|
||||
* plate(GBK, 剩余全部字节)
|
||||
* </pre>
|
||||
*/
|
||||
public final class RegisterBodyParser implements BodyParser {
|
||||
|
||||
private static final Charset GBK = Charset.forName("GBK");
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_REGISTER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer buf) {
|
||||
int province = buf.getShort() & 0xFFFF;
|
||||
int city = buf.getShort() & 0xFFFF;
|
||||
String maker = readAscii(buf, 5);
|
||||
String deviceType = readAscii(buf, 20);
|
||||
String deviceId = readAscii(buf, 7);
|
||||
int plateColor = buf.get() & 0xFF;
|
||||
byte[] plateBytes = new byte[buf.remaining()];
|
||||
buf.get(plateBytes);
|
||||
String plate = new String(plateBytes, GBK).trim();
|
||||
return new Jt808Body.Register(province, city, maker, deviceType, deviceId, plateColor, plate);
|
||||
}
|
||||
|
||||
private static String readAscii(ByteBuffer buf, int len) {
|
||||
byte[] b = new byte[len];
|
||||
buf.get(b);
|
||||
return new String(b, StandardCharsets.US_ASCII).trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.lingniu.ingest.protocol.jt808.config;
|
||||
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.downlink.Jt808CommandDispatcher;
|
||||
import com.lingniu.ingest.protocol.jt808.handler.Jt808LocationHandler;
|
||||
import com.lingniu.ingest.protocol.jt808.inbound.Jt808NettyServer;
|
||||
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
|
||||
import com.lingniu.ingest.session.CommandDispatcher;
|
||||
import com.lingniu.ingest.session.SessionStore;
|
||||
import com.lingniu.ingest.session.config.SessionCoreAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@AutoConfiguration(before = SessionCoreAutoConfiguration.class)
|
||||
@EnableConfigurationProperties(Jt808Properties.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.jt808", name = "enabled", havingValue = "true")
|
||||
public class Jt808AutoConfiguration {
|
||||
|
||||
@Bean public BodyParser jt808RegisterBodyParser() { return new RegisterBodyParser(); }
|
||||
@Bean public BodyParser jt808LocationBodyParser() { return new LocationBodyParser(); }
|
||||
@Bean public BodyParser jt808HeartbeatBodyParser() { return new HeartbeatBodyParser(); }
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public BodyParserRegistry jt808BodyParserRegistry(List<BodyParser> parsers) {
|
||||
return new BodyParserRegistry(parsers);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808MessageDecoder jt808MessageDecoder(BodyParserRegistry registry) {
|
||||
return new Jt808MessageDecoder(registry);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808EventMapper jt808EventMapper() {
|
||||
return new Jt808EventMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808LocationHandler jt808LocationHandler(Jt808EventMapper mapper) {
|
||||
return new Jt808LocationHandler(mapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808ChannelRegistry jt808ChannelRegistry() {
|
||||
return new Jt808ChannelRegistry();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808PendingRequests jt808PendingRequests() {
|
||||
return new Jt808PendingRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
* JT808 模块启用时抢占 {@link CommandDispatcher} Bean。
|
||||
* 通过 {@code @AutoConfiguration(before = SessionCoreAutoConfiguration.class)} 确保本 Bean
|
||||
* 先注册,session-core 的 {@code @ConditionalOnMissingBean} 会因此跳过 Noop 默认实现。
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public CommandDispatcher jt808CommandDispatcher(SessionStore sessions,
|
||||
Jt808ChannelRegistry channelRegistry,
|
||||
Jt808PendingRequests pendingRequests) {
|
||||
return new Jt808CommandDispatcher(sessions, channelRegistry, pendingRequests);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808NettyServer jt808NettyServer(Jt808Properties props,
|
||||
Jt808MessageDecoder decoder,
|
||||
Dispatcher dispatcher,
|
||||
SessionStore sessions,
|
||||
Jt808ChannelRegistry channelRegistry,
|
||||
Jt808PendingRequests pendingRequests) {
|
||||
return new Jt808NettyServer(props.getPort(), props.getWorkerThreads(),
|
||||
decoder, dispatcher, sessions, channelRegistry, pendingRequests);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lingniu.ingest.protocol.jt808.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.jt808")
|
||||
public class Jt808Properties {
|
||||
private boolean enabled = false;
|
||||
private int port = 10808;
|
||||
private int workerThreads = 0;
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public int getPort() { return port; }
|
||||
public void setPort(int port) { this.port = port; }
|
||||
public int getWorkerThreads() { return workerThreads; }
|
||||
public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.lingniu.ingest.protocol.jt808.downlink;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
|
||||
import com.lingniu.ingest.session.CommandDispatcher;
|
||||
import com.lingniu.ingest.session.DeviceSession;
|
||||
import com.lingniu.ingest.session.SessionStore;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.Channel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* JT808 侧的 {@link CommandDispatcher} 实现:把 session-core 的调用桥接到具体 Netty Channel。
|
||||
*
|
||||
* <p>{@link #notify(String, Object)} 异步下发后立即返回;
|
||||
* {@link #request(String, Object, Class, Duration)} 通过 {@link Jt808PendingRequests} 等待设备应答。
|
||||
*
|
||||
* <p>入参 {@code command} 必须是 {@link Jt808Commands.DownlinkCommand},其他类型返回失败 future。
|
||||
*/
|
||||
public final class Jt808CommandDispatcher implements CommandDispatcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Jt808CommandDispatcher.class);
|
||||
|
||||
private final SessionStore sessions;
|
||||
private final Jt808ChannelRegistry channelRegistry;
|
||||
private final Jt808PendingRequests pendingRequests;
|
||||
|
||||
public Jt808CommandDispatcher(SessionStore sessions,
|
||||
Jt808ChannelRegistry channelRegistry,
|
||||
Jt808PendingRequests pendingRequests) {
|
||||
this.sessions = sessions;
|
||||
this.channelRegistry = channelRegistry;
|
||||
this.pendingRequests = pendingRequests;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> notify(String sessionId, Object command) {
|
||||
CommandContext ctx;
|
||||
try {
|
||||
ctx = resolve(sessionId, command);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
byte[] frame = Jt808FrameEncoder.encode(ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body());
|
||||
CompletableFuture<Void> cf = new CompletableFuture<>();
|
||||
ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> {
|
||||
if (f.isSuccess()) cf.complete(null);
|
||||
else cf.completeExceptionally(f.cause());
|
||||
});
|
||||
return cf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> CompletableFuture<T> request(String sessionId, Object command,
|
||||
Class<T> responseType, Duration timeout) {
|
||||
CommandContext ctx;
|
||||
try {
|
||||
ctx = resolve(sessionId, command);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
|
||||
CompletableFuture<Jt808Message> pending = pendingRequests.await(ctx.phone, ctx.serial);
|
||||
byte[] frame = Jt808FrameEncoder.encode(ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body());
|
||||
ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> {
|
||||
if (!f.isSuccess()) {
|
||||
pendingRequests.cancel(ctx.phone, ctx.serial);
|
||||
pending.completeExceptionally(f.cause());
|
||||
}
|
||||
});
|
||||
|
||||
return pending.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
|
||||
.whenComplete((r, ex) -> {
|
||||
if (ex != null) pendingRequests.cancel(ctx.phone, ctx.serial);
|
||||
})
|
||||
.thenApply(msg -> {
|
||||
if (responseType.isInstance(msg)) return responseType.cast(msg);
|
||||
throw new CompletionException(new ClassCastException(
|
||||
"jt808 response type mismatch, expect=" + responseType.getSimpleName()
|
||||
+ " actual=" + msg.getClass().getSimpleName()));
|
||||
});
|
||||
}
|
||||
|
||||
private CommandContext resolve(String sessionId, Object command) {
|
||||
if (!(command instanceof Jt808Commands.DownlinkCommand cmd)) {
|
||||
throw new IllegalArgumentException(
|
||||
"expected Jt808Commands.DownlinkCommand, got " + command.getClass().getName());
|
||||
}
|
||||
String phone = resolvePhone(sessionId);
|
||||
Channel channel = channelRegistry.find(phone)
|
||||
.orElseThrow(() -> new IllegalStateException("no active channel for phone=" + phone));
|
||||
int serial = channelRegistry.nextSerial(phone);
|
||||
return new CommandContext(phone, serial, channel, cmd);
|
||||
}
|
||||
|
||||
private String resolvePhone(String sessionId) {
|
||||
Optional<DeviceSession> s = sessions.findBySessionId(sessionId)
|
||||
.or(() -> sessions.findByPhone(sessionId))
|
||||
.or(() -> sessions.findByVin(sessionId));
|
||||
return s.map(DeviceSession::phone)
|
||||
.filter(p -> p != null && !p.isBlank())
|
||||
.orElse(sessionId); // 回落:直接把 sessionId 当 phone
|
||||
}
|
||||
|
||||
private record CommandContext(String phone, int serial, Channel channel, Jt808Commands.DownlinkCommand cmd) {}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.lingniu.ingest.protocol.jt808.downlink;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 常用下行命令 body 构造器。返回的是 body 字节,外层由 {@link com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder} 包装。
|
||||
*/
|
||||
public final class Jt808Commands {
|
||||
|
||||
private static final Charset GBK = Charset.forName("GBK");
|
||||
|
||||
private Jt808Commands() {}
|
||||
|
||||
/** 0x8001 平台通用应答(ack 一条终端上行)。 */
|
||||
public static DownlinkCommand platformAck(int ackSerial, int ackMsgId, int result) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream(5);
|
||||
writeU16(os, ackSerial);
|
||||
writeU16(os, ackMsgId);
|
||||
os.write(result & 0xFF);
|
||||
return new DownlinkCommand(Jt808MessageId.PLATFORM_GENERAL_RESPONSE, os.toByteArray());
|
||||
}
|
||||
|
||||
/** 0x8100 注册应答(result=0 成功)。 */
|
||||
public static DownlinkCommand registerAck(int ackSerial, int result, String token) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU16(os, ackSerial);
|
||||
os.write(result & 0xFF);
|
||||
if (result == 0 && token != null) {
|
||||
byte[] tokenBytes = token.getBytes(StandardCharsets.US_ASCII);
|
||||
os.write(tokenBytes, 0, tokenBytes.length);
|
||||
}
|
||||
return new DownlinkCommand(Jt808MessageId.PLATFORM_REGISTER_ACK, os.toByteArray());
|
||||
}
|
||||
|
||||
/** 0x8201 位置信息查询。 */
|
||||
public static DownlinkCommand queryLocation() {
|
||||
return new DownlinkCommand(Jt808MessageId.PLATFORM_QUERY_LOCATION, new byte[0]);
|
||||
}
|
||||
|
||||
/** 0x8104 查询终端参数(全量)。 */
|
||||
public static DownlinkCommand queryParameters() {
|
||||
return new DownlinkCommand(Jt808MessageId.PLATFORM_QUERY_PARAMS, new byte[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 0x8103 设置终端参数:参数项列表格式。
|
||||
*
|
||||
* <p>body 形如 {@code count(1) + N*paramItem},每个 paramItem 为 {@code paramId(4) + length(1) + value}。
|
||||
*/
|
||||
public static DownlinkCommand setParameters(java.util.List<ParamItem> items) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
os.write(items.size() & 0xFF);
|
||||
for (ParamItem item : items) {
|
||||
writeU32(os, item.paramId());
|
||||
os.write(item.value().length & 0xFF);
|
||||
os.write(item.value(), 0, item.value().length);
|
||||
}
|
||||
return new DownlinkCommand(Jt808MessageId.PLATFORM_SET_PARAMS, os.toByteArray());
|
||||
}
|
||||
|
||||
/** 0x8105 终端控制:命令字(1) + 命令参数(GBK)。 */
|
||||
public static DownlinkCommand terminalControl(int commandWord, String commandParams) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
os.write(commandWord & 0xFF);
|
||||
if (commandParams != null) {
|
||||
byte[] bytes = commandParams.getBytes(GBK);
|
||||
os.write(bytes, 0, bytes.length);
|
||||
}
|
||||
return new DownlinkCommand(Jt808MessageId.PLATFORM_TERMINAL_CONTROL, os.toByteArray());
|
||||
}
|
||||
|
||||
public record DownlinkCommand(int messageId, byte[] body) {}
|
||||
|
||||
public record ParamItem(long paramId, byte[] value) {}
|
||||
|
||||
private static void writeU16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeU32(ByteArrayOutputStream os, long v) {
|
||||
os.write((int) ((v >> 24) & 0xFF));
|
||||
os.write((int) ((v >> 16) & 0xFF));
|
||||
os.write((int) ((v >> 8) & 0xFF));
|
||||
os.write((int) (v & 0xFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.lingniu.ingest.protocol.jt808.handler;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.annotation.AsyncBatch;
|
||||
import com.lingniu.ingest.api.annotation.EventEmit;
|
||||
import com.lingniu.ingest.api.annotation.MessageMapping;
|
||||
import com.lingniu.ingest.api.annotation.ProtocolHandler;
|
||||
import com.lingniu.ingest.api.annotation.RateLimited;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JT808 示例 Handler:演示注解驱动的路由与事件产出。
|
||||
*
|
||||
* <p>位置上报使用 {@code @AsyncBatch} 批量聚合(对标旧代码里的 4000/1s 批量落库)。
|
||||
* 带 {@code @AsyncBatch} 的方法签名约定为 {@code List<PayloadType>}。
|
||||
*/
|
||||
@ProtocolHandler(protocol = ProtocolId.JT808)
|
||||
public class Jt808LocationHandler {
|
||||
|
||||
private final Jt808EventMapper mapper;
|
||||
|
||||
public Jt808LocationHandler(Jt808EventMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@MessageMapping(command = Jt808MessageId.TERMINAL_LOCATION, desc = "位置信息汇报(批量)")
|
||||
@RateLimited(perVin = 20)
|
||||
@AsyncBatch(size = 4000, waitMs = 1000, poolSize = 2)
|
||||
@EventEmit(VehicleEvent.Location.class)
|
||||
public List<VehicleEvent> onLocationBatch(List<Jt808Message> batch) {
|
||||
List<VehicleEvent> all = new ArrayList<>(batch.size());
|
||||
for (Jt808Message msg : batch) {
|
||||
all.addAll(mapper.toEvents(msg));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@MessageMapping(command = {Jt808MessageId.TERMINAL_REGISTER, Jt808MessageId.TERMINAL_AUTH}, desc = "注册/鉴权")
|
||||
@EventEmit(VehicleEvent.Login.class)
|
||||
public List<VehicleEvent> onLogin(Jt808Message msg) {
|
||||
return mapper.toEvents(msg);
|
||||
}
|
||||
|
||||
@MessageMapping(command = Jt808MessageId.TERMINAL_HEARTBEAT, desc = "心跳")
|
||||
@EventEmit(VehicleEvent.Heartbeat.class)
|
||||
public List<VehicleEvent> onHeartbeat(Jt808Message msg) {
|
||||
return mapper.toEvents(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.lingniu.ingest.protocol.jt808.inbound;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
|
||||
import com.lingniu.ingest.session.DeviceSession;
|
||||
import com.lingniu.ingest.session.SessionStore;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* JT808 Netty 入站处理器。职责:
|
||||
* <ol>
|
||||
* <li>解码 → 构造 RawFrame → 交给 {@link Dispatcher}
|
||||
* <li>对 0x0100 注册 / 0x0102 鉴权 → 创建 / 更新 {@link DeviceSession} 并绑定 Channel
|
||||
* <li>对 0x0001 终端通用应答 → 唤醒 {@link Jt808PendingRequests} 里等待的 future
|
||||
* <li>对 0x0002 心跳 → 下发 0x8001 通用应答(若平台要求)
|
||||
* <li>连接断开 → 清理 Channel 绑定
|
||||
* </ol>
|
||||
*/
|
||||
public class Jt808ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Jt808ChannelHandler.class);
|
||||
|
||||
private final Jt808MessageDecoder decoder;
|
||||
private final Dispatcher dispatcher;
|
||||
private final SessionStore sessions;
|
||||
private final Jt808ChannelRegistry channelRegistry;
|
||||
private final Jt808PendingRequests pendingRequests;
|
||||
|
||||
public Jt808ChannelHandler(Jt808MessageDecoder decoder, Dispatcher dispatcher,
|
||||
SessionStore sessions, Jt808ChannelRegistry channelRegistry,
|
||||
Jt808PendingRequests pendingRequests) {
|
||||
this.decoder = decoder;
|
||||
this.dispatcher = dispatcher;
|
||||
this.sessions = sessions;
|
||||
this.channelRegistry = channelRegistry;
|
||||
this.pendingRequests = pendingRequests;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext ctx, byte[] frame) {
|
||||
Jt808Message msg;
|
||||
try {
|
||||
msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
} catch (Exception e) {
|
||||
log.warn("[jt808] decode failed peer={} len={}", addr(ctx), frame.length, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// ====== 会话维护 & 同步应答匹配 ======
|
||||
String phone = msg.header().phone();
|
||||
switch (msg.header().messageId()) {
|
||||
case Jt808MessageId.TERMINAL_REGISTER -> {
|
||||
DeviceSession session = upsertSession(ctx, msg);
|
||||
channelRegistry.bind(phone, ctx.channel());
|
||||
byte[] registerAck = Jt808FrameEncoder.encode(
|
||||
Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).messageId(),
|
||||
phone,
|
||||
channelRegistry.nextSerial(phone),
|
||||
Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).body());
|
||||
ctx.writeAndFlush(Unpooled.wrappedBuffer(registerAck));
|
||||
}
|
||||
case Jt808MessageId.TERMINAL_AUTH -> {
|
||||
upsertSession(ctx, msg);
|
||||
channelRegistry.bind(phone, ctx.channel());
|
||||
ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0);
|
||||
}
|
||||
case Jt808MessageId.TERMINAL_HEARTBEAT -> {
|
||||
channelRegistry.bind(phone, ctx.channel());
|
||||
ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0);
|
||||
}
|
||||
case Jt808MessageId.TERMINAL_GENERAL_RESPONSE -> {
|
||||
// body: ackSerial(2) + ackMsgId(2) + result(1)
|
||||
if (msg.body() instanceof Jt808Body.Raw raw && raw.bytes().length >= 5) {
|
||||
int ackSerial = ((raw.bytes()[0] & 0xFF) << 8) | (raw.bytes()[1] & 0xFF);
|
||||
pendingRequests.complete(phone, ackSerial, msg);
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
// 定位/其他上行:也可能是对同步命令的应答
|
||||
pendingRequests.complete(phone, msg.header().serialNo(), msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 派发到 Dispatcher ======
|
||||
Map<String, String> meta = new HashMap<>(4);
|
||||
meta.put("vin", phone);
|
||||
meta.put("seq", Integer.toString(msg.header().serialNo()));
|
||||
meta.put("peer", addr(ctx));
|
||||
RawFrame rf = new RawFrame(
|
||||
ProtocolId.JT808,
|
||||
msg.header().messageId(),
|
||||
0,
|
||||
msg,
|
||||
frame,
|
||||
meta,
|
||||
Instant.now());
|
||||
dispatcher.dispatch(rf);
|
||||
}
|
||||
|
||||
private DeviceSession upsertSession(ChannelHandlerContext ctx, Jt808Message msg) {
|
||||
String phone = msg.header().phone();
|
||||
String sessionId = "jt808-" + phone;
|
||||
DeviceSession session = sessions.findBySessionId(sessionId)
|
||||
.map(s -> s.touch(Instant.now()))
|
||||
.orElseGet(() -> new DeviceSession(
|
||||
sessionId, ProtocolId.JT808, null, phone,
|
||||
generateToken(phone),
|
||||
addr(ctx),
|
||||
Instant.now(), Instant.now(),
|
||||
Map.of("protocolVersion", msg.header().version().name())));
|
||||
if (msg.body() instanceof Jt808Body.Register reg && reg.plate() != null) {
|
||||
session = session.withVin(reg.deviceId()); // 没有 VIN 则用 deviceId 占位
|
||||
}
|
||||
sessions.put(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private void ackTerminal(ChannelHandlerContext ctx, String phone, int ackSerial, int ackMsgId, int result) {
|
||||
var cmd = Jt808Commands.platformAck(ackSerial, ackMsgId, result);
|
||||
byte[] frame = Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body());
|
||||
ctx.writeAndFlush(Unpooled.wrappedBuffer(frame));
|
||||
}
|
||||
|
||||
private static String generateToken(String phone) {
|
||||
return UUID.nameUUIDFromBytes(("jt808:" + phone).getBytes()).toString().replace("-", "").substring(0, 16);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext ctx) {
|
||||
channelRegistry.unbind(ctx.channel());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
|
||||
log.warn("[jt808] channel error peer={}", addr(ctx), cause);
|
||||
ctx.close();
|
||||
}
|
||||
|
||||
private static String addr(ChannelHandlerContext ctx) {
|
||||
if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) {
|
||||
return i.getAddress().getHostAddress() + ":" + i.getPort();
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.lingniu.ingest.protocol.jt808.inbound;
|
||||
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameDecoder;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
|
||||
import com.lingniu.ingest.session.SessionStore;
|
||||
import io.netty.bootstrap.ServerBootstrap;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.channel.EventLoopGroup;
|
||||
import io.netty.channel.nio.NioEventLoopGroup;
|
||||
import io.netty.channel.socket.SocketChannel;
|
||||
import io.netty.channel.socket.nio.NioServerSocketChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
|
||||
public class Jt808NettyServer implements InitializingBean, DisposableBean {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Jt808NettyServer.class);
|
||||
|
||||
private final int port;
|
||||
private final int workerThreads;
|
||||
private final Jt808MessageDecoder messageDecoder;
|
||||
private final Dispatcher dispatcher;
|
||||
private final SessionStore sessions;
|
||||
private final Jt808ChannelRegistry channelRegistry;
|
||||
private final Jt808PendingRequests pendingRequests;
|
||||
|
||||
private EventLoopGroup boss;
|
||||
private EventLoopGroup workers;
|
||||
private Channel serverChannel;
|
||||
|
||||
public Jt808NettyServer(int port, int workerThreads,
|
||||
Jt808MessageDecoder messageDecoder, Dispatcher dispatcher,
|
||||
SessionStore sessions,
|
||||
Jt808ChannelRegistry channelRegistry,
|
||||
Jt808PendingRequests pendingRequests) {
|
||||
this.port = port;
|
||||
this.workerThreads = workerThreads;
|
||||
this.messageDecoder = messageDecoder;
|
||||
this.dispatcher = dispatcher;
|
||||
this.sessions = sessions;
|
||||
this.channelRegistry = channelRegistry;
|
||||
this.pendingRequests = pendingRequests;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() throws Exception {
|
||||
ThreadFactory bossTf = r -> new Thread(r, "jt808-boss");
|
||||
ThreadFactory wkTf = r -> new Thread(r, "jt808-worker");
|
||||
this.boss = new NioEventLoopGroup(1, bossTf);
|
||||
this.workers = new NioEventLoopGroup(
|
||||
workerThreads == 0 ? Runtime.getRuntime().availableProcessors() * 2 : workerThreads,
|
||||
wkTf);
|
||||
|
||||
ServerBootstrap b = new ServerBootstrap();
|
||||
b.group(boss, workers)
|
||||
.channel(NioServerSocketChannel.class)
|
||||
.option(ChannelOption.SO_BACKLOG, 1024)
|
||||
.childOption(ChannelOption.TCP_NODELAY, true)
|
||||
.childOption(ChannelOption.SO_KEEPALIVE, true)
|
||||
.childHandler(new ChannelInitializer<SocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(SocketChannel ch) {
|
||||
ch.pipeline()
|
||||
.addLast("frame", new Jt808FrameDecoder())
|
||||
.addLast("dispatch", new Jt808ChannelHandler(
|
||||
messageDecoder, dispatcher, sessions,
|
||||
channelRegistry, pendingRequests));
|
||||
}
|
||||
});
|
||||
this.serverChannel = b.bind(port).sync().channel();
|
||||
log.info("[jt808] Netty server listening on :{}", port);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
try {
|
||||
if (serverChannel != null) serverChannel.close().sync();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
if (boss != null) boss.shutdownGracefully();
|
||||
if (workers != null) workers.shutdownGracefully();
|
||||
log.info("[jt808] Netty server stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.lingniu.ingest.protocol.jt808.mapper;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.LocationPayload;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.spi.EventMapper;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* JT808 消息 → 领域事件映射。sealed body 允许 switch 穷尽处理。
|
||||
*
|
||||
* <p>备注:JT808 里没有强制的 VIN 字段 —— 我们用 {@code phone}(终端手机号/设备号)
|
||||
* 作为 VIN 占位,真实 VIN 需要通过后续的 session-core 从注册消息里补齐。
|
||||
*/
|
||||
public final class Jt808EventMapper implements EventMapper<Jt808Message> {
|
||||
|
||||
@Override
|
||||
public List<VehicleEvent> toEvents(Jt808Message message) {
|
||||
var header = message.header();
|
||||
Instant ingestTime = Instant.now();
|
||||
Map<String, String> meta = Map.of(
|
||||
"messageId", "0x" + Integer.toHexString(header.messageId()),
|
||||
"protocolVersion", header.version().name(),
|
||||
"phone", header.phone());
|
||||
String vin = header.phone(); // TODO: 由 session-core 注入真实 VIN
|
||||
|
||||
return switch (message.body()) {
|
||||
case Jt808Body.Location loc -> List.of(new VehicleEvent.Location(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, loc.deviceTime(), ingestTime, null, meta,
|
||||
new LocationPayload(loc.longitude(), loc.latitude(),
|
||||
loc.altitudeM(), loc.speedKmh(), loc.directionDeg(),
|
||||
loc.alarmFlag(), loc.statusFlag())));
|
||||
|
||||
case Jt808Body.Register reg -> List.of(new VehicleEvent.Login(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta,
|
||||
reg.deviceId(), header.version().name()));
|
||||
|
||||
case Jt808Body.Auth auth -> List.of(new VehicleEvent.Login(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta,
|
||||
auth.imei() == null ? "" : auth.imei(), header.version().name()));
|
||||
|
||||
case Jt808Body.Heartbeat _ -> List.of(new VehicleEvent.Heartbeat(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta));
|
||||
|
||||
case Jt808Body.Raw _ -> List.of(); // 未解析的消息体:暂不产出事件
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.lingniu.ingest.protocol.jt808.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 已解析的消息体。sealed,新增消息体时必须同步新增子类型与 Parser。
|
||||
*/
|
||||
public sealed interface Jt808Body
|
||||
permits Jt808Body.Register, Jt808Body.Auth, Jt808Body.Location,
|
||||
Jt808Body.Heartbeat, Jt808Body.Raw {
|
||||
|
||||
/** 终端注册 0x0100。 */
|
||||
record Register(
|
||||
int province,
|
||||
int city,
|
||||
String maker,
|
||||
String deviceType,
|
||||
String deviceId,
|
||||
int plateColor,
|
||||
String plate
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** 终端鉴权 0x0102。 */
|
||||
record Auth(String token, String imei, String softwareVersion) implements Jt808Body {}
|
||||
|
||||
/** 位置信息汇报 0x0200。 */
|
||||
record Location(
|
||||
long alarmFlag,
|
||||
long statusFlag,
|
||||
double longitude,
|
||||
double latitude,
|
||||
int altitudeM,
|
||||
double speedKmh,
|
||||
int directionDeg,
|
||||
Instant deviceTime
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** 心跳 0x0002(无 body)。 */
|
||||
record Heartbeat() implements Jt808Body {}
|
||||
|
||||
/** 未实现 Parser 的消息体,透传原始字节。 */
|
||||
record Raw(int messageId, byte[] bytes) implements Jt808Body {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.lingniu.ingest.protocol.jt808.model;
|
||||
|
||||
/**
|
||||
* JT/T 808 消息头。
|
||||
*
|
||||
* @param messageId 消息 ID
|
||||
* @param bodyLength 消息体长度(从 attrs 低 10 位解出)
|
||||
* @param encryptType 加密类型(0=不加密 / 1=RSA)
|
||||
* @param subpacket 是否分包
|
||||
* @param version 协议版本(2013 或 2019)
|
||||
* @param phone 终端手机号 / 设备识别号(2013:12 位,2019:20 位)
|
||||
* @param serialNo 消息流水号
|
||||
* @param totalPackets 分包总数(不分包时为 0)
|
||||
* @param packetSeq 分包序号(从 1 开始)
|
||||
*/
|
||||
public record Jt808Header(
|
||||
int messageId,
|
||||
int bodyLength,
|
||||
int encryptType,
|
||||
boolean subpacket,
|
||||
ProtocolVersion version,
|
||||
String phone,
|
||||
int serialNo,
|
||||
int totalPackets,
|
||||
int packetSeq
|
||||
) {
|
||||
public enum ProtocolVersion { V2013, V2019 }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.protocol.jt808.model;
|
||||
|
||||
/**
|
||||
* 完整解析后的 JT808 消息。
|
||||
*/
|
||||
public record Jt808Message(Jt808Header header, Jt808Body body) {}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.lingniu.ingest.protocol.jt808.model;
|
||||
|
||||
/**
|
||||
* JT/T 808 消息 ID 常量集(节选核心消息,按需继续扩展)。
|
||||
*
|
||||
* <p>使用常量类而非 enum:808 消息种类多(超过 80 个),新增高频,常量类比 enum 维护成本更低。
|
||||
*/
|
||||
public final class Jt808MessageId {
|
||||
|
||||
private Jt808MessageId() {}
|
||||
|
||||
// ===== 终端上行 =====
|
||||
public static final int TERMINAL_GENERAL_RESPONSE = 0x0001;
|
||||
public static final int TERMINAL_HEARTBEAT = 0x0002;
|
||||
public static final int TERMINAL_UNREGISTER = 0x0003;
|
||||
public static final int TERMINAL_REGISTER = 0x0100;
|
||||
public static final int TERMINAL_AUTH = 0x0102;
|
||||
public static final int TERMINAL_PARAMS_REPORT = 0x0104;
|
||||
public static final int TERMINAL_ATTRS_REPORT = 0x0107;
|
||||
public static final int TERMINAL_LOCATION = 0x0200;
|
||||
public static final int TERMINAL_LOCATION_BATCH = 0x0704;
|
||||
public static final int TERMINAL_EVENT_REPORT = 0x0301;
|
||||
public static final int TERMINAL_CAN_DATA_REPORT = 0x0705;
|
||||
public static final int TERMINAL_MEDIA_EVENT = 0x0800;
|
||||
public static final int TERMINAL_MEDIA_UPLOAD = 0x0801;
|
||||
public static final int TERMINAL_PASSTHROUGH = 0x0900;
|
||||
|
||||
// ===== 平台下行 =====
|
||||
public static final int PLATFORM_GENERAL_RESPONSE = 0x8001;
|
||||
public static final int PLATFORM_REGISTER_ACK = 0x8100;
|
||||
public static final int PLATFORM_SET_PARAMS = 0x8103;
|
||||
public static final int PLATFORM_QUERY_PARAMS = 0x8104;
|
||||
public static final int PLATFORM_TERMINAL_CONTROL = 0x8105;
|
||||
public static final int PLATFORM_QUERY_LOCATION = 0x8201;
|
||||
public static final int PLATFORM_TRACK_CONTROL = 0x8202;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.lingniu.ingest.protocol.jt808.session;
|
||||
|
||||
import io.netty.channel.Channel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* JT808 终端 Channel 注册表。按 {@code phone} 索引,一个 phone 对应一个活跃 Channel。
|
||||
*
|
||||
* <p>{@link Jt808CommandDispatcher} 通过本类查 Channel;{@code Jt808ChannelHandler} 在
|
||||
* 连接建立 / 断开 / 鉴权完成时维护映射。
|
||||
*
|
||||
* <p>线程安全:{@link ConcurrentHashMap}。
|
||||
*/
|
||||
public final class Jt808ChannelRegistry {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Jt808ChannelRegistry.class);
|
||||
|
||||
private final ConcurrentMap<String, Channel> byPhone = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<Channel, String> reverse = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, AtomicInteger> serials = new ConcurrentHashMap<>();
|
||||
|
||||
public void bind(String phone, Channel channel) {
|
||||
if (phone == null || phone.isBlank() || channel == null) return;
|
||||
Channel old = byPhone.put(phone, channel);
|
||||
reverse.put(channel, phone);
|
||||
if (old != null && old != channel) {
|
||||
reverse.remove(old);
|
||||
old.close();
|
||||
log.info("channel rebound phone={} oldChannel={} newChannel={}", phone, old.id(), channel.id());
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Channel> find(String phone) {
|
||||
if (phone == null) return Optional.empty();
|
||||
Channel c = byPhone.get(phone);
|
||||
if (c == null || !c.isActive()) {
|
||||
if (c != null) unbind(c);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(c);
|
||||
}
|
||||
|
||||
public void unbind(Channel channel) {
|
||||
String phone = reverse.remove(channel);
|
||||
if (phone != null) byPhone.remove(phone, channel);
|
||||
}
|
||||
|
||||
public int nextSerial(String phone) {
|
||||
return serials.computeIfAbsent(phone, k -> new AtomicInteger())
|
||||
.updateAndGet(i -> (i + 1) & 0xFFFF);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return byPhone.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.lingniu.ingest.protocol.jt808.session;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
/**
|
||||
* 下行命令的请求/应答同步匹配表。
|
||||
*
|
||||
* <p>调用 {@link #await(String, int)} 时注册一个 {@link CompletableFuture},
|
||||
* 对应 key = {@code phone + ':' + serial};当 {@code Jt808ChannelHandler} 收到 0x0001 通用应答
|
||||
* 或其它同步应答消息时调用 {@link #complete(String, int, Jt808Message)} 触发 future 完成。
|
||||
*/
|
||||
public final class Jt808PendingRequests {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Jt808PendingRequests.class);
|
||||
|
||||
private final ConcurrentMap<String, CompletableFuture<Jt808Message>> pending = new ConcurrentHashMap<>();
|
||||
|
||||
public CompletableFuture<Jt808Message> await(String phone, int serial) {
|
||||
CompletableFuture<Jt808Message> cf = new CompletableFuture<>();
|
||||
pending.put(key(phone, serial), cf);
|
||||
return cf;
|
||||
}
|
||||
|
||||
public void complete(String phone, int serial, Jt808Message response) {
|
||||
CompletableFuture<Jt808Message> cf = pending.remove(key(phone, serial));
|
||||
if (cf != null) {
|
||||
cf.complete(response);
|
||||
} else {
|
||||
log.debug("no pending request for phone={} serial={}", phone, serial);
|
||||
}
|
||||
}
|
||||
|
||||
public void cancel(String phone, int serial) {
|
||||
CompletableFuture<Jt808Message> cf = pending.remove(key(phone, serial));
|
||||
if (cf != null) cf.cancel(true);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return pending.size();
|
||||
}
|
||||
|
||||
private static String key(String phone, int serial) {
|
||||
return phone + ":" + serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* JT808 黄金样本集回放测试:遍历 {@code src/test/resources/samples/jt808/*.hex}。
|
||||
*
|
||||
* <p>样本文件格式:一行十六进制(完整 0x7e 包裹帧),脚本脱敏产出。
|
||||
* 测试会去掉首尾 0x7e,执行反转义后调用 {@link Jt808MessageDecoder}。
|
||||
*/
|
||||
class Jt808DecoderGoldenTest {
|
||||
|
||||
private final Jt808MessageDecoder decoder = new Jt808MessageDecoder(
|
||||
new BodyParserRegistry(List.of(
|
||||
new RegisterBodyParser(),
|
||||
new LocationBodyParser(),
|
||||
new HeartbeatBodyParser())));
|
||||
|
||||
@TestFactory
|
||||
Collection<DynamicTest> replaySamples() throws URISyntaxException, IOException {
|
||||
var url = getClass().getClassLoader().getResource("samples/jt808");
|
||||
if (url == null) return List.of();
|
||||
Path dir = Paths.get(url.toURI());
|
||||
try (Stream<Path> s = Files.list(dir)) {
|
||||
return s.filter(p -> p.toString().endsWith(".hex"))
|
||||
.sorted()
|
||||
.map(this::toTest)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
private DynamicTest toTest(Path sample) {
|
||||
return DynamicTest.dynamicTest(sample.getFileName().toString(), () -> {
|
||||
byte[] framed = readHex(sample);
|
||||
assertThat(framed[0]).as("must start with 0x7e").isEqualTo((byte) 0x7e);
|
||||
assertThat(framed[framed.length - 1]).as("must end with 0x7e").isEqualTo((byte) 0x7e);
|
||||
|
||||
byte[] unescaped = Jt808Escape.unescape(framed, 1, framed.length - 2);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped));
|
||||
|
||||
assertThat(msg.header().messageId()).isNotNegative();
|
||||
assertThat(msg.header().phone()).isNotEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] readHex(Path path) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String line : Files.readAllLines(path)) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue;
|
||||
sb.append(trimmed);
|
||||
}
|
||||
String hex = sb.toString();
|
||||
int len = hex.length() / 2;
|
||||
byte[] out = new byte[len];
|
||||
for (int i = 0; i < len; i++) {
|
||||
out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.codec.BcdCodec;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808DecoderTest {
|
||||
|
||||
private final Jt808MessageDecoder decoder = new Jt808MessageDecoder(
|
||||
new BodyParserRegistry(List.of(
|
||||
new RegisterBodyParser(),
|
||||
new LocationBodyParser(),
|
||||
new HeartbeatBodyParser())));
|
||||
|
||||
@Test
|
||||
void decodesSyntheticLocationFrame() {
|
||||
byte[] body = buildLocationBody();
|
||||
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 0x0001, body);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
|
||||
assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION);
|
||||
assertThat(msg.header().phone()).isEqualTo("123456789012");
|
||||
assertThat(msg.header().serialNo()).isEqualTo(1);
|
||||
|
||||
assertThat(msg.body()).isInstanceOf(Jt808Body.Location.class);
|
||||
Jt808Body.Location loc = (Jt808Body.Location) msg.body();
|
||||
assertThat(loc.longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001));
|
||||
assertThat(loc.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001));
|
||||
assertThat(loc.speedKmh()).isEqualTo(52.3, org.assertj.core.data.Offset.offset(0.01));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesSyntheticHeartbeatFrame() {
|
||||
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 0x0002, new byte[0]);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT);
|
||||
assertThat(msg.body()).isInstanceOf(Jt808Body.Heartbeat.class);
|
||||
}
|
||||
|
||||
// ===== helpers =====
|
||||
|
||||
private static byte[] buildLocationBody() {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU32(os, 0); // alarmFlag
|
||||
writeU32(os, 0x00030000); // statusFlag
|
||||
writeU32(os, 39_916_527L); // lat
|
||||
writeU32(os, 116_397_128L); // lon
|
||||
writeU16(os, 50); // altitude
|
||||
writeU16(os, 523); // speed 52.3
|
||||
writeU16(os, 90); // direction
|
||||
byte[] bcd = BcdCodec.encode("240102030405");
|
||||
os.write(bcd, 0, 6);
|
||||
return os.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) {
|
||||
// header: msgId(2) attrs(2) phone(6 BCD) serial(2)
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU16(os, msgId);
|
||||
int attrs = body.length & 0x03FF; // 2013 版,无子包、无加密
|
||||
writeU16(os, attrs);
|
||||
byte[] phoneBcd = BcdCodec.encode(phone);
|
||||
os.write(phoneBcd, 0, 6);
|
||||
writeU16(os, serial);
|
||||
os.write(body, 0, body.length);
|
||||
byte[] raw = os.toByteArray();
|
||||
byte bcc = BccChecksum.compute(raw, 0, raw.length);
|
||||
|
||||
ByteArrayOutputStream framed = new ByteArrayOutputStream();
|
||||
framed.write(raw, 0, raw.length);
|
||||
framed.write(bcc & 0xFF);
|
||||
return framed.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeU16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeU32(ByteArrayOutputStream os, long v) {
|
||||
os.write((int) ((v >> 24) & 0xFF));
|
||||
os.write((int) ((v >> 16) & 0xFF));
|
||||
os.write((int) ((v >> 8) & 0xFF));
|
||||
os.write((int) (v & 0xFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808EscapeTest {
|
||||
|
||||
@Test
|
||||
void roundTrip() {
|
||||
byte[] raw = {0x30, 0x7e, 0x08, 0x7d, (byte) 0xFF};
|
||||
byte[] escaped = Jt808Escape.escape(raw);
|
||||
assertThat(escaped).containsExactly(0x30, 0x7d, 0x02, 0x08, 0x7d, 0x01, 0xFF);
|
||||
|
||||
byte[] back = Jt808Escape.unescape(escaped, 0, escaped.length);
|
||||
assertThat(back).containsExactly(raw[0], raw[1], raw[2], raw[3], raw[4]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noEscapeNeeded() {
|
||||
byte[] raw = {0x01, 0x02, 0x03};
|
||||
assertThat(Jt808Escape.escape(raw)).containsExactly(0x01, 0x02, 0x03);
|
||||
assertThat(Jt808Escape.unescape(raw, 0, raw.length)).containsExactly(0x01, 0x02, 0x03);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 端到端验证:encoder 生成的下行帧可以被 decoder 反向解析,且字段对称。
|
||||
*/
|
||||
class Jt808FrameEncoderTest {
|
||||
|
||||
private final Jt808MessageDecoder decoder = new Jt808MessageDecoder(
|
||||
new BodyParserRegistry(List.of(
|
||||
new RegisterBodyParser(),
|
||||
new LocationBodyParser(),
|
||||
new HeartbeatBodyParser())));
|
||||
|
||||
@Test
|
||||
void encodeThenDecodePlatformAck() {
|
||||
var cmd = Jt808Commands.platformAck(0x1234, 0x0200, 0);
|
||||
byte[] framed = Jt808FrameEncoder.encode(cmd.messageId(), "123456789012", 7, cmd.body());
|
||||
|
||||
// strip outer 0x7e and unescape
|
||||
byte[] unescaped = Jt808Escape.unescape(framed, 1, framed.length - 2);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped));
|
||||
|
||||
assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.PLATFORM_GENERAL_RESPONSE);
|
||||
assertThat(msg.header().phone()).isEqualTo("123456789012");
|
||||
assertThat(msg.header().serialNo()).isEqualTo(7);
|
||||
assertThat(msg.body()).isInstanceOf(Jt808Body.Raw.class);
|
||||
Jt808Body.Raw raw = (Jt808Body.Raw) msg.body();
|
||||
// body: ackSerial(2) + ackMsgId(2) + result(1) = 5 bytes
|
||||
assertThat(raw.bytes()).hasSize(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodeQueryLocationProducesFramedWith7eBoundary() {
|
||||
byte[] framed = Jt808FrameEncoder.encode(
|
||||
Jt808MessageId.PLATFORM_QUERY_LOCATION, "123456789012", 1, new byte[0]);
|
||||
assertThat(framed[0]).isEqualTo((byte) 0x7e);
|
||||
assertThat(framed[framed.length - 1]).isEqualTo((byte) 0x7e);
|
||||
// 中间不应出现裸 0x7e
|
||||
for (int i = 1; i < framed.length - 1; i++) {
|
||||
assertThat(framed[i]).isNotEqualTo((byte) 0x7e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.lingniu.ingest.protocol.jt808.mapper;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808EventMapperTest {
|
||||
|
||||
private final Jt808EventMapper mapper = new Jt808EventMapper();
|
||||
|
||||
@Test
|
||||
void locationBodyProducesLocationEvent() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION, 28, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var body = new Jt808Body.Location(
|
||||
0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z"));
|
||||
var msg = new Jt808Message(header, body);
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(msg);
|
||||
|
||||
assertThat(events).hasSize(1);
|
||||
assertThat(events.get(0)).isInstanceOf(VehicleEvent.Location.class);
|
||||
assertThat(events.get(0).source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(events.get(0).vin()).isEqualTo("123456789012");
|
||||
}
|
||||
|
||||
@Test
|
||||
void heartbeatBodyProducesHeartbeatEvent() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_HEARTBEAT, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var msg = new Jt808Message(header, new Jt808Body.Heartbeat());
|
||||
assertThat(mapper.toEvents(msg)).hasSize(1)
|
||||
.first().isInstanceOf(VehicleEvent.Heartbeat.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
7e010200070000000000230000687569746f6e67417e
|
||||
@@ -0,0 +1 @@
|
||||
7e0102001800000000006500016e65382f51525a50664738596f7145575155577236513d3d607e
|
||||
@@ -0,0 +1 @@
|
||||
7e02000036000000000001072d000000000008000301ceb03b0732f26e000d02af0010260413123353010400052425020200000302000025040000000030011f3101192c7e
|
||||
@@ -0,0 +1 @@
|
||||
7e020040460100000000000000000002002c00000000000c000301bb0e2107213304003f0000010d26041312335514040000000017020000010400006722030200002504000000002a020000300115310118ea0402008000fb7e
|
||||
@@ -0,0 +1 @@
|
||||
7e020000360000000000100278000000000008000301d596aa0735b9910001004b01472604131234030104000a42c10202000003020000250400000000300115310117107e
|
||||
@@ -0,0 +1 @@
|
||||
7e020000360000000001020443000000000008000301d2d74907376cde000f00000000260413123354010400078789020200000302000025040000000030011b310112db7e
|
||||
@@ -0,0 +1 @@
|
||||
7e02004048010000000000000000020204d2000000000048000301cc1d4f073ed6640010033301492604131234001404000000001702000001040000f0542504000000002a02000030011f310110ea04020c8300ef0400000000d67e
|
||||
@@ -0,0 +1 @@
|
||||
7e02000036000000000413013d000000000008000201d392280736f1e30007000000002604131311220104000f334a020200000302000025040000000030011931010a097e
|
||||
@@ -0,0 +1 @@
|
||||
7e0704003b000000000058034e0001010036000000000008000301e26c120725c15100110000000726041313111301040006ffc6030200002504000000002a02000030010a31010c3c7e
|
||||
Reference in New Issue
Block a user