feat: make gb32960 archive history query production ready
This commit is contained in:
66
modules/protocols/protocol-jt1078/pom.xml
Normal file
66
modules/protocols/protocol-jt1078/pom.xml
Normal file
@@ -0,0 +1,66 @@
|
||||
<?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-jt1078</artifactId>
|
||||
<name>protocol-jt1078</name>
|
||||
<description>
|
||||
JT/T 1078 音视频信令接入。复用 protocol-jt808 的 Netty server 与会话,
|
||||
只通过注册新的 BodyParser 热插拔扩展消息集(0x1003 / 0x1005 / 0x1205 / 0x1206)。
|
||||
</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>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 Bean,Spring 会把它们注入到 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lingniu.ingest.protocol.jt1078.media;
|
||||
|
||||
public record Jt1078ReceivedRtpPacket(
|
||||
Jt1078RtpPacket packet,
|
||||
String peer
|
||||
) {
|
||||
public Jt1078ReceivedRtpPacket {
|
||||
peer = peer == null ? "" : peer;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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>包头固定魔数 0x30316364,header 固定 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
com.lingniu.ingest.protocol.jt1078.config.Jt1078AutoConfiguration
|
||||
com.lingniu.ingest.protocol.jt1078.config.Jt1078SignalAutoConfiguration
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user