feat: make gb32960 archive history query production ready
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* JT808 黄金样本集回放测试:遍历 {@code src/test/resources/samples/jt808/*.hex}。
|
||||
*
|
||||
* <p>样本文件格式:一行十六进制(完整 0x7e 包裹帧),脚本脱敏产出。
|
||||
* 测试会去掉首尾 0x7e,执行反转义后调用 {@link Jt808MessageDecoder}。
|
||||
*/
|
||||
class Jt808DecoderGoldenTest {
|
||||
|
||||
private final Jt808MessageDecoder decoder = new Jt808MessageDecoder(
|
||||
new BodyParserRegistry(List.of(
|
||||
new RegisterBodyParser(),
|
||||
new LocationBodyParser(),
|
||||
new HeartbeatBodyParser())));
|
||||
|
||||
@TestFactory
|
||||
Collection<DynamicTest> replaySamples() throws URISyntaxException, IOException {
|
||||
var url = getClass().getClassLoader().getResource("samples/jt808");
|
||||
if (url == null) return List.of();
|
||||
Path dir = Paths.get(url.toURI());
|
||||
try (Stream<Path> s = Files.list(dir)) {
|
||||
return s.filter(p -> p.toString().endsWith(".hex"))
|
||||
.sorted()
|
||||
.map(this::toTest)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
private DynamicTest toTest(Path sample) {
|
||||
return DynamicTest.dynamicTest(sample.getFileName().toString(), () -> {
|
||||
byte[] framed = readHex(sample);
|
||||
assertThat(framed[0]).as("must start with 0x7e").isEqualTo((byte) 0x7e);
|
||||
assertThat(framed[framed.length - 1]).as("must end with 0x7e").isEqualTo((byte) 0x7e);
|
||||
|
||||
byte[] unescaped = Jt808Escape.unescape(framed, 1, framed.length - 2);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped));
|
||||
|
||||
assertThat(msg.header().messageId()).isNotNegative();
|
||||
assertThat(msg.header().phone()).isNotEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] readHex(Path path) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String line : Files.readAllLines(path)) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue;
|
||||
sb.append(trimmed);
|
||||
}
|
||||
String hex = sb.toString();
|
||||
int len = hex.length() / 2;
|
||||
byte[] out = new byte[len];
|
||||
for (int i = 0; i < len; i++) {
|
||||
out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.codec.BcdCodec;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.AuthBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBatchBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.MediaEventBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.MediaUploadBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.PassthroughBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalAttrsBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalParamsReportBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.UnregisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808DecoderTest {
|
||||
|
||||
private final Jt808MessageDecoder decoder = new Jt808MessageDecoder(
|
||||
new BodyParserRegistry(List.of(
|
||||
new RegisterBodyParser(),
|
||||
new AuthBodyParser(),
|
||||
new LocationBodyParser(),
|
||||
new LocationBatchBodyParser(),
|
||||
new UnregisterBodyParser(),
|
||||
new TerminalParamsReportBodyParser(),
|
||||
new TerminalAttrsBodyParser(),
|
||||
new MediaEventBodyParser(),
|
||||
new MediaUploadBodyParser(),
|
||||
new PassthroughBodyParser(),
|
||||
new HeartbeatBodyParser())));
|
||||
|
||||
@Test
|
||||
void decodesSyntheticLocationFrame() {
|
||||
byte[] body = buildLocationBody();
|
||||
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 0x0001, body);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
|
||||
assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION);
|
||||
assertThat(msg.header().phone()).isEqualTo("123456789012");
|
||||
assertThat(msg.header().serialNo()).isEqualTo(1);
|
||||
|
||||
assertThat(msg.body()).isInstanceOf(Jt808Body.Location.class);
|
||||
Jt808Body.Location loc = (Jt808Body.Location) msg.body();
|
||||
assertThat(loc.longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001));
|
||||
assertThat(loc.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001));
|
||||
assertThat(loc.speedKmh()).isEqualTo(52.3, org.assertj.core.data.Offset.offset(0.01));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesLocationExtensionItems() {
|
||||
byte[] body = bytes(os -> {
|
||||
os.write(buildLocationBody(), 0, buildLocationBody().length);
|
||||
os.write(0x01); // mileage, DWORD, 0.1 km
|
||||
os.write(4);
|
||||
writeU32(os, 12345);
|
||||
os.write(0x30); // wireless signal strength
|
||||
os.write(1);
|
||||
os.write(88);
|
||||
});
|
||||
|
||||
Jt808Body.Location loc = (Jt808Body.Location) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_LOCATION, "123456789012", 9, body))).body();
|
||||
|
||||
assertThat(loc.extensionItems()).containsOnlyKeys(0x01, 0x30);
|
||||
assertThat(loc.extensionItems().get(0x01)).containsExactly(0x00, 0x00, 0x30, 0x39);
|
||||
assertThat(loc.extensionItems().get(0x30)).containsExactly(88);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesSyntheticHeartbeatFrame() {
|
||||
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 0x0002, new byte[0]);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT);
|
||||
assertThat(msg.body()).isInstanceOf(Jt808Body.Heartbeat.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesAuthFrameAndExtractsImeiWhenPresent() {
|
||||
byte[] authBody = "AUTH-CODE,123456789012345,SW-1".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
|
||||
Jt808Body.Auth auth = (Jt808Body.Auth) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_AUTH, "123456789012", 10, authBody))).body();
|
||||
|
||||
assertThat(auth.token()).isEqualTo("AUTH-CODE,123456789012345,SW-1");
|
||||
assertThat(auth.imei()).isEqualTo("123456789012345");
|
||||
assertThat(auth.softwareVersion()).isEqualTo("SW-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesLogoutParamsAttrsMediaAndPassthroughFrames() {
|
||||
assertThat(decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_UNREGISTER, "123456789012", 3, new byte[0]))).body())
|
||||
.isInstanceOf(Jt808Body.Unregister.class);
|
||||
|
||||
byte[] paramsBody = bytes(os -> {
|
||||
writeU16(os, 7);
|
||||
os.write(1);
|
||||
writeU32(os, 0x00000080L);
|
||||
os.write(1);
|
||||
os.write(5);
|
||||
});
|
||||
Jt808Body.ParamsReport params = (Jt808Body.ParamsReport) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_PARAMS_REPORT, "123456789012", 4, paramsBody))).body();
|
||||
assertThat(params.responseSerialNo()).isEqualTo(7);
|
||||
assertThat(params.parameters()).containsKey(0x00000080L);
|
||||
|
||||
byte[] attrsBody = bytes(os -> {
|
||||
writeU16(os, 0x1234);
|
||||
writeAscii(os, "MAKER", 5);
|
||||
writeAscii(os, "MODEL-X", 20);
|
||||
writeAscii(os, "DEV1234", 7);
|
||||
os.write(BcdCodec.encode("89860012345678901234"), 0, 10);
|
||||
writeAscii(os, "HW1", 10);
|
||||
writeAscii(os, "SW1", 10);
|
||||
os.write(1);
|
||||
os.write(2);
|
||||
});
|
||||
Jt808Body.TerminalAttrs attrs = (Jt808Body.TerminalAttrs) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_ATTRS_REPORT, "123456789012", 5, attrsBody))).body();
|
||||
assertThat(attrs.maker()).isEqualTo("MAKER");
|
||||
assertThat(attrs.terminalId()).isEqualTo("DEV1234");
|
||||
|
||||
byte[] mediaEventBody = bytes(os -> {
|
||||
writeU32(os, 42);
|
||||
os.write(0);
|
||||
os.write(1);
|
||||
os.write(2);
|
||||
os.write(3);
|
||||
});
|
||||
assertThat(decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_MEDIA_EVENT, "123456789012", 6, mediaEventBody))).body())
|
||||
.isInstanceOf(Jt808Body.MediaEvent.class);
|
||||
|
||||
byte[] passthroughBody = bytes(os -> {
|
||||
os.write(0x41);
|
||||
os.write(new byte[]{0x01, 0x02, 0x03}, 0, 3);
|
||||
});
|
||||
Jt808Body.Passthrough passthrough = (Jt808Body.Passthrough) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_PASSTHROUGH, "123456789012", 7, passthroughBody))).body();
|
||||
assertThat(passthrough.passthroughType()).isEqualTo(0x41);
|
||||
assertThat(passthrough.data()).containsExactly(0x01, 0x02, 0x03);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesLocationBatchFrame() {
|
||||
byte[] location = buildLocationBody();
|
||||
byte[] batchBody = bytes(os -> {
|
||||
writeU16(os, 2);
|
||||
os.write(0);
|
||||
writeU16(os, location.length);
|
||||
os.write(location, 0, location.length);
|
||||
writeU16(os, location.length);
|
||||
os.write(location, 0, location.length);
|
||||
});
|
||||
|
||||
Jt808Body.LocationBatch batch = (Jt808Body.LocationBatch) decoder.decode(ByteBuffer.wrap(buildFrame(
|
||||
Jt808MessageId.TERMINAL_LOCATION_BATCH, "123456789012", 8, batchBody))).body();
|
||||
|
||||
assertThat(batch.batchType()).isEqualTo(0);
|
||||
assertThat(batch.locations()).hasSize(2);
|
||||
assertThat(batch.locations().getFirst().longitude()).isEqualTo(116.397128);
|
||||
}
|
||||
|
||||
// ===== helpers =====
|
||||
|
||||
private static byte[] buildLocationBody() {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU32(os, 0); // alarmFlag
|
||||
writeU32(os, 0x00030000); // statusFlag
|
||||
writeU32(os, 39_916_527L); // lat
|
||||
writeU32(os, 116_397_128L); // lon
|
||||
writeU16(os, 50); // altitude
|
||||
writeU16(os, 523); // speed 52.3
|
||||
writeU16(os, 90); // direction
|
||||
byte[] bcd = BcdCodec.encode("240102030405");
|
||||
os.write(bcd, 0, 6);
|
||||
return os.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] bytes(IoConsumer<ByteArrayOutputStream> writer) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writer.accept(os);
|
||||
return os.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeAscii(ByteArrayOutputStream os, String value, int len) {
|
||||
byte[] out = new byte[len];
|
||||
byte[] raw = value.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
System.arraycopy(raw, 0, out, 0, Math.min(raw.length, out.length));
|
||||
os.write(out, 0, out.length);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface IoConsumer<T> {
|
||||
void accept(T value);
|
||||
}
|
||||
|
||||
private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) {
|
||||
// header: msgId(2) attrs(2) phone(6 BCD) serial(2)
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU16(os, msgId);
|
||||
int attrs = body.length & 0x03FF; // 2013 版,无子包、无加密
|
||||
writeU16(os, attrs);
|
||||
byte[] phoneBcd = BcdCodec.encode(phone);
|
||||
os.write(phoneBcd, 0, 6);
|
||||
writeU16(os, serial);
|
||||
os.write(body, 0, body.length);
|
||||
byte[] raw = os.toByteArray();
|
||||
byte bcc = BccChecksum.compute(raw, 0, raw.length);
|
||||
|
||||
ByteArrayOutputStream framed = new ByteArrayOutputStream();
|
||||
framed.write(raw, 0, raw.length);
|
||||
framed.write(bcc & 0xFF);
|
||||
return framed.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeU16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeU32(ByteArrayOutputStream os, long v) {
|
||||
os.write((int) ((v >> 24) & 0xFF));
|
||||
os.write((int) ((v >> 16) & 0xFF));
|
||||
os.write((int) ((v >> 8) & 0xFF));
|
||||
os.write((int) (v & 0xFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808EscapeTest {
|
||||
|
||||
@Test
|
||||
void roundTrip() {
|
||||
byte[] raw = {0x30, 0x7e, 0x08, 0x7d, (byte) 0xFF};
|
||||
byte[] escaped = Jt808Escape.escape(raw);
|
||||
assertThat(escaped).containsExactly(0x30, 0x7d, 0x02, 0x08, 0x7d, 0x01, 0xFF);
|
||||
|
||||
byte[] back = Jt808Escape.unescape(escaped, 0, escaped.length);
|
||||
assertThat(back).containsExactly(raw[0], raw[1], raw[2], raw[3], raw[4]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noEscapeNeeded() {
|
||||
byte[] raw = {0x01, 0x02, 0x03};
|
||||
assertThat(Jt808Escape.escape(raw)).containsExactly(0x01, 0x02, 0x03);
|
||||
assertThat(Jt808Escape.unescape(raw, 0, raw.length)).containsExactly(0x01, 0x02, 0x03);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808FrameDecoderTest {
|
||||
|
||||
@Test
|
||||
void garbageWithoutFrameBoundaryEmitsMalformedCandidate() {
|
||||
EmbeddedChannel channel = new EmbeddedChannel(new Jt808FrameDecoder());
|
||||
|
||||
channel.writeInbound(Unpooled.wrappedBuffer(new byte[]{0x01, 0x02, 0x03}));
|
||||
|
||||
Jt808MalformedFrame malformed = channel.readInbound();
|
||||
assertThat(malformed.rawBytes()).containsExactly(0x01, 0x02, 0x03);
|
||||
assertThat(malformed.reason()).contains("missing frame boundary");
|
||||
assertThat(malformed.peer()).isNotNull();
|
||||
assertThat((Object) channel.readInbound()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedUnclosedFrameEmitsMalformedCandidate() {
|
||||
byte[] bytes = new byte[16 * 1024 + 2];
|
||||
bytes[0] = 0x7e;
|
||||
bytes[1] = 0x01;
|
||||
EmbeddedChannel channel = new EmbeddedChannel(new Jt808FrameDecoder());
|
||||
|
||||
channel.writeInbound(Unpooled.wrappedBuffer(bytes));
|
||||
|
||||
Jt808MalformedFrame malformed = channel.readInbound();
|
||||
assertThat(malformed.rawBytes()).containsExactly(bytes);
|
||||
assertThat(malformed.reason()).contains("too large");
|
||||
assertThat(malformed.peer()).isNotNull();
|
||||
assertThat((Object) channel.readInbound()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.lingniu.ingest.protocol.jt808.codec;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 端到端验证:encoder 生成的下行帧可以被 decoder 反向解析,且字段对称。
|
||||
*/
|
||||
class Jt808FrameEncoderTest {
|
||||
|
||||
private final Jt808MessageDecoder decoder = new Jt808MessageDecoder(
|
||||
new BodyParserRegistry(List.of(
|
||||
new RegisterBodyParser(),
|
||||
new LocationBodyParser(),
|
||||
new HeartbeatBodyParser())));
|
||||
|
||||
@Test
|
||||
void encodeThenDecodePlatformAck() {
|
||||
var cmd = Jt808Commands.platformAck(0x1234, 0x0200, 0);
|
||||
byte[] framed = Jt808FrameEncoder.encode(cmd.messageId(), "123456789012", 7, cmd.body());
|
||||
|
||||
// strip outer 0x7e and unescape
|
||||
byte[] unescaped = Jt808Escape.unescape(framed, 1, framed.length - 2);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped));
|
||||
|
||||
assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.PLATFORM_GENERAL_RESPONSE);
|
||||
assertThat(msg.header().phone()).isEqualTo("123456789012");
|
||||
assertThat(msg.header().serialNo()).isEqualTo(7);
|
||||
assertThat(msg.body()).isInstanceOf(Jt808Body.Raw.class);
|
||||
Jt808Body.Raw raw = (Jt808Body.Raw) msg.body();
|
||||
// body: ackSerial(2) + ackMsgId(2) + result(1) = 5 bytes
|
||||
assertThat(raw.bytes()).hasSize(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodeQueryLocationProducesFramedWith7eBoundary() {
|
||||
byte[] framed = Jt808FrameEncoder.encode(
|
||||
Jt808MessageId.PLATFORM_QUERY_LOCATION, "123456789012", 1, new byte[0]);
|
||||
assertThat(framed[0]).isEqualTo((byte) 0x7e);
|
||||
assertThat(framed[framed.length - 1]).isEqualTo((byte) 0x7e);
|
||||
// 中间不应出现裸 0x7e
|
||||
for (int i = 1; i < framed.length - 1; i++) {
|
||||
assertThat(framed[i]).isNotEqualTo((byte) 0x7e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedBodyWhenSubpackageIsNotUsed() {
|
||||
assertThatThrownBy(() -> Jt808FrameEncoder.encode(
|
||||
Jt808MessageId.PLATFORM_SET_PARAMS, "123456789012", 1, new byte[1024]))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("jt808 body too large");
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodesOversizedBodyAsSubpackages() {
|
||||
byte[] body = new byte[2048];
|
||||
for (int i = 0; i < body.length; i++) {
|
||||
body[i] = (byte) i;
|
||||
}
|
||||
|
||||
List<byte[]> frames = Jt808FrameEncoder.encodeSubpackages(
|
||||
Jt808MessageId.PLATFORM_SET_PARAMS, "123456789012", 9, body);
|
||||
|
||||
assertThat(frames).hasSize(3);
|
||||
java.io.ByteArrayOutputStream joined = new java.io.ByteArrayOutputStream();
|
||||
for (int i = 0; i < frames.size(); i++) {
|
||||
byte[] unescaped = Jt808Escape.unescape(frames.get(i), 1, frames.get(i).length - 2);
|
||||
Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped));
|
||||
assertThat(msg.header().subpacket()).isTrue();
|
||||
assertThat(msg.header().totalPackets()).isEqualTo(3);
|
||||
assertThat(msg.header().packetSeq()).isEqualTo(i + 1);
|
||||
assertThat(msg.header().serialNo()).isEqualTo(9);
|
||||
assertThat(msg.body()).isInstanceOf(Jt808Body.Raw.class);
|
||||
joined.writeBytes(((Jt808Body.Raw) msg.body()).bytes());
|
||||
}
|
||||
assertThat(joined.toByteArray()).containsExactly(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lingniu.ingest.protocol.jt808.downlink;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808Escape;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
|
||||
import com.lingniu.ingest.session.DeviceSession;
|
||||
import com.lingniu.ingest.session.InMemorySessionStore;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808CommandDispatcherTest {
|
||||
|
||||
@Test
|
||||
void notifyWritesSubpackageFramesForLargeBody() {
|
||||
InMemorySessionStore sessions = new InMemorySessionStore();
|
||||
sessions.put(new DeviceSession("sid-1", ProtocolId.JT808, "LNVIN000000000303",
|
||||
"123456789012", "", "127.0.0.1:10000", Instant.now(), Instant.now(), Map.of()));
|
||||
Jt808ChannelRegistry channels = new Jt808ChannelRegistry();
|
||||
EmbeddedChannel channel = new EmbeddedChannel();
|
||||
channels.bind("123456789012", channel);
|
||||
Jt808CommandDispatcher dispatcher = new Jt808CommandDispatcher(
|
||||
sessions, channels, new Jt808PendingRequests());
|
||||
byte[] body = new byte[2048];
|
||||
|
||||
dispatcher.notify("sid-1", new Jt808Commands.DownlinkCommand(
|
||||
Jt808MessageId.PLATFORM_SET_PARAMS, body)).join();
|
||||
|
||||
Jt808MessageDecoder decoder = new Jt808MessageDecoder(new BodyParserRegistry(List.of()));
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
ByteBuf outbound = channel.readOutbound();
|
||||
byte[] frame = new byte[outbound.readableBytes()];
|
||||
outbound.readBytes(frame);
|
||||
byte[] unescaped = Jt808Escape.unescape(frame, 1, frame.length - 2);
|
||||
Jt808Message decoded = decoder.decode(ByteBuffer.wrap(unescaped));
|
||||
assertThat(decoded.header().subpacket()).isTrue();
|
||||
assertThat(decoded.header().totalPackets()).isEqualTo(3);
|
||||
assertThat(decoded.header().packetSeq()).isEqualTo(i);
|
||||
assertThat(decoded.body()).isInstanceOf(Jt808Body.Raw.class);
|
||||
}
|
||||
assertThat((Object) channel.readOutbound()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lingniu.ingest.protocol.jt808.downlink;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808CommandsTest {
|
||||
|
||||
@Test
|
||||
void deleteAreaEncodesCountAndDwordAreaIds() {
|
||||
var command = Jt808Commands.deleteArea(List.of(1L, 0x01020304L));
|
||||
|
||||
assertThat(command.messageId()).isEqualTo(0x8601);
|
||||
assertThat(command.body()).containsExactly(
|
||||
0x02,
|
||||
0x00, 0x00, 0x00, 0x01,
|
||||
0x01, 0x02, 0x03, 0x04);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.lingniu.ingest.protocol.jt808.handler;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.core.dispatcher.AnnotationHandlerBeanPostProcessor;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808LocationHandlerTest {
|
||||
|
||||
@Test
|
||||
void unknownRawMessageRoutesToPassthroughHandler() {
|
||||
Jt808LocationHandler handler = new Jt808LocationHandler(
|
||||
new Jt808EventMapper(new InMemoryVehicleIdentityService()));
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
new AnnotationHandlerBeanPostProcessor(registry).postProcessAfterInitialization(handler, "jt808Handler");
|
||||
Jt808Message message = new Jt808Message(
|
||||
new Jt808Header(0x0F01, 3, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 7, 0, 0),
|
||||
new Jt808Body.Raw(0x0F01, new byte[]{0x01, 0x02, 0x03}));
|
||||
|
||||
var definitions = registry.resolve(ProtocolId.JT808, 0x0F01, 0);
|
||||
|
||||
assertThat(definitions).hasSize(1);
|
||||
var events = new HandlerInvoker().invoke(definitions.getFirst(), new RawFrame(
|
||||
ProtocolId.JT808, 0x0F01, 0, message, new byte[]{0x7e},
|
||||
Map.of("phone", "123456789012"), Instant.now()), new IngestContext("trace-1"));
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.passthroughType()).isEqualTo(0x0F01);
|
||||
assertThat(passthrough.metadata()).containsEntry("rawBody", "true");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package com.lingniu.ingest.protocol.jt808.inbound;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.sink.EventSink;
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.codec.BcdCodec;
|
||||
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
|
||||
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerDefinition;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
|
||||
import com.lingniu.ingest.core.pipeline.InterceptorChain;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityBinding;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MalformedFrame;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.AuthBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.handler.Jt808LocationHandler;
|
||||
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
|
||||
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
|
||||
import com.lingniu.ingest.session.InMemorySessionStore;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808ChannelHandlerTest {
|
||||
|
||||
@Test
|
||||
void registerSessionUsesResolvedInternalVinInsteadOfDeviceIdPlaceholder() {
|
||||
InMemorySessionStore sessions = new InMemorySessionStore();
|
||||
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
identity.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "B80808"));
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
new HandlerRegistry(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser()))),
|
||||
dispatcher,
|
||||
sessions,
|
||||
identity,
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
|
||||
channel.writeInbound(buildFrame(
|
||||
Jt808MessageId.TERMINAL_REGISTER,
|
||||
"123456789012",
|
||||
1,
|
||||
buildRegisterBody("DEV808", "B80808")));
|
||||
|
||||
assertThat(sessions.findByPhone("123456789012"))
|
||||
.get()
|
||||
.extracting(session -> session.vin())
|
||||
.isEqualTo("LNVIN000000000808");
|
||||
assertThat(identity.resolve(new com.lingniu.ingest.identity.VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "123456789012", "", "")).vin())
|
||||
.isEqualTo("LNVIN000000000808");
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void authSessionUsesResolvedInternalVinFromImei() {
|
||||
InMemorySessionStore sessions = new InMemorySessionStore();
|
||||
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
identity.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "LNVIN000000AUTH01", "123456789012", "123456789012345", ""));
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
new HandlerRegistry(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new AuthBodyParser()))),
|
||||
dispatcher,
|
||||
sessions,
|
||||
identity,
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
|
||||
channel.writeInbound(buildFrame(
|
||||
Jt808MessageId.TERMINAL_AUTH,
|
||||
"123456789012",
|
||||
2,
|
||||
"TOKEN,123456789012345,SW1".getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
|
||||
|
||||
assertThat(sessions.findByPhone("123456789012"))
|
||||
.get()
|
||||
.extracting(session -> session.vin())
|
||||
.isEqualTo("LNVIN000000AUTH01");
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inactiveChannelRemovesSessionStoreEntry() {
|
||||
InMemorySessionStore sessions = new InMemorySessionStore();
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
new HandlerRegistry(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new AuthBodyParser()))),
|
||||
dispatcher,
|
||||
sessions,
|
||||
new InMemoryVehicleIdentityService(),
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
|
||||
channel.writeInbound(buildFrame(
|
||||
Jt808MessageId.TERMINAL_AUTH,
|
||||
"123456789012",
|
||||
2,
|
||||
"TOKEN,123456789012345,SW1".getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
|
||||
assertThat(sessions.findByPhone("123456789012")).isPresent();
|
||||
|
||||
channel.close();
|
||||
|
||||
assertThat(sessions.findByPhone("123456789012")).isEmpty();
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulFrameArchiveUsesResolvedVehicleIdentity() throws Exception {
|
||||
RecordingSink sink = new RecordingSink(1);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
new HandlerRegistry(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
identity.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "粤B80808"));
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new LocationBodyParser()))),
|
||||
dispatcher,
|
||||
new InMemorySessionStore(),
|
||||
identity,
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 1, buildLocationBody());
|
||||
|
||||
channel.writeInbound(frame);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.vin()).isEqualTo("LNVIN000000000808");
|
||||
assertThat(archive.metadata())
|
||||
.containsEntry("vin", "LNVIN000000000808")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "true")
|
||||
.containsEntry("identitySource", "BOUND_PHONE");
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedFrameIsArchivedAndDispatchedAsPassthrough() throws Exception {
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registryWithRawHandler(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of())),
|
||||
dispatcher,
|
||||
new InMemorySessionStore(),
|
||||
new InMemoryVehicleIdentityService(),
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] malformed = new byte[]{0x01, 0x02, 0x03};
|
||||
|
||||
channel.writeInbound(malformed);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive raw = (VehicleEvent.RawArchive) event;
|
||||
assertThat(raw.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(raw.command()).isZero();
|
||||
assertThat(raw.rawBytes()).containsExactly(malformed);
|
||||
assertThat(raw.metadata())
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(passthrough.vin()).isEqualTo("unknown");
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
assertThat(passthrough.data()).containsExactly(malformed);
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void frameBoundaryMalformedCandidateIsArchivedAndDispatchedAsPassthrough() throws Exception {
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registryWithRawHandler(),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of())),
|
||||
dispatcher,
|
||||
new InMemorySessionStore(),
|
||||
new InMemoryVehicleIdentityService(),
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] raw = new byte[]{0x01, 0x02, 0x03};
|
||||
|
||||
channel.writeInbound(new Jt808MalformedFrame(raw, "unknown", "jt808 missing frame boundary"));
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(archive.rawBytes()).containsExactly(raw);
|
||||
assertThat(archive.metadata()).containsEntry("frameError", "true")
|
||||
.containsEntry("frameErrorMessage", "jt808 missing frame boundary")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(passthrough.metadata()).containsEntry("frameError", "true")
|
||||
.containsEntry("frameErrorMessage", "jt808 missing frame boundary")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
assertThat(passthrough.data()).containsExactly(raw);
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityResolverFailureStillArchivesAndDispatchesDecodedLocationWithUnknownIdentity() throws Exception {
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
var failingIdentity = (com.lingniu.ingest.identity.VehicleIdentityResolver) lookup -> {
|
||||
throw new IllegalStateException("identity backend unavailable");
|
||||
};
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registryWithLocationHandler(new DirectLocationHandler(new Jt808EventMapper(failingIdentity))),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
Jt808ChannelHandler handler = new Jt808ChannelHandler(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new LocationBodyParser()))),
|
||||
dispatcher,
|
||||
new InMemorySessionStore(),
|
||||
new InMemoryVehicleIdentityService(),
|
||||
failingIdentity,
|
||||
new Jt808ChannelRegistry(),
|
||||
new Jt808PendingRequests());
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 1, buildLocationBody());
|
||||
|
||||
channel.writeInbound(frame);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(archive.command()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION);
|
||||
assertThat(archive.rawBytes()).containsExactly(frame);
|
||||
assertThat(archive.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Location.class);
|
||||
VehicleEvent.Location location = (VehicleEvent.Location) event;
|
||||
assertThat(location.source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(location.vin()).isEqualTo("unknown");
|
||||
assertThat(location.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
private static HandlerRegistry registryWithRawHandler() throws NoSuchMethodException {
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
Jt808LocationHandler rawHandler = new Jt808LocationHandler(
|
||||
new Jt808EventMapper(new InMemoryVehicleIdentityService()));
|
||||
registry.register(new HandlerDefinition(
|
||||
ProtocolId.JT808,
|
||||
0,
|
||||
0,
|
||||
"test raw",
|
||||
rawHandler,
|
||||
Jt808LocationHandler.class.getMethod("onRaw", Jt808Message.class),
|
||||
Jt808Message.class,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static HandlerRegistry registryWithLocationHandler(DirectLocationHandler locationHandler) throws NoSuchMethodException {
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
registry.register(new HandlerDefinition(
|
||||
ProtocolId.JT808,
|
||||
Jt808MessageId.TERMINAL_LOCATION,
|
||||
0,
|
||||
"test location",
|
||||
locationHandler,
|
||||
DirectLocationHandler.class.getMethod("onLocation", Jt808Message.class),
|
||||
Jt808Message.class,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
return registry;
|
||||
}
|
||||
|
||||
public static final class DirectLocationHandler {
|
||||
private final Jt808EventMapper mapper;
|
||||
|
||||
private DirectLocationHandler(Jt808EventMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<VehicleEvent> onLocation(Jt808Message msg) {
|
||||
return mapper.toEvents(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildLocationBody() {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU32(os, 0);
|
||||
writeU32(os, 0x00030000);
|
||||
writeU32(os, 39_916_527L);
|
||||
writeU32(os, 116_397_128L);
|
||||
writeU16(os, 50);
|
||||
writeU16(os, 523);
|
||||
writeU16(os, 90);
|
||||
byte[] bcd = BcdCodec.encode("240102030405");
|
||||
os.write(bcd, 0, 6);
|
||||
return os.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] buildRegisterBody(String deviceId, String plate) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU16(os, 44);
|
||||
writeU16(os, 4401);
|
||||
writeAscii(os, "MAKER", 5);
|
||||
writeAscii(os, "TYPE-A", 20);
|
||||
writeAscii(os, deviceId, 7);
|
||||
os.write(1);
|
||||
byte[] plateBytes = plate.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
os.write(plateBytes, 0, plateBytes.length);
|
||||
return os.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeAscii(ByteArrayOutputStream os, String value, int len) {
|
||||
byte[] raw = value.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
int copy = Math.min(raw.length, len);
|
||||
os.write(raw, 0, copy);
|
||||
for (int i = copy; i < len; i++) {
|
||||
os.write(0x20);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) {
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeU16(os, msgId);
|
||||
writeU16(os, body.length & 0x03FF);
|
||||
byte[] phoneBcd = BcdCodec.encode(phone);
|
||||
os.write(phoneBcd, 0, 6);
|
||||
writeU16(os, serial);
|
||||
os.write(body, 0, body.length);
|
||||
byte[] raw = os.toByteArray();
|
||||
byte bcc = BccChecksum.compute(raw, 0, raw.length);
|
||||
|
||||
ByteArrayOutputStream framed = new ByteArrayOutputStream();
|
||||
framed.write(raw, 0, raw.length);
|
||||
framed.write(bcc & 0xFF);
|
||||
return framed.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeU16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeU32(ByteArrayOutputStream os, long v) {
|
||||
os.write((int) ((v >> 24) & 0xFF));
|
||||
os.write((int) ((v >> 16) & 0xFF));
|
||||
os.write((int) ((v >> 8) & 0xFF));
|
||||
os.write((int) (v & 0xFF));
|
||||
}
|
||||
|
||||
private static final class RecordingSink implements EventSink {
|
||||
private final List<VehicleEvent> events = new CopyOnWriteArrayList<>();
|
||||
private final CountDownLatch latch;
|
||||
|
||||
private RecordingSink(int expectedEvents) {
|
||||
this.latch = new CountDownLatch(expectedEvents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "recording";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> publish(VehicleEvent event) {
|
||||
events.add(event);
|
||||
latch.countDown();
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
private boolean await() throws InterruptedException {
|
||||
return latch.await(3, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.lingniu.ingest.protocol.jt808.mapper;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityBinding;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jt808EventMapperTest {
|
||||
|
||||
private final InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
private final Jt808EventMapper mapper = new Jt808EventMapper(identity);
|
||||
|
||||
@Test
|
||||
void locationBodyProducesLocationEvent() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION, 28, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var body = new Jt808Body.Location(
|
||||
0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z"));
|
||||
var msg = new Jt808Message(header, body);
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(msg);
|
||||
|
||||
assertThat(events).hasSize(1);
|
||||
assertThat(events.get(0)).isInstanceOf(VehicleEvent.Location.class);
|
||||
assertThat(events.get(0).source()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(events.get(0).vin()).isEqualTo("123456789012");
|
||||
assertThat(events.get(0).metadata())
|
||||
.containsEntry("vin", "123456789012")
|
||||
.containsEntry("identityResolved", "false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void locationExtensionItemsAreExposedAsMetadata() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION, 35, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var body = new Jt808Body.Location(
|
||||
0, 0, 116.397128, 39.916527, 50, 52.3, 90,
|
||||
Instant.parse("2024-01-02T03:04:05Z"),
|
||||
java.util.Map.of(0x01, new byte[]{0x00, 0x00, 0x30, 0x39}, 0x30, new byte[]{0x58}));
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(header, body));
|
||||
|
||||
assertThat(events).singleElement()
|
||||
.extracting(VehicleEvent::metadata)
|
||||
.satisfies(meta -> assertThat(meta)
|
||||
.containsEntry("jt808.extra.0x01", "00003039")
|
||||
.containsEntry("jt808.extra.0x30", "58"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void locationMileageExtensionIsMappedToInternalMileageField() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION, 34, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var body = new Jt808Body.Location(
|
||||
0, 0, 116.397128, 39.916527, 50, 52.3, 90,
|
||||
Instant.parse("2024-01-02T03:04:05Z"),
|
||||
java.util.Map.of(0x01, new byte[]{0x00, 0x00, 0x30, 0x39}));
|
||||
|
||||
VehicleEvent.Location event = (VehicleEvent.Location) mapper.toEvents(new Jt808Message(header, body)).getFirst();
|
||||
|
||||
assertThat(event.payload().totalMileageKm()).isEqualTo(1234.5);
|
||||
assertThat(event.metadata()).containsEntry("jt808.extra.0x01", "00003039");
|
||||
}
|
||||
|
||||
@Test
|
||||
void locationBodyUsesBoundVehicleIdentity() {
|
||||
identity.bind(new VehicleIdentityBinding(ProtocolId.JT808,
|
||||
"LNVIN000000000009", "123456789012", "DEV009", "粤B99999"));
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION, 28, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var body = new Jt808Body.Location(
|
||||
0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z"));
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(header, body));
|
||||
|
||||
assertThat(events).hasSize(1);
|
||||
assertThat(events.get(0).vin()).isEqualTo("LNVIN000000000009");
|
||||
assertThat(events.get(0).metadata())
|
||||
.containsEntry("vin", "LNVIN000000000009")
|
||||
.containsEntry("identityResolved", "true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityResolverFailureStillProducesLocationEventWithUnknownVin() {
|
||||
Jt808EventMapper mapperWithFailingIdentity = new Jt808EventMapper(lookup -> {
|
||||
throw new IllegalStateException("identity backend unavailable");
|
||||
});
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION, 28, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var body = new Jt808Body.Location(
|
||||
0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z"));
|
||||
|
||||
List<VehicleEvent> events = mapperWithFailingIdentity.toEvents(new Jt808Message(header, body));
|
||||
|
||||
assertThat(events).singleElement()
|
||||
.satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Location.class);
|
||||
assertThat(event.vin()).isEqualTo("unknown");
|
||||
assertThat(event.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void heartbeatBodyProducesHeartbeatEvent() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_HEARTBEAT, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var msg = new Jt808Message(header, new Jt808Body.Heartbeat());
|
||||
assertThat(mapper.toEvents(msg)).hasSize(1)
|
||||
.first().isInstanceOf(VehicleEvent.Heartbeat.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unregisterBodyProducesLogoutEvent() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_UNREGISTER, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
|
||||
assertThat(mapper.toEvents(new Jt808Message(header, new Jt808Body.Unregister())))
|
||||
.singleElement()
|
||||
.isInstanceOf(VehicleEvent.Logout.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void locationBatchProducesOneLocationEventPerPoint() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_LOCATION_BATCH, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
var loc = new Jt808Body.Location(
|
||||
0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z"));
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(
|
||||
header, new Jt808Body.LocationBatch(0, List.of(loc, loc))));
|
||||
|
||||
assertThat(events).hasSize(2)
|
||||
.allSatisfy(event -> assertThat(event).isInstanceOf(VehicleEvent.Location.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mediaAndPassthroughBodiesProduceQueryableEvents() {
|
||||
var header = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_MEDIA_EVENT, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
|
||||
assertThat(mapper.toEvents(new Jt808Message(
|
||||
header, new Jt808Body.MediaEvent(42, 0, 1, 2, 3))))
|
||||
.singleElement()
|
||||
.isInstanceOf(VehicleEvent.MediaMeta.class);
|
||||
|
||||
var passHeader = new Jt808Header(
|
||||
Jt808MessageId.TERMINAL_PASSTHROUGH, 0, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 2, 0, 0);
|
||||
assertThat(mapper.toEvents(new Jt808Message(
|
||||
passHeader, new Jt808Body.Passthrough(0x41, new byte[]{1, 2, 3}))))
|
||||
.singleElement()
|
||||
.satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.passthroughType()).isEqualTo(Jt808MessageId.TERMINAL_PASSTHROUGH);
|
||||
assertThat(passthrough.metadata()).containsEntry("passthroughType", "0x41");
|
||||
assertThat(passthrough.data()).containsExactly(1, 2, 3);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawBodyProducesPassthroughEventSoUnknownMessagesRemainQueryable() {
|
||||
var header = new Jt808Header(
|
||||
0x0F01, 3, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 9, 0, 0);
|
||||
|
||||
assertThat(mapper.toEvents(new Jt808Message(
|
||||
header, new Jt808Body.Raw(0x0F01, new byte[]{0x11, 0x22, 0x33}))))
|
||||
.singleElement()
|
||||
.satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
var passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.passthroughType()).isEqualTo(0x0F01);
|
||||
assertThat(passthrough.data()).containsExactly(0x11, 0x22, 0x33);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("vin", "123456789012")
|
||||
.containsEntry("rawBody", "true");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedBodyPassthroughMetadataUsesUnknownInternalVin() {
|
||||
var header = new Jt808Header(
|
||||
0, 3, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "123456789012", 9, 0, 0);
|
||||
|
||||
assertThat(mapper.toEvents(new Jt808Message(
|
||||
header, new Jt808Body.Malformed(new byte[]{0x01, 0x02, 0x03}, "127.0.0.1:7611", "decode failed"))))
|
||||
.singleElement()
|
||||
.satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
assertThat(event.vin()).isEqualTo("unknown");
|
||||
assertThat(event.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("parseError", "true");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
7e010200070000000000230000687569746f6e67417e
|
||||
@@ -0,0 +1 @@
|
||||
7e0102001800000000006500016e65382f51525a50664738596f7145575155577236513d3d607e
|
||||
@@ -0,0 +1 @@
|
||||
7e02000036000000000001072d000000000008000301ceb03b0732f26e000d02af0010260413123353010400052425020200000302000025040000000030011f3101192c7e
|
||||
@@ -0,0 +1 @@
|
||||
7e020040460100000000000000000002002c00000000000c000301bb0e2107213304003f0000010d26041312335514040000000017020000010400006722030200002504000000002a020000300115310118ea0402008000fb7e
|
||||
@@ -0,0 +1 @@
|
||||
7e020000360000000000100278000000000008000301d596aa0735b9910001004b01472604131234030104000a42c10202000003020000250400000000300115310117107e
|
||||
@@ -0,0 +1 @@
|
||||
7e020000360000000001020443000000000008000301d2d74907376cde000f00000000260413123354010400078789020200000302000025040000000030011b310112db7e
|
||||
@@ -0,0 +1 @@
|
||||
7e02004048010000000000000000020204d2000000000048000301cc1d4f073ed6640010033301492604131234001404000000001702000001040000f0542504000000002a02000030011f310110ea04020c8300ef0400000000d67e
|
||||
@@ -0,0 +1 @@
|
||||
7e02000036000000000413013d000000000008000201d392280736f1e30007000000002604131311220104000f334a020200000302000025040000000030011931010a097e
|
||||
@@ -0,0 +1 @@
|
||||
7e0704003b000000000058034e0001010036000000000008000301e26c120725c15100110000000726041313111301040006ffc6030200002504000000002a02000030010a31010c3c7e
|
||||
Reference in New Issue
Block a user