feat: make gb32960 archive history query production ready
This commit is contained in:
59
modules/protocols/protocol-jt808/pom.xml
Normal file
59
modules/protocols/protocol-jt808/pom.xml
Normal file
@@ -0,0 +1,59 @@
|
||||
<?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>
|
||||
<relativePath>../../../pom.xml</relativePath>
|
||||
</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>com.lingniu.ingest</groupId>
|
||||
<artifactId>vehicle-identity</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,67 @@
|
||||
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) {
|
||||
int len = in.readableBytes();
|
||||
byte[] raw = new byte[len];
|
||||
in.readBytes(raw);
|
||||
out.add(new Jt808MalformedFrame(raw, peer(ctx), "jt808 missing frame boundary"));
|
||||
return;
|
||||
}
|
||||
if (startIdx != in.readerIndex()) {
|
||||
int len = startIdx - in.readerIndex();
|
||||
byte[] raw = new byte[len];
|
||||
in.readBytes(raw);
|
||||
out.add(new Jt808MalformedFrame(raw, peer(ctx), "jt808 leading bytes before frame boundary"));
|
||||
}
|
||||
int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e);
|
||||
if (endIdx < 0) {
|
||||
if (in.readableBytes() > MAX_FRAME) {
|
||||
int len = in.readableBytes();
|
||||
byte[] raw = new byte[len];
|
||||
in.readBytes(raw);
|
||||
out.add(new Jt808MalformedFrame(raw, peer(ctx), "jt808 unclosed frame too large: " + len));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static String peer(ChannelHandlerContext ctx) {
|
||||
if (ctx == null || ctx.channel() == null || ctx.channel().remoteAddress() == null) {
|
||||
return "";
|
||||
}
|
||||
return ctx.channel().remoteAddress().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JT/T 808 下行帧编码器:把 header + body 字节拼装 → 加 XOR 校验 → 转义 → 外包 0x7e。
|
||||
*
|
||||
* <p>支持 2013 与 2019 版基础头;短命令可直接编码,长命令通过 {@link #encodeSubpackages}
|
||||
* 拆分为 JT/T 808 分包帧。
|
||||
*/
|
||||
public final class Jt808FrameEncoder {
|
||||
|
||||
private static final int MAX_BODY_LENGTH = 0x03FF;
|
||||
|
||||
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, 0, 0);
|
||||
return wrap(raw);
|
||||
}
|
||||
|
||||
public static List<byte[]> encodeSubpackages(int messageId, String phone, int serialNo, byte[] body) {
|
||||
return encodeSubpackages(messageId, phone, serialNo, body, Jt808Header.ProtocolVersion.V2013);
|
||||
}
|
||||
|
||||
public static List<byte[]> encodeSubpackages(int messageId,
|
||||
String phone,
|
||||
int serialNo,
|
||||
byte[] body,
|
||||
Jt808Header.ProtocolVersion version) {
|
||||
if (body == null) {
|
||||
body = new byte[0];
|
||||
}
|
||||
if (body.length <= MAX_BODY_LENGTH) {
|
||||
return List.of(encode(messageId, phone, serialNo, body, version));
|
||||
}
|
||||
int totalPackets = (body.length + MAX_BODY_LENGTH - 1) / MAX_BODY_LENGTH;
|
||||
if (totalPackets > 0xFFFF) {
|
||||
throw new IllegalArgumentException("jt808 subpackage count too large: " + totalPackets);
|
||||
}
|
||||
List<byte[]> frames = new ArrayList<>(totalPackets);
|
||||
for (int i = 0; i < totalPackets; i++) {
|
||||
int offset = i * MAX_BODY_LENGTH;
|
||||
int len = Math.min(MAX_BODY_LENGTH, body.length - offset);
|
||||
byte[] chunk = new byte[len];
|
||||
System.arraycopy(body, offset, chunk, 0, len);
|
||||
frames.add(wrap(buildRaw(messageId, phone, serialNo, chunk, version, totalPackets, i + 1)));
|
||||
}
|
||||
return List.copyOf(frames);
|
||||
}
|
||||
|
||||
private static byte[] wrap(byte[] raw) {
|
||||
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, int totalPackets, int packetSeq) {
|
||||
if (body == null) {
|
||||
body = new byte[0];
|
||||
}
|
||||
if (body.length > MAX_BODY_LENGTH) {
|
||||
throw new IllegalArgumentException("jt808 body too large without subpackage: "
|
||||
+ body.length + " > " + MAX_BODY_LENGTH);
|
||||
}
|
||||
boolean subpacket = totalPackets > 0;
|
||||
boolean v2019 = version == Jt808Header.ProtocolVersion.V2019;
|
||||
int phoneLen = v2019 ? 10 : 6;
|
||||
int len = 2 /* msgId */ + 2 /* attrs */ + (v2019 ? 1 : 0)
|
||||
+ phoneLen + 2 /* serial */ + (subpacket ? 4 : 0) + 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 (subpacket) attrs |= 0x2000;
|
||||
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);
|
||||
|
||||
if (subpacket) {
|
||||
os.write((totalPackets >> 8) & 0xFF);
|
||||
os.write(totalPackets & 0xFF);
|
||||
os.write((packetSeq >> 8) & 0xFF);
|
||||
os.write(packetSeq & 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,7 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
public record Jt808MalformedFrame(
|
||||
byte[] rawBytes,
|
||||
String peer,
|
||||
String reason
|
||||
) {}
|
||||
@@ -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,49 @@
|
||||
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.StandardCharsets;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class AuthBodyParser implements BodyParser {
|
||||
|
||||
private static final Pattern IMEI = Pattern.compile("(?<!\\d)(\\d{15})(?!\\d)");
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_AUTH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer buf) {
|
||||
byte[] raw = new byte[buf.remaining()];
|
||||
buf.get(raw);
|
||||
String token = new String(raw, StandardCharsets.UTF_8)
|
||||
.replace("\u0000", "")
|
||||
.trim();
|
||||
Matcher matcher = IMEI.matcher(token);
|
||||
String imei = matcher.find() ? matcher.group(1) : "";
|
||||
String softwareVersion = softwareVersion(token, imei);
|
||||
return new Jt808Body.Auth(token, imei, softwareVersion);
|
||||
}
|
||||
|
||||
private static String softwareVersion(String token, String imei) {
|
||||
if (imei.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
int pos = token.indexOf(imei);
|
||||
if (pos < 0) {
|
||||
return "";
|
||||
}
|
||||
String tail = token.substring(pos + imei.length()).trim();
|
||||
while (tail.startsWith(",") || tail.startsWith(";") || tail.startsWith("|")) {
|
||||
tail = tail.substring(1).trim();
|
||||
}
|
||||
return tail;
|
||||
}
|
||||
}
|
||||
@@ -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,40 @@
|
||||
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.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class LocationBatchBodyParser implements BodyParser {
|
||||
|
||||
private final LocationBodyParser locationParser = new LocationBodyParser();
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_LOCATION_BATCH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
|
||||
int count = body.remaining() >= 2 ? body.getShort() & 0xFFFF : 0;
|
||||
int batchType = body.remaining() >= 1 ? body.get() & 0xFF : 0;
|
||||
List<Jt808Body.Location> locations = new ArrayList<>(count);
|
||||
for (int i = 0; i < count && body.remaining() >= 2; i++) {
|
||||
int len = body.getShort() & 0xFFFF;
|
||||
if (len > body.remaining()) {
|
||||
len = body.remaining();
|
||||
}
|
||||
ByteBuffer slice = body.slice(body.position(), len);
|
||||
Jt808Body parsed = locationParser.parse(header, slice);
|
||||
if (parsed instanceof Jt808Body.Location location) {
|
||||
locations.add(location);
|
||||
}
|
||||
body.position(body.position() + len);
|
||||
}
|
||||
return new Jt808Body.LocationBatch(batchType, List.copyOf(locations));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 0x0200 位置信息汇报。
|
||||
*
|
||||
* <p>固定 28 字节 + 可选附加项。附加项按 JT/T 808 的 ID/长度/值 TLV 保留原始字节,
|
||||
* 由上层统计或导出服务按配置解释。
|
||||
* <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)));
|
||||
|
||||
Map<Integer, byte[]> extensions = parseExtensions(buf);
|
||||
|
||||
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(),
|
||||
extensions);
|
||||
}
|
||||
|
||||
private static Map<Integer, byte[]> parseExtensions(ByteBuffer buf) {
|
||||
if (!buf.hasRemaining()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Integer, byte[]> out = new LinkedHashMap<>();
|
||||
while (buf.remaining() >= 2) {
|
||||
int id = buf.get() & 0xFF;
|
||||
int len = buf.get() & 0xFF;
|
||||
if (len > buf.remaining()) {
|
||||
byte[] truncated = new byte[buf.remaining()];
|
||||
buf.get(truncated);
|
||||
out.put(id, truncated);
|
||||
break;
|
||||
}
|
||||
byte[] value = new byte[len];
|
||||
buf.get(value);
|
||||
out.put(id, value);
|
||||
}
|
||||
if (buf.hasRemaining()) {
|
||||
byte[] tail = new byte[buf.remaining()];
|
||||
buf.get(tail);
|
||||
out.put(0xFFFF, tail);
|
||||
}
|
||||
return Map.copyOf(out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
|
||||
public final class MediaEventBodyParser implements BodyParser {
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_MEDIA_EVENT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
|
||||
long mediaId = body.remaining() >= 4 ? body.getInt() & 0xFFFFFFFFL : 0;
|
||||
int mediaType = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
int mediaFormat = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
int eventCode = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
int channelId = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
return new Jt808Body.MediaEvent(mediaId, mediaType, mediaFormat, eventCode, channelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
|
||||
public final class MediaUploadBodyParser implements BodyParser {
|
||||
|
||||
private final LocationBodyParser locationParser = new LocationBodyParser();
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_MEDIA_UPLOAD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
|
||||
long mediaId = body.remaining() >= 4 ? body.getInt() & 0xFFFFFFFFL : 0;
|
||||
int mediaType = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
int mediaFormat = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
int eventCode = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
int channelId = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
Jt808Body.Location location = null;
|
||||
if (body.remaining() >= 28) {
|
||||
ByteBuffer loc = body.slice(body.position(), 28);
|
||||
if (locationParser.parse(header, loc) instanceof Jt808Body.Location parsed) {
|
||||
location = parsed;
|
||||
}
|
||||
body.position(body.position() + 28);
|
||||
}
|
||||
byte[] data = new byte[body.remaining()];
|
||||
body.get(data);
|
||||
return new Jt808Body.MediaUpload(mediaId, mediaType, mediaFormat, eventCode, channelId, location, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
|
||||
public final class PassthroughBodyParser implements BodyParser {
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_PASSTHROUGH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
|
||||
int type = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
byte[] data = new byte[body.remaining()];
|
||||
body.get(data);
|
||||
return new Jt808Body.Passthrough(type, data);
|
||||
}
|
||||
}
|
||||
@@ -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,58 @@
|
||||
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.nio.charset.StandardCharsets;
|
||||
|
||||
public final class TerminalAttrsBodyParser implements BodyParser {
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_ATTRS_REPORT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
|
||||
int terminalType = readU16(body);
|
||||
String maker = readAscii(body, 5);
|
||||
String model = readAscii(body, 20);
|
||||
String terminalId = readAscii(body, 7);
|
||||
String iccid = readBcd(body, 10);
|
||||
String hardwareVersion = readAscii(body, 10);
|
||||
String firmwareVersion = readAscii(body, 10);
|
||||
int gnssProperty = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
int commProperty = body.hasRemaining() ? body.get() & 0xFF : 0;
|
||||
if (body.hasRemaining()) {
|
||||
body.position(body.limit());
|
||||
}
|
||||
return new Jt808Body.TerminalAttrs(
|
||||
terminalType, maker, model, terminalId, iccid, hardwareVersion, firmwareVersion,
|
||||
gnssProperty, commProperty);
|
||||
}
|
||||
|
||||
private static int readU16(ByteBuffer body) {
|
||||
return body.remaining() >= 2 ? body.getShort() & 0xFFFF : 0;
|
||||
}
|
||||
|
||||
private static String readAscii(ByteBuffer body, int len) {
|
||||
int n = Math.min(len, body.remaining());
|
||||
byte[] bytes = new byte[n];
|
||||
body.get(bytes);
|
||||
if (n < len && body.hasRemaining()) {
|
||||
body.position(Math.min(body.limit(), body.position() + (len - n)));
|
||||
}
|
||||
return new String(bytes, StandardCharsets.US_ASCII).trim();
|
||||
}
|
||||
|
||||
private static String readBcd(ByteBuffer body, int len) {
|
||||
int n = Math.min(len, body.remaining());
|
||||
byte[] bytes = new byte[n];
|
||||
body.get(bytes);
|
||||
return BcdCodec.decode(bytes).trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class TerminalParamsReportBodyParser implements BodyParser {
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_PARAMS_REPORT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
|
||||
int responseSerialNo = body.remaining() >= 2 ? body.getShort() & 0xFFFF : 0;
|
||||
int count = body.remaining() >= 1 ? body.get() & 0xFF : 0;
|
||||
Map<Long, byte[]> parameters = new LinkedHashMap<>();
|
||||
for (int i = 0; i < count && body.remaining() >= 5; i++) {
|
||||
long paramId = body.getInt() & 0xFFFFFFFFL;
|
||||
int len = body.get() & 0xFF;
|
||||
if (len > body.remaining()) {
|
||||
len = body.remaining();
|
||||
}
|
||||
byte[] value = new byte[len];
|
||||
body.get(value);
|
||||
parameters.put(paramId, value);
|
||||
}
|
||||
return new Jt808Body.ParamsReport(responseSerialNo, Map.copyOf(parameters));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
|
||||
public final class UnregisterBodyParser implements BodyParser {
|
||||
|
||||
@Override
|
||||
public int messageId() {
|
||||
return Jt808MessageId.TERMINAL_UNREGISTER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
|
||||
body.position(body.limit());
|
||||
return new Jt808Body.Unregister();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.lingniu.ingest.protocol.jt808.config;
|
||||
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityRegistry;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
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.AuthBodyParser;
|
||||
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.LocationBatchBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.MediaEventBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.MediaUploadBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.PassthroughBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalAttrsBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalParamsReportBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.UnregisterBodyParser;
|
||||
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 jt808AuthBodyParser() { return new AuthBodyParser(); }
|
||||
@Bean public BodyParser jt808LocationBodyParser() { return new LocationBodyParser(); }
|
||||
@Bean public BodyParser jt808HeartbeatBodyParser() { return new HeartbeatBodyParser(); }
|
||||
@Bean public BodyParser jt808UnregisterBodyParser() { return new UnregisterBodyParser(); }
|
||||
@Bean public BodyParser jt808TerminalParamsReportBodyParser() { return new TerminalParamsReportBodyParser(); }
|
||||
@Bean public BodyParser jt808TerminalAttrsBodyParser() { return new TerminalAttrsBodyParser(); }
|
||||
@Bean public BodyParser jt808LocationBatchBodyParser() { return new LocationBatchBodyParser(); }
|
||||
@Bean public BodyParser jt808MediaEventBodyParser() { return new MediaEventBodyParser(); }
|
||||
@Bean public BodyParser jt808MediaUploadBodyParser() { return new MediaUploadBodyParser(); }
|
||||
@Bean public BodyParser jt808PassthroughBodyParser() { return new PassthroughBodyParser(); }
|
||||
|
||||
@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(VehicleIdentityResolver identityResolver) {
|
||||
return new Jt808EventMapper(identityResolver);
|
||||
}
|
||||
|
||||
@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,
|
||||
VehicleIdentityRegistry identityRegistry,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Jt808ChannelRegistry channelRegistry,
|
||||
Jt808PendingRequests pendingRequests) {
|
||||
return new Jt808NettyServer(props.getPort(), props.getWorkerThreads(),
|
||||
decoder, dispatcher, sessions, identityRegistry, identityResolver,
|
||||
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,131 @@
|
||||
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;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
return writeFrames(ctx, false);
|
||||
}
|
||||
|
||||
@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);
|
||||
writeFrames(ctx, true).whenComplete((unused, ex) -> {
|
||||
if (ex != null) {
|
||||
pendingRequests.cancel(ctx.phone, ctx.serial);
|
||||
pending.completeExceptionally(ex);
|
||||
}
|
||||
});
|
||||
|
||||
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 CompletableFuture<Void> writeFrames(CommandContext ctx, boolean request) {
|
||||
var frames = Jt808FrameEncoder.encodeSubpackages(
|
||||
ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body());
|
||||
CompletableFuture<Void> cf = new CompletableFuture<>();
|
||||
AtomicInteger remaining = new AtomicInteger(frames.size());
|
||||
for (byte[] frame : frames) {
|
||||
ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> {
|
||||
if (!f.isSuccess()) {
|
||||
if (request) {
|
||||
pendingRequests.cancel(ctx.phone, ctx.serial);
|
||||
}
|
||||
cf.completeExceptionally(f.cause());
|
||||
return;
|
||||
}
|
||||
if (remaining.decrementAndGet() == 0) {
|
||||
cf.complete(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
return cf;
|
||||
}
|
||||
|
||||
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,118 @@
|
||||
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;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 常用下行命令 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]);
|
||||
}
|
||||
|
||||
/** 0x8107 查询终端属性。 */
|
||||
public static DownlinkCommand queryAttributes() {
|
||||
return new DownlinkCommand(Jt808MessageId.PLATFORM_QUERY_ATTRS, 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());
|
||||
}
|
||||
|
||||
/** 0x8203 人工确认报警:报警消息流水号(WORD) + 报警类型(DWORD)。 */
|
||||
public static DownlinkCommand alarmAck(int responseSerialNo, long alarmType) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream(6);
|
||||
writeU16(os, responseSerialNo);
|
||||
writeU32(os, alarmType);
|
||||
return new DownlinkCommand(Jt808MessageId.PLATFORM_ALARM_ACK, os.toByteArray());
|
||||
}
|
||||
|
||||
/** 0x8601 删除圆形区域:区域总数(BYTE) + N * 区域ID(DWORD)。 */
|
||||
public static DownlinkCommand deleteArea(List<Long> areaIds) {
|
||||
if (areaIds.size() > 255) {
|
||||
throw new IllegalArgumentException("jt808 delete-area supports at most 255 area ids");
|
||||
}
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream(1 + areaIds.size() * 4);
|
||||
os.write(areaIds.size() & 0xFF);
|
||||
for (Long areaId : areaIds) {
|
||||
writeU32(os, areaId == null ? 0 : areaId);
|
||||
}
|
||||
return new DownlinkCommand(Jt808MessageId.PLATFORM_DELETE_CIRCLE_AREA, 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,81 @@
|
||||
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.Jt808Body;
|
||||
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,
|
||||
Jt808MessageId.TERMINAL_LOCATION_BATCH
|
||||
}, 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_UNREGISTER,
|
||||
Jt808MessageId.TERMINAL_PARAMS_REPORT,
|
||||
Jt808MessageId.TERMINAL_ATTRS_REPORT,
|
||||
Jt808MessageId.TERMINAL_MEDIA_EVENT,
|
||||
Jt808MessageId.TERMINAL_MEDIA_UPLOAD,
|
||||
Jt808MessageId.TERMINAL_PASSTHROUGH
|
||||
}, desc = "终端状态/属性/多媒体/透传")
|
||||
@EventEmit({VehicleEvent.Logout.class, VehicleEvent.Login.class, VehicleEvent.MediaMeta.class, VehicleEvent.Passthrough.class})
|
||||
public List<VehicleEvent> onExtended(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);
|
||||
}
|
||||
|
||||
@MessageMapping(desc = "未解析 JT808 上行兜底透传")
|
||||
@EventEmit(VehicleEvent.Passthrough.class)
|
||||
public List<VehicleEvent> onRaw(Jt808Message msg) {
|
||||
if (!(msg.body() instanceof Jt808Body.Raw) && !(msg.body() instanceof Jt808Body.Malformed)) {
|
||||
return List.of();
|
||||
}
|
||||
return mapper.toEvents(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
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.identity.VehicleIdentity;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityBinding;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityLookup;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityRegistry;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import com.lingniu.ingest.identity.VehicleIdentitySource;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MalformedFrame;
|
||||
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.Jt808Header;
|
||||
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<Object> {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Jt808ChannelHandler.class);
|
||||
private static final String PROCESSING_ERROR_PREFIX = "processing failed: ";
|
||||
|
||||
private final Jt808MessageDecoder decoder;
|
||||
private final Dispatcher dispatcher;
|
||||
private final SessionStore sessions;
|
||||
private final VehicleIdentityRegistry identityRegistry;
|
||||
private final VehicleIdentityResolver identityResolver;
|
||||
private final Jt808ChannelRegistry channelRegistry;
|
||||
private final Jt808PendingRequests pendingRequests;
|
||||
|
||||
public Jt808ChannelHandler(Jt808MessageDecoder decoder, Dispatcher dispatcher,
|
||||
SessionStore sessions, VehicleIdentityRegistry identityRegistry,
|
||||
Jt808ChannelRegistry channelRegistry,
|
||||
Jt808PendingRequests pendingRequests) {
|
||||
this(decoder, dispatcher, sessions, identityRegistry,
|
||||
identityRegistry instanceof VehicleIdentityResolver resolver ? resolver : null,
|
||||
channelRegistry, pendingRequests);
|
||||
}
|
||||
|
||||
public Jt808ChannelHandler(Jt808MessageDecoder decoder, Dispatcher dispatcher,
|
||||
SessionStore sessions, VehicleIdentityRegistry identityRegistry,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Jt808ChannelRegistry channelRegistry,
|
||||
Jt808PendingRequests pendingRequests) {
|
||||
this.decoder = decoder;
|
||||
this.dispatcher = dispatcher;
|
||||
this.sessions = sessions;
|
||||
this.identityRegistry = identityRegistry;
|
||||
this.identityResolver = identityResolver;
|
||||
this.channelRegistry = channelRegistry;
|
||||
this.pendingRequests = pendingRequests;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
|
||||
if (msg instanceof byte[] frame) {
|
||||
handleDecodedFrame(ctx, frame);
|
||||
} else if (msg instanceof Jt808MalformedFrame malformed) {
|
||||
dispatchFrameError(ctx, malformed);
|
||||
} else {
|
||||
ctx.fireChannelRead(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDecodedFrame(ChannelHandlerContext ctx, byte[] frame) {
|
||||
Jt808Message msg;
|
||||
try {
|
||||
msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
} catch (Exception e) {
|
||||
log.warn("[jt808] decode failed peer={} len={} error={}", addr(ctx), frame.length, e.getMessage());
|
||||
log.debug("[jt808] decode failed stack peer={}", addr(ctx), e);
|
||||
dispatchMalformed(ctx, frame, e.getMessage(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processDecodedFrame(ctx, frame, msg);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[jt808] processing failed peer={} phone={} msgId=0x{} error={}",
|
||||
addr(ctx), msg.header().phone(), Integer.toHexString(msg.header().messageId()), e.getMessage());
|
||||
log.debug("[jt808] processing failed stack peer={}", addr(ctx), e);
|
||||
dispatchProcessingError(ctx, frame, msg, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processDecodedFrame(ChannelHandlerContext ctx, byte[] frame, Jt808Message msg) {
|
||||
// ====== 会话维护 & 同步应答匹配 ======
|
||||
String phone = msg.header().phone();
|
||||
IdentityResolution identity = resolveIdentity(msg);
|
||||
switch (msg.header().messageId()) {
|
||||
case Jt808MessageId.TERMINAL_REGISTER -> {
|
||||
DeviceSession session = upsertSession(ctx, msg, identity.identity());
|
||||
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, identity.identity());
|
||||
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<>(6);
|
||||
meta.put("vin", identity.identity().vin());
|
||||
meta.put("phone", phone);
|
||||
meta.put("identityResolved", Boolean.toString(identity.identity().resolved()));
|
||||
meta.put("identitySource", identity.identity().source().name());
|
||||
if (identity.errorMessage() != null) {
|
||||
meta.put("identityError", "true");
|
||||
meta.put("identityErrorMessage", identity.errorMessage());
|
||||
}
|
||||
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 IdentityResolution resolveIdentity(Jt808Message msg) {
|
||||
String phone = msg.header().phone();
|
||||
if (identityResolver == null) {
|
||||
return new IdentityResolution(
|
||||
new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE),
|
||||
null);
|
||||
}
|
||||
try {
|
||||
return new IdentityResolution(
|
||||
identityResolver.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808,
|
||||
"",
|
||||
phone,
|
||||
deviceId(msg.body()),
|
||||
plate(msg.body()))),
|
||||
null);
|
||||
} catch (RuntimeException e) {
|
||||
return new IdentityResolution(
|
||||
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
|
||||
e.getMessage() == null ? "" : e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchFrameError(ChannelHandlerContext ctx, Jt808MalformedFrame malformed) {
|
||||
String peer = malformed.peer() == null || malformed.peer().isBlank() ? addr(ctx) : malformed.peer();
|
||||
dispatchMalformed(peer, malformed.rawBytes(), malformed.reason(), true);
|
||||
}
|
||||
|
||||
private void dispatchMalformed(ChannelHandlerContext ctx, byte[] frame, String errorMessage, boolean frameError) {
|
||||
dispatchMalformed(addr(ctx), frame, errorMessage, frameError);
|
||||
}
|
||||
|
||||
private void dispatchMalformed(String peer, byte[] frame, String errorMessage, boolean frameError) {
|
||||
String message = errorMessage == null ? "" : errorMessage;
|
||||
Jt808Message malformed = new Jt808Message(
|
||||
new Jt808Header(0, frame == null ? 0 : frame.length, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "unknown", 0, 0, 0),
|
||||
new Jt808Body.Malformed(frame == null ? new byte[0] : frame, peer, message));
|
||||
Map<String, String> meta = new HashMap<>(4);
|
||||
meta.put("vin", "unknown");
|
||||
meta.put("peer", peer);
|
||||
meta.put("identityResolved", "false");
|
||||
meta.put("identitySource", VehicleIdentitySource.UNKNOWN.name());
|
||||
if (frameError) {
|
||||
meta.put("frameError", "true");
|
||||
meta.put("frameErrorMessage", message);
|
||||
} else {
|
||||
meta.put("parseError", "true");
|
||||
meta.put("parseErrorMessage", message);
|
||||
}
|
||||
dispatcher.dispatch(new RawFrame(
|
||||
ProtocolId.JT808,
|
||||
0,
|
||||
0,
|
||||
malformed,
|
||||
frame == null ? new byte[0] : frame,
|
||||
meta,
|
||||
Instant.now()));
|
||||
}
|
||||
|
||||
private void dispatchProcessingError(ChannelHandlerContext ctx, byte[] frame, Jt808Message msg, String errorMessage) {
|
||||
String message = errorMessage == null ? "" : errorMessage;
|
||||
Jt808Message failed = new Jt808Message(
|
||||
new Jt808Header(0, frame == null ? 0 : frame.length, 0, false,
|
||||
msg.header().version(), msg.header().phone(), msg.header().serialNo(), 0, 0),
|
||||
new Jt808Body.Malformed(frame == null ? new byte[0] : frame, addr(ctx),
|
||||
PROCESSING_ERROR_PREFIX + message));
|
||||
Map<String, String> meta = new HashMap<>(8);
|
||||
meta.put("vin", "unknown");
|
||||
meta.put("phone", msg.header().phone());
|
||||
meta.put("peer", addr(ctx));
|
||||
meta.put("seq", Integer.toString(msg.header().serialNo()));
|
||||
meta.put("identityResolved", "false");
|
||||
meta.put("identitySource", VehicleIdentitySource.UNKNOWN.name());
|
||||
meta.put("processingError", "true");
|
||||
meta.put("processingErrorMessage", message);
|
||||
dispatcher.dispatch(new RawFrame(
|
||||
ProtocolId.JT808,
|
||||
0,
|
||||
0,
|
||||
failed,
|
||||
frame == null ? new byte[0] : frame,
|
||||
meta,
|
||||
Instant.now()));
|
||||
}
|
||||
|
||||
private DeviceSession upsertSession(ChannelHandlerContext ctx, Jt808Message msg, VehicleIdentity identity) {
|
||||
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, identity.vin(), phone,
|
||||
generateToken(phone),
|
||||
addr(ctx),
|
||||
Instant.now(), Instant.now(),
|
||||
Map.of("protocolVersion", msg.header().version().name())));
|
||||
session = session.withVin(identity.vin());
|
||||
if (msg.body() instanceof Jt808Body.Register reg && reg.plate() != null) {
|
||||
if (session.vin() != null && !session.vin().isBlank()) {
|
||||
identityRegistry.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, session.vin(), phone, reg.deviceId(), reg.plate()));
|
||||
}
|
||||
}
|
||||
sessions.put(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private static String deviceId(Jt808Body body) {
|
||||
if (body instanceof Jt808Body.Register reg) {
|
||||
return reg.deviceId();
|
||||
}
|
||||
if (body instanceof Jt808Body.Auth auth) {
|
||||
return auth.imei();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String plate(Jt808Body body) {
|
||||
return body instanceof Jt808Body.Register reg ? reg.plate() : "";
|
||||
}
|
||||
|
||||
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())
|
||||
.ifPresent(phone -> sessions.remove("jt808-" + phone));
|
||||
}
|
||||
|
||||
@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";
|
||||
}
|
||||
|
||||
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.lingniu.ingest.protocol.jt808.inbound;
|
||||
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityRegistry;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
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 VehicleIdentityRegistry identityRegistry;
|
||||
private final VehicleIdentityResolver identityResolver;
|
||||
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,
|
||||
VehicleIdentityRegistry identityRegistry,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Jt808ChannelRegistry channelRegistry,
|
||||
Jt808PendingRequests pendingRequests) {
|
||||
this.port = port;
|
||||
this.workerThreads = workerThreads;
|
||||
this.messageDecoder = messageDecoder;
|
||||
this.dispatcher = dispatcher;
|
||||
this.sessions = sessions;
|
||||
this.identityRegistry = identityRegistry;
|
||||
this.identityResolver = identityResolver;
|
||||
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,
|
||||
identityRegistry, identityResolver,
|
||||
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,349 @@
|
||||
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.identity.VehicleIdentity;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityLookup;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import com.lingniu.ingest.identity.VehicleIdentitySource;
|
||||
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.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* JT808 消息 → 领域事件映射。sealed body 允许 switch 穷尽处理。
|
||||
*
|
||||
* <p>备注:JT808 里没有强制的 VIN 字段,事件 VIN 统一通过 {@link VehicleIdentityResolver}
|
||||
* 解析;未绑定时才降级为终端手机号,metadata 同步保留内部 vin 与外部 phone。
|
||||
*/
|
||||
public final class Jt808EventMapper implements EventMapper<Jt808Message> {
|
||||
|
||||
private static final String PROCESSING_ERROR_PREFIX = "processing failed: ";
|
||||
|
||||
private final VehicleIdentityResolver identityResolver;
|
||||
|
||||
public Jt808EventMapper(VehicleIdentityResolver identityResolver) {
|
||||
this.identityResolver = identityResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<VehicleEvent> toEvents(Jt808Message message) {
|
||||
var header = message.header();
|
||||
Instant ingestTime = Instant.now();
|
||||
IdentityResolution identity = resolveIdentity(message);
|
||||
Map<String, String> meta = baseMeta(header, identity);
|
||||
String vin = identity.vin();
|
||||
|
||||
return switch (message.body()) {
|
||||
case Jt808Body.Location loc -> List.of(new VehicleEvent.Location(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, loc.deviceTime(), ingestTime, null, locationMeta(meta, loc),
|
||||
new LocationPayload(loc.longitude(), loc.latitude(),
|
||||
loc.altitudeM(), loc.speedKmh(), loc.directionDeg(),
|
||||
loc.alarmFlag(), loc.statusFlag(), totalMileageKm(loc))));
|
||||
|
||||
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.Unregister _ -> List.of(new VehicleEvent.Logout(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta));
|
||||
|
||||
case Jt808Body.LocationBatch batch -> locationBatchEvents(vin, meta, batch, ingestTime);
|
||||
|
||||
case Jt808Body.ParamsReport params -> List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
withMeta(meta, "paramsCount", Integer.toString(params.parameters().size())),
|
||||
header.messageId(),
|
||||
encodeParamsSummary(params)));
|
||||
|
||||
case Jt808Body.TerminalAttrs attrs -> List.of(new VehicleEvent.Login(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
withMeta(meta, "terminalId", attrs.terminalId()),
|
||||
attrs.iccid(), header.version().name()));
|
||||
|
||||
case Jt808Body.MediaEvent media -> List.of(new VehicleEvent.MediaMeta(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
withMeta(meta, "mediaEventCode", Integer.toString(media.eventCode())),
|
||||
Long.toString(media.mediaId()),
|
||||
mediaType(media.mediaType(), media.mediaFormat(), media.channelId()),
|
||||
0,
|
||||
""));
|
||||
|
||||
case Jt808Body.MediaUpload media -> mediaUploadEvents(vin, meta, media, ingestTime);
|
||||
|
||||
case Jt808Body.MediaAttributes attrs -> List.of(new VehicleEvent.MediaMeta(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
withMeta(withMeta(meta, "jt1078Message", "mediaAttributes"),
|
||||
"maxVideoChannels", Integer.toString(attrs.maxVideoChannels())),
|
||||
"jt1078-attrs-" + header.phone(),
|
||||
mediaAttributesType(attrs),
|
||||
attrs.raw() == null ? 0 : attrs.raw().length,
|
||||
""));
|
||||
|
||||
case Jt808Body.FileUploadComplete done -> List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
withMeta(withMeta(withMeta(meta, "jt1078Message", "fileUploadComplete"),
|
||||
"responseSerialNo", Integer.toString(done.responseSerialNo())),
|
||||
"uploadResult", Integer.toString(done.result())),
|
||||
header.messageId(),
|
||||
done.raw() == null ? new byte[0] : done.raw()));
|
||||
|
||||
case Jt808Body.FileList list -> List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
withMeta(withMeta(withMeta(meta, "jt1078Message", "fileList"),
|
||||
"responseSerialNo", Integer.toString(list.responseSerialNo())),
|
||||
"fileCount", Long.toString(list.fileCount())),
|
||||
header.messageId(),
|
||||
list.raw() == null ? new byte[0] : list.raw()));
|
||||
|
||||
case Jt808Body.PassengerVolume volume -> List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
passengerVolumeMeta(meta, volume),
|
||||
header.messageId(),
|
||||
volume.raw() == null ? new byte[0] : volume.raw()));
|
||||
|
||||
case Jt808Body.Passthrough passthrough -> List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
withMeta(meta, "passthroughType", "0x" + Integer.toHexString(passthrough.passthroughType())),
|
||||
header.messageId(),
|
||||
passthrough.data()));
|
||||
|
||||
case Jt808Body.Raw raw -> List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
withMeta(meta, "rawBody", "true"),
|
||||
raw.messageId(),
|
||||
raw.bytes()));
|
||||
|
||||
case Jt808Body.Malformed malformed -> {
|
||||
String errorMessage = malformed.errorMessage() == null ? "" : malformed.errorMessage();
|
||||
boolean processingError = isProcessingError(errorMessage);
|
||||
boolean frameError = isFrameError(errorMessage);
|
||||
String publicErrorMessage = processingError
|
||||
? errorMessage.substring(PROCESSING_ERROR_PREFIX.length())
|
||||
: errorMessage;
|
||||
Map<String, String> errorMeta = withMeta(meta,
|
||||
processingError ? "processingError" : frameError ? "frameError" : "parseError", "true");
|
||||
errorMeta = withMeta(errorMeta, "peer", malformed.peer() == null ? "" : malformed.peer());
|
||||
errorMeta = withMeta(errorMeta,
|
||||
processingError ? "processingErrorMessage" : frameError ? "frameErrorMessage" : "parseErrorMessage",
|
||||
publicErrorMessage);
|
||||
yield List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
errorMeta,
|
||||
header.messageId(),
|
||||
malformed.bytes() == null ? new byte[0] : malformed.bytes()));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private IdentityResolution resolveIdentity(Jt808Message message) {
|
||||
if (message.body() instanceof Jt808Body.Malformed) {
|
||||
return IdentityResolution.ok(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN));
|
||||
}
|
||||
var header = message.header();
|
||||
try {
|
||||
return IdentityResolution.ok(identityResolver.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", header.phone(), deviceId(message.body()), plate(message.body()))));
|
||||
} catch (RuntimeException e) {
|
||||
return new IdentityResolution(
|
||||
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
|
||||
e.getMessage() == null ? "" : e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> baseMeta(Jt808Header header, IdentityResolution identity) {
|
||||
java.util.HashMap<String, String> meta = new java.util.HashMap<>();
|
||||
meta.put("messageId", "0x" + Integer.toHexString(header.messageId()));
|
||||
meta.put("protocolVersion", header.version().name());
|
||||
meta.put("vin", identity.vin());
|
||||
meta.put("phone", header.phone());
|
||||
meta.put("identityResolved", Boolean.toString(identity.identity().resolved()));
|
||||
meta.put("identitySource", identity.identity().source().name());
|
||||
if (identity.errorMessage() != null) {
|
||||
meta.put("identityError", "true");
|
||||
meta.put("identityErrorMessage", identity.errorMessage());
|
||||
}
|
||||
return Map.copyOf(meta);
|
||||
}
|
||||
|
||||
private static boolean isFrameError(String errorMessage) {
|
||||
return errorMessage != null && (errorMessage.startsWith("jt808 missing frame boundary")
|
||||
|| errorMessage.startsWith("jt808 leading bytes before frame boundary")
|
||||
|| errorMessage.startsWith("jt808 unclosed frame too large"));
|
||||
}
|
||||
|
||||
private static boolean isProcessingError(String errorMessage) {
|
||||
return errorMessage != null && errorMessage.startsWith(PROCESSING_ERROR_PREFIX);
|
||||
}
|
||||
|
||||
private static List<VehicleEvent> locationBatchEvents(String vin,
|
||||
Map<String, String> meta,
|
||||
Jt808Body.LocationBatch batch,
|
||||
Instant ingestTime) {
|
||||
List<VehicleEvent> out = new ArrayList<>(batch.locations().size());
|
||||
Map<String, String> batchMeta = withMeta(meta, "batchType", Integer.toString(batch.batchType()));
|
||||
for (Jt808Body.Location loc : batch.locations()) {
|
||||
out.add(locationEvent(vin, batchMeta, loc, ingestTime));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<VehicleEvent> mediaUploadEvents(String vin,
|
||||
Map<String, String> meta,
|
||||
Jt808Body.MediaUpload media,
|
||||
Instant ingestTime) {
|
||||
List<VehicleEvent> out = new ArrayList<>(2);
|
||||
if (media.location() != null) {
|
||||
out.add(locationEvent(vin, withMeta(meta, "mediaId", Long.toString(media.mediaId())),
|
||||
media.location(), ingestTime));
|
||||
}
|
||||
out.add(new VehicleEvent.MediaMeta(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, ingestTime, ingestTime, null,
|
||||
withMeta(meta, "mediaEventCode", Integer.toString(media.eventCode())),
|
||||
Long.toString(media.mediaId()),
|
||||
mediaType(media.mediaType(), media.mediaFormat(), media.channelId()),
|
||||
media.data() == null ? 0 : media.data().length,
|
||||
""));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, String> passengerVolumeMeta(Map<String, String> meta,
|
||||
Jt808Body.PassengerVolume volume) {
|
||||
java.util.HashMap<String, String> copy = new java.util.HashMap<>(meta);
|
||||
copy.put("jt1078Message", "passengerVolume");
|
||||
copy.put("channelId", Integer.toString(volume.channelId()));
|
||||
copy.put("startTime", volume.startTime() == null ? "" : volume.startTime());
|
||||
copy.put("endTime", volume.endTime() == null ? "" : volume.endTime());
|
||||
copy.put("passengerGetOn", Integer.toString(volume.passengerGetOn()));
|
||||
copy.put("passengerGetOff", Integer.toString(volume.passengerGetOff()));
|
||||
return Map.copyOf(copy);
|
||||
}
|
||||
|
||||
private static VehicleEvent.Location locationEvent(String vin,
|
||||
Map<String, String> meta,
|
||||
Jt808Body.Location loc,
|
||||
Instant ingestTime) {
|
||||
return new VehicleEvent.Location(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.JT808, loc.deviceTime(), ingestTime, null, locationMeta(meta, loc),
|
||||
new LocationPayload(loc.longitude(), loc.latitude(),
|
||||
loc.altitudeM(), loc.speedKmh(), loc.directionDeg(),
|
||||
loc.alarmFlag(), loc.statusFlag(), totalMileageKm(loc)));
|
||||
}
|
||||
|
||||
private static Map<String, String> locationMeta(Map<String, String> meta, Jt808Body.Location loc) {
|
||||
if (loc.extensionItems().isEmpty()) {
|
||||
return meta;
|
||||
}
|
||||
java.util.HashMap<String, String> copy = new java.util.HashMap<>(meta);
|
||||
loc.extensionItems().forEach((id, bytes) ->
|
||||
copy.put("jt808.extra.0x" + String.format("%02X", id), hex(bytes)));
|
||||
return Map.copyOf(copy);
|
||||
}
|
||||
|
||||
private static Map<String, String> withMeta(Map<String, String> meta, String key, String value) {
|
||||
java.util.HashMap<String, String> copy = new java.util.HashMap<>(meta);
|
||||
copy.put(key, value == null ? "" : value);
|
||||
return Map.copyOf(copy);
|
||||
}
|
||||
|
||||
private static Double totalMileageKm(Jt808Body.Location loc) {
|
||||
byte[] bytes = loc.extensionItems().get(0x01);
|
||||
if (bytes == null || bytes.length != 4) {
|
||||
return null;
|
||||
}
|
||||
long raw = ((long) bytes[0] & 0xFF) << 24
|
||||
| ((long) bytes[1] & 0xFF) << 16
|
||||
| ((long) bytes[2] & 0xFF) << 8
|
||||
| ((long) bytes[3] & 0xFF);
|
||||
return raw * 0.1;
|
||||
}
|
||||
|
||||
private static String hex(byte[] bytes) {
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(Character.forDigit((b >> 4) & 0x0F, 16));
|
||||
sb.append(Character.forDigit(b & 0x0F, 16));
|
||||
}
|
||||
return sb.toString().toUpperCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private static byte[] encodeParamsSummary(Jt808Body.ParamsReport params) {
|
||||
return ("responseSerialNo=" + params.responseSerialNo()
|
||||
+ ",parameters=" + params.parameters().keySet()).getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String mediaType(int mediaType, int mediaFormat, int channelId) {
|
||||
return "type=" + mediaType + ",format=" + mediaFormat + ",channel=" + channelId;
|
||||
}
|
||||
|
||||
private static String mediaAttributesType(Jt808Body.MediaAttributes attrs) {
|
||||
return "audioEncoding=" + attrs.inputAudioEncoding()
|
||||
+ ",audioChannels=" + attrs.inputAudioChannels()
|
||||
+ ",audioSampleRate=" + attrs.inputAudioSampleRate()
|
||||
+ ",audioBitDepth=" + attrs.inputAudioBitDepth()
|
||||
+ ",audioFrameLength=" + attrs.audioFrameLength()
|
||||
+ ",audioOutput=" + attrs.audioOutputSupported()
|
||||
+ ",videoEncoding=" + attrs.videoEncoding()
|
||||
+ ",maxAudioChannels=" + attrs.maxAudioChannels()
|
||||
+ ",maxVideoChannels=" + attrs.maxVideoChannels();
|
||||
}
|
||||
|
||||
private static String deviceId(Jt808Body body) {
|
||||
if (body instanceof Jt808Body.Register reg) {
|
||||
return reg.deviceId();
|
||||
}
|
||||
if (body instanceof Jt808Body.Auth auth) {
|
||||
return auth.imei();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String plate(Jt808Body body) {
|
||||
return body instanceof Jt808Body.Register reg ? reg.plate() : "";
|
||||
}
|
||||
|
||||
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {
|
||||
private static IdentityResolution ok(VehicleIdentity identity) {
|
||||
return new IdentityResolution(identity, null);
|
||||
}
|
||||
|
||||
private String vin() {
|
||||
return identity.vin();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.lingniu.ingest.protocol.jt808.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 已解析的消息体。sealed,新增消息体时必须同步新增子类型与 Parser。
|
||||
*/
|
||||
public sealed interface Jt808Body
|
||||
permits Jt808Body.Register, Jt808Body.Auth, Jt808Body.Location,
|
||||
Jt808Body.LocationBatch, Jt808Body.Unregister,
|
||||
Jt808Body.ParamsReport, Jt808Body.TerminalAttrs,
|
||||
Jt808Body.MediaEvent, Jt808Body.MediaUpload,
|
||||
Jt808Body.MediaAttributes, Jt808Body.FileUploadComplete,
|
||||
Jt808Body.FileList, Jt808Body.PassengerVolume,
|
||||
Jt808Body.Passthrough, Jt808Body.Heartbeat, Jt808Body.Raw,
|
||||
Jt808Body.Malformed {
|
||||
|
||||
/** 终端注册 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,
|
||||
Map<Integer, byte[]> extensionItems
|
||||
) implements Jt808Body {
|
||||
public Location(long alarmFlag,
|
||||
long statusFlag,
|
||||
double longitude,
|
||||
double latitude,
|
||||
int altitudeM,
|
||||
double speedKmh,
|
||||
int directionDeg,
|
||||
Instant deviceTime) {
|
||||
this(alarmFlag, statusFlag, longitude, latitude, altitudeM, speedKmh,
|
||||
directionDeg, deviceTime, Map.of());
|
||||
}
|
||||
|
||||
public Location {
|
||||
extensionItems = extensionItems == null ? Map.of() : Map.copyOf(extensionItems);
|
||||
}
|
||||
}
|
||||
|
||||
/** 位置信息批量上传 0x0704。 */
|
||||
record LocationBatch(
|
||||
int batchType,
|
||||
List<Location> locations
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** 终端注销 0x0003。 */
|
||||
record Unregister() implements Jt808Body {}
|
||||
|
||||
/** 查询终端参数应答 0x0104。 */
|
||||
record ParamsReport(
|
||||
int responseSerialNo,
|
||||
Map<Long, byte[]> parameters
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** 查询终端属性应答 0x0107。 */
|
||||
record TerminalAttrs(
|
||||
int terminalType,
|
||||
String maker,
|
||||
String terminalModel,
|
||||
String terminalId,
|
||||
String iccid,
|
||||
String hardwareVersion,
|
||||
String firmwareVersion,
|
||||
int gnssProperty,
|
||||
int commProperty
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** 多媒体事件信息上传 0x0800。 */
|
||||
record MediaEvent(
|
||||
long mediaId,
|
||||
int mediaType,
|
||||
int mediaFormat,
|
||||
int eventCode,
|
||||
int channelId
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** 多媒体数据上传 0x0801。 */
|
||||
record MediaUpload(
|
||||
long mediaId,
|
||||
int mediaType,
|
||||
int mediaFormat,
|
||||
int eventCode,
|
||||
int channelId,
|
||||
Location location,
|
||||
byte[] data
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** JT/T 1078 终端上传音视频属性 0x1003。 */
|
||||
record MediaAttributes(
|
||||
int inputAudioEncoding,
|
||||
int inputAudioChannels,
|
||||
int inputAudioSampleRate,
|
||||
int inputAudioBitDepth,
|
||||
int audioFrameLength,
|
||||
boolean audioOutputSupported,
|
||||
int videoEncoding,
|
||||
int maxAudioChannels,
|
||||
int maxVideoChannels,
|
||||
byte[] raw
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** JT/T 1078 文件上传完成通知 0x1206。 */
|
||||
record FileUploadComplete(
|
||||
int responseSerialNo,
|
||||
int result,
|
||||
byte[] raw
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** JT/T 1078 终端上传文件列表 0x1205。 */
|
||||
record FileList(
|
||||
int responseSerialNo,
|
||||
long fileCount,
|
||||
byte[] raw
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** JT/T 1078 终端上传乘客流量 0x1005。 */
|
||||
record PassengerVolume(
|
||||
int channelId,
|
||||
String startTime,
|
||||
String endTime,
|
||||
int passengerGetOn,
|
||||
int passengerGetOff,
|
||||
byte[] raw
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** 数据上行透传 0x0900。 */
|
||||
record Passthrough(
|
||||
int passthroughType,
|
||||
byte[] data
|
||||
) implements Jt808Body {}
|
||||
|
||||
/** 心跳 0x0002(无 body)。 */
|
||||
record Heartbeat() implements Jt808Body {}
|
||||
|
||||
/** 未实现 Parser 的消息体,透传原始字节。 */
|
||||
record Raw(int messageId, byte[] bytes) implements Jt808Body {}
|
||||
|
||||
/** 入站帧整体解析失败,保留原始字节便于冷存、查询和排障。 */
|
||||
record Malformed(byte[] bytes, String peer, String errorMessage) 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,39 @@
|
||||
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_ATTRS = 0x8107;
|
||||
public static final int PLATFORM_QUERY_LOCATION = 0x8201;
|
||||
public static final int PLATFORM_TRACK_CONTROL = 0x8202;
|
||||
public static final int PLATFORM_ALARM_ACK = 0x8203;
|
||||
public static final int PLATFORM_DELETE_CIRCLE_AREA = 0x8601;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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 Optional<String> unbind(Channel channel) {
|
||||
String phone = reverse.remove(channel);
|
||||
if (phone != null) {
|
||||
byPhone.remove(phone, channel);
|
||||
}
|
||||
return Optional.ofNullable(phone);
|
||||
}
|
||||
|
||||
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,239 @@
|
||||
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.AuthBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBatchBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.MediaEventBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.MediaUploadBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.PassthroughBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalAttrsBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalParamsReportBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.UnregisterBodyParser;
|
||||
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 AuthBodyParser(),
|
||||
new LocationBodyParser(),
|
||||
new LocationBatchBodyParser(),
|
||||
new UnregisterBodyParser(),
|
||||
new TerminalParamsReportBodyParser(),
|
||||
new TerminalAttrsBodyParser(),
|
||||
new MediaEventBodyParser(),
|
||||
new MediaUploadBodyParser(),
|
||||
new PassthroughBodyParser(),
|
||||
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 decodesLocationExtensionItems() {
|
||||
byte[] body = bytes(os -> {
|
||||
os.write(buildLocationBody(), 0, buildLocationBody().length);
|
||||
os.write(0x01); // mileage, DWORD, 0.1 km
|
||||
os.write(4);
|
||||
writeU32(os, 12345);
|
||||
os.write(0x30); // wireless signal strength
|
||||
os.write(1);
|
||||
os.write(88);
|
||||
});
|
||||
|
||||
Jt808Body.Location loc = (Jt808Body.Location) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_LOCATION, "123456789012", 9, body))).body();
|
||||
|
||||
assertThat(loc.extensionItems()).containsOnlyKeys(0x01, 0x30);
|
||||
assertThat(loc.extensionItems().get(0x01)).containsExactly(0x00, 0x00, 0x30, 0x39);
|
||||
assertThat(loc.extensionItems().get(0x30)).containsExactly(88);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesAuthFrameAndExtractsImeiWhenPresent() {
|
||||
byte[] authBody = "AUTH-CODE,123456789012345,SW-1".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
|
||||
Jt808Body.Auth auth = (Jt808Body.Auth) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_AUTH, "123456789012", 10, authBody))).body();
|
||||
|
||||
assertThat(auth.token()).isEqualTo("AUTH-CODE,123456789012345,SW-1");
|
||||
assertThat(auth.imei()).isEqualTo("123456789012345");
|
||||
assertThat(auth.softwareVersion()).isEqualTo("SW-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesLogoutParamsAttrsMediaAndPassthroughFrames() {
|
||||
assertThat(decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_UNREGISTER, "123456789012", 3, new byte[0]))).body())
|
||||
.isInstanceOf(Jt808Body.Unregister.class);
|
||||
|
||||
byte[] paramsBody = bytes(os -> {
|
||||
writeU16(os, 7);
|
||||
os.write(1);
|
||||
writeU32(os, 0x00000080L);
|
||||
os.write(1);
|
||||
os.write(5);
|
||||
});
|
||||
Jt808Body.ParamsReport params = (Jt808Body.ParamsReport) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_PARAMS_REPORT, "123456789012", 4, paramsBody))).body();
|
||||
assertThat(params.responseSerialNo()).isEqualTo(7);
|
||||
assertThat(params.parameters()).containsKey(0x00000080L);
|
||||
|
||||
byte[] attrsBody = bytes(os -> {
|
||||
writeU16(os, 0x1234);
|
||||
writeAscii(os, "MAKER", 5);
|
||||
writeAscii(os, "MODEL-X", 20);
|
||||
writeAscii(os, "DEV1234", 7);
|
||||
os.write(BcdCodec.encode("89860012345678901234"), 0, 10);
|
||||
writeAscii(os, "HW1", 10);
|
||||
writeAscii(os, "SW1", 10);
|
||||
os.write(1);
|
||||
os.write(2);
|
||||
});
|
||||
Jt808Body.TerminalAttrs attrs = (Jt808Body.TerminalAttrs) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_ATTRS_REPORT, "123456789012", 5, attrsBody))).body();
|
||||
assertThat(attrs.maker()).isEqualTo("MAKER");
|
||||
assertThat(attrs.terminalId()).isEqualTo("DEV1234");
|
||||
|
||||
byte[] mediaEventBody = bytes(os -> {
|
||||
writeU32(os, 42);
|
||||
os.write(0);
|
||||
os.write(1);
|
||||
os.write(2);
|
||||
os.write(3);
|
||||
});
|
||||
assertThat(decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_MEDIA_EVENT, "123456789012", 6, mediaEventBody))).body())
|
||||
.isInstanceOf(Jt808Body.MediaEvent.class);
|
||||
|
||||
byte[] passthroughBody = bytes(os -> {
|
||||
os.write(0x41);
|
||||
os.write(new byte[]{0x01, 0x02, 0x03}, 0, 3);
|
||||
});
|
||||
Jt808Body.Passthrough passthrough = (Jt808Body.Passthrough) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_PASSTHROUGH, "123456789012", 7, passthroughBody))).body();
|
||||
assertThat(passthrough.passthroughType()).isEqualTo(0x41);
|
||||
assertThat(passthrough.data()).containsExactly(0x01, 0x02, 0x03);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesLocationBatchFrame() {
|
||||
byte[] location = buildLocationBody();
|
||||
byte[] batchBody = bytes(os -> {
|
||||
writeU16(os, 2);
|
||||
os.write(0);
|
||||
writeU16(os, location.length);
|
||||
os.write(location, 0, location.length);
|
||||
writeU16(os, location.length);
|
||||
os.write(location, 0, location.length);
|
||||
});
|
||||
|
||||
Jt808Body.LocationBatch batch = (Jt808Body.LocationBatch) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_LOCATION_BATCH, "123456789012", 8, batchBody))).body();
|
||||
|
||||
assertThat(batch.batchType()).isEqualTo(0);
|
||||
assertThat(batch.locations()).hasSize(2);
|
||||
assertThat(batch.locations().getFirst().longitude()).isEqualTo(116.397128);
|
||||
}
|
||||
|
||||
// ===== 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[] bytes(IoConsumer<ByteArrayOutputStream> writer) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writer.accept(os);
|
||||
return os.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeAscii(ByteArrayOutputStream os, String value, int len) {
|
||||
byte[] out = new byte[len];
|
||||
byte[] raw = value.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
System.arraycopy(raw, 0, out, 0, Math.min(raw.length, out.length));
|
||||
os.write(out, 0, out.length);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface IoConsumer<T> {
|
||||
void accept(T value);
|
||||
}
|
||||
|
||||
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,39 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808FrameDecoderTest {
|
||||
|
||||
@Test
|
||||
void garbageWithoutFrameBoundaryEmitsMalformedCandidate() {
|
||||
EmbeddedChannel channel = new EmbeddedChannel(new Jt808FrameDecoder());
|
||||
|
||||
channel.writeInbound(Unpooled.wrappedBuffer(new byte[]{0x01, 0x02, 0x03}));
|
||||
|
||||
Jt808MalformedFrame malformed = channel.readInbound();
|
||||
assertThat(malformed.rawBytes()).containsExactly(0x01, 0x02, 0x03);
|
||||
assertThat(malformed.reason()).contains("missing frame boundary");
|
||||
assertThat(malformed.peer()).isNotNull();
|
||||
assertThat((Object) channel.readInbound()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedUnclosedFrameEmitsMalformedCandidate() {
|
||||
byte[] bytes = new byte[16 * 1024 + 2];
|
||||
bytes[0] = 0x7e;
|
||||
bytes[1] = 0x01;
|
||||
EmbeddedChannel channel = new EmbeddedChannel(new Jt808FrameDecoder());
|
||||
|
||||
channel.writeInbound(Unpooled.wrappedBuffer(bytes));
|
||||
|
||||
Jt808MalformedFrame malformed = channel.readInbound();
|
||||
assertThat(malformed.rawBytes()).containsExactly(bytes);
|
||||
assertThat(malformed.reason()).contains("too large");
|
||||
assertThat(malformed.peer()).isNotNull();
|
||||
assertThat((Object) channel.readInbound()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 端到端验证: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);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedBodyWhenSubpackageIsNotUsed() {
|
||||
assertThatThrownBy(() -> Jt808FrameEncoder.encode(
|
||||
Jt808MessageId.PLATFORM_SET_PARAMS, "123456789012", 1, new byte[1024]))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("jt808 body too large");
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodesOversizedBodyAsSubpackages() {
|
||||
byte[] body = new byte[2048];
|
||||
for (int i = 0; i < body.length; i++) {
|
||||
body[i] = (byte) i;
|
||||
}
|
||||
|
||||
List<byte[]> frames = Jt808FrameEncoder.encodeSubpackages(
|
||||
Jt808MessageId.PLATFORM_SET_PARAMS, "123456789012", 9, body);
|
||||
|
||||
assertThat(frames).hasSize(3);
|
||||
java.io.ByteArrayOutputStream joined = new java.io.ByteArrayOutputStream();
|
||||
for (int i = 0; i < frames.size(); i++) {
|
||||
byte[] unescaped = Jt808Escape.unescape(frames.get(i), 1, frames.get(i).length - 2);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped));
|
||||
assertThat(msg.header().subpacket()).isTrue();
|
||||
assertThat(msg.header().totalPackets()).isEqualTo(3);
|
||||
assertThat(msg.header().packetSeq()).isEqualTo(i + 1);
|
||||
assertThat(msg.header().serialNo()).isEqualTo(9);
|
||||
assertThat(msg.body()).isInstanceOf(Jt808Body.Raw.class);
|
||||
joined.writeBytes(((Jt808Body.Raw) msg.body()).bytes());
|
||||
}
|
||||
assertThat(joined.toByteArray()).containsExactly(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lingniu.ingest.protocol.jt808.downlink;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808Escape;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
|
||||
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.InMemorySessionStore;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808CommandDispatcherTest {
|
||||
|
||||
@Test
|
||||
void notifyWritesSubpackageFramesForLargeBody() {
|
||||
InMemorySessionStore sessions = new InMemorySessionStore();
|
||||
sessions.put(new DeviceSession("sid-1", ProtocolId.JT808, "LNVIN000000000303",
|
||||
"123456789012", "", "127.0.0.1:10000", Instant.now(), Instant.now(), Map.of()));
|
||||
Jt808ChannelRegistry channels = new Jt808ChannelRegistry();
|
||||
EmbeddedChannel channel = new EmbeddedChannel();
|
||||
channels.bind("123456789012", channel);
|
||||
Jt808CommandDispatcher dispatcher = new Jt808CommandDispatcher(
|
||||
sessions, channels, new Jt808PendingRequests());
|
||||
byte[] body = new byte[2048];
|
||||
|
||||
dispatcher.notify("sid-1", new Jt808Commands.DownlinkCommand(
|
||||
Jt808MessageId.PLATFORM_SET_PARAMS, body)).join();
|
||||
|
||||
Jt808MessageDecoder decoder = new Jt808MessageDecoder(new BodyParserRegistry(List.of()));
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
ByteBuf outbound = channel.readOutbound();
|
||||
byte[] frame = new byte[outbound.readableBytes()];
|
||||
outbound.readBytes(frame);
|
||||
byte[] unescaped = Jt808Escape.unescape(frame, 1, frame.length - 2);
|
||||
Jt808Message decoded = decoder.decode(ByteBuffer.wrap(unescaped));
|
||||
assertThat(decoded.header().subpacket()).isTrue();
|
||||
assertThat(decoded.header().totalPackets()).isEqualTo(3);
|
||||
assertThat(decoded.header().packetSeq()).isEqualTo(i);
|
||||
assertThat(decoded.body()).isInstanceOf(Jt808Body.Raw.class);
|
||||
}
|
||||
assertThat((Object) channel.readOutbound()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lingniu.ingest.protocol.jt808.downlink;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808CommandsTest {
|
||||
|
||||
@Test
|
||||
void deleteAreaEncodesCountAndDwordAreaIds() {
|
||||
var command = Jt808Commands.deleteArea(List.of(1L, 0x01020304L));
|
||||
|
||||
assertThat(command.messageId()).isEqualTo(0x8601);
|
||||
assertThat(command.body()).containsExactly(
|
||||
0x02,
|
||||
0x00, 0x00, 0x00, 0x01,
|
||||
0x01, 0x02, 0x03, 0x04);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.lingniu.ingest.protocol.jt808.handler;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.core.dispatcher.AnnotationHandlerBeanPostProcessor;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808LocationHandlerTest {
|
||||
|
||||
@Test
|
||||
void unknownRawMessageRoutesToPassthroughHandler() {
|
||||
Jt808LocationHandler handler = new Jt808LocationHandler(
|
||||
new Jt808EventMapper(new InMemoryVehicleIdentityService()));
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
new AnnotationHandlerBeanPostProcessor(registry).postProcessAfterInitialization(handler, "jt808Handler");
|
||||
Jt808Message message = new Jt808Message(
|
||||
new Jt808Header(0x0F01, 3, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 7, 0, 0),
|
||||
new Jt808Body.Raw(0x0F01, new byte[]{0x01, 0x02, 0x03}));
|
||||
|
||||
var definitions = registry.resolve(ProtocolId.JT808, 0x0F01, 0);
|
||||
|
||||
assertThat(definitions).hasSize(1);
|
||||
var events = new HandlerInvoker().invoke(definitions.getFirst(), new RawFrame(
|
||||
ProtocolId.JT808, 0x0F01, 0, message, new byte[]{0x7e},
|
||||
Map.of("phone", "123456789012"), Instant.now()), new IngestContext("trace-1"));
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.passthroughType()).isEqualTo(0x0F01);
|
||||
assertThat(passthrough.metadata()).containsEntry("rawBody", "true");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package com.lingniu.ingest.protocol.jt808.inbound;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.sink.EventSink;
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.codec.BcdCodec;
|
||||
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
|
||||
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerDefinition;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
|
||||
import com.lingniu.ingest.core.pipeline.InterceptorChain;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityBinding;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MalformedFrame;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.AuthBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.handler.Jt808LocationHandler;
|
||||
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 com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
|
||||
import com.lingniu.ingest.session.InMemorySessionStore;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808ChannelHandlerTest {
|
||||
|
||||
@Test
|
||||
void registerSessionUsesResolvedInternalVinInsteadOfDeviceIdPlaceholder() {
|
||||
InMemorySessionStore sessions = new InMemorySessionStore();
|
||||
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
identity.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "B80808"));
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
new HandlerRegistry(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser()))),
|
||||
dispatcher,
|
||||
sessions,
|
||||
identity,
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
|
||||
channel.writeInbound(buildFrame(
|
||||
Jt808MessageId.TERMINAL_REGISTER,
|
||||
"123456789012",
|
||||
1,
|
||||
buildRegisterBody("DEV808", "B80808")));
|
||||
|
||||
assertThat(sessions.findByPhone("123456789012"))
|
||||
.get()
|
||||
.extracting(session -> session.vin())
|
||||
.isEqualTo("LNVIN000000000808");
|
||||
assertThat(identity.resolve(new com.lingniu.ingest.identity.VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "123456789012", "", "")).vin())
|
||||
.isEqualTo("LNVIN000000000808");
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void authSessionUsesResolvedInternalVinFromImei() {
|
||||
InMemorySessionStore sessions = new InMemorySessionStore();
|
||||
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
identity.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "LNVIN000000AUTH01", "123456789012", "123456789012345", ""));
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
new HandlerRegistry(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new AuthBodyParser()))),
|
||||
dispatcher,
|
||||
sessions,
|
||||
identity,
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
|
||||
channel.writeInbound(buildFrame(
|
||||
Jt808MessageId.TERMINAL_AUTH,
|
||||
"123456789012",
|
||||
2,
|
||||
"TOKEN,123456789012345,SW1".getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
|
||||
|
||||
assertThat(sessions.findByPhone("123456789012"))
|
||||
.get()
|
||||
.extracting(session -> session.vin())
|
||||
.isEqualTo("LNVIN000000AUTH01");
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inactiveChannelRemovesSessionStoreEntry() {
|
||||
InMemorySessionStore sessions = new InMemorySessionStore();
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
new HandlerRegistry(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new AuthBodyParser()))),
|
||||
dispatcher,
|
||||
sessions,
|
||||
new InMemoryVehicleIdentityService(),
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
|
||||
channel.writeInbound(buildFrame(
|
||||
Jt808MessageId.TERMINAL_AUTH,
|
||||
"123456789012",
|
||||
2,
|
||||
"TOKEN,123456789012345,SW1".getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
|
||||
assertThat(sessions.findByPhone("123456789012")).isPresent();
|
||||
|
||||
channel.close();
|
||||
|
||||
assertThat(sessions.findByPhone("123456789012")).isEmpty();
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulFrameArchiveUsesResolvedVehicleIdentity() throws Exception {
|
||||
RecordingSink sink = new RecordingSink(1);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
new HandlerRegistry(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
identity.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "粤B80808"));
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new LocationBodyParser()))),
|
||||
dispatcher,
|
||||
new InMemorySessionStore(),
|
||||
identity,
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 1, buildLocationBody());
|
||||
|
||||
channel.writeInbound(frame);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.vin()).isEqualTo("LNVIN000000000808");
|
||||
assertThat(archive.metadata())
|
||||
.containsEntry("vin", "LNVIN000000000808")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "true")
|
||||
.containsEntry("identitySource", "BOUND_PHONE");
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedFrameIsArchivedAndDispatchedAsPassthrough() throws Exception {
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registryWithRawHandler(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of())),
|
||||
dispatcher,
|
||||
new InMemorySessionStore(),
|
||||
new InMemoryVehicleIdentityService(),
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] malformed = new byte[]{0x01, 0x02, 0x03};
|
||||
|
||||
channel.writeInbound(malformed);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive raw = (VehicleEvent.RawArchive) event;
|
||||
assertThat(raw.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(raw.command()).isZero();
|
||||
assertThat(raw.rawBytes()).containsExactly(malformed);
|
||||
assertThat(raw.metadata())
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(passthrough.vin()).isEqualTo("unknown");
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
assertThat(passthrough.data()).containsExactly(malformed);
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void frameBoundaryMalformedCandidateIsArchivedAndDispatchedAsPassthrough() throws Exception {
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registryWithRawHandler(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of())),
|
||||
dispatcher,
|
||||
new InMemorySessionStore(),
|
||||
new InMemoryVehicleIdentityService(),
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] raw = new byte[]{0x01, 0x02, 0x03};
|
||||
|
||||
channel.writeInbound(new Jt808MalformedFrame(raw, "unknown", "jt808 missing frame boundary"));
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(archive.rawBytes()).containsExactly(raw);
|
||||
assertThat(archive.metadata()).containsEntry("frameError", "true")
|
||||
.containsEntry("frameErrorMessage", "jt808 missing frame boundary")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(passthrough.metadata()).containsEntry("frameError", "true")
|
||||
.containsEntry("frameErrorMessage", "jt808 missing frame boundary")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
assertThat(passthrough.data()).containsExactly(raw);
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityResolverFailureStillArchivesAndDispatchesDecodedLocationWithUnknownIdentity() throws Exception {
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
var failingIdentity = (com.lingniu.ingest.identity.VehicleIdentityResolver) lookup -> {
|
||||
throw new IllegalStateException("identity backend unavailable");
|
||||
};
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registryWithLocationHandler(new DirectLocationHandler(new Jt808EventMapper(failingIdentity))),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new LocationBodyParser()))),
|
||||
dispatcher,
|
||||
new InMemorySessionStore(),
|
||||
new InMemoryVehicleIdentityService(),
|
||||
failingIdentity,
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 1, buildLocationBody());
|
||||
|
||||
channel.writeInbound(frame);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(archive.command()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION);
|
||||
assertThat(archive.rawBytes()).containsExactly(frame);
|
||||
assertThat(archive.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Location.class);
|
||||
VehicleEvent.Location location = (VehicleEvent.Location) event;
|
||||
assertThat(location.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(location.vin()).isEqualTo("unknown");
|
||||
assertThat(location.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
private static HandlerRegistry registryWithRawHandler() throws NoSuchMethodException {
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
Jt808LocationHandler rawHandler = new Jt808LocationHandler(
|
||||
new Jt808EventMapper(new InMemoryVehicleIdentityService()));
|
||||
registry.register(new HandlerDefinition(
|
||||
ProtocolId.JT808,
|
||||
0,
|
||||
0,
|
||||
"test raw",
|
||||
rawHandler,
|
||||
Jt808LocationHandler.class.getMethod("onRaw", Jt808Message.class),
|
||||
Jt808Message.class,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static HandlerRegistry registryWithLocationHandler(DirectLocationHandler locationHandler) throws NoSuchMethodException {
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
registry.register(new HandlerDefinition(
|
||||
ProtocolId.JT808,
|
||||
Jt808MessageId.TERMINAL_LOCATION,
|
||||
0,
|
||||
"test location",
|
||||
locationHandler,
|
||||
DirectLocationHandler.class.getMethod("onLocation", Jt808Message.class),
|
||||
Jt808Message.class,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
return registry;
|
||||
}
|
||||
|
||||
public static final class DirectLocationHandler {
|
||||
private final Jt808EventMapper mapper;
|
||||
|
||||
private DirectLocationHandler(Jt808EventMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<VehicleEvent> onLocation(Jt808Message msg) {
|
||||
return mapper.toEvents(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildLocationBody() {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU32(os, 0);
|
||||
writeU32(os, 0x00030000);
|
||||
writeU32(os, 39_916_527L);
|
||||
writeU32(os, 116_397_128L);
|
||||
writeU16(os, 50);
|
||||
writeU16(os, 523);
|
||||
writeU16(os, 90);
|
||||
byte[] bcd = BcdCodec.encode("240102030405");
|
||||
os.write(bcd, 0, 6);
|
||||
return os.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] buildRegisterBody(String deviceId, String plate) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU16(os, 44);
|
||||
writeU16(os, 4401);
|
||||
writeAscii(os, "MAKER", 5);
|
||||
writeAscii(os, "TYPE-A", 20);
|
||||
writeAscii(os, deviceId, 7);
|
||||
os.write(1);
|
||||
byte[] plateBytes = plate.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
os.write(plateBytes, 0, plateBytes.length);
|
||||
return os.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeAscii(ByteArrayOutputStream os, String value, int len) {
|
||||
byte[] raw = value.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
int copy = Math.min(raw.length, len);
|
||||
os.write(raw, 0, copy);
|
||||
for (int i = copy; i < len; i++) {
|
||||
os.write(0x20);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU16(os, msgId);
|
||||
writeU16(os, body.length & 0x03FF);
|
||||
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));
|
||||
}
|
||||
|
||||
private static final class RecordingSink implements EventSink {
|
||||
private final List<VehicleEvent> events = new CopyOnWriteArrayList<>();
|
||||
private final CountDownLatch latch;
|
||||
|
||||
private RecordingSink(int expectedEvents) {
|
||||
this.latch = new CountDownLatch(expectedEvents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "recording";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> publish(VehicleEvent event) {
|
||||
events.add(event);
|
||||
latch.countDown();
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
private boolean await() throws InterruptedException {
|
||||
return latch.await(3, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.lingniu.ingest.protocol.jt808.mapper;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityBinding;
|
||||
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 InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
private final Jt808EventMapper mapper = new Jt808EventMapper(identity);
|
||||
|
||||
@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");
|
||||
assertThat(events.get(0).metadata())
|
||||
.containsEntry("vin", "123456789012")
|
||||
.containsEntry("identityResolved", "false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void locationExtensionItemsAreExposedAsMetadata() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION, 35, 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"),
|
||||
java.util.Map.of(0x01, new byte[]{0x00, 0x00, 0x30, 0x39}, 0x30, new byte[]{0x58}));
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(header, body));
|
||||
|
||||
assertThat(events).singleElement()
|
||||
.extracting(VehicleEvent::metadata)
|
||||
.satisfies(meta -> assertThat(meta)
|
||||
.containsEntry("jt808.extra.0x01", "00003039")
|
||||
.containsEntry("jt808.extra.0x30", "58"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void locationMileageExtensionIsMappedToInternalMileageField() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION, 34, 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"),
|
||||
java.util.Map.of(0x01, new byte[]{0x00, 0x00, 0x30, 0x39}));
|
||||
|
||||
VehicleEvent.Location event = (VehicleEvent.Location) mapper.toEvents(new Jt808Message(header, body)).getFirst();
|
||||
|
||||
assertThat(event.payload().totalMileageKm()).isEqualTo(1234.5);
|
||||
assertThat(event.metadata()).containsEntry("jt808.extra.0x01", "00003039");
|
||||
}
|
||||
|
||||
@Test
|
||||
void locationBodyUsesBoundVehicleIdentity() {
|
||||
identity.bind(new VehicleIdentityBinding(ProtocolId.JT808,
|
||||
"LNVIN000000000009", "123456789012", "DEV009", "粤B99999"));
|
||||
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"));
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(header, body));
|
||||
|
||||
assertThat(events).hasSize(1);
|
||||
assertThat(events.get(0).vin()).isEqualTo("LNVIN000000000009");
|
||||
assertThat(events.get(0).metadata())
|
||||
.containsEntry("vin", "LNVIN000000000009")
|
||||
.containsEntry("identityResolved", "true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityResolverFailureStillProducesLocationEventWithUnknownVin() {
|
||||
Jt808EventMapper mapperWithFailingIdentity = new Jt808EventMapper(lookup -> {
|
||||
throw new IllegalStateException("identity backend unavailable");
|
||||
});
|
||||
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"));
|
||||
|
||||
List<VehicleEvent> events = mapperWithFailingIdentity.toEvents(new Jt808Message(header, body));
|
||||
|
||||
assertThat(events).singleElement()
|
||||
.satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Location.class);
|
||||
assertThat(event.vin()).isEqualTo("unknown");
|
||||
assertThat(event.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unregisterBodyProducesLogoutEvent() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_UNREGISTER, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
|
||||
assertThat(mapper.toEvents(new Jt808Message(header, new Jt808Body.Unregister())))
|
||||
.singleElement()
|
||||
.isInstanceOf(VehicleEvent.Logout.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void locationBatchProducesOneLocationEventPerPoint() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION_BATCH, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var loc = new Jt808Body.Location(
|
||||
0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z"));
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(
|
||||
header, new Jt808Body.LocationBatch(0, List.of(loc, loc))));
|
||||
|
||||
assertThat(events).hasSize(2)
|
||||
.allSatisfy(event -> assertThat(event).isInstanceOf(VehicleEvent.Location.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mediaAndPassthroughBodiesProduceQueryableEvents() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_MEDIA_EVENT, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
assertThat(mapper.toEvents(new Jt808Message(
|
||||
header, new Jt808Body.MediaEvent(42, 0, 1, 2, 3))))
|
||||
.singleElement()
|
||||
.isInstanceOf(VehicleEvent.MediaMeta.class);
|
||||
|
||||
var passHeader = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_PASSTHROUGH, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 2, 0, 0);
|
||||
assertThat(mapper.toEvents(new Jt808Message(
|
||||
passHeader, new Jt808Body.Passthrough(0x41, new byte[]{1, 2, 3}))))
|
||||
.singleElement()
|
||||
.satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.passthroughType()).isEqualTo(Jt808MessageId.TERMINAL_PASSTHROUGH);
|
||||
assertThat(passthrough.metadata()).containsEntry("passthroughType", "0x41");
|
||||
assertThat(passthrough.data()).containsExactly(1, 2, 3);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawBodyProducesPassthroughEventSoUnknownMessagesRemainQueryable() {
|
||||
var header = new Jt808Header(
|
||||
0x0F01, 3, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 9, 0, 0);
|
||||
|
||||
assertThat(mapper.toEvents(new Jt808Message(
|
||||
header, new Jt808Body.Raw(0x0F01, new byte[]{0x11, 0x22, 0x33}))))
|
||||
.singleElement()
|
||||
.satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
var passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.passthroughType()).isEqualTo(0x0F01);
|
||||
assertThat(passthrough.data()).containsExactly(0x11, 0x22, 0x33);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("vin", "123456789012")
|
||||
.containsEntry("rawBody", "true");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedBodyPassthroughMetadataUsesUnknownInternalVin() {
|
||||
var header = new Jt808Header(
|
||||
0, 3, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 9, 0, 0);
|
||||
|
||||
assertThat(mapper.toEvents(new Jt808Message(
|
||||
header, new Jt808Body.Malformed(new byte[]{0x01, 0x02, 0x03}, "127.0.0.1:7611", "decode failed"))))
|
||||
.singleElement()
|
||||
.satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
assertThat(event.vin()).isEqualTo("unknown");
|
||||
assertThat(event.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("parseError", "true");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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