feat: make gb32960 archive history query production ready
This commit is contained in:
61
modules/protocols/protocol-jsatl12/pom.xml
Normal file
61
modules/protocols/protocol-jsatl12/pom.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<relativePath>../../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>protocol-jsatl12</artifactId>
|
||||
<name>protocol-jsatl12</name>
|
||||
<description>
|
||||
苏标(JSATL12)主动安全报警附件上传协议。独立 TCP 端口(默认 7612),
|
||||
与 JT808 使用相同的转义规则,但数据帧带 01cd 魔数前缀,用于大文件分块上传。
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>protocol-jt808</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>vehicle-identity</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>sink-archive</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-all</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.codec;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.ByteToMessageDecoder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 苏标报警附件协议分帧。
|
||||
*
|
||||
* <p>两种帧并存:
|
||||
* <ol>
|
||||
* <li>标准 JT/T 808 帧:以 {@code 0x7e} 为分界,承载 0x1210 / 0x1211 / 0x9212 等信令
|
||||
* <li>数据帧:以 {@code 0x30 0x31 0x63 0x64}(即 ASCII "01cd")为魔数,后续 4 字节大端长度 + 数据
|
||||
* </ol>
|
||||
*
|
||||
* <p>本 decoder 合并处理:根据第一个字节决定分帧策略,输出带类型标记的 {@link Jsatl12RawFrame}。
|
||||
*/
|
||||
public class Jsatl12FrameDecoder extends ByteToMessageDecoder {
|
||||
|
||||
private static final byte[] DATA_MAGIC = {0x30, 0x31, 0x63, 0x64};
|
||||
private static final int DEFAULT_MAX_FRAME = 64 * 1024;
|
||||
private final int maxFrameLength;
|
||||
|
||||
public Jsatl12FrameDecoder() {
|
||||
this(DEFAULT_MAX_FRAME);
|
||||
}
|
||||
|
||||
public Jsatl12FrameDecoder(int maxFrameLength) {
|
||||
this.maxFrameLength = maxFrameLength <= 0 ? DEFAULT_MAX_FRAME : maxFrameLength;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
|
||||
while (in.readableBytes() >= 1) {
|
||||
byte first = in.getByte(in.readerIndex());
|
||||
if (first == 0x7e) {
|
||||
if (!decodeJtFrame(in, out)) return;
|
||||
} else if (first == DATA_MAGIC[0]) {
|
||||
if (!decodeDataFrame(in, out)) return;
|
||||
} else {
|
||||
in.skipBytes(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean decodeJtFrame(ByteBuf in, List<Object> out) {
|
||||
if (in.readableBytes() < 2) return false;
|
||||
int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e);
|
||||
if (endIdx < 0) {
|
||||
if (in.readableBytes() > maxFrameLength) {
|
||||
byte[] malformed = new byte[in.readableBytes()];
|
||||
in.readBytes(malformed);
|
||||
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.MALFORMED, malformed));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
int len = endIdx - (in.readerIndex() + 1);
|
||||
if (len <= 0) {
|
||||
in.skipBytes(1);
|
||||
return true;
|
||||
}
|
||||
byte[] raw = new byte[len];
|
||||
in.skipBytes(1);
|
||||
in.readBytes(raw);
|
||||
in.skipBytes(1);
|
||||
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.JT_MESSAGE, raw));
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean decodeDataFrame(ByteBuf in, List<Object> out) {
|
||||
if (in.readableBytes() < DATA_MAGIC.length + 4) return false;
|
||||
for (int i = 0; i < DATA_MAGIC.length; i++) {
|
||||
if (in.getByte(in.readerIndex() + i) != DATA_MAGIC[i]) {
|
||||
in.skipBytes(1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (in.readableBytes() < 62) return false;
|
||||
// DataPacket 布局:4B magic + 50B 文件名 + 4B offset + 4B length + data
|
||||
long dataLen = in.getUnsignedInt(in.readerIndex() + 58);
|
||||
if (dataLen > maxFrameLength) {
|
||||
int malformedLen = in.readableBytes();
|
||||
long declaredTotal = 62L + dataLen;
|
||||
if (declaredTotal <= Integer.MAX_VALUE && in.readableBytes() >= declaredTotal) {
|
||||
malformedLen = (int) declaredTotal;
|
||||
}
|
||||
byte[] malformed = new byte[malformedLen];
|
||||
in.readBytes(malformed);
|
||||
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.MALFORMED, malformed));
|
||||
return true;
|
||||
}
|
||||
int totalFrameLen = 62 + (int) dataLen;
|
||||
if (in.readableBytes() < totalFrameLen) return false;
|
||||
byte[] frame = new byte[totalFrameLen];
|
||||
in.readBytes(frame);
|
||||
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.DATA_CHUNK, frame));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.codec;
|
||||
|
||||
/**
|
||||
* JSATL12 原始帧:区分信令帧、数据帧与无法继续解析但需要审计的畸形帧。
|
||||
*/
|
||||
public record Jsatl12RawFrame(Type type, byte[] bytes) {
|
||||
public enum Type { JT_MESSAGE, DATA_CHUNK, MALFORMED }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.config;
|
||||
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import com.lingniu.ingest.protocol.jsatl12.handler.Jsatl12ArchiveEventHandler;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12AttachmentTracker;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12JtMessageBridge;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12NettyServer;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration;
|
||||
import com.lingniu.ingest.sink.archive.ArchiveStore;
|
||||
import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
@AutoConfiguration(after = {SinkArchiveAutoConfiguration.class, Jt808AutoConfiguration.class})
|
||||
@EnableConfigurationProperties(Jsatl12Properties.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.jsatl12", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnBean({ArchiveStore.class, Jt808MessageDecoder.class})
|
||||
public class Jsatl12AutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jsatl12AttachmentTracker jsatl12AttachmentTracker() {
|
||||
return new Jsatl12AttachmentTracker();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jsatl12JtMessageBridge jsatl12JtMessageBridge(Jt808MessageDecoder decoder,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Jsatl12AttachmentTracker attachmentTracker,
|
||||
Dispatcher dispatcher) {
|
||||
return new Jsatl12JtMessageBridge(decoder, identityResolver, attachmentTracker, dispatcher::dispatch);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jsatl12ArchiveEventHandler jsatl12ArchiveEventHandler() {
|
||||
return new Jsatl12ArchiveEventHandler();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Jsatl12NettyServer jsatl12NettyServer(Jsatl12Properties props,
|
||||
ArchiveStore archive,
|
||||
Jsatl12JtMessageBridge jtMessageBridge,
|
||||
Jsatl12AttachmentTracker attachmentTracker,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Dispatcher dispatcher) {
|
||||
return new Jsatl12NettyServer(props.getPort(), props.getWorkerThreads(),
|
||||
props.getMaxFrameLength(), archive, jtMessageBridge, attachmentTracker, identityResolver, dispatcher::dispatch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** JSATL12 主动安全附件接入配置。 */
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.jsatl12")
|
||||
public class Jsatl12Properties {
|
||||
private boolean enabled = false;
|
||||
private int port = 7612;
|
||||
private int workerThreads = 0;
|
||||
private int maxFrameLength = 64 * 1024;
|
||||
/** 附件落盘根目录,支持 file:// / s3:// / oss:// 形式,由 sink-archive 解析。 */
|
||||
private String fileStore = "file:///var/lingniu/alarm-files";
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public int getPort() { return port; }
|
||||
public void setPort(int port) { this.port = port; }
|
||||
public int getWorkerThreads() { return workerThreads; }
|
||||
public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; }
|
||||
public int getMaxFrameLength() { return maxFrameLength; }
|
||||
public void setMaxFrameLength(int maxFrameLength) { this.maxFrameLength = maxFrameLength; }
|
||||
public String getFileStore() { return fileStore; }
|
||||
public void setFileStore(String fileStore) { this.fileStore = fileStore; }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.handler;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.annotation.EventEmit;
|
||||
import com.lingniu.ingest.api.annotation.MessageMapping;
|
||||
import com.lingniu.ingest.api.annotation.ProtocolHandler;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchiveFailure;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchivedChunk;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedFrame;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedJtMessage;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MessageId;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@ProtocolHandler(protocol = ProtocolId.JSATL12)
|
||||
public final class Jsatl12ArchiveEventHandler {
|
||||
|
||||
@MessageMapping(command = Jsatl12MessageId.DATA_CHUNK_ARCHIVED, desc = "主动安全附件数据块已归档")
|
||||
@EventEmit(VehicleEvent.MediaMeta.class)
|
||||
public List<VehicleEvent> onDataChunkArchived(Jsatl12ArchivedChunk chunk) {
|
||||
Instant eventTime = chunk.archivedAt() == null ? Instant.now() : chunk.archivedAt();
|
||||
String peer = chunk.peer() == null || chunk.peer().isBlank() ? "unknown" : chunk.peer();
|
||||
return List.of(new VehicleEvent.MediaMeta(
|
||||
UUID.randomUUID().toString(),
|
||||
nullToUnknown(chunk.vin()),
|
||||
ProtocolId.JSATL12,
|
||||
eventTime,
|
||||
Instant.now(),
|
||||
null,
|
||||
baseMeta(
|
||||
"vin", nullToUnknown(chunk.vin()),
|
||||
"phone", nullToEmpty(chunk.phone()),
|
||||
"identityResolved", Boolean.toString(chunk.identityResolved()),
|
||||
"identitySource", nullToEmpty(chunk.identitySource()),
|
||||
"peer", peer,
|
||||
"archiveKey", nullToEmpty(chunk.archiveKey()),
|
||||
"archiveRef", nullToEmpty(chunk.archiveRef()),
|
||||
"fileName", nullToEmpty(chunk.fileName()),
|
||||
"fileOffset", Long.toString(Math.max(0, chunk.fileOffset())),
|
||||
"declaredChunkSizeBytes", Long.toString(Math.max(0, chunk.declaredSizeBytes())),
|
||||
"chunkSizeBytes", Long.toString(Math.max(0, chunk.sizeBytes()))),
|
||||
"jsatl12-" + peer + "-" + eventTime.toEpochMilli(),
|
||||
"jsatl12-data-chunk",
|
||||
Math.max(0, chunk.sizeBytes()),
|
||||
nullToEmpty(chunk.archiveRef())));
|
||||
}
|
||||
|
||||
@MessageMapping(command = Jsatl12MessageId.MALFORMED_JT_MESSAGE, desc = "主动安全 JT 消息解析失败兜底")
|
||||
@EventEmit(VehicleEvent.Passthrough.class)
|
||||
public List<VehicleEvent> onMalformedJtMessage(Jsatl12MalformedJtMessage malformed) {
|
||||
Instant eventTime = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt();
|
||||
String peer = malformed.peer() == null || malformed.peer().isBlank() ? "unknown" : malformed.peer();
|
||||
return List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
"unknown",
|
||||
ProtocolId.JSATL12,
|
||||
eventTime,
|
||||
Instant.now(),
|
||||
null,
|
||||
baseMeta(
|
||||
"peer", peer,
|
||||
"source", "jsatl12-jt-message",
|
||||
"parseError", "true",
|
||||
"parseErrorMessage", nullToEmpty(malformed.errorMessage())),
|
||||
Jsatl12MessageId.MALFORMED_JT_MESSAGE,
|
||||
malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes()));
|
||||
}
|
||||
|
||||
@MessageMapping(command = Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED, desc = "主动安全附件数据块归档失败兜底")
|
||||
@EventEmit(VehicleEvent.Passthrough.class)
|
||||
public List<VehicleEvent> onArchiveFailure(Jsatl12ArchiveFailure failure) {
|
||||
Instant eventTime = failure.failedAt() == null ? Instant.now() : failure.failedAt();
|
||||
String peer = failure.peer() == null || failure.peer().isBlank() ? "unknown" : failure.peer();
|
||||
return List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
"unknown",
|
||||
ProtocolId.JSATL12,
|
||||
eventTime,
|
||||
Instant.now(),
|
||||
null,
|
||||
baseMeta(
|
||||
"peer", peer,
|
||||
"source", "jsatl12-data-chunk",
|
||||
"archiveError", "true",
|
||||
"archiveErrorMessage", nullToEmpty(failure.errorMessage()),
|
||||
"archiveKey", nullToEmpty(failure.archiveKey()),
|
||||
"chunkSizeBytes", Long.toString(Math.max(0, failure.sizeBytes()))),
|
||||
Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED,
|
||||
failure.rawBytes() == null ? new byte[0] : failure.rawBytes()));
|
||||
}
|
||||
|
||||
@MessageMapping(command = Jsatl12MessageId.MALFORMED_FRAME, desc = "主动安全附件帧边界异常兜底")
|
||||
@EventEmit(VehicleEvent.Passthrough.class)
|
||||
public List<VehicleEvent> onMalformedFrame(Jsatl12MalformedFrame malformed) {
|
||||
Instant eventTime = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt();
|
||||
String peer = malformed.peer() == null || malformed.peer().isBlank() ? "unknown" : malformed.peer();
|
||||
return List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
"unknown",
|
||||
ProtocolId.JSATL12,
|
||||
eventTime,
|
||||
Instant.now(),
|
||||
null,
|
||||
baseMeta(
|
||||
"peer", peer,
|
||||
"source", "jsatl12-frame",
|
||||
"frameError", "true",
|
||||
"frameErrorMessage", nullToEmpty(malformed.errorMessage()),
|
||||
"parseError", "true",
|
||||
"parseErrorMessage", nullToEmpty(malformed.errorMessage())),
|
||||
Jsatl12MessageId.MALFORMED_FRAME,
|
||||
malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes()));
|
||||
}
|
||||
|
||||
private static Map<String, String> baseMeta(String... kv) {
|
||||
java.util.HashMap<String, String> out = new java.util.HashMap<>();
|
||||
out.put("vin", "unknown");
|
||||
out.put("identityResolved", "false");
|
||||
out.put("identitySource", "UNKNOWN");
|
||||
for (int i = 0; i + 1 < kv.length; i += 2) {
|
||||
out.put(kv[i], kv[i + 1] == null ? "" : kv[i + 1]);
|
||||
}
|
||||
return Map.copyOf(out);
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String nullToUnknown(String value) {
|
||||
return value == null || value.isBlank() ? "unknown" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record Jsatl12ArchiveFailure(
|
||||
byte[] rawBytes,
|
||||
String archiveKey,
|
||||
long sizeBytes,
|
||||
String peer,
|
||||
String errorMessage,
|
||||
Instant failedAt
|
||||
) {}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record Jsatl12ArchivedChunk(
|
||||
String archiveRef,
|
||||
String archiveKey,
|
||||
String fileName,
|
||||
long fileOffset,
|
||||
long declaredSizeBytes,
|
||||
long sizeBytes,
|
||||
String peer,
|
||||
Instant archivedAt,
|
||||
String phone,
|
||||
String vin,
|
||||
boolean identityResolved,
|
||||
String identitySource
|
||||
) {
|
||||
public Jsatl12ArchivedChunk {
|
||||
phone = phone == null ? "" : phone.trim();
|
||||
vin = vin == null || vin.isBlank() ? "unknown" : vin.trim();
|
||||
identitySource = identitySource == null || identitySource.isBlank() ? "UNKNOWN" : identitySource.trim();
|
||||
}
|
||||
|
||||
public Jsatl12ArchivedChunk(String archiveRef,
|
||||
String archiveKey,
|
||||
String fileName,
|
||||
long fileOffset,
|
||||
long declaredSizeBytes,
|
||||
long sizeBytes,
|
||||
String peer,
|
||||
Instant archivedAt) {
|
||||
this(archiveRef, archiveKey, fileName, fileOffset, declaredSizeBytes, sizeBytes, peer, archivedAt,
|
||||
"", "unknown", false, "UNKNOWN");
|
||||
}
|
||||
|
||||
public Jsatl12ArchivedChunk(String archiveRef,
|
||||
String archiveKey,
|
||||
long sizeBytes,
|
||||
String peer,
|
||||
Instant archivedAt) {
|
||||
this(archiveRef, archiveKey, "", 0, sizeBytes, sizeBytes, peer, archivedAt,
|
||||
"", "unknown", false, "UNKNOWN");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public final class Jsatl12AttachmentTracker {
|
||||
|
||||
private final Map<String, Map<String, FileState>> filesByPhone = new HashMap<>();
|
||||
|
||||
public synchronized void registerAttachmentList(String phone, byte[] body) {
|
||||
String key = normalizePhone(phone);
|
||||
byte[] raw = body == null ? new byte[0] : body;
|
||||
if (raw.length < 57) {
|
||||
throw new IllegalArgumentException("jsatl12 T1210 body too short: " + raw.length);
|
||||
}
|
||||
int total = raw[56] & 0xFF;
|
||||
int pos = 57;
|
||||
Map<String, FileState> files = filesByPhone.computeIfAbsent(key, ignored -> new HashMap<>());
|
||||
for (int i = 0; i < total && pos < raw.length; i++) {
|
||||
int nameLen = raw[pos++] & 0xFF;
|
||||
if (pos + nameLen + 4 > raw.length) {
|
||||
throw new IllegalArgumentException("jsatl12 T1210 item truncated at index " + i);
|
||||
}
|
||||
String name = new String(raw, pos, nameLen, StandardCharsets.UTF_8).trim();
|
||||
pos += nameLen;
|
||||
long size = Jsatl12DataPacket.readDword(raw, pos);
|
||||
pos += 4;
|
||||
files.put(name, new FileState(name, 0, size));
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Jsatl12DataPacket recordDataPacket(String phone, byte[] rawPacket) {
|
||||
Jsatl12DataPacket packet = Jsatl12DataPacket.parse(rawPacket);
|
||||
String key = normalizePhone(phone);
|
||||
Map<String, FileState> files = filesByPhone.computeIfAbsent(key, ignored -> new HashMap<>());
|
||||
FileState state = files.computeIfAbsent(packet.name(),
|
||||
ignored -> new FileState(packet.name(), 0, packet.offset() + packet.length()));
|
||||
state.ranges.add(new Range(packet.offset(), packet.offset() + packet.length()));
|
||||
return packet;
|
||||
}
|
||||
|
||||
public synchronized Jsatl12UploadCompletionAck completeUpload(String phone, byte[] body) {
|
||||
T1212 completion = parseT1212(body);
|
||||
String key = normalizePhone(phone);
|
||||
Map<String, FileState> files = filesByPhone.computeIfAbsent(key, ignored -> new HashMap<>());
|
||||
FileState state = files.computeIfAbsent(completion.name(),
|
||||
ignored -> new FileState(completion.name(), completion.type(), completion.size()));
|
||||
mergeUnknownFileInto(key, state);
|
||||
state.type = completion.type();
|
||||
state.size = completion.size();
|
||||
|
||||
List<Jsatl12UploadCompletionAck.Range> missing = missingRanges(state);
|
||||
if (missing.isEmpty()) {
|
||||
files.remove(completion.name());
|
||||
if (files.isEmpty()) {
|
||||
filesByPhone.remove(key);
|
||||
}
|
||||
return new Jsatl12UploadCompletionAck(completion.name(), completion.type(), 0, List.of());
|
||||
}
|
||||
return new Jsatl12UploadCompletionAck(completion.name(), completion.type(), 1, missing);
|
||||
}
|
||||
|
||||
private void mergeUnknownFileInto(String ownerKey, FileState target) {
|
||||
if ("unknown".equals(ownerKey)) {
|
||||
return;
|
||||
}
|
||||
Map<String, FileState> unknownFiles = filesByPhone.get("unknown");
|
||||
if (unknownFiles == null) {
|
||||
return;
|
||||
}
|
||||
FileState unknown = unknownFiles.remove(target.name);
|
||||
if (unknown == null) {
|
||||
return;
|
||||
}
|
||||
target.ranges.addAll(unknown.ranges);
|
||||
if (unknownFiles.isEmpty()) {
|
||||
filesByPhone.remove("unknown");
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Set<String> knownFiles(String phone) {
|
||||
Map<String, FileState> files = filesByPhone.get(normalizePhone(phone));
|
||||
if (files == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return Set.copyOf(new HashSet<>(files.keySet()));
|
||||
}
|
||||
|
||||
public synchronized String ownerPhone(String fileName) {
|
||||
String name = fileName == null ? "" : fileName.trim();
|
||||
if (name.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
for (Map.Entry<String, Map<String, FileState>> entry : filesByPhone.entrySet()) {
|
||||
if (!"unknown".equals(entry.getKey()) && entry.getValue().containsKey(name)) {
|
||||
return entry.getKey();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static List<Jsatl12UploadCompletionAck.Range> missingRanges(FileState state) {
|
||||
List<Range> ranges = new ArrayList<>(state.ranges);
|
||||
ranges.sort((a, b) -> Long.compare(a.start, b.start));
|
||||
List<Jsatl12UploadCompletionAck.Range> missing = new ArrayList<>();
|
||||
long cursor = 0;
|
||||
for (Range range : ranges) {
|
||||
if (range.end <= cursor) {
|
||||
continue;
|
||||
}
|
||||
if (range.start > cursor) {
|
||||
missing.add(new Jsatl12UploadCompletionAck.Range(cursor, range.start - cursor));
|
||||
}
|
||||
cursor = Math.max(cursor, range.end);
|
||||
if (cursor >= state.size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cursor < state.size) {
|
||||
missing.add(new Jsatl12UploadCompletionAck.Range(cursor, state.size - cursor));
|
||||
}
|
||||
return List.copyOf(missing);
|
||||
}
|
||||
|
||||
private static T1212 parseT1212(byte[] body) {
|
||||
byte[] raw = body == null ? new byte[0] : body;
|
||||
if (raw.length < 6) {
|
||||
throw new IllegalArgumentException("jsatl12 T1212 body too short: " + raw.length);
|
||||
}
|
||||
int nameLen = raw[0] & 0xFF;
|
||||
if (raw.length < 1 + nameLen + 1 + 4) {
|
||||
throw new IllegalArgumentException("jsatl12 T1212 body truncated");
|
||||
}
|
||||
String name = new String(raw, 1, nameLen, StandardCharsets.UTF_8).trim();
|
||||
int type = raw[1 + nameLen] & 0xFF;
|
||||
long size = Jsatl12DataPacket.readDword(raw, 2 + nameLen);
|
||||
return new T1212(name, type, size);
|
||||
}
|
||||
|
||||
private static String normalizePhone(String phone) {
|
||||
return phone == null || phone.isBlank() ? "unknown" : phone;
|
||||
}
|
||||
|
||||
private static final class FileState {
|
||||
private final String name;
|
||||
private final List<Range> ranges = new ArrayList<>();
|
||||
private int type;
|
||||
private long size;
|
||||
|
||||
private FileState(String name, int type, long size) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
this.size = Math.max(0, size);
|
||||
}
|
||||
}
|
||||
|
||||
private record Range(long start, long end) {}
|
||||
|
||||
private record T1212(String name, int type, long size) {}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.identity.VehicleIdentity;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityLookup;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import com.lingniu.ingest.identity.VehicleIdentitySource;
|
||||
import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12RawFrame;
|
||||
import com.lingniu.ingest.sink.archive.ArchiveStore;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* JSATL12 入站处理器。
|
||||
*
|
||||
* <p>当前实现:把 DATA_CHUNK 帧写入 {@link ArchiveStore} 后派发统一事件引用,文件名按日期 + 对端 IP +
|
||||
* 时间戳生成;JT_MESSAGE 帧复用 JT808 decoder 后进入统一 Dispatcher。
|
||||
*
|
||||
* <p>路径模板:{@code yyyy/MM/dd/<peerIp>/<epochMillis>.bin}。
|
||||
*/
|
||||
public class Jsatl12ChannelHandler extends SimpleChannelInboundHandler<Jsatl12RawFrame> {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Jsatl12ChannelHandler.class);
|
||||
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.systemDefault());
|
||||
|
||||
/** 为每个 Channel 持久化一个文件 key,保证同一次传输追加到同一个文件。 */
|
||||
private static final Cache<ChannelHandlerContext, String> KEYS = Caffeine.newBuilder()
|
||||
.expireAfterAccess(Duration.ofMinutes(30))
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final ArchiveStore archive;
|
||||
private final Jsatl12JtMessageBridge jtMessageBridge;
|
||||
private final Jsatl12AttachmentTracker attachmentTracker;
|
||||
private final VehicleIdentityResolver identityResolver;
|
||||
private final Consumer<RawFrame> dataChunkDispatcher;
|
||||
|
||||
public Jsatl12ChannelHandler(ArchiveStore archive, Jsatl12JtMessageBridge jtMessageBridge) {
|
||||
this(archive, jtMessageBridge, new Jsatl12AttachmentTracker(), ignored -> {});
|
||||
}
|
||||
|
||||
public Jsatl12ChannelHandler(ArchiveStore archive,
|
||||
Jsatl12JtMessageBridge jtMessageBridge,
|
||||
Consumer<RawFrame> dataChunkDispatcher) {
|
||||
this(archive, jtMessageBridge, new Jsatl12AttachmentTracker(), null, dataChunkDispatcher);
|
||||
}
|
||||
|
||||
public Jsatl12ChannelHandler(ArchiveStore archive,
|
||||
Jsatl12JtMessageBridge jtMessageBridge,
|
||||
Jsatl12AttachmentTracker attachmentTracker,
|
||||
Consumer<RawFrame> dataChunkDispatcher) {
|
||||
this(archive, jtMessageBridge, attachmentTracker, null, dataChunkDispatcher);
|
||||
}
|
||||
|
||||
public Jsatl12ChannelHandler(ArchiveStore archive,
|
||||
Jsatl12JtMessageBridge jtMessageBridge,
|
||||
Jsatl12AttachmentTracker attachmentTracker,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Consumer<RawFrame> dataChunkDispatcher) {
|
||||
this.archive = archive;
|
||||
this.jtMessageBridge = jtMessageBridge;
|
||||
this.attachmentTracker = attachmentTracker == null ? new Jsatl12AttachmentTracker() : attachmentTracker;
|
||||
this.identityResolver = identityResolver;
|
||||
this.dataChunkDispatcher = dataChunkDispatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext ctx, Jsatl12RawFrame frame) {
|
||||
switch (frame.type()) {
|
||||
case JT_MESSAGE -> {
|
||||
jtMessageBridge.dispatch(frame.bytes(), addr(ctx))
|
||||
.ifPresent(response -> ctx.writeAndFlush(io.netty.buffer.Unpooled.wrappedBuffer(response)));
|
||||
}
|
||||
case MALFORMED -> {
|
||||
byte[] raw = frame.bytes() == null ? new byte[0] : frame.bytes();
|
||||
Instant now = Instant.now();
|
||||
String peer = addr(ctx);
|
||||
String errorMessage = "malformed jsatl12 frame";
|
||||
Jsatl12MalformedFrame malformed = new Jsatl12MalformedFrame(raw, peer, errorMessage, now);
|
||||
dataChunkDispatcher.accept(new RawFrame(
|
||||
ProtocolId.JSATL12,
|
||||
Jsatl12MessageId.MALFORMED_FRAME,
|
||||
0,
|
||||
malformed,
|
||||
raw,
|
||||
unresolvedMeta(
|
||||
"source", "jsatl12-frame",
|
||||
"peer", peer,
|
||||
"frameError", "true",
|
||||
"frameErrorMessage", errorMessage,
|
||||
"frameSizeBytes", Integer.toString(raw.length)),
|
||||
now));
|
||||
}
|
||||
case DATA_CHUNK -> {
|
||||
String key = KEYS.get(ctx, k -> {
|
||||
String day = DAY_FMT.format(Instant.now());
|
||||
return "jsatl12/" + day + "/" + addr(ctx).replace(':', '_')
|
||||
+ "/" + Instant.now().toEpochMilli() + ".bin";
|
||||
});
|
||||
try {
|
||||
byte[] all = frame.bytes();
|
||||
Jsatl12DataPacket packet = attachmentTracker.recordDataPacket(null, all);
|
||||
byte[] payload = packet.data();
|
||||
int payloadLen = payload.length;
|
||||
if (payloadLen > 0) {
|
||||
String phone = attachmentTracker.ownerPhone(packet.name());
|
||||
IdentityResolution identity = resolveIdentity(phone);
|
||||
String archiveRef = archive.append(key, payload);
|
||||
Instant now = Instant.now();
|
||||
Jsatl12ArchivedChunk archived = new Jsatl12ArchivedChunk(
|
||||
archiveRef,
|
||||
key,
|
||||
packet.name(),
|
||||
packet.offset(),
|
||||
packet.length(),
|
||||
payloadLen,
|
||||
addr(ctx),
|
||||
now,
|
||||
phone,
|
||||
identity.identity().vin(),
|
||||
identity.identity().resolved(),
|
||||
identity.identity().source().name());
|
||||
dataChunkDispatcher.accept(new RawFrame(
|
||||
ProtocolId.JSATL12,
|
||||
Jsatl12MessageId.DATA_CHUNK_ARCHIVED,
|
||||
0,
|
||||
archived,
|
||||
null,
|
||||
identityMeta(identity,
|
||||
"phone", phone,
|
||||
"source", "jsatl12-data-chunk",
|
||||
"peer", addr(ctx),
|
||||
"fileName", packet.name(),
|
||||
"fileOffset", Long.toString(packet.offset()),
|
||||
"declaredChunkSizeBytes", Long.toString(packet.length()),
|
||||
"archiveRef", archiveRef,
|
||||
"archiveKey", key,
|
||||
"chunkSizeBytes", Integer.toString(payloadLen)),
|
||||
now));
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.warn("[jsatl12] write chunk failed key={}", key, e);
|
||||
byte[] all = frame.bytes();
|
||||
int payloadLen = Math.max(0, all.length - 62);
|
||||
Instant now = Instant.now();
|
||||
Jsatl12ArchiveFailure failure = new Jsatl12ArchiveFailure(
|
||||
all,
|
||||
key,
|
||||
payloadLen,
|
||||
addr(ctx),
|
||||
e.getMessage() == null ? "" : e.getMessage(),
|
||||
now);
|
||||
dataChunkDispatcher.accept(new RawFrame(
|
||||
ProtocolId.JSATL12,
|
||||
Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED,
|
||||
0,
|
||||
failure,
|
||||
all,
|
||||
unresolvedMeta(
|
||||
"source", "jsatl12-data-chunk",
|
||||
"peer", addr(ctx),
|
||||
"archiveKey", key,
|
||||
"archiveError", "true",
|
||||
"archiveErrorMessage", failure.errorMessage(),
|
||||
"chunkSizeBytes", Long.toString(payloadLen)),
|
||||
now));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext ctx) {
|
||||
String key = KEYS.getIfPresent(ctx);
|
||||
if (key != null) {
|
||||
log.info("[jsatl12] channel closed, archived key={}", key);
|
||||
KEYS.invalidate(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> unresolvedMeta(String... kv) {
|
||||
HashMap<String, String> out = new HashMap<>();
|
||||
out.put("vin", "unknown");
|
||||
out.put("identityResolved", "false");
|
||||
out.put("identitySource", "UNKNOWN");
|
||||
for (int i = 0; i + 1 < kv.length; i += 2) {
|
||||
out.put(kv[i], kv[i + 1] == null ? "" : kv[i + 1]);
|
||||
}
|
||||
return Map.copyOf(out);
|
||||
}
|
||||
|
||||
private IdentityResolution resolveIdentity(String phone) {
|
||||
if (identityResolver != null) {
|
||||
try {
|
||||
return new IdentityResolution(
|
||||
identityResolver.resolve(new VehicleIdentityLookup(ProtocolId.JT808, "", phone, "", "")),
|
||||
null);
|
||||
} catch (RuntimeException e) {
|
||||
return new IdentityResolution(
|
||||
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
|
||||
e.getMessage() == null ? "" : e.getMessage());
|
||||
}
|
||||
}
|
||||
if (phone != null && !phone.isBlank()) {
|
||||
return new IdentityResolution(new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE), null);
|
||||
}
|
||||
return new IdentityResolution(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), null);
|
||||
}
|
||||
|
||||
private static Map<String, String> identityMeta(IdentityResolution resolution, String... kv) {
|
||||
VehicleIdentity identity = resolution.identity();
|
||||
HashMap<String, String> out = new HashMap<>();
|
||||
out.put("vin", identity.vin());
|
||||
out.put("identityResolved", Boolean.toString(identity.resolved()));
|
||||
out.put("identitySource", identity.source().name());
|
||||
if (resolution.errorMessage() != null) {
|
||||
out.put("identityError", "true");
|
||||
out.put("identityErrorMessage", resolution.errorMessage());
|
||||
}
|
||||
for (int i = 0; i + 1 < kv.length; i += 2) {
|
||||
out.put(kv[i], kv[i + 1] == null ? "" : kv[i + 1]);
|
||||
}
|
||||
return Map.copyOf(out);
|
||||
}
|
||||
|
||||
private static String addr(ChannelHandlerContext ctx) {
|
||||
if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) {
|
||||
return i.getAddress().getHostAddress() + ":" + i.getPort();
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
public record Jsatl12DataPacket(String name, long offset, long length, byte[] data) {
|
||||
|
||||
private static final int HEADER_LENGTH = 62;
|
||||
|
||||
public Jsatl12DataPacket {
|
||||
name = name == null ? "" : name.trim();
|
||||
data = data == null ? new byte[0] : Arrays.copyOf(data, data.length);
|
||||
if (offset < 0) {
|
||||
throw new IllegalArgumentException("jsatl12 data packet offset must not be negative");
|
||||
}
|
||||
if (length < 0) {
|
||||
throw new IllegalArgumentException("jsatl12 data packet length must not be negative");
|
||||
}
|
||||
if (length != data.length) {
|
||||
throw new IllegalArgumentException("jsatl12 data packet length " + length
|
||||
+ " does not match data length " + data.length);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] data() {
|
||||
return Arrays.copyOf(data, data.length);
|
||||
}
|
||||
|
||||
public static Jsatl12DataPacket parse(byte[] bytes) {
|
||||
byte[] raw = bytes == null ? new byte[0] : bytes;
|
||||
if (raw.length < HEADER_LENGTH) {
|
||||
throw new IllegalArgumentException("jsatl12 data packet too short: " + raw.length);
|
||||
}
|
||||
if (raw[0] != 0x30 || raw[1] != 0x31 || raw[2] != 0x63 || raw[3] != 0x64) {
|
||||
throw new IllegalArgumentException("jsatl12 data packet magic mismatch");
|
||||
}
|
||||
String name = readFixedString(raw, 4, 50);
|
||||
long offset = readDword(raw, 54);
|
||||
long length = readDword(raw, 58);
|
||||
if (length > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("jsatl12 data packet length too large: " + length);
|
||||
}
|
||||
if (raw.length < HEADER_LENGTH + (int) length) {
|
||||
throw new IllegalArgumentException("jsatl12 data packet body truncated: " + raw.length);
|
||||
}
|
||||
byte[] data = Arrays.copyOfRange(raw, HEADER_LENGTH, HEADER_LENGTH + (int) length);
|
||||
return new Jsatl12DataPacket(name, offset, length, data);
|
||||
}
|
||||
|
||||
private static String readFixedString(byte[] raw, int offset, int length) {
|
||||
int end = offset;
|
||||
int max = Math.min(raw.length, offset + length);
|
||||
while (end < max && raw[end] != 0) {
|
||||
end++;
|
||||
}
|
||||
return new String(raw, offset, end - offset, StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
|
||||
static long readDword(byte[] raw, int offset) {
|
||||
return ((raw[offset] & 0xFFL) << 24)
|
||||
| ((raw[offset + 1] & 0xFFL) << 16)
|
||||
| ((raw[offset + 2] & 0xFFL) << 8)
|
||||
| (raw[offset + 3] & 0xFFL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.identity.VehicleIdentity;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityLookup;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import com.lingniu.ingest.identity.VehicleIdentitySource;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808Escape;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder;
|
||||
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 java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class Jsatl12JtMessageBridge {
|
||||
|
||||
private final Jt808MessageDecoder decoder;
|
||||
private final VehicleIdentityResolver identityResolver;
|
||||
private final Jsatl12AttachmentTracker attachmentTracker;
|
||||
private final Consumer<RawFrame> dispatcher;
|
||||
|
||||
public Jsatl12JtMessageBridge(Jt808MessageDecoder decoder, Consumer<RawFrame> dispatcher) {
|
||||
this(decoder, (VehicleIdentityResolver) null, dispatcher);
|
||||
}
|
||||
|
||||
public Jsatl12JtMessageBridge(Jt808MessageDecoder decoder,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Consumer<RawFrame> dispatcher) {
|
||||
this(decoder, identityResolver, new Jsatl12AttachmentTracker(), dispatcher);
|
||||
}
|
||||
|
||||
public Jsatl12JtMessageBridge(Jt808MessageDecoder decoder,
|
||||
Jsatl12AttachmentTracker attachmentTracker,
|
||||
Consumer<RawFrame> dispatcher) {
|
||||
this(decoder, null, attachmentTracker, dispatcher);
|
||||
}
|
||||
|
||||
public Jsatl12JtMessageBridge(Jt808MessageDecoder decoder,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Jsatl12AttachmentTracker attachmentTracker,
|
||||
Consumer<RawFrame> dispatcher) {
|
||||
this.decoder = decoder;
|
||||
this.identityResolver = identityResolver;
|
||||
this.attachmentTracker = attachmentTracker == null ? new Jsatl12AttachmentTracker() : attachmentTracker;
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public Optional<byte[]> dispatch(byte[] jtMessageBytes, String peer) {
|
||||
Instant receivedAt = Instant.now();
|
||||
String peerValue = peer == null ? "" : peer;
|
||||
byte[] raw = jtMessageBytes == null ? new byte[0] : jtMessageBytes;
|
||||
byte[] unescaped;
|
||||
Jt808Message message;
|
||||
try {
|
||||
unescaped = Jt808Escape.unescape(raw, 0, raw.length);
|
||||
message = decoder.decode(ByteBuffer.wrap(unescaped));
|
||||
} catch (RuntimeException e) {
|
||||
dispatchMalformed(raw, peerValue, e, receivedAt);
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
IdentityResolution identity = resolveIdentity(message.header().phone());
|
||||
Map<String, String> meta = new HashMap<>();
|
||||
meta.put("vin", identity.identity().vin());
|
||||
meta.put("phone", message.header().phone());
|
||||
meta.put("identityResolved", Boolean.toString(identity.identity().resolved()));
|
||||
meta.put("identitySource", identity.identity().source().name());
|
||||
if (identity.errorMessage() != null) {
|
||||
meta.put("identityError", "true");
|
||||
meta.put("identityErrorMessage", identity.errorMessage());
|
||||
}
|
||||
meta.put("peer", peerValue);
|
||||
meta.put("source", "jsatl12-signaling");
|
||||
dispatcher.accept(new RawFrame(
|
||||
ProtocolId.JT808,
|
||||
message.header().messageId(),
|
||||
0,
|
||||
message,
|
||||
unescaped,
|
||||
meta,
|
||||
receivedAt));
|
||||
return handleJsatl12Signaling(message);
|
||||
} catch (RuntimeException e) {
|
||||
dispatchMalformed(raw, peerValue, e, receivedAt);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<byte[]> handleJsatl12Signaling(Jt808Message message) {
|
||||
if (!(message.body() instanceof Jt808Body.Raw rawBody)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (message.header().messageId() == 0x1210) {
|
||||
attachmentTracker.registerAttachmentList(message.header().phone(), rawBody.bytes());
|
||||
return Optional.empty();
|
||||
}
|
||||
if (message.header().messageId() == 0x1212) {
|
||||
Jsatl12UploadCompletionAck ack =
|
||||
attachmentTracker.completeUpload(message.header().phone(), rawBody.bytes());
|
||||
return Optional.of(Jt808FrameEncoder.encode(
|
||||
0x9212,
|
||||
message.header().phone(),
|
||||
message.header().serialNo(),
|
||||
encodeAckBody(ack),
|
||||
message.header().version()));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static byte[] encodeAckBody(Jsatl12UploadCompletionAck ack) {
|
||||
byte[] name = ack.name().getBytes(StandardCharsets.UTF_8);
|
||||
if (name.length > 255) {
|
||||
throw new IllegalArgumentException("jsatl12 completion ack filename too long: " + ack.name());
|
||||
}
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(4 + name.length + ack.missingRanges().size() * 8);
|
||||
out.write(name.length);
|
||||
out.write(name, 0, name.length);
|
||||
out.write(ack.type() & 0xFF);
|
||||
out.write(ack.result() & 0xFF);
|
||||
out.write(ack.missingRanges().size() & 0xFF);
|
||||
for (Jsatl12UploadCompletionAck.Range range : ack.missingRanges()) {
|
||||
writeDword(out, range.offset());
|
||||
writeDword(out, range.length());
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeDword(ByteArrayOutputStream out, long value) {
|
||||
if (value < 0 || value > 0xFFFF_FFFFL) {
|
||||
throw new IllegalArgumentException("jsatl12 dword out of range: " + value);
|
||||
}
|
||||
out.write((int) ((value >>> 24) & 0xFF));
|
||||
out.write((int) ((value >>> 16) & 0xFF));
|
||||
out.write((int) ((value >>> 8) & 0xFF));
|
||||
out.write((int) (value & 0xFF));
|
||||
}
|
||||
|
||||
private IdentityResolution resolveIdentity(String phone) {
|
||||
if (identityResolver == null) {
|
||||
return new IdentityResolution(
|
||||
new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE),
|
||||
null);
|
||||
}
|
||||
try {
|
||||
return new IdentityResolution(
|
||||
identityResolver.resolve(new VehicleIdentityLookup(ProtocolId.JT808, "", phone, "", "")),
|
||||
null);
|
||||
} catch (RuntimeException e) {
|
||||
return new IdentityResolution(
|
||||
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
|
||||
e.getMessage() == null ? "" : e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchMalformed(byte[] raw, String peer, RuntimeException error, Instant receivedAt) {
|
||||
Map<String, String> meta = new HashMap<>();
|
||||
meta.put("vin", "unknown");
|
||||
meta.put("identityResolved", "false");
|
||||
meta.put("identitySource", VehicleIdentitySource.UNKNOWN.name());
|
||||
meta.put("peer", peer);
|
||||
meta.put("source", "jsatl12-jt-message");
|
||||
meta.put("parseError", "true");
|
||||
meta.put("parseErrorMessage", error.getMessage() == null ? "" : error.getMessage());
|
||||
dispatcher.accept(new RawFrame(
|
||||
ProtocolId.JSATL12,
|
||||
Jsatl12MessageId.MALFORMED_JT_MESSAGE,
|
||||
0,
|
||||
new Jsatl12MalformedJtMessage(raw, peer, error.getMessage(), receivedAt),
|
||||
raw,
|
||||
meta,
|
||||
receivedAt));
|
||||
}
|
||||
|
||||
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record Jsatl12MalformedFrame(
|
||||
byte[] rawBytes,
|
||||
String peer,
|
||||
String errorMessage,
|
||||
Instant receivedAt
|
||||
) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record Jsatl12MalformedJtMessage(
|
||||
byte[] rawBytes,
|
||||
String peer,
|
||||
String errorMessage,
|
||||
Instant receivedAt
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
public final class Jsatl12MessageId {
|
||||
|
||||
public static final int DATA_CHUNK_ARCHIVED = 0x30316364;
|
||||
public static final int MALFORMED_JT_MESSAGE = 0x30316A65;
|
||||
public static final int DATA_CHUNK_ARCHIVE_FAILED = 0x30316665;
|
||||
public static final int MALFORMED_FRAME = 0x30316672;
|
||||
|
||||
private Jsatl12MessageId() {}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12FrameDecoder;
|
||||
import com.lingniu.ingest.sink.archive.ArchiveStore;
|
||||
import io.netty.bootstrap.ServerBootstrap;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.channel.EventLoopGroup;
|
||||
import io.netty.channel.nio.NioEventLoopGroup;
|
||||
import io.netty.channel.socket.SocketChannel;
|
||||
import io.netty.channel.socket.nio.NioServerSocketChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 苏标报警附件 Netty 服务端。独立端口(默认 7612)。
|
||||
*/
|
||||
public final class Jsatl12NettyServer implements InitializingBean, DisposableBean {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Jsatl12NettyServer.class);
|
||||
|
||||
private final int port;
|
||||
private final int workerThreads;
|
||||
private final int maxFrameLength;
|
||||
private final ArchiveStore archive;
|
||||
private final Jsatl12JtMessageBridge jtMessageBridge;
|
||||
private final Jsatl12AttachmentTracker attachmentTracker;
|
||||
private final VehicleIdentityResolver identityResolver;
|
||||
private final Consumer<RawFrame> dataChunkDispatcher;
|
||||
|
||||
private EventLoopGroup boss;
|
||||
private EventLoopGroup workers;
|
||||
private Channel serverChannel;
|
||||
|
||||
public Jsatl12NettyServer(int port,
|
||||
int workerThreads,
|
||||
int maxFrameLength,
|
||||
ArchiveStore archive,
|
||||
Jsatl12JtMessageBridge jtMessageBridge,
|
||||
Jsatl12AttachmentTracker attachmentTracker,
|
||||
Consumer<RawFrame> dataChunkDispatcher) {
|
||||
this(port, workerThreads, maxFrameLength, archive, jtMessageBridge, attachmentTracker, null, dataChunkDispatcher);
|
||||
}
|
||||
|
||||
public Jsatl12NettyServer(int port,
|
||||
int workerThreads,
|
||||
int maxFrameLength,
|
||||
ArchiveStore archive,
|
||||
Jsatl12JtMessageBridge jtMessageBridge,
|
||||
Jsatl12AttachmentTracker attachmentTracker,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Consumer<RawFrame> dataChunkDispatcher) {
|
||||
this.port = port;
|
||||
this.workerThreads = workerThreads;
|
||||
this.maxFrameLength = maxFrameLength;
|
||||
this.archive = archive;
|
||||
this.jtMessageBridge = jtMessageBridge;
|
||||
this.attachmentTracker = attachmentTracker == null ? new Jsatl12AttachmentTracker() : attachmentTracker;
|
||||
this.identityResolver = identityResolver;
|
||||
this.dataChunkDispatcher = dataChunkDispatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() throws Exception {
|
||||
ThreadFactory bossTf = r -> new Thread(r, "jsatl12-boss");
|
||||
ThreadFactory wkTf = r -> new Thread(r, "jsatl12-worker");
|
||||
this.boss = new NioEventLoopGroup(1, bossTf);
|
||||
int threads = workerThreads > 0 ? workerThreads : Runtime.getRuntime().availableProcessors();
|
||||
this.workers = new NioEventLoopGroup(threads, wkTf);
|
||||
|
||||
ServerBootstrap b = new ServerBootstrap();
|
||||
b.group(boss, workers)
|
||||
.channel(NioServerSocketChannel.class)
|
||||
.option(ChannelOption.SO_BACKLOG, 256)
|
||||
.childOption(ChannelOption.TCP_NODELAY, true)
|
||||
.childOption(ChannelOption.SO_KEEPALIVE, true)
|
||||
.childHandler(new ChannelInitializer<SocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(SocketChannel ch) {
|
||||
ch.pipeline()
|
||||
.addLast("frame", new Jsatl12FrameDecoder(maxFrameLength))
|
||||
.addLast("dispatch", new Jsatl12ChannelHandler(
|
||||
archive, jtMessageBridge, attachmentTracker, identityResolver, dataChunkDispatcher));
|
||||
}
|
||||
});
|
||||
this.serverChannel = b.bind(port).sync().channel();
|
||||
log.info("jsatl12 Netty server listening on :{} workers={} maxFrameLength={}",
|
||||
port, threads, maxFrameLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
try {
|
||||
if (serverChannel != null) serverChannel.close().sync();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
if (boss != null) boss.shutdownGracefully();
|
||||
if (workers != null) workers.shutdownGracefully();
|
||||
log.info("jsatl12 Netty server stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record Jsatl12UploadCompletionAck(
|
||||
String name,
|
||||
int type,
|
||||
int result,
|
||||
List<Range> missingRanges
|
||||
) {
|
||||
public Jsatl12UploadCompletionAck {
|
||||
name = name == null ? "" : name;
|
||||
missingRanges = missingRanges == null ? List.of() : List.copyOf(missingRanges);
|
||||
}
|
||||
|
||||
public record Range(long offset, long length) {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.protocol.jsatl12.config.Jsatl12AutoConfiguration
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.codec;
|
||||
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jsatl12DataPacketDecoderTest {
|
||||
|
||||
@Test
|
||||
void dataPacketUsesFilenameOffsetLengthLayoutInsteadOfMagicLengthLayout() {
|
||||
EmbeddedChannel channel = new EmbeddedChannel(new Jsatl12FrameDecoder(128));
|
||||
byte[] packet = dataPacket("ADAS_001.jpg", 3, new byte[]{0x04, 0x05, 0x06});
|
||||
|
||||
channel.writeInbound(Unpooled.wrappedBuffer(packet));
|
||||
|
||||
Jsatl12RawFrame frame = channel.readInbound();
|
||||
assertThat(frame.type()).isEqualTo(Jsatl12RawFrame.Type.DATA_CHUNK);
|
||||
assertThat(frame.bytes()).containsExactly(packet);
|
||||
Object next = channel.readInbound();
|
||||
assertThat(next).isNull();
|
||||
}
|
||||
|
||||
private static byte[] dataPacket(String name, int offset, byte[] data) {
|
||||
byte[] out = new byte[62 + data.length];
|
||||
out[0] = 0x30;
|
||||
out[1] = 0x31;
|
||||
out[2] = 0x63;
|
||||
out[3] = 0x64;
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
System.arraycopy(nameBytes, 0, out, 4, nameBytes.length);
|
||||
writeDword(out, 54, offset);
|
||||
writeDword(out, 58, data.length);
|
||||
System.arraycopy(data, 0, out, 62, data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void writeDword(byte[] out, int offset, long value) {
|
||||
out[offset] = (byte) ((value >>> 24) & 0xFF);
|
||||
out[offset + 1] = (byte) ((value >>> 16) & 0xFF);
|
||||
out[offset + 2] = (byte) ((value >>> 8) & 0xFF);
|
||||
out[offset + 3] = (byte) (value & 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.codec;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jsatl12FrameDecoderTest {
|
||||
|
||||
@Test
|
||||
void decodesDataChunkFrame() {
|
||||
EmbeddedChannel channel = new EmbeddedChannel(new Jsatl12FrameDecoder(16));
|
||||
byte[] packet = dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03});
|
||||
|
||||
channel.writeInbound(Unpooled.wrappedBuffer(packet));
|
||||
|
||||
Jsatl12RawFrame frame = channel.readInbound();
|
||||
assertThat(frame.type()).isEqualTo(Jsatl12RawFrame.Type.DATA_CHUNK);
|
||||
assertThat(frame.bytes()).containsExactly(packet);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedDataFrameEmitsMalformedCandidate() {
|
||||
EmbeddedChannel channel = new EmbeddedChannel(new Jsatl12FrameDecoder(2));
|
||||
byte[] packet = dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03});
|
||||
|
||||
channel.writeInbound(Unpooled.wrappedBuffer(packet));
|
||||
|
||||
Jsatl12RawFrame decoded = channel.readInbound();
|
||||
assertThat(decoded.type()).isEqualTo(Jsatl12RawFrame.Type.MALFORMED);
|
||||
assertThat(decoded.bytes()).containsExactly(packet);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedUnclosedJtFrameEmitsMalformedCandidate() {
|
||||
EmbeddedChannel channel = new EmbeddedChannel(new Jsatl12FrameDecoder(4));
|
||||
|
||||
channel.writeInbound(Unpooled.wrappedBuffer(new byte[]{
|
||||
0x7e, 0x01, 0x02, 0x03, 0x04, 0x05
|
||||
}));
|
||||
|
||||
Jsatl12RawFrame decoded = channel.readInbound();
|
||||
assertThat(decoded.type()).isEqualTo(Jsatl12RawFrame.Type.MALFORMED);
|
||||
assertThat(decoded.bytes()).containsExactly(
|
||||
0x7e, 0x01, 0x02, 0x03, 0x04, 0x05);
|
||||
assertThat((Object) channel.readInbound()).isNull();
|
||||
}
|
||||
|
||||
private static byte[] dataPacket(String name, int offset, byte[] data) {
|
||||
byte[] out = new byte[62 + data.length];
|
||||
out[0] = 0x30;
|
||||
out[1] = 0x31;
|
||||
out[2] = 0x63;
|
||||
out[3] = 0x64;
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
System.arraycopy(nameBytes, 0, out, 4, nameBytes.length);
|
||||
writeDword(out, 54, offset);
|
||||
writeDword(out, 58, data.length);
|
||||
System.arraycopy(data, 0, out, 62, data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void writeDword(byte[] out, int offset, long value) {
|
||||
out[offset] = (byte) ((value >>> 24) & 0xFF);
|
||||
out[offset + 1] = (byte) ((value >>> 16) & 0xFF);
|
||||
out[offset + 2] = (byte) ((value >>> 8) & 0xFF);
|
||||
out[offset + 3] = (byte) (value & 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.handler;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchiveFailure;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchivedChunk;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedFrame;
|
||||
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedJtMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jsatl12ArchiveEventHandlerTest {
|
||||
|
||||
@Test
|
||||
void archivedDataChunkProducesMediaMetaEvent() {
|
||||
Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler();
|
||||
Jsatl12ArchivedChunk chunk = new Jsatl12ArchivedChunk(
|
||||
"file:///archive/jsatl12/2026/06/22/peer/file.bin",
|
||||
"jsatl12/2026/06/22/peer/file.bin",
|
||||
"ADAS_001.jpg",
|
||||
64,
|
||||
128,
|
||||
128,
|
||||
"10.0.0.8:7612",
|
||||
Instant.parse("2026-06-22T01:02:03Z"));
|
||||
|
||||
List<VehicleEvent> events = handler.onDataChunkArchived(chunk);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.MediaMeta.class);
|
||||
VehicleEvent.MediaMeta media = (VehicleEvent.MediaMeta) event;
|
||||
assertThat(media.source()).isEqualTo(ProtocolId.JSATL12);
|
||||
assertThat(media.vin()).isEqualTo("unknown");
|
||||
assertThat(media.eventTime()).isEqualTo(Instant.parse("2026-06-22T01:02:03Z"));
|
||||
assertThat(media.archiveRef()).isEqualTo("file:///archive/jsatl12/2026/06/22/peer/file.bin");
|
||||
assertThat(media.sizeBytes()).isEqualTo(128);
|
||||
assertThat(media.mediaType()).isEqualTo("jsatl12-data-chunk");
|
||||
assertThat(media.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("archiveKey", "jsatl12/2026/06/22/peer/file.bin")
|
||||
.containsEntry("fileName", "ADAS_001.jpg")
|
||||
.containsEntry("fileOffset", "64")
|
||||
.containsEntry("declaredChunkSizeBytes", "128")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivedDataChunkKeepsResolvedVehicleIdentity() {
|
||||
Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler();
|
||||
Jsatl12ArchivedChunk chunk = new Jsatl12ArchivedChunk(
|
||||
"file:///archive/jsatl12/2026/06/22/peer/file.bin",
|
||||
"jsatl12/2026/06/22/peer/file.bin",
|
||||
"ADAS_001.jpg",
|
||||
0,
|
||||
128,
|
||||
128,
|
||||
"10.0.0.8:7612",
|
||||
Instant.parse("2026-06-22T01:02:03Z"),
|
||||
"123456789012",
|
||||
"LNVIN00000001212",
|
||||
true,
|
||||
"BOUND_PHONE");
|
||||
|
||||
List<VehicleEvent> events = handler.onDataChunkArchived(chunk);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.MediaMeta.class);
|
||||
VehicleEvent.MediaMeta media = (VehicleEvent.MediaMeta) event;
|
||||
assertThat(media.vin()).isEqualTo("LNVIN00000001212");
|
||||
assertThat(media.metadata())
|
||||
.containsEntry("vin", "LNVIN00000001212")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "true")
|
||||
.containsEntry("identitySource", "BOUND_PHONE");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJtMessageProducesPassthroughEvent() {
|
||||
Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler();
|
||||
byte[] raw = new byte[]{0x01, 0x02, 0x03};
|
||||
Jsatl12MalformedJtMessage malformed = new Jsatl12MalformedJtMessage(
|
||||
raw,
|
||||
"10.0.0.8:7612",
|
||||
"jt808 frame too short",
|
||||
Instant.parse("2026-06-22T01:02:03Z"));
|
||||
|
||||
List<VehicleEvent> events = handler.onMalformedJtMessage(malformed);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.JSATL12);
|
||||
assertThat(passthrough.vin()).isEqualTo("unknown");
|
||||
assertThat(passthrough.eventTime()).isEqualTo(Instant.parse("2026-06-22T01:02:03Z"));
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("peer", "10.0.0.8:7612")
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("source", "jsatl12-jt-message")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
assertThat(passthrough.data()).containsExactly(raw);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveFailureProducesPassthroughEvent() {
|
||||
Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler();
|
||||
byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64, 0, 0, 0, 1, 0x7f};
|
||||
Jsatl12ArchiveFailure failure = new Jsatl12ArchiveFailure(
|
||||
raw,
|
||||
"jsatl12/2026/06/22/peer/file.bin",
|
||||
1,
|
||||
"10.0.0.8:7612",
|
||||
"archive backend unavailable",
|
||||
Instant.parse("2026-06-22T01:02:03Z"));
|
||||
|
||||
List<VehicleEvent> events = handler.onArchiveFailure(failure);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.JSATL12);
|
||||
assertThat(passthrough.vin()).isEqualTo("unknown");
|
||||
assertThat(passthrough.eventTime()).isEqualTo(Instant.parse("2026-06-22T01:02:03Z"));
|
||||
assertThat(passthrough.data()).containsExactly(raw);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("archiveError", "true")
|
||||
.containsEntry("archiveKey", "jsatl12/2026/06/22/peer/file.bin")
|
||||
.containsEntry("chunkSizeBytes", "1")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedFrameProducesPassthroughEvent() {
|
||||
Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler();
|
||||
byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64, 0, 0, 0, 3, 0x01, 0x02, 0x03};
|
||||
Jsatl12MalformedFrame malformed = new Jsatl12MalformedFrame(
|
||||
raw,
|
||||
"10.0.0.8:7612",
|
||||
"malformed jsatl12 frame",
|
||||
Instant.parse("2026-06-22T01:02:03Z"));
|
||||
|
||||
List<VehicleEvent> events = handler.onMalformedFrame(malformed);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.JSATL12);
|
||||
assertThat(passthrough.vin()).isEqualTo("unknown");
|
||||
assertThat(passthrough.eventTime()).isEqualTo(Instant.parse("2026-06-22T01:02:03Z"));
|
||||
assertThat(passthrough.data()).containsExactly(raw);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("peer", "10.0.0.8:7612")
|
||||
.containsEntry("source", "jsatl12-frame")
|
||||
.containsEntry("frameError", "true")
|
||||
.containsEntry("frameErrorMessage", "malformed jsatl12 frame")
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("parseErrorMessage", "malformed jsatl12 frame")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jsatl12AttachmentTrackerTest {
|
||||
|
||||
@Test
|
||||
void completionAckRequestsOnlyMissingRangesUntilFileIsComplete() {
|
||||
Jsatl12AttachmentTracker tracker = new Jsatl12AttachmentTracker();
|
||||
String phone = "13800138000";
|
||||
String fileName = "ADAS_001.jpg";
|
||||
|
||||
tracker.registerAttachmentList(phone, t1210Body(fileName, 6));
|
||||
tracker.recordDataPacket(phone, dataPacket(fileName, 0, new byte[]{0x01, 0x02, 0x03}));
|
||||
|
||||
Jsatl12UploadCompletionAck first = tracker.completeUpload(phone, t1212Body(fileName, 2, 6));
|
||||
|
||||
assertThat(first.name()).isEqualTo(fileName);
|
||||
assertThat(first.type()).isEqualTo(2);
|
||||
assertThat(first.result()).isEqualTo(1);
|
||||
assertThat(first.missingRanges()).containsExactly(new Jsatl12UploadCompletionAck.Range(3, 3));
|
||||
|
||||
tracker.recordDataPacket(phone, dataPacket(fileName, 3, new byte[]{0x04, 0x05, 0x06}));
|
||||
|
||||
Jsatl12UploadCompletionAck second = tracker.completeUpload(phone, t1212Body(fileName, 2, 6));
|
||||
|
||||
assertThat(second.result()).isZero();
|
||||
assertThat(second.missingRanges()).isEmpty();
|
||||
assertThat(tracker.knownFiles(phone)).doesNotContain(fileName);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dataPacketCanBeParsedFromProductionLayout() {
|
||||
Jsatl12DataPacket packet = Jsatl12DataPacket.parse(dataPacket("BSD_002.bin", 12, new byte[]{0x01, 0x02}));
|
||||
|
||||
assertThat(packet.name()).isEqualTo("BSD_002.bin");
|
||||
assertThat(packet.offset()).isEqualTo(12);
|
||||
assertThat(packet.length()).isEqualTo(2);
|
||||
assertThat(packet.data()).containsExactly(0x01, 0x02);
|
||||
}
|
||||
|
||||
private static byte[] t1210Body(String fileName, long size) {
|
||||
byte[] name = fileName.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] out = new byte[57 + 1 + name.length + 4];
|
||||
putAscii(out, 0, 7, "DEV0001");
|
||||
putAscii(out, 7, 7, "DEV0001");
|
||||
out[13] = 0x26;
|
||||
out[14] = 0x06;
|
||||
out[15] = 0x22;
|
||||
out[16] = 0x10;
|
||||
out[17] = 0x30;
|
||||
out[18] = 0x45;
|
||||
out[19] = 0x01;
|
||||
out[20] = 0x01;
|
||||
putAscii(out, 23, 32, "ALARM-NO-001");
|
||||
out[55] = 0;
|
||||
out[56] = 1;
|
||||
out[57] = (byte) name.length;
|
||||
System.arraycopy(name, 0, out, 58, name.length);
|
||||
writeDword(out, 58 + name.length, size);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] t1212Body(String fileName, int type, long size) {
|
||||
byte[] name = fileName.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] out = new byte[1 + name.length + 1 + 4];
|
||||
out[0] = (byte) name.length;
|
||||
System.arraycopy(name, 0, out, 1, name.length);
|
||||
out[1 + name.length] = (byte) type;
|
||||
writeDword(out, 2 + name.length, size);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] dataPacket(String name, int offset, byte[] data) {
|
||||
byte[] out = new byte[62 + data.length];
|
||||
out[0] = 0x30;
|
||||
out[1] = 0x31;
|
||||
out[2] = 0x63;
|
||||
out[3] = 0x64;
|
||||
putAscii(out, 4, 50, name);
|
||||
writeDword(out, 54, offset);
|
||||
writeDword(out, 58, data.length);
|
||||
System.arraycopy(data, 0, out, 62, data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void putAscii(byte[] out, int offset, int len, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
System.arraycopy(bytes, 0, out, offset, Math.min(bytes.length, len));
|
||||
}
|
||||
|
||||
private static void writeDword(byte[] out, int offset, long value) {
|
||||
out[offset] = (byte) ((value >>> 24) & 0xFF);
|
||||
out[offset + 1] = (byte) ((value >>> 16) & 0xFF);
|
||||
out[offset + 2] = (byte) ((value >>> 8) & 0xFF);
|
||||
out[offset + 3] = (byte) (value & 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityBinding;
|
||||
import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12RawFrame;
|
||||
import com.lingniu.ingest.sink.archive.ArchiveStore;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jsatl12ChannelHandlerTest {
|
||||
|
||||
@Test
|
||||
void dataChunkArchivesPayloadAndDispatchesQueryableArchiveReference() {
|
||||
RecordingArchiveStore archive = new RecordingArchiveStore();
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler(archive, null, dispatched::add);
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
|
||||
channel.writeInbound(new Jsatl12RawFrame(
|
||||
Jsatl12RawFrame.Type.DATA_CHUNK,
|
||||
dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03})));
|
||||
|
||||
assertThat(archive.lastChunk).containsExactly(0x01, 0x02, 0x03);
|
||||
assertThat(dispatched).hasSize(1);
|
||||
RawFrame frame = dispatched.getFirst();
|
||||
assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12);
|
||||
assertThat(frame.command()).isEqualTo(Jsatl12MessageId.DATA_CHUNK_ARCHIVED);
|
||||
assertThat(frame.payload()).isInstanceOf(Jsatl12ArchivedChunk.class);
|
||||
Jsatl12ArchivedChunk chunk = (Jsatl12ArchivedChunk) frame.payload();
|
||||
assertThat(chunk.archiveRef()).isEqualTo("memory://" + archive.lastKey);
|
||||
assertThat(chunk.sizeBytes()).isEqualTo(3);
|
||||
assertThat(chunk.peer()).isEqualTo("unknown");
|
||||
assertThat(frame.sourceMeta())
|
||||
.containsEntry("source", "jsatl12-data-chunk")
|
||||
.containsEntry("fileName", "ADAS_001.jpg")
|
||||
.containsEntry("fileOffset", "0")
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dataChunkUsesAttachmentListPhoneForVehicleIdentity() {
|
||||
RecordingArchiveStore archive = new RecordingArchiveStore();
|
||||
Jsatl12AttachmentTracker tracker = new Jsatl12AttachmentTracker();
|
||||
String phone = "123456789012";
|
||||
tracker.registerAttachmentList(phone, t1210Body("ADAS_001.jpg", 3));
|
||||
InMemoryVehicleIdentityService identities = new InMemoryVehicleIdentityService();
|
||||
identities.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808,
|
||||
"LNVIN00000001212",
|
||||
phone,
|
||||
"",
|
||||
""));
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler(
|
||||
archive,
|
||||
null,
|
||||
tracker,
|
||||
identities,
|
||||
dispatched::add);
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
|
||||
channel.writeInbound(new Jsatl12RawFrame(
|
||||
Jsatl12RawFrame.Type.DATA_CHUNK,
|
||||
dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03})));
|
||||
|
||||
assertThat(dispatched).singleElement().satisfies(frame -> {
|
||||
assertThat(frame.sourceMeta())
|
||||
.containsEntry("phone", phone)
|
||||
.containsEntry("vin", "LNVIN00000001212")
|
||||
.containsEntry("identityResolved", "true")
|
||||
.containsEntry("identitySource", "BOUND_PHONE");
|
||||
assertThat(frame.payload()).isInstanceOf(Jsatl12ArchivedChunk.class);
|
||||
Jsatl12ArchivedChunk chunk = (Jsatl12ArchivedChunk) frame.payload();
|
||||
assertThat(chunk.phone()).isEqualTo(phone);
|
||||
assertThat(chunk.vin()).isEqualTo("LNVIN00000001212");
|
||||
assertThat(chunk.identityResolved()).isTrue();
|
||||
assertThat(chunk.identitySource()).isEqualTo("BOUND_PHONE");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void dataChunkStillArchivesWhenIdentityResolverFails() {
|
||||
RecordingArchiveStore archive = new RecordingArchiveStore();
|
||||
Jsatl12AttachmentTracker tracker = new Jsatl12AttachmentTracker();
|
||||
String phone = "123456789012";
|
||||
tracker.registerAttachmentList(phone, t1210Body("ADAS_001.jpg", 3));
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler(
|
||||
archive,
|
||||
null,
|
||||
tracker,
|
||||
lookup -> {
|
||||
throw new IllegalStateException("identity backend unavailable");
|
||||
},
|
||||
dispatched::add);
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] frameBytes = dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03});
|
||||
|
||||
channel.writeInbound(new Jsatl12RawFrame(Jsatl12RawFrame.Type.DATA_CHUNK, frameBytes));
|
||||
|
||||
assertThat(archive.lastChunk).containsExactly(0x01, 0x02, 0x03);
|
||||
assertThat(dispatched).singleElement().satisfies(frame -> {
|
||||
assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12);
|
||||
assertThat(frame.command()).isEqualTo(Jsatl12MessageId.DATA_CHUNK_ARCHIVED);
|
||||
assertThat(frame.rawBytes()).isNull();
|
||||
assertThat(frame.payload()).isInstanceOf(Jsatl12ArchivedChunk.class);
|
||||
Jsatl12ArchivedChunk chunk = (Jsatl12ArchivedChunk) frame.payload();
|
||||
assertThat(chunk.phone()).isEqualTo(phone);
|
||||
assertThat(chunk.vin()).isEqualTo("unknown");
|
||||
assertThat(chunk.identityResolved()).isFalse();
|
||||
assertThat(chunk.identitySource()).isEqualTo("UNKNOWN");
|
||||
assertThat(frame.sourceMeta())
|
||||
.containsEntry("phone", phone)
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable")
|
||||
.containsEntry("archiveRef", "memory://" + archive.lastKey);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void dataChunkArchiveFailureDispatchesPassthroughCandidateWithRawPayload() {
|
||||
FailingArchiveStore archive = new FailingArchiveStore();
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler(archive, null, dispatched::add);
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] frameBytes = dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03});
|
||||
|
||||
channel.writeInbound(new Jsatl12RawFrame(Jsatl12RawFrame.Type.DATA_CHUNK, frameBytes));
|
||||
|
||||
assertThat(dispatched).singleElement().satisfies(frame -> {
|
||||
assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12);
|
||||
assertThat(frame.command()).isEqualTo(Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED);
|
||||
assertThat(frame.rawBytes()).containsExactly(frameBytes);
|
||||
assertThat(frame.payload()).isInstanceOf(Jsatl12ArchiveFailure.class);
|
||||
Jsatl12ArchiveFailure failure = (Jsatl12ArchiveFailure) frame.payload();
|
||||
assertThat(failure.rawBytes()).containsExactly(frameBytes);
|
||||
assertThat(failure.sizeBytes()).isEqualTo(3);
|
||||
assertThat(failure.peer()).isEqualTo("unknown");
|
||||
assertThat(failure.errorMessage()).contains("archive backend unavailable");
|
||||
assertThat(frame.sourceMeta())
|
||||
.containsEntry("source", "jsatl12-data-chunk")
|
||||
.containsEntry("archiveError", "true")
|
||||
.containsEntry("chunkSizeBytes", "3")
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedFrameDispatchesPassthroughCandidateWithRawPayload() {
|
||||
RecordingArchiveStore archive = new RecordingArchiveStore();
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler(archive, null, dispatched::add);
|
||||
EmbeddedChannel channel = new EmbeddedChannel(handler);
|
||||
byte[] raw = new byte[]{
|
||||
0x30, 0x31, 0x63, 0x64,
|
||||
0x00, 0x00, 0x00, 0x03,
|
||||
0x01, 0x02, 0x03
|
||||
};
|
||||
|
||||
channel.writeInbound(new Jsatl12RawFrame(Jsatl12RawFrame.Type.MALFORMED, raw));
|
||||
|
||||
assertThat(dispatched).singleElement().satisfies(frame -> {
|
||||
assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12);
|
||||
assertThat(frame.command()).isEqualTo(Jsatl12MessageId.MALFORMED_FRAME);
|
||||
assertThat(frame.rawBytes()).containsExactly(raw);
|
||||
assertThat(frame.payload()).isInstanceOf(Jsatl12MalformedFrame.class);
|
||||
Jsatl12MalformedFrame malformed = (Jsatl12MalformedFrame) frame.payload();
|
||||
assertThat(malformed.rawBytes()).containsExactly(raw);
|
||||
assertThat(malformed.peer()).isEqualTo("unknown");
|
||||
assertThat(malformed.errorMessage()).contains("malformed jsatl12 frame");
|
||||
assertThat(frame.sourceMeta())
|
||||
.containsEntry("source", "jsatl12-frame")
|
||||
.containsEntry("frameError", "true")
|
||||
.containsEntry("frameSizeBytes", Integer.toString(raw.length))
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] dataPacket(String name, int offset, byte[] data) {
|
||||
byte[] out = new byte[62 + data.length];
|
||||
out[0] = 0x30;
|
||||
out[1] = 0x31;
|
||||
out[2] = 0x63;
|
||||
out[3] = 0x64;
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
System.arraycopy(nameBytes, 0, out, 4, nameBytes.length);
|
||||
writeDword(out, 54, offset);
|
||||
writeDword(out, 58, data.length);
|
||||
System.arraycopy(data, 0, out, 62, data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] t1210Body(String fileName, long size) {
|
||||
byte[] name = fileName.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] out = new byte[57 + 1 + name.length + 4];
|
||||
putAscii(out, 0, 7, "DEV0001");
|
||||
putAscii(out, 7, 7, "DEV0001");
|
||||
putAscii(out, 23, 32, "ALARM-NO-001");
|
||||
out[55] = 0;
|
||||
out[56] = 1;
|
||||
out[57] = (byte) name.length;
|
||||
System.arraycopy(name, 0, out, 58, name.length);
|
||||
writeDword(out, 58 + name.length, size);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void putAscii(byte[] out, int offset, int len, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
System.arraycopy(bytes, 0, out, offset, Math.min(bytes.length, len));
|
||||
}
|
||||
|
||||
private static void writeDword(byte[] out, int offset, long value) {
|
||||
out[offset] = (byte) ((value >>> 24) & 0xFF);
|
||||
out[offset + 1] = (byte) ((value >>> 16) & 0xFF);
|
||||
out[offset + 2] = (byte) ((value >>> 8) & 0xFF);
|
||||
out[offset + 3] = (byte) (value & 0xFF);
|
||||
}
|
||||
|
||||
private static final class RecordingArchiveStore implements ArchiveStore {
|
||||
private String lastKey;
|
||||
private byte[] lastChunk;
|
||||
|
||||
@Override
|
||||
public String put(String key, InputStream data, long length) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String append(String key, byte[] chunk) {
|
||||
this.lastKey = key;
|
||||
this.lastChunk = chunk;
|
||||
return "memory://" + key;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream get(String key) {
|
||||
return new ByteArrayInputStream(new byte[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long size(String key) throws IOException {
|
||||
return lastChunk == null ? 0 : lastChunk.length;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FailingArchiveStore implements ArchiveStore {
|
||||
@Override
|
||||
public String put(String key, InputStream data, long length) throws IOException {
|
||||
throw new IOException("archive backend unavailable");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String append(String key, byte[] chunk) throws IOException {
|
||||
throw new IOException("archive backend unavailable");
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream get(String key) {
|
||||
return new ByteArrayInputStream(new byte[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long size(String key) throws IOException {
|
||||
throw new IOException("archive backend unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.lingniu.ingest.protocol.jsatl12.inbound;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.codec.BcdCodec;
|
||||
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.Jt808Escape;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.codec.parser.PassthroughBodyParser;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Jsatl12JtMessageBridgeTest {
|
||||
|
||||
@Test
|
||||
void decodesJtMessageAndDispatchesAsJt808RawFrame() {
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
InMemoryVehicleIdentityService identities = new InMemoryVehicleIdentityService();
|
||||
identities.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808,
|
||||
"LNVIN00000001212",
|
||||
"123456789012",
|
||||
"",
|
||||
""));
|
||||
Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new HeartbeatBodyParser()))),
|
||||
identities,
|
||||
dispatched::add);
|
||||
|
||||
bridge.dispatch(buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 3), "127.0.0.1:7612");
|
||||
|
||||
assertThat(dispatched).hasSize(1);
|
||||
RawFrame frame = dispatched.get(0);
|
||||
assertThat(frame.protocolId()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(frame.command()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT);
|
||||
assertThat(frame.sourceMeta())
|
||||
.containsEntry("vin", "LNVIN00000001212")
|
||||
.containsEntry("phone", "123456789012")
|
||||
.containsEntry("identityResolved", "true")
|
||||
.containsEntry("identitySource", "BOUND_PHONE");
|
||||
assertThat(frame.payload()).isInstanceOf(com.lingniu.ingest.protocol.jt808.model.Jt808Message.class);
|
||||
assertThat(((com.lingniu.ingest.protocol.jt808.model.Jt808Message) frame.payload()).body())
|
||||
.isInstanceOf(Jt808Body.Heartbeat.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bridgeUsesExtendedJt808Parsers() {
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new PassthroughBodyParser()))),
|
||||
dispatched::add);
|
||||
byte[] body = new byte[]{0x41, 0x01, 0x02};
|
||||
|
||||
bridge.dispatch(buildFrame(Jt808MessageId.TERMINAL_PASSTHROUGH, "123456789012", 4, body),
|
||||
"127.0.0.1:7612");
|
||||
|
||||
assertThat(dispatched).hasSize(1);
|
||||
assertThat(dispatched.get(0).command()).isEqualTo(Jt808MessageId.TERMINAL_PASSTHROUGH);
|
||||
assertThat(((com.lingniu.ingest.protocol.jt808.model.Jt808Message) dispatched.get(0).payload()).body())
|
||||
.isInstanceOf(Jt808Body.Passthrough.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJtMessageIsDispatchedAsJsatl12PassthroughCandidate() {
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new HeartbeatBodyParser()))),
|
||||
dispatched::add);
|
||||
byte[] malformed = new byte[]{0x01, 0x02, 0x03};
|
||||
|
||||
bridge.dispatch(malformed, "127.0.0.1:7612");
|
||||
|
||||
assertThat(dispatched).hasSize(1);
|
||||
RawFrame frame = dispatched.getFirst();
|
||||
assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12);
|
||||
assertThat(frame.command()).isEqualTo(Jsatl12MessageId.MALFORMED_JT_MESSAGE);
|
||||
assertThat(frame.rawBytes()).containsExactly(malformed);
|
||||
assertThat(frame.sourceMeta())
|
||||
.containsEntry("peer", "127.0.0.1:7612")
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("source", "jsatl12-jt-message")
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
assertThat(frame.payload()).isInstanceOf(Jsatl12MalformedJtMessage.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityResolverFailureStillDispatchesDecodedJt808MessageWithUnknownIdentity() {
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new HeartbeatBodyParser()))),
|
||||
lookup -> {
|
||||
throw new IllegalStateException("identity backend unavailable");
|
||||
},
|
||||
dispatched::add);
|
||||
byte[] raw = buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 3);
|
||||
|
||||
bridge.dispatch(raw, "127.0.0.1:7612");
|
||||
|
||||
assertThat(dispatched).singleElement().satisfies(frame -> {
|
||||
assertThat(frame.protocolId()).isEqualTo(ProtocolId.JT808);
|
||||
assertThat(frame.command()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT);
|
||||
assertThat(frame.rawBytes()).isNotNull();
|
||||
assertThat(frame.payload()).isInstanceOf(com.lingniu.ingest.protocol.jt808.model.Jt808Message.class);
|
||||
assertThat(frame.sourceMeta())
|
||||
.containsEntry("source", "jsatl12-signaling")
|
||||
.containsEntry("peer", "127.0.0.1:7612")
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadCompletionReturnsT9212RetransmissionAck() {
|
||||
List<RawFrame> dispatched = new ArrayList<>();
|
||||
Jsatl12AttachmentTracker tracker = new Jsatl12AttachmentTracker();
|
||||
Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge(
|
||||
new Jt808MessageDecoder(new BodyParserRegistry(List.of())),
|
||||
tracker,
|
||||
dispatched::add);
|
||||
String phone = "123456789012";
|
||||
String fileName = "ADAS_001.jpg";
|
||||
|
||||
Optional<byte[]> t1210Ack = bridge.dispatch(buildFrame(0x1210, phone, 10, t1210Body(fileName, 6)),
|
||||
"127.0.0.1:7612");
|
||||
tracker.recordDataPacket(phone, dataPacket(fileName, 0, new byte[]{0x01, 0x02, 0x03}));
|
||||
Optional<byte[]> t9212 = bridge.dispatch(buildFrame(0x1212, phone, 11, t1212Body(fileName, 2, 6)),
|
||||
"127.0.0.1:7612");
|
||||
|
||||
assertThat(t1210Ack).isEmpty();
|
||||
assertThat(t9212).isPresent();
|
||||
byte[] unescaped = Jt808Escape.unescape(t9212.get(), 1, t9212.get().length - 2);
|
||||
var ack = new Jt808MessageDecoder(new BodyParserRegistry(List.of()))
|
||||
.decode(ByteBuffer.wrap(unescaped));
|
||||
assertThat(ack.header().messageId()).isEqualTo(0x9212);
|
||||
assertThat(ack.header().phone()).isEqualTo(phone);
|
||||
assertThat(ack.header().serialNo()).isEqualTo(11);
|
||||
Jt808Body.Raw raw = (Jt808Body.Raw) ack.body();
|
||||
byte[] body = raw.bytes();
|
||||
int nameLen = body[0] & 0xFF;
|
||||
assertThat(new String(body, 1, nameLen, StandardCharsets.UTF_8)).isEqualTo(fileName);
|
||||
assertThat(body[1 + nameLen] & 0xFF).isEqualTo(2);
|
||||
assertThat(body[2 + nameLen] & 0xFF).isEqualTo(1);
|
||||
assertThat(body[3 + nameLen] & 0xFF).isEqualTo(1);
|
||||
assertThat(readDword(body, 4 + nameLen)).isEqualTo(3);
|
||||
assertThat(readDword(body, 8 + nameLen)).isEqualTo(3);
|
||||
}
|
||||
|
||||
private static byte[] buildFrame(int msgId, String phone, int serial) {
|
||||
return buildFrame(msgId, phone, serial, new byte[0]);
|
||||
}
|
||||
|
||||
private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) {
|
||||
byte[] framed = Jt808FrameEncoder.encode(msgId, phone, serial, body);
|
||||
return Jt808Escape.unescape(framed, 1, framed.length - 2);
|
||||
}
|
||||
|
||||
private static void writeU16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static byte[] t1210Body(String fileName, long size) {
|
||||
byte[] name = fileName.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] out = new byte[57 + 1 + name.length + 4];
|
||||
putAscii(out, 0, 7, "DEV0001");
|
||||
putAscii(out, 7, 7, "DEV0001");
|
||||
putAscii(out, 23, 32, "ALARM-NO-001");
|
||||
out[55] = 0;
|
||||
out[56] = 1;
|
||||
out[57] = (byte) name.length;
|
||||
System.arraycopy(name, 0, out, 58, name.length);
|
||||
writeDword(out, 58 + name.length, size);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] t1212Body(String fileName, int type, long size) {
|
||||
byte[] name = fileName.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] out = new byte[1 + name.length + 1 + 4];
|
||||
out[0] = (byte) name.length;
|
||||
System.arraycopy(name, 0, out, 1, name.length);
|
||||
out[1 + name.length] = (byte) type;
|
||||
writeDword(out, 2 + name.length, size);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] dataPacket(String name, int offset, byte[] data) {
|
||||
byte[] out = new byte[62 + data.length];
|
||||
out[0] = 0x30;
|
||||
out[1] = 0x31;
|
||||
out[2] = 0x63;
|
||||
out[3] = 0x64;
|
||||
putAscii(out, 4, 50, name);
|
||||
writeDword(out, 54, offset);
|
||||
writeDword(out, 58, data.length);
|
||||
System.arraycopy(data, 0, out, 62, data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void putAscii(byte[] out, int offset, int len, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
System.arraycopy(bytes, 0, out, offset, Math.min(bytes.length, len));
|
||||
}
|
||||
|
||||
private static void writeDword(byte[] out, int offset, long value) {
|
||||
out[offset] = (byte) ((value >>> 24) & 0xFF);
|
||||
out[offset + 1] = (byte) ((value >>> 16) & 0xFF);
|
||||
out[offset + 2] = (byte) ((value >>> 8) & 0xFF);
|
||||
out[offset + 3] = (byte) (value & 0xFF);
|
||||
}
|
||||
|
||||
private static long readDword(byte[] raw, int offset) {
|
||||
return ((raw[offset] & 0xFFL) << 24)
|
||||
| ((raw[offset + 1] & 0xFFL) << 16)
|
||||
| ((raw[offset + 2] & 0xFFL) << 8)
|
||||
| (raw[offset + 3] & 0xFFL);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user