feat: make gb32960 archive history query production ready

This commit is contained in:
kkfluous
2026-06-23 11:55:44 +08:00
parent b14871ff1c
commit ba68ffe061
462 changed files with 23639 additions and 2341 deletions

View 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>

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,7 @@
package com.lingniu.ingest.protocol.jt808.codec;
public record Jt808MalformedFrame(
byte[] rawBytes,
String peer,
String reason
) {}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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(); }
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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) {}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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) {}
}

View File

@@ -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");
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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 {}
}

View File

@@ -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 }
}

View File

@@ -0,0 +1,6 @@
package com.lingniu.ingest.protocol.jt808.model;
/**
* 完整解析后的 JT808 消息。
*/
public record Jt808Message(Jt808Header header, Jt808Body body) {}

View File

@@ -0,0 +1,39 @@
package com.lingniu.ingest.protocol.jt808.model;
/**
* JT/T 808 消息 ID 常量集(节选核心消息,按需继续扩展)。
*
* <p>使用常量类而非 enum808 消息种类多(超过 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;
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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");
});
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
});
}
}

View File

@@ -0,0 +1 @@
7e010200070000000000230000687569746f6e67417e

View File

@@ -0,0 +1 @@
7e0102001800000000006500016e65382f51525a50664738596f7145575155577236513d3d607e

View File

@@ -0,0 +1 @@
7e02000036000000000001072d000000000008000301ceb03b0732f26e000d02af0010260413123353010400052425020200000302000025040000000030011f3101192c7e

View File

@@ -0,0 +1 @@
7e020040460100000000000000000002002c00000000000c000301bb0e2107213304003f0000010d26041312335514040000000017020000010400006722030200002504000000002a020000300115310118ea0402008000fb7e

View File

@@ -0,0 +1 @@
7e020000360000000000100278000000000008000301d596aa0735b9910001004b01472604131234030104000a42c10202000003020000250400000000300115310117107e

View File

@@ -0,0 +1 @@
7e020000360000000001020443000000000008000301d2d74907376cde000f00000000260413123354010400078789020200000302000025040000000030011b310112db7e

View File

@@ -0,0 +1 @@
7e02004048010000000000000000020204d2000000000048000301cc1d4f073ed6640010033301492604131234001404000000001702000001040000f0542504000000002a02000030011f310110ea04020c8300ef0400000000d67e

View File

@@ -0,0 +1 @@
7e02000036000000000413013d000000000008000201d392280736f1e30007000000002604131311220104000f334a020200000302000025040000000030011931010a097e

View File

@@ -0,0 +1 @@
7e0704003b000000000058034e0001010036000000000008000301e26c120725c15100110000000726041313111301040006ffc6030200002504000000002a02000030010a31010c3c7e