feat: make gb32960 archive history query production ready

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
com.lingniu.ingest.protocol.jsatl12.config.Jsatl12AutoConfiguration

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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