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,23 @@
package com.lingniu.ingest.protocol.jt1078.codec.parser;
import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId;
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import java.nio.ByteBuffer;
/** 0x1205 终端上传文件列表,摘要字段结构化,明细原始 body 保留。 */
public final class FileListBodyParser implements BodyParser {
@Override public int messageId() { return Jt1078MessageId.TERMINAL_FILE_LIST; }
@Override
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
byte[] raw = new byte[body.remaining()];
body.get(raw);
ByteBuffer b = ByteBuffer.wrap(raw);
int responseSerialNo = b.remaining() >= 2 ? b.getShort() & 0xFFFF : 0;
long fileCount = b.remaining() >= 4 ? b.getInt() & 0xFFFFFFFFL : 0L;
return new Jt808Body.FileList(responseSerialNo, fileCount, raw);
}
}

View File

@@ -0,0 +1,23 @@
package com.lingniu.ingest.protocol.jt1078.codec.parser;
import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId;
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import java.nio.ByteBuffer;
/** 0x1206 文件上传完成通知。 */
public final class FileUploadCompleteBodyParser implements BodyParser {
@Override public int messageId() { return Jt1078MessageId.TERMINAL_FILE_UPLOAD_DONE; }
@Override
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
byte[] raw = new byte[body.remaining()];
body.get(raw);
ByteBuffer b = ByteBuffer.wrap(raw);
int responseSerialNo = b.remaining() >= 2 ? b.getShort() & 0xFFFF : 0;
int result = b.hasRemaining() ? b.get() & 0xFF : 0;
return new Jt808Body.FileUploadComplete(responseSerialNo, result, raw);
}
}

View File

@@ -0,0 +1,39 @@
package com.lingniu.ingest.protocol.jt1078.codec.parser;
import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId;
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import java.nio.ByteBuffer;
/** 0x1003 终端上传音视频属性。 */
public final class MediaAttributesBodyParser implements BodyParser {
@Override public int messageId() { return Jt1078MessageId.TERMINAL_MEDIA_ATTRS_REPORT; }
@Override
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
byte[] raw = new byte[body.remaining()];
body.get(raw);
ByteBuffer b = ByteBuffer.wrap(raw);
return new Jt808Body.MediaAttributes(
readU8(b),
readU8(b),
readU8(b),
readU8(b),
readU16(b),
readU8(b) == 1,
readU8(b),
readU8(b),
readU8(b),
raw);
}
private static int readU8(ByteBuffer b) {
return b.hasRemaining() ? b.get() & 0xFF : 0;
}
private static int readU16(ByteBuffer b) {
return b.remaining() >= 2 ? b.getShort() & 0xFFFF : 0;
}
}

View File

@@ -0,0 +1,56 @@
package com.lingniu.ingest.protocol.jt1078.codec.parser;
import com.lingniu.ingest.codec.BcdCodec;
import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId;
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import java.nio.ByteBuffer;
/** 0x1005 终端上传乘客流量,字段结构化后仍按可查询透传事件入库。 */
public final class PassengerVolumeBodyParser implements BodyParser {
@Override public int messageId() { return Jt1078MessageId.TERMINAL_PASSENGER_VOLUME; }
@Override
public Jt808Body parse(Jt808Header header, ByteBuffer body) {
byte[] raw = new byte[body.remaining()];
body.get(raw);
ByteBuffer b = ByteBuffer.wrap(raw);
return new Jt808Body.PassengerVolume(
readU8(b),
readBcdTime(b),
readBcdTime(b),
readU16(b),
readU16(b),
raw);
}
private static int readU8(ByteBuffer b) {
return b.hasRemaining() ? b.get() & 0xFF : 0;
}
private static int readU16(ByteBuffer b) {
return b.remaining() >= 2 ? b.getShort() & 0xFFFF : 0;
}
private static String readBcdTime(ByteBuffer b) {
if (b.remaining() < 6) {
byte[] tail = new byte[b.remaining()];
b.get(tail);
return "";
}
byte[] time = new byte[6];
b.get(time);
String ts = BcdCodec.decode(time);
if (ts.length() != 12) {
return "";
}
return "20" + ts.substring(0, 2)
+ "-" + ts.substring(2, 4)
+ "-" + ts.substring(4, 6)
+ "T" + ts.substring(6, 8)
+ ":" + ts.substring(8, 10)
+ ":" + ts.substring(10, 12);
}
}

View File

@@ -0,0 +1,105 @@
package com.lingniu.ingest.protocol.jt1078.config;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.identity.VehicleIdentityResolver;
import com.lingniu.ingest.protocol.jt1078.codec.parser.FileUploadCompleteBodyParser;
import com.lingniu.ingest.protocol.jt1078.codec.parser.FileListBodyParser;
import com.lingniu.ingest.protocol.jt1078.codec.parser.MediaAttributesBodyParser;
import com.lingniu.ingest.protocol.jt1078.codec.parser.PassengerVolumeBodyParser;
import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MalformedRtpHandler;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpEventPublisher;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaArchiveService;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaStreamServer;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078UdpMediaStreamServer;
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
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.ConditionalOnExpression;
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;
/**
* jt1078 在 jt808 之前装配它自己的 Parser BeanSpring 会把它们注入到 jt808 的
* {@code BodyParserRegistry} 里,实现"同一个 Netty 端口复用、消息集热插拔"。
*/
@AutoConfiguration(before = Jt808AutoConfiguration.class, after = SinkArchiveAutoConfiguration.class)
@EnableConfigurationProperties(Jt1078Properties.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.jt1078", name = "enabled", havingValue = "true")
public class Jt1078AutoConfiguration {
@Bean
public BodyParser jt1078MediaAttributesBodyParser() {
return new MediaAttributesBodyParser();
}
@Bean
public BodyParser jt1078PassengerVolumeBodyParser() {
return new PassengerVolumeBodyParser();
}
@Bean
public BodyParser jt1078FileListBodyParser() {
return new FileListBodyParser();
}
@Bean
public BodyParser jt1078FileUploadCompleteBodyParser() {
return new FileUploadCompleteBodyParser();
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(ArchiveStore.class)
public Jt1078MediaArchiveService jt1078MediaArchiveService(ArchiveStore archive,
VehicleIdentityResolver identityResolver,
DisruptorEventBus eventBus,
Jt1078Properties props) {
return new Jt1078MediaArchiveService(
archive, identityResolver, eventBus::publish, props.getMediaStream().getSegmentSeconds());
}
@Bean
@ConditionalOnMissingBean
public Jt1078MalformedRtpEventPublisher jt1078MalformedRtpEventPublisher(VehicleIdentityResolver identityResolver,
Dispatcher dispatcher) {
return new Jt1078MalformedRtpEventPublisher(identityResolver, dispatcher::dispatch);
}
@Bean
@ConditionalOnMissingBean
public Jt1078MalformedRtpHandler jt1078MalformedRtpHandler(VehicleIdentityResolver identityResolver) {
return new Jt1078MalformedRtpHandler(identityResolver);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(Jt1078MediaArchiveService.class)
@ConditionalOnExpression("${lingniu.ingest.jt1078.media-stream.enabled:true} "
+ "&& ${lingniu.ingest.jt1078.media-stream.tcp-enabled:true}")
public Jt1078MediaStreamServer jt1078MediaStreamServer(Jt1078Properties props,
Jt1078MediaArchiveService archiveService,
Jt1078MalformedRtpEventPublisher malformedPublisher) {
Jt1078Properties.MediaStream media = props.getMediaStream();
return new Jt1078MediaStreamServer(
media.getPort(), media.getWorkerThreads(), media.getMaxPacketLength(), archiveService, malformedPublisher);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(Jt1078MediaArchiveService.class)
@ConditionalOnExpression("${lingniu.ingest.jt1078.media-stream.enabled:true} "
+ "&& ${lingniu.ingest.jt1078.media-stream.udp-enabled:false}")
public Jt1078UdpMediaStreamServer jt1078UdpMediaStreamServer(Jt1078Properties props,
Jt1078MediaArchiveService archiveService,
Jt1078MalformedRtpEventPublisher malformedPublisher) {
Jt1078Properties.MediaStream media = props.getMediaStream();
return new Jt1078UdpMediaStreamServer(
media.getUdpPort(), media.getWorkerThreads(), media.getMaxPacketLength(), archiveService, malformedPublisher);
}
}

View File

@@ -0,0 +1,43 @@
package com.lingniu.ingest.protocol.jt1078.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.jt1078")
public class Jt1078Properties {
private boolean enabled = false;
private MediaStream mediaStream = new MediaStream();
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public MediaStream getMediaStream() { return mediaStream; }
public void setMediaStream(MediaStream mediaStream) { this.mediaStream = mediaStream; }
public static class MediaStream {
private boolean enabled = true;
private boolean tcpEnabled = true;
private boolean udpEnabled = false;
private int port = 11078;
private int udpPort = 11078;
private int workerThreads = 0;
private int maxPacketLength = 1024 * 1024;
private int segmentSeconds = 300;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public boolean isTcpEnabled() { return tcpEnabled; }
public void setTcpEnabled(boolean tcpEnabled) { this.tcpEnabled = tcpEnabled; }
public boolean isUdpEnabled() { return udpEnabled; }
public void setUdpEnabled(boolean udpEnabled) { this.udpEnabled = udpEnabled; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
public int getUdpPort() { return udpPort; }
public void setUdpPort(int udpPort) { this.udpPort = udpPort; }
public int getWorkerThreads() { return workerThreads; }
public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; }
public int getMaxPacketLength() { return maxPacketLength; }
public void setMaxPacketLength(int maxPacketLength) { this.maxPacketLength = maxPacketLength; }
public int getSegmentSeconds() { return segmentSeconds; }
public void setSegmentSeconds(int segmentSeconds) { this.segmentSeconds = segmentSeconds; }
}
}

View File

@@ -0,0 +1,25 @@
package com.lingniu.ingest.protocol.jt1078.config;
import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MediaSignalHandler;
import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration;
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
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.context.annotation.Bean;
/**
* 1078 信令复用 JT808 解码和 mapper必须在 JT808 AutoConfiguration 之后判断。
*/
@AutoConfiguration(after = Jt808AutoConfiguration.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.jt1078", name = "enabled", havingValue = "true")
@ConditionalOnBean(Jt808EventMapper.class)
public class Jt1078SignalAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public Jt1078MediaSignalHandler jt1078MediaSignalHandler(Jt808EventMapper mapper) {
return new Jt1078MediaSignalHandler(mapper);
}
}

View File

@@ -0,0 +1,198 @@
package com.lingniu.ingest.protocol.jt1078.downlink;
import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId;
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
import java.io.ByteArrayOutputStream;
import java.nio.charset.Charset;
/** JT/T 1078 下行信令 body 构造器,外层帧仍由 JT808 通道发送。 */
public final class Jt1078Commands {
private static final Charset GBK = Charset.forName("GBK");
private Jt1078Commands() {}
/** 0x1003 查询终端音视频属性。 */
public static Jt808Commands.DownlinkCommand queryMediaAttributes() {
return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_QUERY_MEDIA_ATTRS, new byte[0]);
}
/** 0x9101 实时音视频传输请求。 */
public static Jt808Commands.DownlinkCommand realtimePlay(String ip,
int tcpPort,
int udpPort,
int channelNo,
int mediaType,
int streamType) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeStringWithU8Length(os, ip);
writeU16(os, tcpPort);
writeU16(os, udpPort);
writeU8(os, channelNo);
writeU8(os, mediaType);
writeU8(os, streamType);
return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_REALTIME_PLAY, os.toByteArray());
}
/** 0x9102 音视频实时传输控制。 */
public static Jt808Commands.DownlinkCommand realtimeControl(int channelNo,
int command,
int closeType,
int streamType) {
ByteArrayOutputStream os = new ByteArrayOutputStream(4);
writeU8(os, channelNo);
writeU8(os, command);
writeU8(os, closeType);
writeU8(os, streamType);
return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_REALTIME_CONTROL, os.toByteArray());
}
/** 0x9201 平台下发远程录像回放请求。 */
public static Jt808Commands.DownlinkCommand historyPlay(String ip,
int tcpPort,
int udpPort,
int channelNo,
int mediaType,
int streamType,
int storageType,
int playbackMode,
int playbackSpeed,
String startTime,
String endTime) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeStringWithU8Length(os, ip);
writeU16(os, tcpPort);
writeU16(os, udpPort);
writeU8(os, channelNo);
writeU8(os, mediaType);
writeU8(os, streamType);
writeU8(os, storageType);
writeU8(os, playbackMode);
writeU8(os, playbackSpeed);
writeBcdTime(os, startTime);
writeBcdTime(os, endTime);
return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_HISTORY_PLAY, os.toByteArray());
}
/** 0x9202 平台下发远程录像回放控制。 */
public static Jt808Commands.DownlinkCommand historyControl(int channelNo,
int playbackMode,
int playbackSpeed,
String playbackTime) {
ByteArrayOutputStream os = new ByteArrayOutputStream(9);
writeU8(os, channelNo);
writeU8(os, playbackMode);
writeU8(os, playbackSpeed);
writeBcdTime(os, playbackTime);
return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_HISTORY_CONTROL, os.toByteArray());
}
/** 0x9205 查询资源列表。 */
public static Jt808Commands.DownlinkCommand resourceSearch(int channelNo,
String startTime,
String endTime,
long warnBit1,
long warnBit2,
int mediaType,
int streamType,
int storageType) {
ByteArrayOutputStream os = new ByteArrayOutputStream(24);
writeU8(os, channelNo);
writeBcdTime(os, startTime);
writeBcdTime(os, endTime);
writeU32(os, warnBit1);
writeU32(os, warnBit2);
writeU8(os, mediaType);
writeU8(os, streamType);
writeU8(os, storageType);
return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_RESOURCE_SEARCH, os.toByteArray());
}
/** 0x9206 文件上传指令。 */
public static Jt808Commands.DownlinkCommand fileUpload(String ip,
int port,
String username,
String password,
String path,
int channelNo,
String startTime,
String endTime,
long warnBit1,
long warnBit2,
int mediaType,
int streamType,
int storageType,
int condition) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeStringWithU8Length(os, ip);
writeU16(os, port);
writeStringWithU8Length(os, username);
writeStringWithU8Length(os, password);
writeStringWithU8Length(os, path);
writeU8(os, channelNo);
writeBcdTime(os, startTime);
writeBcdTime(os, endTime);
writeU32(os, warnBit1);
writeU32(os, warnBit2);
writeU8(os, mediaType);
writeU8(os, streamType);
writeU8(os, storageType);
writeU8(os, condition);
return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_FILE_UPLOAD, os.toByteArray());
}
/** 0x9207 文件上传控制。 */
public static Jt808Commands.DownlinkCommand fileUploadControl(int responseSerialNo, int command) {
ByteArrayOutputStream os = new ByteArrayOutputStream(3);
writeU16(os, responseSerialNo);
writeU8(os, command);
return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_FILE_UPLOAD_CONTROL, os.toByteArray());
}
private static void writeStringWithU8Length(ByteArrayOutputStream os, String value) {
byte[] bytes = value == null ? new byte[0] : value.getBytes(GBK);
if (bytes.length > 255) {
throw new IllegalArgumentException("jt1078 string field too long: " + bytes.length);
}
writeU8(os, bytes.length);
os.write(bytes, 0, bytes.length);
}
private static void writeBcdTime(ByteArrayOutputStream os, String value) {
String digits = value == null ? "" : value.replaceAll("\\D", "");
if (digits.length() == 14 && digits.startsWith("20")) {
digits = digits.substring(2);
}
if (digits.isBlank()) {
digits = "000000000000";
}
if (digits.length() != 12) {
throw new IllegalArgumentException("jt1078 time must be YYMMDDHHMMSS or yyyy-MM-dd HH:mm:ss: " + value);
}
for (int i = 0; i < digits.length(); i += 2) {
int high = Character.digit(digits.charAt(i), 10);
int low = Character.digit(digits.charAt(i + 1), 10);
if (high < 0 || low < 0) {
throw new IllegalArgumentException("jt1078 time contains non-decimal digit: " + value);
}
os.write((high << 4) | low);
}
}
private static void writeU8(ByteArrayOutputStream os, int v) {
os.write(v & 0xFF);
}
private static void writeU16(ByteArrayOutputStream os, int v) {
os.write((v >> 8) & 0xFF);
os.write(v & 0xFF);
}
private static void writeU32(ByteArrayOutputStream os, long v) {
os.write((int) ((v >> 24) & 0xFF));
os.write((int) ((v >> 16) & 0xFF));
os.write((int) ((v >> 8) & 0xFF));
os.write((int) (v & 0xFF));
}
}

View File

@@ -0,0 +1,88 @@
package com.lingniu.ingest.protocol.jt1078.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.identity.InMemoryVehicleIdentityService;
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.jt1078.media.Jt1078MalformedRtpPacket;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaMessageId;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@ProtocolHandler(protocol = ProtocolId.JT1078)
public final class Jt1078MalformedRtpHandler {
private final VehicleIdentityResolver identityResolver;
public Jt1078MalformedRtpHandler() {
this(new InMemoryVehicleIdentityService());
}
public Jt1078MalformedRtpHandler(VehicleIdentityResolver identityResolver) {
this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver;
}
@MessageMapping(command = Jt1078MediaMessageId.MALFORMED_RTP, desc = "JT1078 RTP 入口坏包兜底")
@EventEmit(VehicleEvent.Passthrough.class)
public List<VehicleEvent> onMalformedRtp(Jt1078MalformedRtpPacket malformed) {
if (malformed == null) {
return List.of();
}
Instant eventTime = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt();
IdentityResolution identity = resolveIdentity(malformed);
Map<String, String> metadata = new HashMap<>();
metadata.put("transport", safe(malformed.transport()));
metadata.put("peer", safe(malformed.peer()));
metadata.put("sim", safe(malformed.sim()));
metadata.put("vin", identity.identity().vin());
metadata.put("identityResolved", Boolean.toString(identity.identity().resolved()));
metadata.put("identitySource", identity.identity().source().name());
if (identity.errorMessage() != null) {
metadata.put("identityError", "true");
metadata.put("identityErrorMessage", identity.errorMessage());
}
metadata.put("malformedRtp", "true");
metadata.put("parseError", "true");
metadata.put("parseErrorMessage", safe(malformed.reason()));
metadata.put("packetLength", Integer.toString(malformed.packetLength()));
metadata.put("reason", safe(malformed.reason()));
return List.of(new VehicleEvent.Passthrough(
UUID.randomUUID().toString(),
identity.identity().vin(),
ProtocolId.JT1078,
eventTime,
Instant.now(),
null,
Map.copyOf(metadata),
Jt1078MediaMessageId.MALFORMED_RTP,
malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes()));
}
private IdentityResolution resolveIdentity(Jt1078MalformedRtpPacket malformed) {
try {
return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup(
ProtocolId.JT1078, "", safe(malformed.sim()), "", "")), null);
} catch (RuntimeException e) {
return new IdentityResolution(
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
e.getMessage() == null ? "" : e.getMessage());
}
}
private static String safe(String value) {
return value == null ? "" : value;
}
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {
}
}

View File

@@ -0,0 +1,36 @@
package com.lingniu.ingest.protocol.jt1078.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.jt1078.model.Jt1078MessageId;
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import java.util.List;
/**
* JT/T 1078 信令复用 JT808 包头、连接与 dispatcher但路由独立放在 1078 模块内。
*/
@ProtocolHandler(protocol = ProtocolId.JT808)
public final class Jt1078MediaSignalHandler {
private final Jt808EventMapper mapper;
public Jt1078MediaSignalHandler(Jt808EventMapper mapper) {
this.mapper = mapper;
}
@MessageMapping(command = {
Jt1078MessageId.TERMINAL_MEDIA_ATTRS_REPORT,
Jt1078MessageId.TERMINAL_PASSENGER_VOLUME,
Jt1078MessageId.TERMINAL_FILE_LIST,
Jt1078MessageId.TERMINAL_FILE_UPLOAD_DONE
}, desc = "JT/T 1078 音视频信令")
@EventEmit({VehicleEvent.MediaMeta.class, VehicleEvent.Passthrough.class})
public List<VehicleEvent> onMediaSignal(Jt808Message msg) {
return mapper.toEvents(msg);
}
}

View File

@@ -0,0 +1,80 @@
package com.lingniu.ingest.protocol.jt1078.media;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.pipeline.RawFrame;
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
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 java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
public final class Jt1078MalformedRtpEventPublisher {
private final VehicleIdentityResolver identityResolver;
private final Consumer<RawFrame> dispatcher;
public Jt1078MalformedRtpEventPublisher(Consumer<RawFrame> dispatcher) {
this(new InMemoryVehicleIdentityService(), dispatcher);
}
public Jt1078MalformedRtpEventPublisher(VehicleIdentityResolver identityResolver,
Consumer<RawFrame> dispatcher) {
this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver;
this.dispatcher = dispatcher;
}
public void publish(Jt1078MalformedRtpPacket malformed) {
if (malformed == null) {
return;
}
Instant now = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt();
IdentityResolution identity = resolveIdentity(malformed);
Map<String, String> metadata = new HashMap<>();
metadata.put("transport", safe(malformed.transport()));
metadata.put("peer", safe(malformed.peer()));
metadata.put("sim", safe(malformed.sim()));
metadata.put("identityResolved", Boolean.toString(identity.identity().resolved()));
metadata.put("identitySource", identity.identity().source().name());
if (identity.errorMessage() != null) {
metadata.put("identityError", "true");
metadata.put("identityErrorMessage", identity.errorMessage());
}
metadata.put("malformedRtp", "true");
metadata.put("parseError", "true");
metadata.put("parseErrorMessage", safe(malformed.reason()));
metadata.put("packetLength", Integer.toString(malformed.packetLength()));
metadata.put("reason", safe(malformed.reason()));
metadata.put("vin", identity.identity().vin());
dispatcher.accept(new RawFrame(
ProtocolId.JT1078,
Jt1078MediaMessageId.MALFORMED_RTP,
0,
malformed,
malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes(),
Map.copyOf(metadata),
now
));
}
private IdentityResolution resolveIdentity(Jt1078MalformedRtpPacket malformed) {
try {
return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup(
ProtocolId.JT1078, "", malformed.sim(), "", "")), null);
} catch (RuntimeException e) {
return new IdentityResolution(
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
e.getMessage() == null ? "" : e.getMessage());
}
}
private static String safe(String value) {
return value == null ? "" : value;
}
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {}
}

View File

@@ -0,0 +1,22 @@
package com.lingniu.ingest.protocol.jt1078.media;
import java.time.Instant;
public record Jt1078MalformedRtpPacket(
String transport,
String peer,
String sim,
byte[] rawBytes,
int packetLength,
String reason,
Instant receivedAt
) {
public Jt1078MalformedRtpPacket(String transport,
String peer,
byte[] rawBytes,
int packetLength,
String reason,
Instant receivedAt) {
this(transport, peer, "", rawBytes, packetLength, reason, receivedAt);
}
}

View File

@@ -0,0 +1,194 @@
package com.lingniu.ingest.protocol.jt1078.media;
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.event.VehicleEvent;
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.sink.archive.ArchiveStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
public final class Jt1078MediaArchiveService {
private static final Logger log = LoggerFactory.getLogger(Jt1078MediaArchiveService.class);
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.systemDefault());
private final ArchiveStore archive;
private final VehicleIdentityResolver identityResolver;
private final Consumer<VehicleEvent> eventPublisher;
private final int segmentSeconds;
private final Cache<String, String> publishedSegmentRefs = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofHours(2))
.maximumSize(200_000)
.build();
public Jt1078MediaArchiveService(ArchiveStore archive,
VehicleIdentityResolver identityResolver,
Consumer<VehicleEvent> eventPublisher,
int segmentSeconds) {
this.archive = archive;
this.identityResolver = identityResolver;
this.eventPublisher = eventPublisher;
this.segmentSeconds = Math.max(60, segmentSeconds);
}
public void archive(Jt1078RtpPacket packet, String peer) {
if (packet == null || packet.payload() == null || packet.payload().length == 0) {
return;
}
IdentityResolution identity = resolveIdentity(packet);
String vin = identity.identity().vin();
long segment = segment(packet.timestamp());
String key = key(vin, packet, segment);
try {
String uri = archive.append(key, packet.payload());
long segmentSizeBytes = archiveSize(key, packet.payload().length);
publishOnce(packet, peer, identity, vin, segment, key, uri, segmentSizeBytes);
} catch (Exception e) {
log.warn("[jt1078] media archive failed sim={} channel={} sequence={} peer={}",
packet.sim(), packet.channelId(), packet.sequence(), peer, e);
publishArchiveFailure(packet, peer, identity, vin, segment, key, e);
}
}
private void publishArchiveFailure(Jt1078RtpPacket packet,
String peer,
IdentityResolution identity,
String vin,
long segment,
String archiveKey,
Exception error) {
Instant now = Instant.now();
Map<String, String> metadata = baseMetadata(packet, peer, identity);
metadata.put("sequence", Integer.toString(packet.sequence()));
metadata.put("segment", Long.toString(segment));
metadata.put("archiveKey", archiveKey == null ? "" : archiveKey);
metadata.put("archiveError", "true");
metadata.put("archiveErrorMessage", error.getMessage() == null ? "" : error.getMessage());
eventPublisher.accept(new VehicleEvent.Passthrough(
UUID.randomUUID().toString(),
vin,
ProtocolId.JT1078,
now,
now,
null,
Map.copyOf(metadata),
packet.dataType(),
packet.payload()));
}
private void publishOnce(Jt1078RtpPacket packet,
String peer,
IdentityResolution identity,
String vin,
long segment,
String archiveKey,
String uri,
long segmentSizeBytes) {
String segmentKey = archiveKey == null || archiveKey.isBlank()
? packet.sim() + ':' + packet.channelId() + ':' + packet.dataType() + ':' + segment
: archiveKey;
String old = publishedSegmentRefs.getIfPresent(segmentKey);
if (old != null) {
return;
}
publishedSegmentRefs.put(segmentKey, uri);
Instant now = Instant.now();
Map<String, String> metadata = baseMetadata(packet, peer, identity);
metadata.put("segment", Long.toString(segment));
metadata.put("sequence", Integer.toString(packet.sequence()));
metadata.put("segmentSizeBytes", Long.toString(segmentSizeBytes));
metadata.put("archiveKey", archiveKey == null ? "" : archiveKey);
eventPublisher.accept(new VehicleEvent.MediaMeta(
UUID.randomUUID().toString(),
vin,
ProtocolId.JT1078,
now,
now,
null,
Map.copyOf(metadata),
packet.sim() + "-" + packet.channelId() + "-" + segment,
mediaType(packet),
segmentSizeBytes,
uri));
}
private long archiveSize(String key, int fallbackBytes) {
try {
return archive.size(key);
} catch (Exception e) {
return Math.max(0, fallbackBytes);
}
}
private IdentityResolution resolveIdentity(Jt1078RtpPacket packet) {
try {
return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup(
ProtocolId.JT1078, "", packet.sim(), "", "")), null);
} catch (RuntimeException e) {
return new IdentityResolution(
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
e.getMessage() == null ? "" : e.getMessage());
}
}
private static Map<String, String> baseMetadata(Jt1078RtpPacket packet,
String peer,
IdentityResolution identity) {
Map<String, String> metadata = new LinkedHashMap<>();
metadata.put("sim", packet.sim());
metadata.put("channelId", Integer.toString(packet.channelId()));
metadata.put("dataType", Integer.toString(packet.dataType()));
metadata.put("packetType", Integer.toString(packet.packetType()));
metadata.put("peer", peer == null ? "" : peer);
metadata.put("vin", identity.identity().vin());
metadata.put("identityResolved", Boolean.toString(identity.identity().resolved()));
metadata.put("identitySource", identity.identity().source().name());
if (identity.errorMessage() != null) {
metadata.put("identityError", "true");
metadata.put("identityErrorMessage", identity.errorMessage());
}
return metadata;
}
private long segment(long timestamp) {
long seconds = timestamp > 10_000_000_000L ? timestamp / 1000 : timestamp;
return seconds - Math.floorMod(seconds, segmentSeconds);
}
private static String key(String vin, Jt1078RtpPacket packet, long segment) {
String day = DAY_FMT.format(Instant.ofEpochSecond(Math.max(0, segment)));
String safeVin = safe(vin);
return "jt1078/" + day + "/" + safeVin + "/ch-" + packet.channelId()
+ "/dt-" + packet.dataType() + "/" + segment + ".media";
}
private static String mediaType(Jt1078RtpPacket packet) {
return "rtp,dataType=" + packet.dataType()
+ ",packetType=" + packet.packetType()
+ ",channel=" + packet.channelId();
}
private static String safe(String raw) {
if (raw == null || raw.isBlank()) {
return "unknown";
}
return raw.replaceAll("[^A-Za-z0-9_.-]", "_");
}
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {
}
}

View File

@@ -0,0 +1,9 @@
package com.lingniu.ingest.protocol.jt1078.media;
public final class Jt1078MediaMessageId {
/** 内部消息JT1078 RTP 入口坏包,走 Dispatcher 统一 RawArchive + Passthrough 链路。 */
public static final int MALFORMED_RTP = 0x72747065; // "rtpe"
private Jt1078MediaMessageId() {}
}

View File

@@ -0,0 +1,36 @@
package com.lingniu.ingest.protocol.jt1078.media;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import java.net.InetSocketAddress;
public final class Jt1078MediaStreamHandler extends SimpleChannelInboundHandler<Object> {
private final Jt1078MediaArchiveService archiveService;
private final Jt1078MalformedRtpEventPublisher malformedPublisher;
public Jt1078MediaStreamHandler(Jt1078MediaArchiveService archiveService,
Jt1078MalformedRtpEventPublisher malformedPublisher) {
this.archiveService = archiveService;
this.malformedPublisher = malformedPublisher;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof Jt1078RtpPacket packet) {
archiveService.archive(packet, addr(ctx));
} else if (msg instanceof Jt1078MalformedRtpPacket malformed) {
malformedPublisher.publish(malformed);
} else {
ctx.fireChannelRead(msg);
}
}
private static String addr(ChannelHandlerContext ctx) {
if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) {
return i.getAddress().getHostAddress() + ":" + i.getPort();
}
return "unknown";
}
}

View File

@@ -0,0 +1,83 @@
package com.lingniu.ingest.protocol.jt1078.media;
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;
public final class Jt1078MediaStreamServer implements InitializingBean, DisposableBean {
private static final Logger log = LoggerFactory.getLogger(Jt1078MediaStreamServer.class);
private final int port;
private final int workerThreads;
private final int maxPacketLength;
private final Jt1078MediaArchiveService archiveService;
private final Jt1078MalformedRtpEventPublisher malformedPublisher;
private EventLoopGroup boss;
private EventLoopGroup workers;
private Channel serverChannel;
public Jt1078MediaStreamServer(int port,
int workerThreads,
int maxPacketLength,
Jt1078MediaArchiveService archiveService,
Jt1078MalformedRtpEventPublisher malformedPublisher) {
this.port = port;
this.workerThreads = workerThreads;
this.maxPacketLength = maxPacketLength;
this.archiveService = archiveService;
this.malformedPublisher = malformedPublisher;
}
@Override
public void afterPropertiesSet() throws Exception {
ThreadFactory bossTf = r -> new Thread(r, "jt1078-media-boss");
ThreadFactory wkTf = r -> new Thread(r, "jt1078-media-worker");
this.boss = new NioEventLoopGroup(1, bossTf);
this.workers = new NioEventLoopGroup(
workerThreads == 0 ? Runtime.getRuntime().availableProcessors() : workerThreads,
wkTf);
ServerBootstrap b = new ServerBootstrap();
b.group(boss, workers)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast("rtp", new Jt1078RtpPacketDecoder(maxPacketLength))
.addLast("archive", new Jt1078MediaStreamHandler(archiveService, malformedPublisher));
}
});
this.serverChannel = b.bind(port).sync().channel();
log.info("[jt1078] media stream server listening on :{}", port);
}
@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("[jt1078] media stream server stopped");
}
}
}

View File

@@ -0,0 +1,27 @@
package com.lingniu.ingest.protocol.jt1078.media;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.ChannelHandlerContext;
public final class Jt1078ReceivedMediaStreamHandler extends SimpleChannelInboundHandler<Object> {
private final Jt1078MediaArchiveService archiveService;
private final Jt1078MalformedRtpEventPublisher malformedPublisher;
public Jt1078ReceivedMediaStreamHandler(Jt1078MediaArchiveService archiveService,
Jt1078MalformedRtpEventPublisher malformedPublisher) {
this.archiveService = archiveService;
this.malformedPublisher = malformedPublisher;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof Jt1078ReceivedRtpPacket received) {
archiveService.archive(received.packet(), received.peer());
} else if (msg instanceof Jt1078MalformedRtpPacket malformed) {
malformedPublisher.publish(malformed);
} else {
ctx.fireChannelRead(msg);
}
}
}

View File

@@ -0,0 +1,10 @@
package com.lingniu.ingest.protocol.jt1078.media;
public record Jt1078ReceivedRtpPacket(
Jt1078RtpPacket packet,
String peer
) {
public Jt1078ReceivedRtpPacket {
peer = peer == null ? "" : peer;
}
}

View File

@@ -0,0 +1,11 @@
package com.lingniu.ingest.protocol.jt1078.media;
public record Jt1078RtpPacket(
int sequence,
String sim,
int channelId,
int dataType,
int packetType,
long timestamp,
byte[] payload
) {}

View File

@@ -0,0 +1,83 @@
package com.lingniu.ingest.protocol.jt1078.media;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.time.Instant;
import java.util.List;
/**
* JT/T 1078 TCP RTP 包解码器。
*
* <p>包头固定魔数 0x30316364header 固定 28 字节,随后是 bodyLength 指定的媒体负载。
*/
public final class Jt1078RtpPacketDecoder extends ByteToMessageDecoder {
private final int maxPacketLength;
public Jt1078RtpPacketDecoder(int maxPacketLength) {
this.maxPacketLength = maxPacketLength;
}
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
while (true) {
if (in.readableBytes() < Jt1078RtpPacketParser.HEADER_LEN) {
return;
}
int frameStart = findMagic(in);
if (frameStart < 0) {
in.skipBytes(Math.max(0, in.readableBytes() - 3));
return;
}
if (frameStart > in.readerIndex()) {
in.skipBytes(frameStart - in.readerIndex());
}
if (in.readableBytes() < Jt1078RtpPacketParser.HEADER_LEN) {
return;
}
int length = in.getUnsignedShort(in.readerIndex() + 26);
int packetLength = Jt1078RtpPacketParser.HEADER_LEN + length;
if (packetLength > maxPacketLength) {
String sim = Jt1078RtpPacketParser.peekSim(in);
int available = in.readableBytes();
int rawLength = Math.min(available, packetLength);
byte[] raw = new byte[rawLength];
in.readBytes(raw);
out.add(new Jt1078MalformedRtpPacket(
"tcp",
peer(ctx),
sim,
raw,
packetLength,
"jt1078 rtp packet too large: " + packetLength,
Instant.now()));
return;
}
if (in.readableBytes() < packetLength) {
return;
}
ByteBuf packet = in.readSlice(packetLength);
out.add(Jt1078RtpPacketParser.parse(packet));
}
}
private static String peer(ChannelHandlerContext ctx) {
if (ctx == null || ctx.channel() == null || ctx.channel().remoteAddress() == null) {
return "";
}
return ctx.channel().remoteAddress().toString();
}
private static int findMagic(ByteBuf in) {
int start = in.readerIndex();
int end = in.writerIndex() - 3;
for (int i = start; i < end; i++) {
if (in.getInt(i) == Jt1078RtpPacketParser.MAGIC) {
return i;
}
}
return -1;
}
}

View File

@@ -0,0 +1,59 @@
package com.lingniu.ingest.protocol.jt1078.media;
import io.netty.buffer.ByteBuf;
import io.netty.handler.codec.CorruptedFrameException;
final class Jt1078RtpPacketParser {
static final int MAGIC = 0x30316364;
static final int HEADER_LEN = 28;
private Jt1078RtpPacketParser() {}
static Jt1078RtpPacket parse(ByteBuf in) {
int magic = in.readInt();
if (magic != MAGIC) {
throw new CorruptedFrameException("jt1078 invalid magic: 0x" + Integer.toHexString(magic));
}
int sequence = in.readUnsignedShort();
String sim = readBcd(in, 6);
int channelId = in.readUnsignedByte();
int dataAndPacketType = in.readUnsignedByte();
int dataType = (dataAndPacketType >>> 4) & 0x0F;
int packetType = dataAndPacketType & 0x0F;
long timestamp = in.readLong();
in.skipBytes(4);
int bodyLength = in.readUnsignedShort();
if (bodyLength > in.readableBytes()) {
throw new CorruptedFrameException("jt1078 body length exceeds readable bytes: "
+ bodyLength + " > " + in.readableBytes());
}
byte[] payload = new byte[bodyLength];
in.readBytes(payload);
return new Jt1078RtpPacket(sequence, sim, channelId, dataType, packetType, timestamp, payload);
}
static String peekSim(ByteBuf in) {
if (in == null || in.readableBytes() < HEADER_LEN || in.getInt(in.readerIndex()) != MAGIC) {
return "";
}
StringBuilder sb = new StringBuilder(12);
int simOffset = in.readerIndex() + 6;
for (int i = 0; i < 6; i++) {
int b = in.getUnsignedByte(simOffset + i);
sb.append((b >>> 4) & 0x0F);
sb.append(b & 0x0F);
}
return sb.toString();
}
private static String readBcd(ByteBuf in, int len) {
StringBuilder sb = new StringBuilder(len * 2);
for (int i = 0; i < len; i++) {
int b = in.readUnsignedByte();
sb.append((b >>> 4) & 0x0F);
sb.append(b & 0x0F);
}
return sb.toString();
}
}

View File

@@ -0,0 +1,78 @@
package com.lingniu.ingest.protocol.jt1078.media;
import io.netty.bootstrap.Bootstrap;
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.DatagramChannel;
import io.netty.channel.socket.nio.NioDatagramChannel;
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;
public final class Jt1078UdpMediaStreamServer implements InitializingBean, DisposableBean {
private static final Logger log = LoggerFactory.getLogger(Jt1078UdpMediaStreamServer.class);
private final int port;
private final int workerThreads;
private final int maxPacketLength;
private final Jt1078MediaArchiveService archiveService;
private final Jt1078MalformedRtpEventPublisher malformedPublisher;
private EventLoopGroup workers;
private Channel serverChannel;
public Jt1078UdpMediaStreamServer(int port,
int workerThreads,
int maxPacketLength,
Jt1078MediaArchiveService archiveService,
Jt1078MalformedRtpEventPublisher malformedPublisher) {
this.port = port;
this.workerThreads = workerThreads;
this.maxPacketLength = maxPacketLength;
this.archiveService = archiveService;
this.malformedPublisher = malformedPublisher;
}
@Override
public void afterPropertiesSet() throws Exception {
ThreadFactory wkTf = r -> new Thread(r, "jt1078-media-udp-worker");
int threads = workerThreads == 0 ? Runtime.getRuntime().availableProcessors() : workerThreads;
this.workers = new NioEventLoopGroup(threads, wkTf);
Bootstrap b = new Bootstrap();
b.group(workers)
.channel(NioDatagramChannel.class)
.option(ChannelOption.SO_BROADCAST, false)
.option(ChannelOption.SO_RCVBUF, Math.max(1024 * 1024, maxPacketLength * 2))
.handler(new ChannelInitializer<DatagramChannel>() {
@Override
protected void initChannel(DatagramChannel ch) {
ch.pipeline()
.addLast("rtp", new Jt1078UdpRtpPacketDecoder(maxPacketLength))
.addLast("archive", new Jt1078ReceivedMediaStreamHandler(archiveService, malformedPublisher));
}
});
this.serverChannel = b.bind(port).sync().channel();
log.info("[jt1078] udp media stream server listening on :{} workers={} maxPacketLength={}",
port, threads, maxPacketLength);
}
@Override
public void destroy() {
try {
if (serverChannel != null) serverChannel.close().sync();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (workers != null) workers.shutdownGracefully();
log.info("[jt1078] udp media stream server stopped");
}
}
}

View File

@@ -0,0 +1,59 @@
package com.lingniu.ingest.protocol.jt1078.media;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.socket.DatagramPacket;
import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.MessageToMessageDecoder;
import java.net.InetSocketAddress;
import java.time.Instant;
import java.util.List;
public final class Jt1078UdpRtpPacketDecoder extends MessageToMessageDecoder<DatagramPacket> {
private final int maxPacketLength;
public Jt1078UdpRtpPacketDecoder(int maxPacketLength) {
this.maxPacketLength = maxPacketLength;
}
@Override
protected void decode(ChannelHandlerContext ctx, DatagramPacket msg, List<Object> out) throws Exception {
int packetLength = msg.content().readableBytes();
if (packetLength > maxPacketLength) {
out.add(malformed(msg, packetLength, "jt1078 udp rtp packet too large: " + packetLength));
return;
}
if (packetLength < Jt1078RtpPacketParser.HEADER_LEN) {
out.add(malformed(msg, packetLength, "jt1078 udp rtp packet too short: " + packetLength));
return;
}
try {
out.add(new Jt1078ReceivedRtpPacket(
Jt1078RtpPacketParser.parse(msg.content().slice()),
peer(msg.sender())));
} catch (CorruptedFrameException | IndexOutOfBoundsException e) {
out.add(malformed(msg, packetLength, e.getMessage()));
}
}
private static Jt1078MalformedRtpPacket malformed(DatagramPacket msg, int packetLength, String reason) {
byte[] raw = new byte[Math.max(0, msg.content().readableBytes())];
msg.content().getBytes(msg.content().readerIndex(), raw);
return new Jt1078MalformedRtpPacket(
"udp",
peer(msg.sender()),
Jt1078RtpPacketParser.peekSim(msg.content().slice()),
raw,
packetLength,
reason == null ? "" : reason,
Instant.now());
}
private static String peer(InetSocketAddress sender) {
if (sender == null) {
return "";
}
return sender.getAddress().getHostAddress() + ":" + sender.getPort();
}
}

View File

@@ -0,0 +1,21 @@
package com.lingniu.ingest.protocol.jt1078.model;
/** JT/T 1078 消息 ID 常量(核心信令子集)。 */
public final class Jt1078MessageId {
private Jt1078MessageId() {}
public static final int TERMINAL_MEDIA_ATTRS_REPORT = 0x1003;
public static final int TERMINAL_PASSENGER_VOLUME = 0x1005;
public static final int TERMINAL_FILE_LIST = 0x1205;
public static final int TERMINAL_FILE_UPLOAD_DONE = 0x1206;
public static final int PLATFORM_QUERY_MEDIA_ATTRS = 0x1003;
public static final int PLATFORM_REALTIME_PLAY = 0x9101;
public static final int PLATFORM_REALTIME_CONTROL = 0x9102;
public static final int PLATFORM_HISTORY_PLAY = 0x9201;
public static final int PLATFORM_HISTORY_CONTROL = 0x9202;
public static final int PLATFORM_RESOURCE_SEARCH = 0x9205;
public static final int PLATFORM_FILE_UPLOAD = 0x9206;
public static final int PLATFORM_FILE_UPLOAD_CONTROL = 0x9207;
}

View File

@@ -0,0 +1,2 @@
com.lingniu.ingest.protocol.jt1078.config.Jt1078AutoConfiguration
com.lingniu.ingest.protocol.jt1078.config.Jt1078SignalAutoConfiguration

View File

@@ -0,0 +1,93 @@
package com.lingniu.ingest.protocol.jt1078;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId;
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Jt1078EventMappingTest {
private final Jt808EventMapper mapper = new Jt808EventMapper(new InMemoryVehicleIdentityService());
private final Jt808Header header = new Jt808Header(
0x1003, 0, 0, false, Jt808Header.ProtocolVersion.V2013, "13800138000", 1, 0, 0);
@Test
void mediaAttributesProduceQueryableMediaMetaEvent() {
Jt808Body.MediaAttributes attrs = new Jt808Body.MediaAttributes(1, 2, 3, 4, 320, true, 98, 4, 8, new byte[]{1, 2});
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(header, attrs));
assertThat(events).hasSize(1);
assertThat(events.getFirst()).isInstanceOf(VehicleEvent.MediaMeta.class);
VehicleEvent.MediaMeta event = (VehicleEvent.MediaMeta) events.getFirst();
assertThat(event.mediaType()).contains("videoEncoding=98");
assertThat(event.metadata()).containsEntry("jt1078Message", "mediaAttributes");
}
@Test
void fileUploadCompleteProducesPassthroughEventWithResultMetadata() {
Jt808Body.FileUploadComplete done = new Jt808Body.FileUploadComplete(0x1234, 0, new byte[]{0x12, 0x34, 0});
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(header, done));
assertThat(events).hasSize(1);
assertThat(events.getFirst()).isInstanceOf(VehicleEvent.Passthrough.class);
VehicleEvent.Passthrough event = (VehicleEvent.Passthrough) events.getFirst();
assertThat(event.metadata()).containsEntry("jt1078Message", "fileUploadComplete");
assertThat(event.metadata()).containsEntry("uploadResult", "0");
}
@Test
void fileListProducesPassthroughEventWithQueryableSummaryMetadata() {
Jt808Header fileListHeader = new Jt808Header(
Jt1078MessageId.TERMINAL_FILE_LIST, 0, 0, false,
Jt808Header.ProtocolVersion.V2013, "13800138000", 1, 0, 0);
byte[] raw = new byte[]{0x12, 0x34, 0, 0, 0, 2};
Jt808Body.FileList fileList = new Jt808Body.FileList(0x1234, 2, raw);
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(fileListHeader, fileList));
assertThat(events).hasSize(1);
assertThat(events.getFirst()).isInstanceOf(VehicleEvent.Passthrough.class);
VehicleEvent.Passthrough event = (VehicleEvent.Passthrough) events.getFirst();
assertThat(event.passthroughType()).isEqualTo(Jt1078MessageId.TERMINAL_FILE_LIST);
assertThat(event.data()).containsExactly(raw);
assertThat(event.metadata())
.containsEntry("jt1078Message", "fileList")
.containsEntry("responseSerialNo", "4660")
.containsEntry("fileCount", "2");
}
@Test
void passengerVolumeProducesPassthroughEventWithQueryableMetadata() {
Jt808Header passengerHeader = new Jt808Header(
Jt1078MessageId.TERMINAL_PASSENGER_VOLUME, 0, 0, false,
Jt808Header.ProtocolVersion.V2013, "13800138000", 1, 0, 0);
byte[] raw = new byte[]{1, 2, 3};
Jt808Body.PassengerVolume passengerVolume = new Jt808Body.PassengerVolume(
2, "2026-06-22T15:30:00", "2026-06-22T16:00:00", 21, 3, raw);
List<VehicleEvent> events = mapper.toEvents(new Jt808Message(passengerHeader, passengerVolume));
assertThat(events).hasSize(1);
assertThat(events.getFirst()).isInstanceOf(VehicleEvent.Passthrough.class);
VehicleEvent.Passthrough event = (VehicleEvent.Passthrough) events.getFirst();
assertThat(event.passthroughType()).isEqualTo(Jt1078MessageId.TERMINAL_PASSENGER_VOLUME);
assertThat(event.data()).containsExactly(raw);
assertThat(event.metadata())
.containsEntry("jt1078Message", "passengerVolume")
.containsEntry("channelId", "2")
.containsEntry("startTime", "2026-06-22T15:30:00")
.containsEntry("endTime", "2026-06-22T16:00:00")
.containsEntry("passengerGetOn", "21")
.containsEntry("passengerGetOff", "3");
}
}

View File

@@ -0,0 +1,108 @@
package com.lingniu.ingest.protocol.jt1078;
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.jt1078.media.Jt1078MalformedRtpEventPublisher;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaMessageId;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
import static org.assertj.core.api.Assertions.assertThat;
class Jt1078MalformedRtpEventPublisherTest {
@Test
void malformedRtpPacketProducesRawFrameForUnifiedArchiveAndPassthrough() {
ArrayList<RawFrame> frames = new ArrayList<>();
Jt1078MalformedRtpEventPublisher publisher = new Jt1078MalformedRtpEventPublisher(frames::add);
Instant receivedAt = Instant.parse("2026-06-22T08:30:00Z");
byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64};
publisher.publish(new Jt1078MalformedRtpPacket(
"udp", "10.0.0.8:1078", raw, raw.length, "jt1078 udp rtp packet too short: 4", receivedAt));
assertThat(frames).hasSize(1);
RawFrame frame = frames.getFirst();
assertThat(frame.protocolId()).isEqualTo(ProtocolId.JT1078);
assertThat(frame.command()).isEqualTo(Jt1078MediaMessageId.MALFORMED_RTP);
assertThat(frame.payload()).isInstanceOf(Jt1078MalformedRtpPacket.class);
assertThat(frame.rawBytes()).containsExactly(raw);
assertThat(frame.receivedAt()).isEqualTo(receivedAt);
assertThat(frame.sourceMeta()).containsEntry("transport", "udp")
.containsEntry("peer", "10.0.0.8:1078")
.containsEntry("malformedRtp", "true")
.containsEntry("parseError", "true")
.containsEntry("parseErrorMessage", "jt1078 udp rtp packet too short: 4")
.containsEntry("packetLength", "4")
.containsEntry("reason", "jt1078 udp rtp packet too short: 4");
}
@Test
void malformedRtpPacketUsesSharedIdentityWhenSimIsAvailable() {
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
identity.bind(new VehicleIdentityBinding(
ProtocolId.JT1078, "LNVIN107800000001", "013812345678", "", ""));
ArrayList<RawFrame> frames = new ArrayList<>();
Jt1078MalformedRtpEventPublisher publisher = new Jt1078MalformedRtpEventPublisher(identity, frames::add);
Instant receivedAt = Instant.parse("2026-06-22T08:30:00Z");
byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64, 0, 1};
publisher.publish(new Jt1078MalformedRtpPacket(
"tcp",
"10.0.0.8:1078",
"013812345678",
raw,
128,
"jt1078 rtp packet too large: 128",
receivedAt));
assertThat(frames).hasSize(1);
RawFrame frame = frames.getFirst();
assertThat(frame.sourceMeta())
.containsEntry("vin", "LNVIN107800000001")
.containsEntry("sim", "013812345678")
.containsEntry("identityResolved", "true")
.containsEntry("identitySource", "BOUND_PHONE");
}
@Test
void identityResolverFailureStillPublishesMalformedRtpWithOriginalPayload() {
ArrayList<RawFrame> frames = new ArrayList<>();
Jt1078MalformedRtpEventPublisher publisher = new Jt1078MalformedRtpEventPublisher(
lookup -> {
throw new IllegalStateException("identity backend unavailable");
},
frames::add);
Instant receivedAt = Instant.parse("2026-06-22T08:30:00Z");
byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64, 0, 1};
publisher.publish(new Jt1078MalformedRtpPacket(
"tcp",
"10.0.0.8:1078",
"013812345678",
raw,
raw.length,
"jt1078 rtp packet too short: 6",
receivedAt));
assertThat(frames).singleElement().satisfies(frame -> {
assertThat(frame.protocolId()).isEqualTo(ProtocolId.JT1078);
assertThat(frame.command()).isEqualTo(Jt1078MediaMessageId.MALFORMED_RTP);
assertThat(frame.rawBytes()).containsExactly(raw);
assertThat(frame.sourceMeta())
.containsEntry("vin", "unknown")
.containsEntry("sim", "013812345678")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "UNKNOWN")
.containsEntry("identityError", "true")
.containsEntry("identityErrorMessage", "identity backend unavailable")
.containsEntry("parseError", "true")
.containsEntry("parseErrorMessage", "jt1078 rtp packet too short: 6");
});
}
}

View File

@@ -0,0 +1,120 @@
package com.lingniu.ingest.protocol.jt1078;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.RawArchiveKeys;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
import com.lingniu.ingest.identity.VehicleIdentityBinding;
import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MalformedRtpHandler;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Jt1078MalformedRtpHandlerTest {
@Test
void malformedRtpRawFramePayloadMapsToPassthroughWithDiagnostics() {
byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64};
Jt1078MalformedRtpHandler handler = new Jt1078MalformedRtpHandler(new InMemoryVehicleIdentityService());
List<VehicleEvent> events = handler.onMalformedRtp(new Jt1078MalformedRtpPacket(
"udp",
"10.0.0.8:1078",
raw,
raw.length,
"jt1078 udp rtp packet too short: 4",
Instant.parse("2026-06-22T08:30:00Z")));
assertThat(events).singleElement().satisfies(event -> {
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
assertThat(passthrough.source()).isEqualTo(ProtocolId.JT1078);
assertThat(passthrough.vin()).isEqualTo("unknown");
assertThat(passthrough.eventTime()).isEqualTo(Instant.parse("2026-06-22T08:30:00Z"));
assertThat(passthrough.data()).containsExactly(raw);
assertThat(passthrough.metadata())
.containsEntry("transport", "udp")
.containsEntry("peer", "10.0.0.8:1078")
.containsEntry("vin", "unknown")
.containsEntry("malformedRtp", "true")
.containsEntry("parseError", "true")
.containsEntry("parseErrorMessage", "jt1078 udp rtp packet too short: 4")
.containsEntry("packetLength", "4");
});
}
@Test
void malformedRtpHandlerResolvesVinBySim() {
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
identity.bind(new VehicleIdentityBinding(
ProtocolId.JT1078, "LNVIN107800000001", "013812345678", "", ""));
Jt1078MalformedRtpHandler handler = new Jt1078MalformedRtpHandler(identity);
List<VehicleEvent> events = handler.onMalformedRtp(new Jt1078MalformedRtpPacket(
"tcp",
"10.0.0.8:1078",
"013812345678",
new byte[]{0x30, 0x31, 0x63, 0x64},
128,
"jt1078 rtp packet too large: 128",
Instant.parse("2026-06-22T08:30:00Z")));
assertThat(events).singleElement().satisfies(event -> {
assertThat(event.vin()).isEqualTo("LNVIN107800000001");
assertThat(event.metadata())
.containsEntry("vin", "LNVIN107800000001")
.containsEntry("sim", "013812345678")
.containsEntry("identityResolved", "true")
.containsEntry("identitySource", "BOUND_PHONE");
});
}
@Test
void identityResolverFailureStillProducesPassthroughWithRawPayload() {
Jt1078MalformedRtpHandler handler = new Jt1078MalformedRtpHandler(lookup -> {
throw new IllegalStateException("identity backend unavailable");
});
byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64};
List<VehicleEvent> events = handler.onMalformedRtp(new Jt1078MalformedRtpPacket(
"udp",
"10.0.0.8:1078",
"013812345678",
raw,
raw.length,
"jt1078 udp rtp packet too short: 4",
Instant.parse("2026-06-22T08:30:00Z")));
assertThat(events).singleElement().satisfies(event -> {
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
assertThat(passthrough.vin()).isEqualTo("unknown");
assertThat(passthrough.data()).containsExactly(raw);
assertThat(passthrough.metadata())
.containsEntry("vin", "unknown")
.containsEntry("sim", "013812345678")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "UNKNOWN")
.containsEntry("identityError", "true")
.containsEntry("identityErrorMessage", "identity backend unavailable")
.containsEntry("parseError", "true");
});
}
@Test
void dispatcherEnrichmentCanAttachRawArchiveReferenceToMalformedRtpPassthrough() {
byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64};
Jt1078MalformedRtpHandler handler = new Jt1078MalformedRtpHandler(new InMemoryVehicleIdentityService());
VehicleEvent.Passthrough event = (VehicleEvent.Passthrough) handler.onMalformedRtp(new Jt1078MalformedRtpPacket(
"udp", "10.0.0.8:1078", raw, raw.length, "bad", Instant.parse("2026-06-22T08:30:00Z")))
.getFirst();
assertThat(event.metadata()).doesNotContainKey(RawArchiveKeys.META_URI);
assertThat(event.data()).containsExactly(raw);
}
}

View File

@@ -0,0 +1,198 @@
package com.lingniu.ingest.protocol.jt1078;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
import com.lingniu.ingest.identity.VehicleIdentityBinding;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaArchiveService;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078RtpPacket;
import com.lingniu.ingest.sink.archive.ArchiveStore;
import com.lingniu.ingest.sink.archive.LocalArchiveStore;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Jt1078MediaArchiveServiceTest {
@TempDir
Path tempDir;
@Test
void archivesPayloadBySegmentAndPublishesMediaMetaOncePerSegment() throws Exception {
LocalArchiveStore store = new LocalArchiveStore(tempDir.toString());
List<VehicleEvent> published = new ArrayList<>();
Jt1078MediaArchiveService service = new Jt1078MediaArchiveService(
store, new InMemoryVehicleIdentityService(), published::add, 300);
Jt1078RtpPacket p1 = new Jt1078RtpPacket(1, "013800138000", 3, 0, 0, 1_000L, new byte[]{1, 2});
Jt1078RtpPacket p2 = new Jt1078RtpPacket(2, "013800138000", 3, 0, 1, 1_100L, new byte[]{3, 4, 5});
service.archive(p1, "127.0.0.1:12345");
service.archive(p2, "127.0.0.1:12345");
assertThat(published).hasSize(1);
VehicleEvent.MediaMeta meta = (VehicleEvent.MediaMeta) published.getFirst();
assertThat(meta.source()).isEqualTo(ProtocolId.JT1078);
assertThat(meta.vin()).isEqualTo("013800138000");
assertThat(meta.sizeBytes()).isEqualTo(2);
assertThat(meta.archiveRef()).startsWith("file://");
assertThat(meta.metadata())
.containsEntry("vin", "013800138000")
.containsEntry("channelId", "3")
.containsEntry("segment", "900")
.containsEntry("segmentSizeBytes", "2")
.containsEntry("archiveKey", "jt1078/1970/01/01/013800138000/ch-3/dt-0/900.media");
Path archived = Path.of(meta.archiveRef().substring("file://".length()));
assertThat(Files.readAllBytes(archived)).containsExactly(1, 2, 3, 4, 5);
}
@Test
void archiveMediaMetaUsesResolvedInternalVinInMetadataAndArchiveKey() throws Exception {
LocalArchiveStore store = new LocalArchiveStore(tempDir.toString());
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
identity.bind(new VehicleIdentityBinding(
ProtocolId.JT1078, "LNVIN107800000009", "013800138000", "", ""));
List<VehicleEvent> published = new ArrayList<>();
Jt1078MediaArchiveService service = new Jt1078MediaArchiveService(
store, identity, published::add, 300);
service.archive(new Jt1078RtpPacket(
1, "013800138000", 3, 0, 0, 1_000L, new byte[]{1, 2}), "127.0.0.1:12345");
assertThat(published).singleElement()
.isInstanceOf(VehicleEvent.MediaMeta.class)
.satisfies(event -> {
assertThat(event.vin()).isEqualTo("LNVIN107800000009");
assertThat(event.metadata())
.containsEntry("vin", "LNVIN107800000009")
.containsEntry("sim", "013800138000")
.containsEntry("identityResolved", "true")
.containsEntry("archiveKey", "jt1078/1970/01/01/LNVIN107800000009/ch-3/dt-0/900.media");
});
}
@Test
void identityChangeWithinSameSegmentPublishesMediaMetaForNewArchiveKey() throws Exception {
LocalArchiveStore store = new LocalArchiveStore(tempDir.toString());
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
List<VehicleEvent> published = new ArrayList<>();
Jt1078MediaArchiveService service = new Jt1078MediaArchiveService(
store, identity, published::add, 300);
service.archive(new Jt1078RtpPacket(
1, "013800138000", 3, 0, 0, 1_000L, new byte[]{1, 2}), "127.0.0.1:12345");
identity.bind(new VehicleIdentityBinding(
ProtocolId.JT1078, "LNVIN107800000009", "013800138000", "", ""));
service.archive(new Jt1078RtpPacket(
2, "013800138000", 3, 0, 1, 1_100L, new byte[]{3, 4}), "127.0.0.1:12345");
assertThat(published)
.extracting(event -> event.metadata().get("archiveKey"))
.containsExactly(
"jt1078/1970/01/01/013800138000/ch-3/dt-0/900.media",
"jt1078/1970/01/01/LNVIN107800000009/ch-3/dt-0/900.media");
assertThat(published)
.extracting(VehicleEvent::vin)
.containsExactly("013800138000", "LNVIN107800000009");
}
@Test
void identityResolverFailureStillArchivesPayloadAndPublishesMediaMeta() throws Exception {
LocalArchiveStore store = new LocalArchiveStore(tempDir.toString());
List<VehicleEvent> published = new ArrayList<>();
Jt1078MediaArchiveService service = new Jt1078MediaArchiveService(
store,
lookup -> {
throw new IllegalStateException("identity backend unavailable");
},
published::add,
300);
service.archive(new Jt1078RtpPacket(
1, "013800138000", 3, 0, 0, 1_000L, new byte[]{1, 2}), "127.0.0.1:12345");
assertThat(published).singleElement()
.isInstanceOf(VehicleEvent.MediaMeta.class)
.satisfies(event -> {
VehicleEvent.MediaMeta media = (VehicleEvent.MediaMeta) event;
assertThat(media.vin()).isEqualTo("unknown");
assertThat(media.archiveRef()).startsWith("file://");
assertThat(media.metadata())
.containsEntry("vin", "unknown")
.containsEntry("sim", "013800138000")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "UNKNOWN")
.containsEntry("identityError", "true")
.containsEntry("identityErrorMessage", "identity backend unavailable")
.containsEntry("archiveKey", "jt1078/1970/01/01/unknown/ch-3/dt-0/900.media");
Path archived = Path.of(media.archiveRef().substring("file://".length()));
assertThat(Files.readAllBytes(archived)).containsExactly(1, 2);
});
}
@Test
void archiveFailurePublishesPassthroughErrorEventWithOriginalPayload() {
List<VehicleEvent> published = new ArrayList<>();
Jt1078MediaArchiveService service = new Jt1078MediaArchiveService(
new FailingArchiveStore(), new InMemoryVehicleIdentityService(), published::add, 300);
Jt1078RtpPacket packet = new Jt1078RtpPacket(
9, "013800138000", 4, 2, 1, 1_000L, new byte[]{9, 8, 7});
service.archive(packet, "127.0.0.1:9000");
assertThat(published).singleElement()
.satisfies(event -> {
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
assertThat(passthrough.source()).isEqualTo(ProtocolId.JT1078);
assertThat(passthrough.vin()).isEqualTo("013800138000");
assertThat(passthrough.passthroughType()).isEqualTo(2);
assertThat(passthrough.data()).containsExactly(9, 8, 7);
assertThat(passthrough.metadata())
.containsEntry("archiveError", "true")
.containsEntry("vin", "013800138000")
.containsEntry("sim", "013800138000")
.containsEntry("channelId", "4")
.containsEntry("sequence", "9")
.containsEntry("peer", "127.0.0.1:9000")
.containsEntry("segment", "900")
.containsEntry("archiveKey", "jt1078/1970/01/01/013800138000/ch-4/dt-2/900.media");
});
}
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) throws IOException {
throw new IOException("archive backend unavailable");
}
@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,87 @@
package com.lingniu.ingest.protocol.jt1078;
import com.lingniu.ingest.protocol.jt1078.codec.parser.FileUploadCompleteBodyParser;
import com.lingniu.ingest.protocol.jt1078.codec.parser.FileListBodyParser;
import com.lingniu.ingest.protocol.jt1078.codec.parser.MediaAttributesBodyParser;
import com.lingniu.ingest.protocol.jt1078.codec.parser.PassengerVolumeBodyParser;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import org.junit.jupiter.api.Test;
import java.nio.ByteBuffer;
import static org.assertj.core.api.Assertions.assertThat;
class Jt1078ParserTest {
private final Jt808Header header = new Jt808Header(
0, 0, 0, false, Jt808Header.ProtocolVersion.V2013, "13800138000", 1, 0, 0);
@Test
void parsesMediaAttributesAsSemanticBody() {
byte[] raw = new byte[]{1, 2, 3, 4, 0x01, 0x40, 1, 98, 4, 8};
Jt808Body body = new MediaAttributesBodyParser().parse(header, ByteBuffer.wrap(raw));
assertThat(body).isInstanceOf(Jt808Body.MediaAttributes.class);
Jt808Body.MediaAttributes attrs = (Jt808Body.MediaAttributes) body;
assertThat(attrs.inputAudioEncoding()).isEqualTo(1);
assertThat(attrs.inputAudioChannels()).isEqualTo(2);
assertThat(attrs.inputAudioSampleRate()).isEqualTo(3);
assertThat(attrs.inputAudioBitDepth()).isEqualTo(4);
assertThat(attrs.audioFrameLength()).isEqualTo(320);
assertThat(attrs.audioOutputSupported()).isTrue();
assertThat(attrs.videoEncoding()).isEqualTo(98);
assertThat(attrs.maxAudioChannels()).isEqualTo(4);
assertThat(attrs.maxVideoChannels()).isEqualTo(8);
assertThat(attrs.raw()).containsExactly(raw);
}
@Test
void parsesFileUploadCompleteAsSemanticBody() {
byte[] raw = new byte[]{0x12, 0x34, 1, 2, 3, 4};
Jt808Body body = new FileUploadCompleteBodyParser().parse(header, ByteBuffer.wrap(raw));
assertThat(body).isInstanceOf(Jt808Body.FileUploadComplete.class);
Jt808Body.FileUploadComplete done = (Jt808Body.FileUploadComplete) body;
assertThat(done.responseSerialNo()).isEqualTo(0x1234);
assertThat(done.result()).isEqualTo(1);
assertThat(done.raw()).containsExactly(raw);
}
@Test
void parsesFileListSummaryAsSemanticBody() {
byte[] raw = new byte[]{0x12, 0x34, 0x00, 0x00, 0x00, 0x02, 9, 8, 7};
Jt808Body body = new FileListBodyParser().parse(header, ByteBuffer.wrap(raw));
assertThat(body).isInstanceOf(Jt808Body.FileList.class);
Jt808Body.FileList list = (Jt808Body.FileList) body;
assertThat(list.responseSerialNo()).isEqualTo(0x1234);
assertThat(list.fileCount()).isEqualTo(2);
assertThat(list.raw()).containsExactly(raw);
}
@Test
void parsesPassengerVolumeAsSemanticBody() {
byte[] raw = new byte[]{
0x02,
0x26, 0x06, 0x22, 0x15, 0x30, 0x00,
0x26, 0x06, 0x22, 0x16, 0x00, 0x00,
0x00, 0x15,
0x00, 0x03
};
Jt808Body body = new PassengerVolumeBodyParser().parse(header, ByteBuffer.wrap(raw));
assertThat(body).isInstanceOf(Jt808Body.PassengerVolume.class);
Jt808Body.PassengerVolume passengerVolume = (Jt808Body.PassengerVolume) body;
assertThat(passengerVolume.channelId()).isEqualTo(2);
assertThat(passengerVolume.startTime()).isEqualTo("2026-06-22T15:30:00");
assertThat(passengerVolume.endTime()).isEqualTo("2026-06-22T16:00:00");
assertThat(passengerVolume.passengerGetOn()).isEqualTo(21);
assertThat(passengerVolume.passengerGetOff()).isEqualTo(3);
assertThat(passengerVolume.raw()).containsExactly(raw);
}
}

View File

@@ -0,0 +1,91 @@
package com.lingniu.ingest.protocol.jt1078;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078RtpPacket;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078RtpPacketDecoder;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket;
import io.netty.buffer.Unpooled;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class Jt1078RtpPacketDecoderTest {
@Test
void decodesCompleteTcpRtpPacketAndKeepsPayloadOnly() {
EmbeddedChannel channel = new EmbeddedChannel(new Jt1078RtpPacketDecoder(1024));
channel.writeInbound(Unpooled.wrappedBuffer(packet(7, "013800138000", 2, 3, 1, 123456789L,
new byte[]{1, 2, 3, 4})));
Jt1078RtpPacket packet = channel.readInbound();
assertThat(packet.sequence()).isEqualTo(7);
assertThat(packet.sim()).isEqualTo("013800138000");
assertThat(packet.channelId()).isEqualTo(2);
assertThat(packet.dataType()).isEqualTo(3);
assertThat(packet.packetType()).isEqualTo(1);
assertThat(packet.timestamp()).isEqualTo(123456789L);
assertThat(packet.payload()).containsExactly(1, 2, 3, 4);
}
@Test
void waitsForFragmentedPayloadBeforeEmittingPacket() {
byte[] packet = packet(8, "013800138000", 1, 0, 0, 1L, new byte[]{9, 8, 7});
EmbeddedChannel channel = new EmbeddedChannel(new Jt1078RtpPacketDecoder(1024));
channel.writeInbound(Unpooled.wrappedBuffer(packet, 0, packet.length - 1));
assertThat((Object) channel.readInbound()).isNull();
channel.writeInbound(Unpooled.wrappedBuffer(packet, packet.length - 1, 1));
Jt1078RtpPacket decoded = channel.readInbound();
assertThat(decoded.payload()).containsExactly(9, 8, 7);
}
@Test
void oversizedTcpRtpPacketEmitsMalformedCandidateInsteadOfClosingPipeline() {
byte[] packet = packet(9, "013800138000", 1, 0, 0, 1L, new byte[]{1, 2, 3, 4});
EmbeddedChannel channel = new EmbeddedChannel(new Jt1078RtpPacketDecoder(30));
channel.writeInbound(Unpooled.wrappedBuffer(packet));
Jt1078MalformedRtpPacket malformed = channel.readInbound();
assertThat(malformed.transport()).isEqualTo("tcp");
assertThat(malformed.sim()).isEqualTo("013800138000");
assertThat(malformed.reason()).contains("too large");
assertThat(malformed.rawBytes()).containsExactly(packet);
assertThat(malformed.packetLength()).isEqualTo(packet.length);
assertThat((Object) channel.readInbound()).isNull();
}
static byte[] packet(int sequence, String sim, int channelId, int dataType, int packetType,
long timestamp, byte[] payload) {
byte[] out = new byte[28 + payload.length];
out[0] = 0x30;
out[1] = 0x31;
out[2] = 0x63;
out[3] = 0x64;
out[4] = (byte) (sequence >>> 8);
out[5] = (byte) sequence;
byte[] bcd = bcd(sim);
System.arraycopy(bcd, 0, out, 6, bcd.length);
out[12] = (byte) channelId;
out[13] = (byte) ((dataType << 4) | (packetType & 0x0F));
for (int i = 0; i < 8; i++) {
out[14 + i] = (byte) (timestamp >>> (56 - i * 8));
}
out[26] = (byte) (payload.length >>> 8);
out[27] = (byte) payload.length;
System.arraycopy(payload, 0, out, 28, payload.length);
return out;
}
private static byte[] bcd(String digits) {
byte[] out = new byte[6];
for (int i = 0; i < out.length; i++) {
int hi = Character.digit(digits.charAt(i * 2), 10);
int lo = Character.digit(digits.charAt(i * 2 + 1), 10);
out[i] = (byte) ((hi << 4) | lo);
}
return out;
}
}

View File

@@ -0,0 +1,89 @@
package com.lingniu.ingest.protocol.jt1078;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078RtpPacket;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078ReceivedRtpPacket;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078UdpRtpPacketDecoder;
import io.netty.buffer.Unpooled;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.channel.socket.DatagramPacket;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import static org.assertj.core.api.Assertions.assertThat;
class Jt1078UdpRtpPacketDecoderTest {
@Test
void decodesSingleUdpDatagramRtpPacket() {
EmbeddedChannel channel = new EmbeddedChannel(new Jt1078UdpRtpPacketDecoder(1024));
InetSocketAddress sender = new InetSocketAddress("127.0.0.1", 19000);
InetSocketAddress recipient = new InetSocketAddress("127.0.0.1", 10780);
channel.writeInbound(new DatagramPacket(Unpooled.wrappedBuffer(Jt1078RtpPacketDecoderTest.packet(
9, "013800138000", 4, 0, 2, 123456789L, new byte[]{5, 6, 7})), recipient, sender));
Jt1078ReceivedRtpPacket received = channel.readInbound();
Jt1078RtpPacket decoded = received.packet();
assertThat(received.peer()).isEqualTo("127.0.0.1:19000");
assertThat(decoded.sequence()).isEqualTo(9);
assertThat(decoded.sim()).isEqualTo("013800138000");
assertThat(decoded.channelId()).isEqualTo(4);
assertThat(decoded.packetType()).isEqualTo(2);
assertThat(decoded.payload()).containsExactly(5, 6, 7);
}
@Test
void shortUdpDatagramEmitsMalformedCandidateWithPeerAndRawBytes() {
EmbeddedChannel channel = new EmbeddedChannel(new Jt1078UdpRtpPacketDecoder(1024));
InetSocketAddress sender = new InetSocketAddress("127.0.0.1", 19000);
InetSocketAddress recipient = new InetSocketAddress("127.0.0.1", 10780);
channel.writeInbound(new DatagramPacket(Unpooled.wrappedBuffer(new byte[]{1, 2, 3}), recipient, sender));
Jt1078MalformedRtpPacket malformed = channel.readInbound();
assertThat(malformed.transport()).isEqualTo("udp");
assertThat(malformed.peer()).isEqualTo("127.0.0.1:19000");
assertThat(malformed.reason()).contains("too short");
assertThat(malformed.rawBytes()).containsExactly(1, 2, 3);
assertThat(malformed.packetLength()).isEqualTo(3);
}
@Test
void invalidUdpDatagramEmitsMalformedCandidateInsteadOfThrowing() {
EmbeddedChannel channel = new EmbeddedChannel(new Jt1078UdpRtpPacketDecoder(1024));
InetSocketAddress sender = new InetSocketAddress("127.0.0.1", 19000);
InetSocketAddress recipient = new InetSocketAddress("127.0.0.1", 10780);
byte[] invalid = Jt1078RtpPacketDecoderTest.packet(
9, "013800138000", 4, 0, 2, 123456789L, new byte[]{5, 6, 7});
invalid[0] = 0x12;
channel.writeInbound(new DatagramPacket(Unpooled.wrappedBuffer(invalid), recipient, sender));
Jt1078MalformedRtpPacket malformed = channel.readInbound();
assertThat(malformed.transport()).isEqualTo("udp");
assertThat(malformed.peer()).isEqualTo("127.0.0.1:19000");
assertThat(malformed.reason()).contains("invalid magic");
assertThat(malformed.rawBytes()).containsExactly(invalid);
assertThat(malformed.packetLength()).isEqualTo(invalid.length);
}
@Test
void oversizedUdpDatagramKeepsSimForIdentityResolution() {
EmbeddedChannel channel = new EmbeddedChannel(new Jt1078UdpRtpPacketDecoder(30));
InetSocketAddress sender = new InetSocketAddress("127.0.0.1", 19000);
InetSocketAddress recipient = new InetSocketAddress("127.0.0.1", 10780);
byte[] oversized = Jt1078RtpPacketDecoderTest.packet(
10, "013800138000", 4, 0, 2, 123456789L, new byte[]{5, 6, 7, 8});
channel.writeInbound(new DatagramPacket(Unpooled.wrappedBuffer(oversized), recipient, sender));
Jt1078MalformedRtpPacket malformed = channel.readInbound();
assertThat(malformed.transport()).isEqualTo("udp");
assertThat(malformed.sim()).isEqualTo("013800138000");
assertThat(malformed.reason()).contains("too large");
assertThat(malformed.rawBytes()).containsExactly(oversized);
assertThat(malformed.packetLength()).isEqualTo(oversized.length);
}
}

View File

@@ -0,0 +1,218 @@
package com.lingniu.ingest.protocol.jt1078.config;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.config.IngestCoreAutoConfiguration;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.sink.EventSink;
import com.lingniu.ingest.identity.VehicleIdentity;
import com.lingniu.ingest.identity.VehicleIdentityResolver;
import com.lingniu.ingest.identity.VehicleIdentitySource;
import com.lingniu.ingest.identity.config.VehicleIdentityAutoConfiguration;
import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MediaSignalHandler;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpEventPublisher;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaArchiveService;
import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration;
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
import com.lingniu.ingest.session.config.SessionCoreAutoConfiguration;
import com.lingniu.ingest.sink.archive.ArchiveStore;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.assertj.core.api.Assertions.assertThat;
class Jt1078AutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
IngestCoreAutoConfiguration.class,
Jt1078AutoConfiguration.class,
Jt1078SignalAutoConfiguration.class))
.withUserConfiguration(SupportBeans.class)
.withPropertyValues(
"lingniu.ingest.jt1078.enabled=true",
"lingniu.ingest.jt1078.media-stream.enabled=false");
@Test
void mediaStreamDefaultsToLegacyProductionPort() {
Jt1078Properties props = new Jt1078Properties();
assertThat(props.getMediaStream().getPort()).isEqualTo(11078);
assertThat(props.getMediaStream().getUdpPort()).isEqualTo(11078);
}
@Test
void mediaArchiveStartsWithoutJt808Mapper() {
runner.run(context -> {
assertThat(context).hasSingleBean(Jt1078MediaArchiveService.class);
assertThat(context).doesNotHaveBean(Jt808EventMapper.class);
assertThat(context).doesNotHaveBean(Jt1078MediaSignalHandler.class);
});
}
@Test
void mediaSignalHandlerStartsWhenJt808IsEnabled() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
Jt1078AutoConfiguration.class,
Jt1078SignalAutoConfiguration.class,
Jt808AutoConfiguration.class,
IngestCoreAutoConfiguration.class,
SessionCoreAutoConfiguration.class,
VehicleIdentityAutoConfiguration.class))
.withPropertyValues(
"lingniu.ingest.jt1078.enabled=true",
"lingniu.ingest.jt1078.media-stream.enabled=false",
"lingniu.ingest.jt808.enabled=true",
"lingniu.ingest.jt808.port=0")
.run(context -> {
assertThat(context).hasSingleBean(Jt808EventMapper.class);
assertThat(context).hasSingleBean(Jt1078MediaSignalHandler.class);
});
}
@Test
void malformedRtpPublisherUsesConfiguredIdentityResolver() {
runner.run(context -> {
Jt1078MalformedRtpEventPublisher publisher = context.getBean(Jt1078MalformedRtpEventPublisher.class);
CapturingSink sink = context.getBean(CapturingSink.class);
publisher.publish(new Jt1078MalformedRtpPacket(
"tcp",
"10.0.0.8:1078",
"013812345678",
new byte[]{0x30, 0x31, 0x63, 0x64},
128,
"jt1078 rtp packet too large: 128",
java.time.Instant.parse("2026-06-22T08:30:00Z")));
List<VehicleEvent> events = sink.awaitCount(2);
assertThat(events).anySatisfy(event -> {
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
assertThat(event.vin()).isEqualTo("LNVIN107800000001");
assertThat(event.metadata())
.containsEntry("sim", "013812345678")
.containsEntry("identityResolved", "true")
.containsEntry("identitySource", "BOUND_PHONE")
.containsEntry("parseError", "true");
});
assertThat(events).anySatisfy(event -> {
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
assertThat(event.vin()).isEqualTo("LNVIN107800000001");
assertThat(event.metadata())
.containsEntry("sim", "013812345678")
.containsEntry("identityResolved", "true")
.containsEntry("identitySource", "BOUND_PHONE")
.containsEntry("parseError", "true")
.containsKey("rawArchiveUri");
});
});
}
@Configuration(proxyBeanMethods = false)
static class SupportBeans {
@Bean
ArchiveStore archiveStore() {
return new InMemoryArchiveStore();
}
@Bean
VehicleIdentityResolver vehicleIdentityResolver() {
return lookup -> {
if (!lookup.vin().isBlank()) {
return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN);
}
if ("013812345678".equals(lookup.phone())) {
return new VehicleIdentity("LNVIN107800000001", true, VehicleIdentitySource.BOUND_PHONE);
}
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
};
}
@Bean(destroyMethod = "close")
DisruptorEventBus disruptorEventBus(CapturingSink sink) {
return new DisruptorEventBus(1024, "blocking", List.of(sink));
}
@Bean
CapturingSink capturingSink() {
return new CapturingSink();
}
}
private static final class CapturingSink implements EventSink {
private final CopyOnWriteArrayList<VehicleEvent> events = new CopyOnWriteArrayList<>();
@Override
public String name() {
return "capturing";
}
@Override
public CompletableFuture<Void> publish(VehicleEvent event) {
events.add(event);
return CompletableFuture.completedFuture(null);
}
VehicleEvent awaitOne() throws InterruptedException {
for (int i = 0; i < 50; i++) {
if (!events.isEmpty()) {
return events.getFirst();
}
Thread.sleep(20);
}
throw new AssertionError("expected one event");
}
List<VehicleEvent> awaitCount(int count) throws InterruptedException {
for (int i = 0; i < 50; i++) {
if (events.size() >= count) {
return List.copyOf(events);
}
Thread.sleep(20);
}
throw new AssertionError("expected " + count + " events, got " + events.size());
}
}
private static final class InMemoryArchiveStore implements ArchiveStore {
@Override
public String put(String key, InputStream data, long length) throws IOException {
data.transferTo(OutputStream.nullOutputStream());
return "memory://" + key;
}
@Override
public String append(String key, byte[] chunk) {
return "memory://" + key;
}
@Override
public InputStream get(String key) {
return new ByteArrayInputStream(new byte[0]);
}
@Override
public boolean exists(String key) {
return true;
}
@Override
public long size(String key) {
return 0;
}
}
}

View File

@@ -0,0 +1,127 @@
package com.lingniu.ingest.protocol.jt1078.downlink;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class Jt1078CommandsTest {
@Test
void realtimePlayEncodesServerPortsChannelAndStreamOptions() {
var command = Jt1078Commands.realtimePlay("10.1.2.3", 11078, 11079, 2, 1, 0);
assertThat(command.messageId()).isEqualTo(0x9101);
assertThat(command.body()).containsExactly(
0x08, '1', '0', '.', '1', '.', '2', '.', '3',
0x2B, 0x46,
0x2B, 0x47,
0x02,
0x01,
0x00);
}
@Test
void resourceSearchEncodesBcdTimeAndQueryOptions() {
var command = Jt1078Commands.resourceSearch(
3,
"2026-06-22 08:09:10",
"260622180000",
0x01020304L,
0x05060708L,
3,
2,
1);
assertThat(command.messageId()).isEqualTo(0x9205);
assertThat(command.body()).containsExactly(
0x03,
0x26, 0x06, 0x22, 0x08, 0x09, 0x10,
0x26, 0x06, 0x22, 0x18, 0x00, 0x00,
0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08,
0x03,
0x02,
0x01);
}
@Test
void queryMediaAttributesUses1003RequestMessageIdWithEmptyBody() {
var command = Jt1078Commands.queryMediaAttributes();
assertThat(command.messageId()).isEqualTo(0x1003);
assertThat(command.body()).isEmpty();
}
@Test
void realtimeControlEncodesChannelCommandCloseTypeAndStreamType() {
var command = Jt1078Commands.realtimeControl(2, 1, 0, 1);
assertThat(command.messageId()).isEqualTo(0x9102);
assertThat(command.body()).containsExactly(0x02, 0x01, 0x00, 0x01);
}
@Test
void historyPlayEncodesPlaybackOptionsAndBcdTimes() {
var command = Jt1078Commands.historyPlay(
"media.example.cn", 11078, 0, 4,
3, 2, 1, 0, 0,
"260622080910", "260622180000");
assertThat(command.messageId()).isEqualTo(0x9201);
assertThat(command.body()).containsExactly(
0x10, 'm', 'e', 'd', 'i', 'a', '.', 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'n',
0x2B, 0x46,
0x00, 0x00,
0x04,
0x03,
0x02,
0x01,
0x00,
0x00,
0x26, 0x06, 0x22, 0x08, 0x09, 0x10,
0x26, 0x06, 0x22, 0x18, 0x00, 0x00);
}
@Test
void historyControlEncodesPlaybackTime() {
var command = Jt1078Commands.historyControl(4, 5, 0, "260622090000");
assertThat(command.messageId()).isEqualTo(0x9202);
assertThat(command.body()).containsExactly(
0x04, 0x05, 0x00,
0x26, 0x06, 0x22, 0x09, 0x00, 0x00);
}
@Test
void fileUploadEncodesServerAccountPathAndQueryOptions() {
var command = Jt1078Commands.fileUpload(
"10.1.2.3", 11078, "user", "pass", "/data/upload",
2, "260622080910", "260622180000",
1, 2, 3, 2, 1, 0x07);
assertThat(command.messageId()).isEqualTo(0x9206);
assertThat(command.body()).containsExactly(
0x08, '1', '0', '.', '1', '.', '2', '.', '3',
0x2B, 0x46,
0x04, 'u', 's', 'e', 'r',
0x04, 'p', 'a', 's', 's',
0x0C, '/', 'd', 'a', 't', 'a', '/', 'u', 'p', 'l', 'o', 'a', 'd',
0x02,
0x26, 0x06, 0x22, 0x08, 0x09, 0x10,
0x26, 0x06, 0x22, 0x18, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x02,
0x03,
0x02,
0x01,
0x07);
}
@Test
void fileUploadControlEncodesResponseSerialAndCommand() {
var command = Jt1078Commands.fileUploadControl(0x1234, 2);
assertThat(command.messageId()).isEqualTo(0x9207);
assertThat(command.body()).containsExactly(0x12, 0x34, 0x02);
}
}