feat: productionize raw history ingestion

This commit is contained in:
lingniu
2026-06-30 23:21:58 +08:00
parent 3cc7ac9669
commit cbba617801
100 changed files with 2995 additions and 1697 deletions

View File

@@ -43,7 +43,7 @@ class Gb32960ChannelHandlerAckBoundaryTest {
dispatch.awaitPublishCount(1);
dispatch.completeDurability();
ByteBuf ack = readOutboundAfterRunningTasks(channel);
ByteBuf ack = awaitOutbound(channel);
assertThat(ack).isNotNull();
assertThat(ack.getUnsignedByte(2)).isEqualTo((short) 0x02);
assertThat(ack.getUnsignedByte(3)).isEqualTo((short) 0x01);
@@ -85,8 +85,8 @@ class Gb32960ChannelHandlerAckBoundaryTest {
dispatch.completeDurability(0);
ByteBuf firstAck = readOutboundAfterRunningTasks(channel);
ByteBuf secondAck = readOutboundAfterRunningTasks(channel);
ByteBuf firstAck = awaitOutbound(channel);
ByteBuf secondAck = awaitOutbound(channel);
assertThat(firstAck).isNotNull();
assertThat(secondAck).isNotNull();
assertThat(firstAck.getUnsignedByte(2)).isEqualTo((short) 0x02);
@@ -97,42 +97,6 @@ class Gb32960ChannelHandlerAckBoundaryTest {
}
}
@Test
void decodeFailureIsArchivedWithParseErrorMetadata() {
try (DispatchHarness dispatch = new DispatchHarness()) {
EmbeddedChannel channel = new EmbeddedChannel(handlerWith(dispatch.dispatcher()));
byte[] malformed = new byte[]{0x23, 0x23, 0x02};
assertThat(channel.writeInbound(malformed)).isFalse();
dispatch.awaitPublishCount(1);
VehicleEvent event = dispatch.event(0);
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
VehicleEvent.RawArchive raw = (VehicleEvent.RawArchive) event;
assertThat(raw.source()).isEqualTo(com.lingniu.ingest.api.ProtocolId.GB32960);
assertThat(raw.command()).isZero();
assertThat(raw.rawBytes()).containsExactly(malformed);
assertThat(raw.metadata())
.containsEntry("vin", "unknown")
.containsEntry("parseError", "true")
.containsKey("parseErrorMessage");
channel.finishAndReleaseAll();
}
}
private static ByteBuf readOutboundAfterRunningTasks(EmbeddedChannel channel) {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
channel.runPendingTasks();
ByteBuf outbound = channel.readOutbound();
if (outbound != null) {
return outbound;
}
Thread.onSpinWait();
}
return null;
}
private static Gb32960ChannelHandler handlerWith(Dispatcher dispatcher) {
return new Gb32960ChannelHandler(
decoder(),
@@ -174,6 +138,19 @@ class Gb32960ChannelHandlerAckBoundaryTest {
return out.toByteArray();
}
private static ByteBuf awaitOutbound(EmbeddedChannel channel) {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
channel.runPendingTasks();
ByteBuf outbound = channel.readOutbound();
if (outbound != null) {
return outbound;
}
Thread.onSpinWait();
}
return null;
}
private static final class DispatchHarness implements AutoCloseable {
private final ControlledSink sink = new ControlledSink();
private final DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
@@ -214,12 +191,6 @@ class Gb32960ChannelHandlerAckBoundaryTest {
throw new AssertionError("expected " + count + " sink publishes, actual=" + sink.futures.size());
}
VehicleEvent event(int index) {
synchronized (sink.futures) {
return sink.events.get(index);
}
}
@Override
public void close() {
batchExecutor.close();
@@ -229,7 +200,6 @@ class Gb32960ChannelHandlerAckBoundaryTest {
private static final class ControlledSink implements EventSink {
private final List<CompletableFuture<Void>> futures = new ArrayList<>();
private final List<VehicleEvent> events = new ArrayList<>();
@Override
public String name() {
@@ -240,7 +210,6 @@ class Gb32960ChannelHandlerAckBoundaryTest {
public CompletableFuture<Void> publish(VehicleEvent event) {
CompletableFuture<Void> future = new CompletableFuture<>();
synchronized (futures) {
events.add(event);
futures.add(future);
}
return future;

View File

@@ -9,13 +9,14 @@ import com.lingniu.ingest.identity.VehicleIdentityLookup;
import com.lingniu.ingest.identity.VehicleIdentityRegistry;
import com.lingniu.ingest.identity.VehicleIdentityResolver;
import com.lingniu.ingest.identity.VehicleIdentitySource;
import com.lingniu.ingest.identity.VehicleRegistrationBinding;
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder;
import com.lingniu.ingest.protocol.jt808.codec.Jt808MalformedFrame;
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import com.lingniu.ingest.protocol.jt808.model.Jt808LocationAdditionalInfo;
import com.lingniu.ingest.protocol.jt808.model.Jt808LocationFlagInfo;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
@@ -25,17 +26,18 @@ import com.lingniu.ingest.session.SessionStore;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.AttributeKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* JT808 Netty 入站处理器。职责:
@@ -51,8 +53,6 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
private static final Logger log = LoggerFactory.getLogger(Jt808ChannelHandler.class);
private static final String PROCESSING_ERROR_PREFIX = "processing failed: ";
private static final AttributeKey<CompletableFuture<Void>> DURABLE_ACK_CHAIN_ATTR =
AttributeKey.valueOf("jt808.durableAckChain");
private final Jt808MessageDecoder decoder;
private final Dispatcher dispatcher;
@@ -123,21 +123,29 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
// ====== 会话维护 & 同步应答匹配 ======
String phone = msg.header().phone();
IdentityResolution identity = resolveIdentity(msg);
byte[] durableAck = null;
if (msg.body() instanceof Jt808Body.Register reg) {
identity = registerIdentity(msg, reg, identity);
}
byte[] registerAck = null;
switch (msg.header().messageId()) {
case Jt808MessageId.TERMINAL_REGISTER -> {
DeviceSession session = upsertSession(ctx, msg, identity.identity());
channelRegistry.bind(phone, ctx.channel());
durableAck = registerAckFrame(phone, msg.header().serialNo(), session.token());
var command = Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token());
registerAck = Jt808FrameEncoder.encode(
command.messageId(),
phone,
channelRegistry.nextSerial(phone),
command.body());
}
case Jt808MessageId.TERMINAL_AUTH -> {
upsertSession(ctx, msg, identity.identity());
channelRegistry.bind(phone, ctx.channel());
durableAck = terminalAckFrame(phone, msg.header().serialNo(), msg.header().messageId(), 0);
ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0);
}
case Jt808MessageId.TERMINAL_HEARTBEAT -> {
channelRegistry.bind(phone, ctx.channel());
durableAck = terminalAckFrame(phone, msg.header().serialNo(), msg.header().messageId(), 0);
ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0);
}
case Jt808MessageId.TERMINAL_GENERAL_RESPONSE -> {
// body: ackSerial(2) + ackMsgId(2) + result(1)
@@ -153,7 +161,7 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
}
// ====== 派发到 Dispatcher ======
Map<String, String> meta = new HashMap<>(6);
Map<String, String> meta = new HashMap<>(32);
meta.put("vin", identity.identity().vin());
meta.put("phone", phone);
meta.put("identityResolved", Boolean.toString(identity.identity().resolved()));
@@ -163,8 +171,11 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
meta.put("identityErrorMessage", identity.errorMessage());
}
meta.put("seq", Integer.toString(msg.header().serialNo()));
meta.put("messageId", "0x" + Jt808MessageId.hex(msg.header().messageId()));
meta.put("messageName", Jt808MessageId.nameOf(msg.header().messageId()));
meta.put("reportType", Jt808MessageId.reportTypeOf(msg.header().messageId()));
putBodyMetadata(meta, msg.body());
meta.put("peer", addr(ctx));
addRegistrationMetadata(meta, msg.body());
RawFrame rf = new RawFrame(
ProtocolId.JT808,
msg.header().messageId(),
@@ -173,63 +184,19 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
frame,
meta,
Instant.now());
if (durableAck == null) {
dispatcher.dispatch(rf);
if (registerAck != null) {
byte[] ack = registerAck;
dispatcher.dispatchAndAwait(rf)
.thenRun(() -> ctx.writeAndFlush(Unpooled.wrappedBuffer(ack)))
.exceptionally(ex -> {
log.warn("[jt808] register ack withheld because dispatch durability failed peer={} phone={} seq={} error={}",
addr(ctx), phone, msg.header().serialNo(),
ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage());
return null;
});
return;
}
enqueueDurableAck(ctx, phone, msg.header().messageId(), dispatcher.dispatchAndAwait(rf), durableAck);
}
private void enqueueDurableAck(ChannelHandlerContext ctx,
String phone,
int messageId,
CompletableFuture<Void> dispatchFuture,
byte[] ackFrame) {
CompletableFuture<Void> previous = ctx.channel().attr(DURABLE_ACK_CHAIN_ATTR).get();
if (previous == null) {
previous = CompletableFuture.completedFuture(null);
}
CompletableFuture<Void> next = previous
.handle((ignored, previousFailure) -> null)
.thenCompose(ignored -> dispatchFuture.handle((ok, failure) -> failure))
.thenCompose(failure -> runOnEventLoop(ctx,
() -> handleDurableAckResult(ctx, phone, messageId, failure, ackFrame)));
ctx.channel().attr(DURABLE_ACK_CHAIN_ATTR).set(next);
}
private CompletableFuture<Void> runOnEventLoop(ChannelHandlerContext ctx, Runnable task) {
CompletableFuture<Void> done = new CompletableFuture<>();
Runnable wrapped = () -> {
try {
task.run();
done.complete(null);
} catch (Throwable t) {
done.completeExceptionally(t);
}
};
if (ctx.executor().inEventLoop()) {
wrapped.run();
} else {
ctx.executor().execute(wrapped);
}
return done;
}
private void handleDurableAckResult(ChannelHandlerContext ctx,
String phone,
int messageId,
Throwable failure,
byte[] ackFrame) {
if (!ctx.channel().isActive()) {
return;
}
if (failure != null) {
log.warn("[jt808] dispatch failed before ack peer={} phone={} msgId=0x{}",
addr(ctx), phone, Integer.toHexString(messageId), failure);
ctx.close();
return;
}
ctx.writeAndFlush(Unpooled.wrappedBuffer(ackFrame));
dispatcher.dispatch(rf);
}
private IdentityResolution resolveIdentity(Jt808Message msg) {
@@ -267,6 +234,28 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
return new VehicleIdentity("unknown", false, identity.source());
}
private IdentityResolution registerIdentity(Jt808Message msg,
Jt808Body.Register reg,
IdentityResolution current) {
VehicleIdentity currentIdentity = current.identity();
boolean alreadyResolved = currentIdentity != null && currentIdentity.resolved();
String vin = alreadyResolved ? currentIdentity.vin() : generatedInternalVin(msg.header().phone(), reg);
identityRegistry.bind(new VehicleIdentityBinding(
ProtocolId.JT808,
vin,
msg.header().phone(),
reg.deviceId(),
reg.plate(),
Map.of(
"province", Integer.toString(reg.province()),
"city", Integer.toString(reg.city()),
"maker", reg.maker(),
"deviceType", reg.deviceType(),
"plateColor", Integer.toString(reg.plateColor()))));
VehicleIdentitySource source = alreadyResolved ? currentIdentity.source() : VehicleIdentitySource.REGISTERED;
return new IdentityResolution(new VehicleIdentity(vin, true, source), current.errorMessage());
}
private void dispatchFrameError(ChannelHandlerContext ctx, Jt808MalformedFrame malformed) {
String peer = malformed.peer() == null || malformed.peer().isBlank() ? addr(ctx) : malformed.peer();
dispatchMalformed(peer, malformed.rawBytes(), malformed.reason(), true);
@@ -283,7 +272,7 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
new Jt808Header(0, frame == null ? 0 : frame.length, 0, false,
Jt808Header.ProtocolVersion.V2013, "unknown", 0, 0, 0),
new Jt808Body.Malformed(frame == null ? new byte[0] : frame, peer, message));
Map<String, String> meta = new HashMap<>(4);
Map<String, String> meta = new HashMap<>(8);
meta.put("vin", "unknown");
meta.put("peer", peer);
meta.put("identityResolved", "false");
@@ -295,6 +284,7 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
meta.put("parseError", "true");
meta.put("parseErrorMessage", message);
}
putBodyMetadata(meta, malformed.body());
dispatcher.dispatch(new RawFrame(
ProtocolId.JT808,
0,
@@ -312,15 +302,19 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
msg.header().version(), msg.header().phone(), msg.header().serialNo(), 0, 0),
new Jt808Body.Malformed(frame == null ? new byte[0] : frame, addr(ctx),
PROCESSING_ERROR_PREFIX + message));
Map<String, String> meta = new HashMap<>(8);
Map<String, String> meta = new HashMap<>(16);
meta.put("vin", "unknown");
meta.put("phone", msg.header().phone());
meta.put("peer", addr(ctx));
meta.put("seq", Integer.toString(msg.header().serialNo()));
meta.put("messageId", "0x" + Jt808MessageId.hex(msg.header().messageId()));
meta.put("messageName", Jt808MessageId.nameOf(msg.header().messageId()));
meta.put("reportType", Jt808MessageId.reportTypeOf(msg.header().messageId()));
meta.put("identityResolved", "false");
meta.put("identitySource", VehicleIdentitySource.UNKNOWN.name());
meta.put("processingError", "true");
meta.put("processingErrorMessage", message);
putBodyMetadata(meta, failed.body());
dispatcher.dispatch(new RawFrame(
ProtocolId.JT808,
0,
@@ -343,25 +337,6 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
Instant.now(), Instant.now(),
Map.of("protocolVersion", msg.header().version().name())));
session = session.withVin(identity.vin());
if (msg.body() instanceof Jt808Body.Register reg) {
identityRegistry.register(new VehicleRegistrationBinding(
ProtocolId.JT808,
session.vin(),
phone,
reg.deviceId(),
reg.plate(),
reg.province(),
reg.city(),
reg.maker(),
reg.deviceType(),
reg.plateColor()));
if (identity.resolved() && session.vin() != null
&& !session.vin().isBlank()
&& !"unknown".equalsIgnoreCase(session.vin())) {
identityRegistry.bind(new VehicleIdentityBinding(
ProtocolId.JT808, session.vin(), phone, reg.deviceId(), reg.plate()));
}
}
sessions.put(session);
return session;
}
@@ -380,37 +355,175 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
return body instanceof Jt808Body.Register reg ? reg.plate() : "";
}
private static void addRegistrationMetadata(Map<String, String> meta, Jt808Body body) {
if (!(body instanceof Jt808Body.Register reg)) {
return;
private static void putBodyMetadata(Map<String, String> meta, Jt808Body body) {
switch (body) {
case Jt808Body.Register reg -> {
put(meta, "body.province", reg.province());
put(meta, "body.city", reg.city());
put(meta, "body.maker", reg.maker());
put(meta, "body.deviceType", reg.deviceType());
put(meta, "body.deviceId", reg.deviceId());
put(meta, "body.plateColor", reg.plateColor());
put(meta, "body.plate", reg.plate());
}
case Jt808Body.Auth auth -> {
put(meta, "body.token", auth.token());
put(meta, "body.imei", auth.imei());
put(meta, "body.softwareVersion", auth.softwareVersion());
}
case Jt808Body.Location location -> putLocationMetadata(meta, "body.", location);
case Jt808Body.LocationBatch batch -> {
put(meta, "body.batchType", batch.batchType());
put(meta, "body.locationCount", batch.locations() == null ? 0 : batch.locations().size());
}
case Jt808Body.ParamsReport report -> {
put(meta, "body.responseSerialNo", report.responseSerialNo());
put(meta, "body.parameterCount", report.parameters() == null ? 0 : report.parameters().size());
if (report.parameters() != null) {
report.parameters().forEach((id, bytes) ->
put(meta, "body.param.0x" + String.format("%08X", id), toHex(bytes)));
}
}
case Jt808Body.TerminalAttrs attrs -> {
put(meta, "body.terminalType", attrs.terminalType());
put(meta, "body.maker", attrs.maker());
put(meta, "body.terminalModel", attrs.terminalModel());
put(meta, "body.terminalId", attrs.terminalId());
put(meta, "body.iccid", attrs.iccid());
put(meta, "body.hardwareVersion", attrs.hardwareVersion());
put(meta, "body.firmwareVersion", attrs.firmwareVersion());
put(meta, "body.gnssProperty", attrs.gnssProperty());
put(meta, "body.commProperty", attrs.commProperty());
}
case Jt808Body.MediaEvent event -> {
put(meta, "body.mediaId", event.mediaId());
put(meta, "body.mediaType", event.mediaType());
put(meta, "body.mediaFormat", event.mediaFormat());
put(meta, "body.eventCode", event.eventCode());
put(meta, "body.channelId", event.channelId());
}
case Jt808Body.MediaUpload upload -> {
put(meta, "body.mediaId", upload.mediaId());
put(meta, "body.mediaType", upload.mediaType());
put(meta, "body.mediaFormat", upload.mediaFormat());
put(meta, "body.eventCode", upload.eventCode());
put(meta, "body.channelId", upload.channelId());
put(meta, "body.dataSizeBytes", upload.data() == null ? 0 : upload.data().length);
if (upload.location() != null) {
putLocationMetadata(meta, "body.location.", upload.location());
}
}
case Jt808Body.MediaAttributes attrs -> {
put(meta, "body.inputAudioEncoding", attrs.inputAudioEncoding());
put(meta, "body.inputAudioChannels", attrs.inputAudioChannels());
put(meta, "body.inputAudioSampleRate", attrs.inputAudioSampleRate());
put(meta, "body.inputAudioBitDepth", attrs.inputAudioBitDepth());
put(meta, "body.audioFrameLength", attrs.audioFrameLength());
put(meta, "body.audioOutputSupported", attrs.audioOutputSupported());
put(meta, "body.videoEncoding", attrs.videoEncoding());
put(meta, "body.maxAudioChannels", attrs.maxAudioChannels());
put(meta, "body.maxVideoChannels", attrs.maxVideoChannels());
put(meta, "body.rawSizeBytes", attrs.raw() == null ? 0 : attrs.raw().length);
}
case Jt808Body.FileUploadComplete complete -> {
put(meta, "body.responseSerialNo", complete.responseSerialNo());
put(meta, "body.result", complete.result());
put(meta, "body.rawSizeBytes", complete.raw() == null ? 0 : complete.raw().length);
}
case Jt808Body.FileList files -> {
put(meta, "body.responseSerialNo", files.responseSerialNo());
put(meta, "body.fileCount", files.fileCount());
put(meta, "body.rawSizeBytes", files.raw() == null ? 0 : files.raw().length);
}
case Jt808Body.PassengerVolume volume -> {
put(meta, "body.channelId", volume.channelId());
put(meta, "body.startTime", volume.startTime());
put(meta, "body.endTime", volume.endTime());
put(meta, "body.passengerGetOn", volume.passengerGetOn());
put(meta, "body.passengerGetOff", volume.passengerGetOff());
put(meta, "body.rawSizeBytes", volume.raw() == null ? 0 : volume.raw().length);
}
case Jt808Body.Passthrough passthrough -> {
put(meta, "body.passthroughType", "0x" + String.format("%02X", passthrough.passthroughType()));
put(meta, "body.dataSizeBytes", passthrough.data() == null ? 0 : passthrough.data().length);
put(meta, "body.dataHex", toHex(passthrough.data()));
}
case Jt808Body.Raw raw -> {
put(meta, "body.rawMessageId", "0x" + Jt808MessageId.hex(raw.messageId()));
put(meta, "body.rawSizeBytes", raw.bytes() == null ? 0 : raw.bytes().length);
put(meta, "body.rawHex", toHex(raw.bytes()));
}
case Jt808Body.Malformed malformed -> {
put(meta, "body.peer", malformed.peer());
put(meta, "body.errorMessage", malformed.errorMessage());
put(meta, "body.rawSizeBytes", malformed.bytes() == null ? 0 : malformed.bytes().length);
}
case Jt808Body.Heartbeat ignored -> {
}
case Jt808Body.Unregister ignored -> {
}
}
meta.put("jt808.register.province", Integer.toString(reg.province()));
meta.put("jt808.register.city", Integer.toString(reg.city()));
meta.put("jt808.register.maker", nullToEmpty(reg.maker()));
meta.put("jt808.register.deviceType", nullToEmpty(reg.deviceType()));
meta.put("jt808.register.deviceId", nullToEmpty(reg.deviceId()));
meta.put("jt808.register.plateColor", Integer.toString(reg.plateColor()));
meta.put("jt808.register.plate", nullToEmpty(reg.plate()));
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
private static void putLocationMetadata(Map<String, String> meta, String prefix, Jt808Body.Location location) {
put(meta, prefix + "alarmFlag", location.alarmFlag());
put(meta, prefix + "statusFlag", location.statusFlag());
meta.putAll(Jt808LocationFlagInfo.decodeStatus(location.statusFlag(), prefix + "status."));
meta.putAll(Jt808LocationFlagInfo.decodeAlarm(location.alarmFlag(), prefix + "alarm."));
put(meta, prefix + "longitude", location.longitude());
put(meta, prefix + "latitude", location.latitude());
put(meta, prefix + "altitudeM", location.altitudeM());
put(meta, prefix + "speedKmh", location.speedKmh());
put(meta, prefix + "directionDeg", location.directionDeg());
put(meta, prefix + "deviceTime", location.deviceTime());
if (location.extensionItems() != null) {
location.extensionItems().forEach((id, bytes) ->
put(meta, prefix + "extra.0x" + String.format("%02X", id), toHex(bytes)));
meta.putAll(Jt808LocationAdditionalInfo.decode(location.extensionItems(), prefix + "extra."));
}
}
private byte[] registerAckFrame(String phone, int ackSerial, String token) {
var cmd = Jt808Commands.registerAck(ackSerial, 0, token);
return Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body());
private static void put(Map<String, String> meta, String key, Object value) {
if (value != null) {
meta.put(key, String.valueOf(value));
}
}
private byte[] terminalAckFrame(String phone, int ackSerial, int ackMsgId, int result) {
private static String toHex(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return "";
}
StringBuilder out = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
out.append(String.format("%02X", b & 0xFF));
}
return out.toString();
}
private void ackTerminal(ChannelHandlerContext ctx, String phone, int ackSerial, int ackMsgId, int result) {
var cmd = Jt808Commands.platformAck(ackSerial, ackMsgId, result);
return Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body());
byte[] frame = Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body());
ctx.writeAndFlush(Unpooled.wrappedBuffer(frame));
}
private static String generateToken(String phone) {
return UUID.nameUUIDFromBytes(("jt808:" + phone).getBytes()).toString().replace("-", "").substring(0, 16);
}
private static String generatedInternalVin(String phone, Jt808Body.Register reg) {
String material = String.join("|", phone, reg.deviceId(), reg.plate(), reg.maker(), reg.deviceType());
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(material.getBytes(StandardCharsets.UTF_8));
StringBuilder suffix = new StringBuilder(12);
for (int i = 0; i < digest.length && suffix.length() < 12; i++) {
suffix.append(String.format("%02X", digest[i] & 0xFF));
}
return "JT808" + suffix.substring(0, 12);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
channelRegistry.unbind(ctx.channel())

View File

@@ -10,6 +10,8 @@ import com.lingniu.ingest.identity.VehicleIdentityResolver;
import com.lingniu.ingest.identity.VehicleIdentitySource;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import com.lingniu.ingest.protocol.jt808.model.Jt808LocationAdditionalInfo;
import com.lingniu.ingest.protocol.jt808.model.Jt808LocationFlagInfo;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import java.time.Instant;
@@ -276,12 +278,14 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
}
private static Map<String, String> locationMeta(Map<String, String> meta, Jt808Body.Location loc) {
if (loc.extensionItems().isEmpty()) {
return meta;
}
java.util.HashMap<String, String> copy = new java.util.HashMap<>(meta);
loc.extensionItems().forEach((id, bytes) ->
copy.put("jt808.extra.0x" + String.format("%02X", id), hex(bytes)));
copy.putAll(Jt808LocationFlagInfo.decodeStatus(loc.statusFlag(), "jt808.status."));
copy.putAll(Jt808LocationFlagInfo.decodeAlarm(loc.alarmFlag(), "jt808.alarm."));
if (!loc.extensionItems().isEmpty()) {
loc.extensionItems().forEach((id, bytes) ->
copy.put("jt808.extra.0x" + String.format("%02X", id), hex(bytes)));
copy.putAll(Jt808LocationAdditionalInfo.decode(loc.extensionItems()));
}
return Map.copyOf(copy);
}
@@ -304,15 +308,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
}
private static Double totalMileageKm(Jt808Body.Location loc) {
byte[] bytes = loc.extensionItems().get(0x01);
if (bytes == null || bytes.length != 4) {
return null;
}
long raw = ((long) bytes[0] & 0xFF) << 24
| ((long) bytes[1] & 0xFF) << 16
| ((long) bytes[2] & 0xFF) << 8
| ((long) bytes[3] & 0xFF);
return raw * 0.1;
return Jt808LocationAdditionalInfo.mileageKm(loc.extensionItems());
}
private static String hex(byte[] bytes) {

View File

@@ -0,0 +1,247 @@
package com.lingniu.ingest.protocol.jt808.model;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class Jt808LocationAdditionalInfo {
public static final String DEFAULT_PREFIX = "jt808.extra.";
private static final String[] VEHICLE_SIGNAL_BITS = {
"low_beam",
"high_beam",
"right_turn",
"left_turn",
"brake",
"reverse",
"fog_lamp",
"position_lamp",
"horn",
"air_conditioner",
"neutral",
"retarder",
"abs",
"heater",
"clutch"
};
private static final String[] IO_STATUS_BITS = {
"deep_sleep",
"sleep"
};
private Jt808LocationAdditionalInfo() {
}
public static Map<String, String> decode(Map<Integer, byte[]> extensionItems) {
return decode(extensionItems, DEFAULT_PREFIX);
}
public static Map<String, String> decode(Map<Integer, byte[]> extensionItems, String prefix) {
if (extensionItems == null || extensionItems.isEmpty()) {
return Map.of();
}
String p = prefix == null ? "" : prefix;
Map<String, String> out = new LinkedHashMap<>();
extensionItems.forEach((id, bytes) -> decodeItem(out, p, id == null ? -1 : id, bytes));
return Map.copyOf(out);
}
public static Double mileageKm(Map<Integer, byte[]> extensionItems) {
if (extensionItems == null) {
return null;
}
byte[] bytes = extensionItems.get(0x01);
if (bytes == null || bytes.length != 4) {
return null;
}
return u32(bytes, 0) * 0.1;
}
private static void decodeItem(Map<String, String> out, String prefix, int id, byte[] bytes) {
if (bytes == null) {
return;
}
switch (id) {
case 0x01 -> put(out, prefix + "mileage_km", scaled(bytes, 4, 1));
case 0x02 -> put(out, prefix + "fuel_l", scaled(bytes, 2, 1));
case 0x03 -> put(out, prefix + "recorder_speed_kmh", scaled(bytes, 2, 1));
case 0x04 -> put(out, prefix + "manual_alarm_event_id", unsigned(bytes, 2));
case 0x05 -> decodeTirePressure(out, prefix, bytes);
case 0x06 -> put(out, prefix + "compartment_temp_c", signedHighBitWord(bytes));
case 0x11 -> decodeOverspeed(out, prefix, bytes);
case 0x12 -> decodeAreaRouteAlarm(out, prefix, bytes);
case 0x13 -> decodeRouteTime(out, prefix, bytes);
case 0x25 -> decodeVehicleSignal(out, prefix, bytes);
case 0x2A -> decodeIoStatus(out, prefix, bytes);
case 0x2B -> decodeAnalog(out, prefix, bytes);
case 0x30 -> put(out, prefix + "network_signal_strength", unsigned(bytes, 1));
case 0x31 -> put(out, prefix + "gnss_satellite_count", unsigned(bytes, 1));
default -> {
}
}
}
private static void decodeTirePressure(Map<String, String> out, String prefix, byte[] bytes) {
if (bytes.length != 30) {
return;
}
List<String> values = new ArrayList<>();
List<Integer> validIndexes = new ArrayList<>();
List<Integer> invalidIndexes = new ArrayList<>();
for (int i = 0; i < bytes.length; i++) {
int value = u8(bytes, i);
if (value == 0xFF) {
invalidIndexes.add(i);
} else {
validIndexes.add(i);
values.add(Integer.toString(value));
}
}
put(out, prefix + "tire_pressure_pa_values", String.join(",", values));
put(out, prefix + "tire_pressure_valid_indexes", ranges(validIndexes));
put(out, prefix + "tire_pressure_invalid_indexes", ranges(invalidIndexes));
}
private static void decodeOverspeed(Map<String, String> out, String prefix, byte[] bytes) {
if (bytes.length != 1 && bytes.length != 5) {
return;
}
put(out, prefix + "overspeed.position_type", Integer.toString(u8(bytes, 0)));
if (bytes.length == 5) {
put(out, prefix + "overspeed.area_route_id", Long.toString(u32(bytes, 1)));
}
}
private static void decodeAreaRouteAlarm(Map<String, String> out, String prefix, byte[] bytes) {
if (bytes.length != 6) {
return;
}
put(out, prefix + "area_route_alarm.position_type", Integer.toString(u8(bytes, 0)));
put(out, prefix + "area_route_alarm.area_route_id", Long.toString(u32(bytes, 1)));
put(out, prefix + "area_route_alarm.direction", Integer.toString(u8(bytes, 5)));
}
private static void decodeRouteTime(Map<String, String> out, String prefix, byte[] bytes) {
if (bytes.length != 7) {
return;
}
put(out, prefix + "route_time.segment_id", Long.toString(u32(bytes, 0)));
put(out, prefix + "route_time.duration_seconds", Integer.toString(u16(bytes, 4)));
put(out, prefix + "route_time.result", Integer.toString(u8(bytes, 6)));
}
private static void decodeVehicleSignal(Map<String, String> out, String prefix, byte[] bytes) {
if (bytes.length != 4) {
return;
}
long raw = u32(bytes, 0);
put(out, prefix + "vehicle_signal_raw", Long.toString(raw));
for (int i = 0; i < VEHICLE_SIGNAL_BITS.length; i++) {
put(out, prefix + "vehicle_signal." + VEHICLE_SIGNAL_BITS[i], Boolean.toString(bit(raw, i)));
}
}
private static void decodeIoStatus(Map<String, String> out, String prefix, byte[] bytes) {
if (bytes.length != 2) {
return;
}
int raw = u16(bytes, 0);
put(out, prefix + "io_status_raw", Integer.toString(raw));
for (int i = 0; i < IO_STATUS_BITS.length; i++) {
put(out, prefix + "io_status." + IO_STATUS_BITS[i], Boolean.toString(bit(raw, i)));
}
}
private static void decodeAnalog(Map<String, String> out, String prefix, byte[] bytes) {
if (bytes.length != 4) {
return;
}
long raw = u32(bytes, 0);
put(out, prefix + "analog_raw", Long.toString(raw));
put(out, prefix + "analog_ad0", Integer.toString((int) (raw & 0xFFFF)));
put(out, prefix + "analog_ad1", Integer.toString((int) ((raw >>> 16) & 0xFFFF)));
}
private static String scaled(byte[] bytes, int expectedLength, int scale) {
if (bytes.length != expectedLength) {
return null;
}
long raw = expectedLength == 4 ? u32(bytes, 0) : u16(bytes, 0);
return BigDecimal.valueOf(raw, scale).stripTrailingZeros().toPlainString();
}
private static String unsigned(byte[] bytes, int expectedLength) {
if (bytes.length != expectedLength) {
return null;
}
return switch (expectedLength) {
case 1 -> Integer.toString(u8(bytes, 0));
case 2 -> Integer.toString(u16(bytes, 0));
case 4 -> Long.toString(u32(bytes, 0));
default -> null;
};
}
private static String signedHighBitWord(byte[] bytes) {
if (bytes.length != 2) {
return null;
}
int raw = u16(bytes, 0);
int value = (raw & 0x8000) == 0 ? raw : -(raw & 0x7FFF);
return Integer.toString(value);
}
private static int u8(byte[] bytes, int offset) {
return bytes[offset] & 0xFF;
}
private static int u16(byte[] bytes, int offset) {
return (u8(bytes, offset) << 8) | u8(bytes, offset + 1);
}
private static long u32(byte[] bytes, int offset) {
return ((long) u8(bytes, offset) << 24)
| ((long) u8(bytes, offset + 1) << 16)
| ((long) u8(bytes, offset + 2) << 8)
| u8(bytes, offset + 3);
}
private static boolean bit(long raw, int bit) {
return ((raw >>> bit) & 1L) == 1L;
}
private static void put(Map<String, String> out, String key, String value) {
if (value != null) {
out.put(key, value);
}
}
private static String ranges(List<Integer> indexes) {
if (indexes.isEmpty()) {
return "";
}
List<String> ranges = new ArrayList<>();
int start = indexes.getFirst();
int previous = start;
for (int i = 1; i < indexes.size(); i++) {
int current = indexes.get(i);
if (current == previous + 1) {
previous = current;
continue;
}
ranges.add(formatRange(start, previous));
start = current;
previous = current;
}
ranges.add(formatRange(start, previous));
return String.join(",", ranges);
}
private static String formatRange(int start, int end) {
return start == end ? Integer.toString(start) : start + "-" + end;
}
}

View File

@@ -0,0 +1,99 @@
package com.lingniu.ingest.protocol.jt808.model;
import java.util.LinkedHashMap;
import java.util.Map;
public final class Jt808LocationFlagInfo {
private static final String[] ALARM_BITS = {
"emergency",
"overspeed",
"fatigue_driving",
"warning",
"gnss_module_fault",
"gnss_antenna_disconnected",
"gnss_antenna_short_circuit",
"terminal_main_power_undervoltage",
"terminal_main_power_lost",
"display_fault",
"tts_fault",
"camera_fault",
"transport_ic_card_fault",
"overspeed_warning",
"fatigue_driving_warning",
"prohibited_driving",
null,
null,
"daily_driving_timeout",
"overtime_parking",
"area_in_out",
"route_in_out",
"route_time_abnormal",
"route_deviation",
"vss_fault",
"fuel_abnormal",
"stolen",
"illegal_ignition",
"illegal_displacement",
"collision_rollover",
"rollover_warning"
};
private Jt808LocationFlagInfo() {
}
public static Map<String, String> decodeAlarm(long alarmFlag, String prefix) {
Map<String, String> out = new LinkedHashMap<>();
String p = prefix == null ? "" : prefix;
for (int i = 0; i < ALARM_BITS.length; i++) {
String name = ALARM_BITS[i];
if (name != null) {
out.put(p + name, Boolean.toString(bit(alarmFlag, i)));
}
}
return Map.copyOf(out);
}
public static Map<String, String> decodeStatus(long statusFlag, String prefix) {
Map<String, String> out = new LinkedHashMap<>();
String p = prefix == null ? "" : prefix;
out.put(p + "acc_on", Boolean.toString(bit(statusFlag, 0)));
out.put(p + "positioned", Boolean.toString(bit(statusFlag, 1)));
out.put(p + "south_latitude", Boolean.toString(bit(statusFlag, 2)));
out.put(p + "west_longitude", Boolean.toString(bit(statusFlag, 3)));
out.put(p + "out_of_service", Boolean.toString(bit(statusFlag, 4)));
out.put(p + "encrypted", Boolean.toString(bit(statusFlag, 5)));
out.put(p + "forward_collision_warning", Boolean.toString(bit(statusFlag, 6)));
out.put(p + "lane_departure_warning", Boolean.toString(bit(statusFlag, 7)));
out.put(p + "load_status", loadStatus(statusFlag));
out.put(p + "fuel_circuit_cut", Boolean.toString(bit(statusFlag, 10)));
out.put(p + "electric_circuit_cut", Boolean.toString(bit(statusFlag, 11)));
out.put(p + "door_locked", Boolean.toString(bit(statusFlag, 12)));
out.put(p + "door1_open", Boolean.toString(bit(statusFlag, 13)));
out.put(p + "door2_open", Boolean.toString(bit(statusFlag, 14)));
out.put(p + "door3_open", Boolean.toString(bit(statusFlag, 15)));
out.put(p + "door4_open", Boolean.toString(bit(statusFlag, 16)));
out.put(p + "door5_open", Boolean.toString(bit(statusFlag, 17)));
out.put(p + "gps_used", Boolean.toString(bit(statusFlag, 18)));
out.put(p + "beidou_used", Boolean.toString(bit(statusFlag, 19)));
out.put(p + "glonass_used", Boolean.toString(bit(statusFlag, 20)));
out.put(p + "galileo_used", Boolean.toString(bit(statusFlag, 21)));
out.put(p + "moving", Boolean.toString(bit(statusFlag, 22)));
return Map.copyOf(out);
}
private static String loadStatus(long statusFlag) {
int code = (int) ((statusFlag >>> 8) & 0x03);
return switch (code) {
case 0 -> "EMPTY";
case 1 -> "HALF_LOADED";
case 2 -> "RESERVED";
case 3 -> "FULL";
default -> "UNKNOWN";
};
}
private static boolean bit(long raw, int bit) {
return ((raw >>> bit) & 1L) == 1L;
}
}

View File

@@ -36,4 +36,56 @@ public final class Jt808MessageId {
public static final int PLATFORM_TRACK_CONTROL = 0x8202;
public static final int PLATFORM_ALARM_ACK = 0x8203;
public static final int PLATFORM_DELETE_CIRCLE_AREA = 0x8601;
public static String nameOf(int messageId) {
return switch (messageId) {
case TERMINAL_GENERAL_RESPONSE -> "TERMINAL_GENERAL_RESPONSE";
case TERMINAL_HEARTBEAT -> "TERMINAL_HEARTBEAT";
case TERMINAL_UNREGISTER -> "TERMINAL_UNREGISTER";
case TERMINAL_REGISTER -> "TERMINAL_REGISTER";
case TERMINAL_AUTH -> "TERMINAL_AUTH";
case TERMINAL_PARAMS_REPORT -> "TERMINAL_PARAMS_REPORT";
case TERMINAL_ATTRS_REPORT -> "TERMINAL_ATTRS_REPORT";
case TERMINAL_LOCATION -> "TERMINAL_LOCATION";
case TERMINAL_LOCATION_BATCH -> "TERMINAL_LOCATION_BATCH";
case TERMINAL_EVENT_REPORT -> "TERMINAL_EVENT_REPORT";
case TERMINAL_CAN_DATA_REPORT -> "TERMINAL_CAN_DATA_REPORT";
case TERMINAL_MEDIA_EVENT -> "TERMINAL_MEDIA_EVENT";
case TERMINAL_MEDIA_UPLOAD -> "TERMINAL_MEDIA_UPLOAD";
case TERMINAL_PASSTHROUGH -> "TERMINAL_PASSTHROUGH";
case PLATFORM_GENERAL_RESPONSE -> "PLATFORM_GENERAL_RESPONSE";
case PLATFORM_REGISTER_ACK -> "PLATFORM_REGISTER_ACK";
case PLATFORM_SET_PARAMS -> "PLATFORM_SET_PARAMS";
case PLATFORM_QUERY_PARAMS -> "PLATFORM_QUERY_PARAMS";
case PLATFORM_TERMINAL_CONTROL -> "PLATFORM_TERMINAL_CONTROL";
case PLATFORM_QUERY_ATTRS -> "PLATFORM_QUERY_ATTRS";
case PLATFORM_QUERY_LOCATION -> "PLATFORM_QUERY_LOCATION";
case PLATFORM_TRACK_CONTROL -> "PLATFORM_TRACK_CONTROL";
case PLATFORM_ALARM_ACK -> "PLATFORM_ALARM_ACK";
case PLATFORM_DELETE_CIRCLE_AREA -> "PLATFORM_DELETE_CIRCLE_AREA";
default -> "UNKNOWN_0x" + hex(messageId);
};
}
public static String reportTypeOf(int messageId) {
return switch (messageId) {
case TERMINAL_LOCATION -> "LOCATION";
case TERMINAL_LOCATION_BATCH -> "LOCATION_BATCH";
case TERMINAL_HEARTBEAT -> "HEARTBEAT";
case TERMINAL_AUTH -> "AUTH";
case TERMINAL_REGISTER -> "REGISTER";
case TERMINAL_UNREGISTER -> "UNREGISTER";
case TERMINAL_GENERAL_RESPONSE -> "TERMINAL_RESPONSE";
case TERMINAL_PARAMS_REPORT -> "PARAMS_REPORT";
case TERMINAL_ATTRS_REPORT -> "ATTRS_REPORT";
case TERMINAL_MEDIA_EVENT -> "MEDIA_EVENT";
case TERMINAL_MEDIA_UPLOAD -> "MEDIA_UPLOAD";
case TERMINAL_PASSTHROUGH -> "PASSTHROUGH";
default -> "RAW";
};
}
public static String hex(int messageId) {
return String.format("%04X", messageId & 0xFFFF);
}
}

View File

@@ -41,6 +41,7 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
class Jt808ChannelHandlerTest {
@@ -86,7 +87,7 @@ class Jt808ChannelHandlerTest {
}
@Test
void unresolvedRegisterDoesNotBindUnknownVinToExternalIdentifiers() {
void unresolvedRegisterBindsGeneratedInternalVinToExternalIdentifiers() {
InMemorySessionStore sessions = new InMemorySessionStore();
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(new ImmediateKafkaSink()));
@@ -115,9 +116,9 @@ class Jt808ChannelHandlerTest {
assertThat(identity.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, "", "123456789012", "", "")))
.satisfies(resolved -> {
assertThat(resolved.vin()).isEqualTo("123456789012");
assertThat(resolved.resolved()).isFalse();
assertThat(resolved.source()).isEqualTo(VehicleIdentitySource.FALLBACK_PHONE);
assertThat(resolved.vin()).startsWith("JT808");
assertThat(resolved.resolved()).isTrue();
assertThat(resolved.source()).isEqualTo(VehicleIdentitySource.BOUND_PHONE);
});
batchExecutor.close();
@@ -269,14 +270,143 @@ class Jt808ChannelHandlerTest {
assertThat(archive.metadata())
.containsEntry("vin", "LNVIN000000000808")
.containsEntry("phone", "123456789012")
.containsEntry("messageId", "0x0200")
.containsEntry("messageName", "TERMINAL_LOCATION")
.containsEntry("reportType", "LOCATION")
.containsEntry("body.alarmFlag", "0")
.containsEntry("body.statusFlag", "196608")
.containsEntry("body.status.acc_on", "false")
.containsEntry("body.status.load_status", "EMPTY")
.containsEntry("body.status.door4_open", "true")
.containsEntry("body.status.door5_open", "true")
.containsEntry("body.alarm.overspeed", "false")
.containsEntry("body.longitude", "116.397128")
.containsEntry("body.latitude", "39.916527")
.containsEntry("body.altitudeM", "50")
.containsEntry("body.directionDeg", "90")
.containsEntry("body.deviceTime", "2024-01-01T19:04:05Z")
.containsEntry("body.extra.0x01", "00003039")
.containsEntry("body.extra.mileage_km", "1234.5")
.containsEntry("body.extra.0x30", "58")
.containsEntry("body.extra.network_signal_strength", "88")
.containsEntry("identityResolved", "true")
.containsEntry("identitySource", "BOUND_PHONE");
assertThat(Double.parseDouble(archive.metadata().get("body.speedKmh"))).isCloseTo(52.3, within(0.001));
});
batchExecutor.close();
eventBus.close();
}
@Test
void registerArchiveIncludesParsedBodyFieldsForRawQuery() 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 RegisterBodyParser()))),
dispatcher,
new InMemorySessionStore(),
identity,
new Jt808ChannelRegistry(),
new Jt808PendingRequests());
EmbeddedChannel channel = new EmbeddedChannel(handler);
byte[] frame = buildFrame(
Jt808MessageId.TERMINAL_REGISTER,
"123456789012",
3,
buildRegisterBody("DEV808", "B80808"));
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.metadata())
.containsEntry("messageId", "0x0100")
.containsEntry("messageName", "TERMINAL_REGISTER")
.containsEntry("reportType", "REGISTER")
.containsEntry("body.province", "44")
.containsEntry("body.city", "4401")
.containsEntry("body.maker", "MAKER")
.containsEntry("body.deviceType", "TYPE-A")
.containsEntry("body.deviceId", "DEV808")
.containsEntry("body.plateColor", "1")
.containsEntry("body.plate", "B80808");
});
batchExecutor.close();
eventBus.close();
}
@Test
void unboundRegisterCreatesVinBindingForRegisterAndFollowingRawFrames() 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(
new HandlerRegistry(),
new InterceptorChain(List.of()),
new HandlerInvoker(),
eventBus,
batchExecutor);
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
Jt808ChannelHandler handler = new Jt808ChannelHandler(
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser(), new LocationBodyParser()))),
dispatcher,
new InMemorySessionStore(),
identity,
new Jt808ChannelRegistry(),
new Jt808PendingRequests());
EmbeddedChannel channel = new EmbeddedChannel(handler);
channel.writeInbound(buildFrame(
Jt808MessageId.TERMINAL_REGISTER,
"13079963289",
3,
buildRegisterBody("9963289", "HUA00113F")));
channel.writeInbound(buildFrame(
Jt808MessageId.TERMINAL_LOCATION,
"13079963289",
4,
buildLocationBody()));
assertThat(sink.await()).isTrue();
VehicleEvent.RawArchive register = (VehicleEvent.RawArchive) sink.events.get(0);
VehicleEvent.RawArchive location = (VehicleEvent.RawArchive) sink.events.get(1);
assertThat(register.metadata())
.containsEntry("reportType", "REGISTER")
.containsEntry("body.deviceId", "9963289")
.containsEntry("body.plate", "HUA00113F")
.containsEntry("identityResolved", "true")
.containsEntry("identitySource", "REGISTERED");
assertThat(register.vin())
.startsWith("JT808")
.hasSize(17);
assertThat(location.metadata())
.containsEntry("reportType", "LOCATION")
.containsEntry("vin", register.vin())
.containsEntry("identityResolved", "true")
.containsEntry("identitySource", "BOUND_PHONE");
assertThat(location.vin()).isEqualTo(register.vin());
assertThat(identity.resolve(new com.lingniu.ingest.identity.VehicleIdentityLookup(
ProtocolId.JT808, "", "13079963289", "", "")).vin())
.isEqualTo(register.vin());
batchExecutor.close();
eventBus.close();
}
@Test
void unboundLocationArchiveUsesUnknownVinInsteadOfPhoneFallback() throws Exception {
RecordingSink sink = new RecordingSink(1);
@@ -309,6 +439,9 @@ class Jt808ChannelHandlerTest {
assertThat(archive.metadata())
.containsEntry("vin", "unknown")
.containsEntry("phone", "123456789012")
.containsEntry("messageId", "0x0200")
.containsEntry("messageName", "TERMINAL_LOCATION")
.containsEntry("reportType", "LOCATION")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "FALLBACK_PHONE");
});
@@ -351,13 +484,13 @@ class Jt808ChannelHandlerTest {
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
assertThat(archive.command()).isEqualTo(Jt808MessageId.TERMINAL_REGISTER);
assertThat(archive.metadata())
.containsEntry("jt808.register.province", "44")
.containsEntry("jt808.register.city", "4401")
.containsEntry("jt808.register.maker", "MAKER")
.containsEntry("jt808.register.deviceType", "TYPE-A")
.containsEntry("jt808.register.deviceId", "DEV808")
.containsEntry("jt808.register.plateColor", "1")
.containsEntry("jt808.register.plate", "B80808");
.containsEntry("body.province", "44")
.containsEntry("body.city", "4401")
.containsEntry("body.maker", "MAKER")
.containsEntry("body.deviceType", "TYPE-A")
.containsEntry("body.deviceId", "DEV808")
.containsEntry("body.plateColor", "1")
.containsEntry("body.plate", "B80808");
});
batchExecutor.close();
@@ -581,6 +714,12 @@ class Jt808ChannelHandlerTest {
writeU16(os, 90);
byte[] bcd = BcdCodec.encode("240102030405");
os.write(bcd, 0, 6);
os.write(0x01);
os.write(4);
writeU32(os, 12345);
os.write(0x30);
os.write(1);
os.write(0x58);
return os.toByteArray();
}

View File

@@ -38,6 +38,10 @@ class Jt808EventMapperTest {
assertThat(events.get(0).metadata())
.containsEntry("vin", "unknown")
.containsEntry("phone", "123456789012")
.containsEntry("jt808.status.acc_on", "false")
.containsEntry("jt808.status.positioned", "false")
.containsEntry("jt808.status.load_status", "EMPTY")
.containsEntry("jt808.alarm.overspeed", "false")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "FALLBACK_PHONE");
}
@@ -58,7 +62,66 @@ class Jt808EventMapperTest {
.extracting(VehicleEvent::metadata)
.satisfies(meta -> assertThat(meta)
.containsEntry("jt808.extra.0x01", "00003039")
.containsEntry("jt808.extra.0x30", "58"));
.containsEntry("jt808.extra.0x30", "58")
.containsEntry("jt808.extra.mileage_km", "1234.5")
.containsEntry("jt808.extra.network_signal_strength", "88"));
}
@Test
void locationAdditionalItemsAreDecodedAsTable27Fields() {
var header = new Jt808Header(
Jt808MessageId.TERMINAL_LOCATION, 64, 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.ofEntries(
java.util.Map.entry(0x01, new byte[]{0x00, 0x00, 0x30, 0x39}),
java.util.Map.entry(0x02, new byte[]{0x00, 0x64}),
java.util.Map.entry(0x03, new byte[]{0x01, (byte) 0xF4}),
java.util.Map.entry(0x04, new byte[]{0x00, 0x02}),
java.util.Map.entry(0x05, tirePressureBytes()),
java.util.Map.entry(0x06, new byte[]{(byte) 0x80, 0x05}),
java.util.Map.entry(0x11, new byte[]{0x01, 0x00, 0x00, 0x00, 0x2A}),
java.util.Map.entry(0x12, new byte[]{0x04, 0x00, 0x00, 0x00, 0x2B, 0x01}),
java.util.Map.entry(0x13, new byte[]{0x00, 0x00, 0x00, 0x2C, 0x00, 0x78, 0x01}),
java.util.Map.entry(0x25, new byte[]{0x00, 0x00, 0x00, 0x05}),
java.util.Map.entry(0x2A, new byte[]{0x00, 0x03}),
java.util.Map.entry(0x2B, new byte[]{0x12, 0x34, 0x56, 0x78}),
java.util.Map.entry(0x30, new byte[]{0x1F}),
java.util.Map.entry(0x31, new byte[]{0x0C})));
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.mileage_km", "1234.5")
.containsEntry("jt808.extra.fuel_l", "10")
.containsEntry("jt808.extra.recorder_speed_kmh", "50")
.containsEntry("jt808.extra.manual_alarm_event_id", "2")
.containsEntry("jt808.extra.tire_pressure_pa_values", "32,33")
.containsEntry("jt808.extra.tire_pressure_invalid_indexes", "2-29")
.containsEntry("jt808.extra.compartment_temp_c", "-5")
.containsEntry("jt808.extra.overspeed.position_type", "1")
.containsEntry("jt808.extra.overspeed.area_route_id", "42")
.containsEntry("jt808.extra.area_route_alarm.position_type", "4")
.containsEntry("jt808.extra.area_route_alarm.area_route_id", "43")
.containsEntry("jt808.extra.area_route_alarm.direction", "1")
.containsEntry("jt808.extra.route_time.segment_id", "44")
.containsEntry("jt808.extra.route_time.duration_seconds", "120")
.containsEntry("jt808.extra.route_time.result", "1")
.containsEntry("jt808.extra.vehicle_signal_raw", "5")
.containsEntry("jt808.extra.vehicle_signal.low_beam", "true")
.containsEntry("jt808.extra.vehicle_signal.high_beam", "false")
.containsEntry("jt808.extra.vehicle_signal.right_turn", "true")
.containsEntry("jt808.extra.io_status_raw", "3")
.containsEntry("jt808.extra.io_status.deep_sleep", "true")
.containsEntry("jt808.extra.io_status.sleep", "true")
.containsEntry("jt808.extra.analog_raw", "305419896")
.containsEntry("jt808.extra.analog_ad0", "22136")
.containsEntry("jt808.extra.analog_ad1", "4660")
.containsEntry("jt808.extra.network_signal_strength", "31")
.containsEntry("jt808.extra.gnss_satellite_count", "12");
}
@Test
@@ -249,4 +312,12 @@ class Jt808EventMapperTest {
.containsEntry("parseError", "true");
});
}
private static byte[] tirePressureBytes() {
byte[] bytes = new byte[30];
java.util.Arrays.fill(bytes, (byte) 0xFF);
bytes[0] = 32;
bytes[1] = 33;
return bytes;
}
}