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