chore: initial import of lingniu-vehicle-ingest

Multi-module Spring Boot ingest service for vehicle telemetry. Modules:

- ingest-api / ingest-core / ingest-codec-common: shared SPI, dispatcher,
  Disruptor event bus, BCC/BCD codec helpers
- protocol-gb32960: GB/T 32960.3 inbound (Netty + per-version parser
  packages v2016/v2025), platform login auth, VIN whitelist, idle handler
- protocol-jt808 / protocol-jt1078 / protocol-jsatl12: JT/T inbound
- inbound-mqtt / inbound-xinda-push: alternative ingest channels
- session-core: per-channel session state
- sink-archive / sink-mq: persistence sinks (local file / Kafka)
- command-gateway: terminal control command gateway
- bootstrap-all: aggregator Spring Boot app
- observability: Micrometer / Actuator wiring

Includes hex-dump golden samples under protocol-gb32960/src/test/resources
and the GB/T 32960.3-2016 / 2025 reference PDFs under reference/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lingniu-dev
2026-04-15 16:08:57 +08:00
commit 064ecc479c
220 changed files with 11874 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
package com.lingniu.ingest.protocol.jt808.codec;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import java.nio.ByteBuffer;
/**
* 单一消息体解析器 SPI。每个消息 ID 一个实现,替代旧代码里的巨型 switch。
*
* <p>约定:{@code parse} 调用时 buffer.position 指向 body 第 0 字节,
* 解析完成后 position 停在 body 末尾(不消费校验码)。
*/
public interface BodyParser {
int messageId();
Jt808Body parse(Jt808Header header, ByteBuffer body);
}

View File

@@ -0,0 +1,25 @@
package com.lingniu.ingest.protocol.jt808.codec;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* JT808 消息体解析器注册中心。
*/
public final class BodyParserRegistry {
private final Map<Integer, BodyParser> parsers = new HashMap<>();
public BodyParserRegistry(List<BodyParser> list) {
for (BodyParser p : list) parsers.put(p.messageId(), p);
}
public BodyParser find(int messageId) {
return parsers.get(messageId);
}
public int size() {
return parsers.size();
}
}

View File

@@ -0,0 +1,59 @@
package com.lingniu.ingest.protocol.jt808.codec;
/**
* JT/T 808 转义规则:
* <pre>
* 0x7e → 0x7d 0x02
* 0x7d → 0x7d 0x01
* </pre>
* 转义仅作用于 <b>消息头 + 消息体 + 校验码</b>,不包含首尾的 0x7e 标识位。
*/
public final class Jt808Escape {
private Jt808Escape() {}
/** 反转义:从 framed 字节数组中剥除 0x7e 首尾后,恢复原始字节。 */
public static byte[] unescape(byte[] framed, int offset, int length) {
byte[] out = new byte[length];
int outLen = 0;
for (int i = offset; i < offset + length; i++) {
byte b = framed[i];
if (b == 0x7d && i + 1 < offset + length) {
byte next = framed[i + 1];
if (next == 0x02) {
out[outLen++] = 0x7e;
i++;
continue;
} else if (next == 0x01) {
out[outLen++] = 0x7d;
i++;
continue;
}
}
out[outLen++] = b;
}
byte[] trimmed = new byte[outLen];
System.arraycopy(out, 0, trimmed, 0, outLen);
return trimmed;
}
/** 转义:在原始字节上应用 7d/7e 替换,结果不含首尾 0x7e。 */
public static byte[] escape(byte[] raw) {
byte[] out = new byte[raw.length * 2];
int outLen = 0;
for (byte b : raw) {
if (b == 0x7e) {
out[outLen++] = 0x7d;
out[outLen++] = 0x02;
} else if (b == 0x7d) {
out[outLen++] = 0x7d;
out[outLen++] = 0x01;
} else {
out[outLen++] = b;
}
}
byte[] trimmed = new byte[outLen];
System.arraycopy(out, 0, trimmed, 0, outLen);
return trimmed;
}
}

View File

@@ -0,0 +1,51 @@
package com.lingniu.ingest.protocol.jt808.codec;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.util.List;
/**
* Netty 层 JT808 分帧 + 反转义。
*
* <p>寻找配对的 {@code 0x7e ... 0x7e},提取中间字节并调用 {@link Jt808Escape#unescape} 还原,
* 输出到 pipeline 的是 {@code byte[]}(反转义后的原始数据,不含首尾 0x7e
*/
public class Jt808FrameDecoder extends ByteToMessageDecoder {
private static final int MAX_FRAME = 16 * 1024;
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
while (in.readableBytes() >= 2) {
int startIdx = in.indexOf(in.readerIndex(), in.writerIndex(), (byte) 0x7e);
if (startIdx < 0) {
in.skipBytes(in.readableBytes());
return;
}
if (startIdx != in.readerIndex()) {
in.skipBytes(startIdx - in.readerIndex());
}
int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e);
if (endIdx < 0) {
if (in.readableBytes() > MAX_FRAME) {
in.skipBytes(in.readableBytes() - 1);
}
return;
}
int rawLen = endIdx - (in.readerIndex() + 1);
if (rawLen == 0) {
// 两个相邻 0x7e视作空帧跳过一个
in.skipBytes(1);
continue;
}
byte[] raw = new byte[rawLen];
in.skipBytes(1);
in.readBytes(raw);
in.skipBytes(1); // 结束 0x7e
byte[] unescaped = Jt808Escape.unescape(raw, 0, raw.length);
out.add(unescaped);
}
}
}

View File

@@ -0,0 +1,81 @@
package com.lingniu.ingest.protocol.jt808.codec;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.codec.BcdCodec;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import java.io.ByteArrayOutputStream;
/**
* JT/T 808 下行帧编码器:把 header + body 字节拼装 → 加 XOR 校验 → 转义 → 外包 0x7e。
*
* <p>只支持 2013 版6 字节 BCD phone。分包暂不支持PoC 阶段下行多数是短命令)。
*/
public final class Jt808FrameEncoder {
private Jt808FrameEncoder() {}
public static byte[] encode(int messageId, String phone, int serialNo, byte[] body) {
return encode(messageId, phone, serialNo, body, Jt808Header.ProtocolVersion.V2013);
}
public static byte[] encode(int messageId, String phone, int serialNo, byte[] body,
Jt808Header.ProtocolVersion version) {
byte[] raw = buildRaw(messageId, phone, serialNo, body, version);
byte bcc = BccChecksum.compute(raw, 0, raw.length);
ByteArrayOutputStream almost = new ByteArrayOutputStream(raw.length + 1);
almost.write(raw, 0, raw.length);
almost.write(bcc & 0xFF);
byte[] escaped = Jt808Escape.escape(almost.toByteArray());
byte[] out = new byte[escaped.length + 2];
out[0] = 0x7e;
System.arraycopy(escaped, 0, out, 1, escaped.length);
out[out.length - 1] = 0x7e;
return out;
}
private static byte[] buildRaw(int messageId, String phone, int serialNo, byte[] body,
Jt808Header.ProtocolVersion version) {
boolean v2019 = version == Jt808Header.ProtocolVersion.V2019;
int phoneLen = v2019 ? 10 : 6;
int len = 2 /* msgId */ + 2 /* attrs */ + (v2019 ? 1 : 0) + phoneLen + 2 /* serial */ + body.length;
ByteArrayOutputStream os = new ByteArrayOutputStream(len);
// msgId
os.write((messageId >> 8) & 0xFF);
os.write(messageId & 0xFF);
// attrs: 2019 版 bit14 置 1
int attrs = body.length & 0x03FF;
if (v2019) attrs |= 0x4000;
os.write((attrs >> 8) & 0xFF);
os.write(attrs & 0xFF);
// 2019 版多一个 protocolVersion 字节(值 1
if (v2019) os.write(0x01);
// phone BCD
String padded = padLeft(phone, phoneLen * 2);
byte[] bcd = BcdCodec.encode(padded);
os.write(bcd, 0, phoneLen);
// serial
os.write((serialNo >> 8) & 0xFF);
os.write(serialNo & 0xFF);
// body
os.write(body, 0, body.length);
return os.toByteArray();
}
private static String padLeft(String s, int len) {
if (s == null) s = "";
if (s.length() >= len) return s.substring(s.length() - len);
StringBuilder sb = new StringBuilder(len);
for (int i = s.length(); i < len; i++) sb.append('0');
sb.append(s);
return sb.toString();
}
}

View File

@@ -0,0 +1,93 @@
package com.lingniu.ingest.protocol.jt808.codec;
import com.lingniu.ingest.api.spi.DecodeException;
import com.lingniu.ingest.api.spi.FrameDecoder;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.codec.BcdCodec;
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 java.nio.ByteBuffer;
import java.util.Arrays;
/**
* JT808 完整帧(已反转义、含校验码,不含首尾 0x7e → {@link Jt808Message}。
*
* <p>入参 {@link ByteBuffer} 的生命周期由调用方管理,本类只读取。
*/
public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
private static final int HEADER_LEN_2013 = 12;
private static final int HEADER_LEN_2019 = 16; // + phone 延长 4 字节
private final BodyParserRegistry registry;
public Jt808MessageDecoder(BodyParserRegistry registry) {
this.registry = registry;
}
@Override
public Jt808Message decode(ByteBuffer buffer) {
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
if (bytes.length < HEADER_LEN_2013 + 1) {
throw new DecodeException("jt808 frame too short: " + bytes.length);
}
// 校验码:从 msgId 开始到校验码前一字节
byte expected = bytes[bytes.length - 1];
if (!BccChecksum.verify(bytes, 0, bytes.length - 1, expected)) {
throw new DecodeException("jt808 XOR checksum mismatch");
}
int messageId = ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF);
int attrs = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF);
int bodyLength = attrs & 0x03FF;
int encryptType = (attrs >> 10) & 0x07;
boolean subpacket = (attrs & 0x2000) != 0;
boolean v2019 = (attrs & 0x4000) != 0;
Jt808Header.ProtocolVersion version = v2019
? Jt808Header.ProtocolVersion.V2019
: Jt808Header.ProtocolVersion.V2013;
int phoneLen = v2019 ? 10 : 6;
int headerLen = 4 /* msgId+attrs */ + (v2019 ? 1 : 0) /* protocol version */ + phoneLen + 2 /* serial */;
int phoneOffset = 4 + (v2019 ? 1 : 0);
String phone = BcdCodec.decode(Arrays.copyOfRange(bytes, phoneOffset, phoneOffset + phoneLen))
.replaceAll("^0+", "");
int serial = ((bytes[phoneOffset + phoneLen] & 0xFF) << 8)
| (bytes[phoneOffset + phoneLen + 1] & 0xFF);
int totalPkgs = 0, pkgSeq = 0;
int bodyStart = headerLen;
if (subpacket) {
totalPkgs = ((bytes[bodyStart] & 0xFF) << 8) | (bytes[bodyStart + 1] & 0xFF);
pkgSeq = ((bytes[bodyStart + 2] & 0xFF) << 8) | (bytes[bodyStart + 3] & 0xFF);
bodyStart += 4;
}
int bodyEnd = bodyStart + bodyLength;
if (bodyEnd > bytes.length - 1) {
throw new DecodeException("jt808 body length " + bodyLength + " exceeds frame");
}
Jt808Header header = new Jt808Header(
messageId, bodyLength, encryptType, subpacket, version,
phone, serial, totalPkgs, pkgSeq);
ByteBuffer bodyBuf = ByteBuffer.wrap(bytes, bodyStart, bodyLength);
BodyParser parser = registry.find(messageId);
Jt808Body body;
if (parser == null) {
byte[] raw = new byte[bodyLength];
bodyBuf.get(raw);
body = new Jt808Body.Raw(messageId, raw);
} else {
body = parser.parse(header, bodyBuf);
}
return new Jt808Message(header, body);
}
}

View File

@@ -0,0 +1,14 @@
package com.lingniu.ingest.protocol.jt808.codec.parser;
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 com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import java.nio.ByteBuffer;
/** 0x0002 心跳,无 body。 */
public final class HeartbeatBodyParser implements BodyParser {
@Override public int messageId() { return Jt808MessageId.TERMINAL_HEARTBEAT; }
@Override public Jt808Body parse(Jt808Header header, ByteBuffer body) { return new Jt808Body.Heartbeat(); }
}

View File

@@ -0,0 +1,66 @@
package com.lingniu.ingest.protocol.jt808.codec.parser;
import com.lingniu.ingest.codec.BcdCodec;
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 com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import java.nio.ByteBuffer;
import java.time.LocalDateTime;
import java.time.ZoneId;
/**
* 0x0200 位置信息汇报。
*
* <p>固定 28 字节 + 可选附加项(本 PoC 先忽略附加项)。
* <pre>
* alarmFlag(4)
* statusFlag(4)
* latitude(4) // 1/1000000 度
* longitude(4) // 1/1000000 度
* altitudeM(2)
* speed(2) // 1/10 km/h
* direction(2)
* time(6 BCD, YYMMDDHHMMSS)
* </pre>
*/
public final class LocationBodyParser implements BodyParser {
@Override
public int messageId() {
return Jt808MessageId.TERMINAL_LOCATION;
}
@Override
public Jt808Body parse(Jt808Header header, ByteBuffer buf) {
long alarmFlag = buf.getInt() & 0xFFFFFFFFL;
long statusFlag = buf.getInt() & 0xFFFFFFFFL;
long latRaw = buf.getInt() & 0xFFFFFFFFL;
long lonRaw = buf.getInt() & 0xFFFFFFFFL;
int altitudeM = buf.getShort();
double speed = (buf.getShort() & 0xFFFF) * 0.1;
int direction = buf.getShort() & 0xFFFF;
byte[] timeBcd = new byte[6];
buf.get(timeBcd);
String ts = BcdCodec.decode(timeBcd);
LocalDateTime ldt = LocalDateTime.of(
2000 + Integer.parseInt(ts.substring(0, 2)),
Integer.parseInt(ts.substring(2, 4)),
Integer.parseInt(ts.substring(4, 6)),
Integer.parseInt(ts.substring(6, 8)),
Integer.parseInt(ts.substring(8, 10)),
Integer.parseInt(ts.substring(10, 12)));
// 跳过剩余附加项
if (buf.hasRemaining()) buf.position(buf.limit());
return new Jt808Body.Location(
alarmFlag, statusFlag,
lonRaw / 1_000_000.0, latRaw / 1_000_000.0,
altitudeM, speed, direction,
ldt.atZone(ZoneId.of("Asia/Shanghai")).toInstant());
}
}

View File

@@ -0,0 +1,54 @@
package com.lingniu.ingest.protocol.jt808.codec.parser;
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 com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/**
* 0x0100 终端注册。
*
* <p>2013 body 格式:
* <pre>
* provinceId(2)
* cityId(2)
* makerId(5 ASCII)
* deviceType(20 ASCII) -- 2011 版为 8
* deviceId(7 ASCII)
* plateColor(1)
* plate(GBK, 剩余全部字节)
* </pre>
*/
public final class RegisterBodyParser implements BodyParser {
private static final Charset GBK = Charset.forName("GBK");
@Override
public int messageId() {
return Jt808MessageId.TERMINAL_REGISTER;
}
@Override
public Jt808Body parse(Jt808Header header, ByteBuffer buf) {
int province = buf.getShort() & 0xFFFF;
int city = buf.getShort() & 0xFFFF;
String maker = readAscii(buf, 5);
String deviceType = readAscii(buf, 20);
String deviceId = readAscii(buf, 7);
int plateColor = buf.get() & 0xFF;
byte[] plateBytes = new byte[buf.remaining()];
buf.get(plateBytes);
String plate = new String(plateBytes, GBK).trim();
return new Jt808Body.Register(province, city, maker, deviceType, deviceId, plateColor, plate);
}
private static String readAscii(ByteBuffer buf, int len) {
byte[] b = new byte[len];
buf.get(b);
return new String(b, StandardCharsets.US_ASCII).trim();
}
}

View File

@@ -0,0 +1,96 @@
package com.lingniu.ingest.protocol.jt808.config;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry;
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
import com.lingniu.ingest.protocol.jt808.downlink.Jt808CommandDispatcher;
import com.lingniu.ingest.protocol.jt808.handler.Jt808LocationHandler;
import com.lingniu.ingest.protocol.jt808.inbound.Jt808NettyServer;
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
import com.lingniu.ingest.session.CommandDispatcher;
import com.lingniu.ingest.session.SessionStore;
import com.lingniu.ingest.session.config.SessionCoreAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
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;
import java.util.List;
@AutoConfiguration(before = SessionCoreAutoConfiguration.class)
@EnableConfigurationProperties(Jt808Properties.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.jt808", name = "enabled", havingValue = "true")
public class Jt808AutoConfiguration {
@Bean public BodyParser jt808RegisterBodyParser() { return new RegisterBodyParser(); }
@Bean public BodyParser jt808LocationBodyParser() { return new LocationBodyParser(); }
@Bean public BodyParser jt808HeartbeatBodyParser() { return new HeartbeatBodyParser(); }
@Bean
@ConditionalOnMissingBean
public BodyParserRegistry jt808BodyParserRegistry(List<BodyParser> parsers) {
return new BodyParserRegistry(parsers);
}
@Bean
@ConditionalOnMissingBean
public Jt808MessageDecoder jt808MessageDecoder(BodyParserRegistry registry) {
return new Jt808MessageDecoder(registry);
}
@Bean
@ConditionalOnMissingBean
public Jt808EventMapper jt808EventMapper() {
return new Jt808EventMapper();
}
@Bean
@ConditionalOnMissingBean
public Jt808LocationHandler jt808LocationHandler(Jt808EventMapper mapper) {
return new Jt808LocationHandler(mapper);
}
@Bean
@ConditionalOnMissingBean
public Jt808ChannelRegistry jt808ChannelRegistry() {
return new Jt808ChannelRegistry();
}
@Bean
@ConditionalOnMissingBean
public Jt808PendingRequests jt808PendingRequests() {
return new Jt808PendingRequests();
}
/**
* JT808 模块启用时抢占 {@link CommandDispatcher} Bean。
* 通过 {@code @AutoConfiguration(before = SessionCoreAutoConfiguration.class)} 确保本 Bean
* 先注册session-core 的 {@code @ConditionalOnMissingBean} 会因此跳过 Noop 默认实现。
*/
@Bean
@ConditionalOnMissingBean
public CommandDispatcher jt808CommandDispatcher(SessionStore sessions,
Jt808ChannelRegistry channelRegistry,
Jt808PendingRequests pendingRequests) {
return new Jt808CommandDispatcher(sessions, channelRegistry, pendingRequests);
}
@Bean
@ConditionalOnMissingBean
public Jt808NettyServer jt808NettyServer(Jt808Properties props,
Jt808MessageDecoder decoder,
Dispatcher dispatcher,
SessionStore sessions,
Jt808ChannelRegistry channelRegistry,
Jt808PendingRequests pendingRequests) {
return new Jt808NettyServer(props.getPort(), props.getWorkerThreads(),
decoder, dispatcher, sessions, channelRegistry, pendingRequests);
}
}

View File

@@ -0,0 +1,17 @@
package com.lingniu.ingest.protocol.jt808.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.jt808")
public class Jt808Properties {
private boolean enabled = false;
private int port = 10808;
private int workerThreads = 0;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
public int getWorkerThreads() { return workerThreads; }
public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; }
}

View File

@@ -0,0 +1,115 @@
package com.lingniu.ingest.protocol.jt808.downlink;
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
import com.lingniu.ingest.session.CommandDispatcher;
import com.lingniu.ingest.session.DeviceSession;
import com.lingniu.ingest.session.SessionStore;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
/**
* JT808 侧的 {@link CommandDispatcher} 实现:把 session-core 的调用桥接到具体 Netty Channel。
*
* <p>{@link #notify(String, Object)} 异步下发后立即返回;
* {@link #request(String, Object, Class, Duration)} 通过 {@link Jt808PendingRequests} 等待设备应答。
*
* <p>入参 {@code command} 必须是 {@link Jt808Commands.DownlinkCommand},其他类型返回失败 future。
*/
public final class Jt808CommandDispatcher implements CommandDispatcher {
private static final Logger log = LoggerFactory.getLogger(Jt808CommandDispatcher.class);
private final SessionStore sessions;
private final Jt808ChannelRegistry channelRegistry;
private final Jt808PendingRequests pendingRequests;
public Jt808CommandDispatcher(SessionStore sessions,
Jt808ChannelRegistry channelRegistry,
Jt808PendingRequests pendingRequests) {
this.sessions = sessions;
this.channelRegistry = channelRegistry;
this.pendingRequests = pendingRequests;
}
@Override
public CompletableFuture<Void> notify(String sessionId, Object command) {
CommandContext ctx;
try {
ctx = resolve(sessionId, command);
} catch (IllegalArgumentException e) {
return CompletableFuture.failedFuture(e);
}
byte[] frame = Jt808FrameEncoder.encode(ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body());
CompletableFuture<Void> cf = new CompletableFuture<>();
ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> {
if (f.isSuccess()) cf.complete(null);
else cf.completeExceptionally(f.cause());
});
return cf;
}
@Override
public <T> CompletableFuture<T> request(String sessionId, Object command,
Class<T> responseType, Duration timeout) {
CommandContext ctx;
try {
ctx = resolve(sessionId, command);
} catch (IllegalArgumentException e) {
return CompletableFuture.failedFuture(e);
}
CompletableFuture<Jt808Message> pending = pendingRequests.await(ctx.phone, ctx.serial);
byte[] frame = Jt808FrameEncoder.encode(ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body());
ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> {
if (!f.isSuccess()) {
pendingRequests.cancel(ctx.phone, ctx.serial);
pending.completeExceptionally(f.cause());
}
});
return pending.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
.whenComplete((r, ex) -> {
if (ex != null) pendingRequests.cancel(ctx.phone, ctx.serial);
})
.thenApply(msg -> {
if (responseType.isInstance(msg)) return responseType.cast(msg);
throw new CompletionException(new ClassCastException(
"jt808 response type mismatch, expect=" + responseType.getSimpleName()
+ " actual=" + msg.getClass().getSimpleName()));
});
}
private CommandContext resolve(String sessionId, Object command) {
if (!(command instanceof Jt808Commands.DownlinkCommand cmd)) {
throw new IllegalArgumentException(
"expected Jt808Commands.DownlinkCommand, got " + command.getClass().getName());
}
String phone = resolvePhone(sessionId);
Channel channel = channelRegistry.find(phone)
.orElseThrow(() -> new IllegalStateException("no active channel for phone=" + phone));
int serial = channelRegistry.nextSerial(phone);
return new CommandContext(phone, serial, channel, cmd);
}
private String resolvePhone(String sessionId) {
Optional<DeviceSession> s = sessions.findBySessionId(sessionId)
.or(() -> sessions.findByPhone(sessionId))
.or(() -> sessions.findByVin(sessionId));
return s.map(DeviceSession::phone)
.filter(p -> p != null && !p.isBlank())
.orElse(sessionId); // 回落:直接把 sessionId 当 phone
}
private record CommandContext(String phone, int serial, Channel channel, Jt808Commands.DownlinkCommand cmd) {}
}

View File

@@ -0,0 +1,91 @@
package com.lingniu.ingest.protocol.jt808.downlink;
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import java.io.ByteArrayOutputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/**
* 常用下行命令 body 构造器。返回的是 body 字节,外层由 {@link com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder} 包装。
*/
public final class Jt808Commands {
private static final Charset GBK = Charset.forName("GBK");
private Jt808Commands() {}
/** 0x8001 平台通用应答ack 一条终端上行)。 */
public static DownlinkCommand platformAck(int ackSerial, int ackMsgId, int result) {
ByteArrayOutputStream os = new ByteArrayOutputStream(5);
writeU16(os, ackSerial);
writeU16(os, ackMsgId);
os.write(result & 0xFF);
return new DownlinkCommand(Jt808MessageId.PLATFORM_GENERAL_RESPONSE, os.toByteArray());
}
/** 0x8100 注册应答result=0 成功)。 */
public static DownlinkCommand registerAck(int ackSerial, int result, String token) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeU16(os, ackSerial);
os.write(result & 0xFF);
if (result == 0 && token != null) {
byte[] tokenBytes = token.getBytes(StandardCharsets.US_ASCII);
os.write(tokenBytes, 0, tokenBytes.length);
}
return new DownlinkCommand(Jt808MessageId.PLATFORM_REGISTER_ACK, os.toByteArray());
}
/** 0x8201 位置信息查询。 */
public static DownlinkCommand queryLocation() {
return new DownlinkCommand(Jt808MessageId.PLATFORM_QUERY_LOCATION, new byte[0]);
}
/** 0x8104 查询终端参数(全量)。 */
public static DownlinkCommand queryParameters() {
return new DownlinkCommand(Jt808MessageId.PLATFORM_QUERY_PARAMS, new byte[0]);
}
/**
* 0x8103 设置终端参数:参数项列表格式。
*
* <p>body 形如 {@code count(1) + N*paramItem},每个 paramItem 为 {@code paramId(4) + length(1) + value}。
*/
public static DownlinkCommand setParameters(java.util.List<ParamItem> items) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
os.write(items.size() & 0xFF);
for (ParamItem item : items) {
writeU32(os, item.paramId());
os.write(item.value().length & 0xFF);
os.write(item.value(), 0, item.value().length);
}
return new DownlinkCommand(Jt808MessageId.PLATFORM_SET_PARAMS, os.toByteArray());
}
/** 0x8105 终端控制:命令字(1) + 命令参数(GBK)。 */
public static DownlinkCommand terminalControl(int commandWord, String commandParams) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
os.write(commandWord & 0xFF);
if (commandParams != null) {
byte[] bytes = commandParams.getBytes(GBK);
os.write(bytes, 0, bytes.length);
}
return new DownlinkCommand(Jt808MessageId.PLATFORM_TERMINAL_CONTROL, os.toByteArray());
}
public record DownlinkCommand(int messageId, byte[] body) {}
public record ParamItem(long paramId, byte[] value) {}
private static void writeU16(ByteArrayOutputStream os, int v) {
os.write((v >> 8) & 0xFF);
os.write(v & 0xFF);
}
private static void writeU32(ByteArrayOutputStream os, long v) {
os.write((int) ((v >> 24) & 0xFF));
os.write((int) ((v >> 16) & 0xFF));
os.write((int) ((v >> 8) & 0xFF));
os.write((int) (v & 0xFF));
}
}

View File

@@ -0,0 +1,55 @@
package com.lingniu.ingest.protocol.jt808.handler;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.annotation.AsyncBatch;
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.annotation.RateLimited;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import java.util.ArrayList;
import java.util.List;
/**
* JT808 示例 Handler演示注解驱动的路由与事件产出。
*
* <p>位置上报使用 {@code @AsyncBatch} 批量聚合(对标旧代码里的 4000/1s 批量落库)。
* 带 {@code @AsyncBatch} 的方法签名约定为 {@code List<PayloadType>}。
*/
@ProtocolHandler(protocol = ProtocolId.JT808)
public class Jt808LocationHandler {
private final Jt808EventMapper mapper;
public Jt808LocationHandler(Jt808EventMapper mapper) {
this.mapper = mapper;
}
@MessageMapping(command = Jt808MessageId.TERMINAL_LOCATION, desc = "位置信息汇报(批量)")
@RateLimited(perVin = 20)
@AsyncBatch(size = 4000, waitMs = 1000, poolSize = 2)
@EventEmit(VehicleEvent.Location.class)
public List<VehicleEvent> onLocationBatch(List<Jt808Message> batch) {
List<VehicleEvent> all = new ArrayList<>(batch.size());
for (Jt808Message msg : batch) {
all.addAll(mapper.toEvents(msg));
}
return all;
}
@MessageMapping(command = {Jt808MessageId.TERMINAL_REGISTER, Jt808MessageId.TERMINAL_AUTH}, desc = "注册/鉴权")
@EventEmit(VehicleEvent.Login.class)
public List<VehicleEvent> onLogin(Jt808Message msg) {
return mapper.toEvents(msg);
}
@MessageMapping(command = Jt808MessageId.TERMINAL_HEARTBEAT, desc = "心跳")
@EventEmit(VehicleEvent.Heartbeat.class)
public List<VehicleEvent> onHeartbeat(Jt808Message msg) {
return mapper.toEvents(msg);
}
}

View File

@@ -0,0 +1,165 @@
package com.lingniu.ingest.protocol.jt808.inbound;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.pipeline.RawFrame;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder;
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
import com.lingniu.ingest.session.DeviceSession;
import com.lingniu.ingest.session.SessionStore;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* JT808 Netty 入站处理器。职责:
* <ol>
* <li>解码 → 构造 RawFrame → 交给 {@link Dispatcher}
* <li>对 0x0100 注册 / 0x0102 鉴权 → 创建 / 更新 {@link DeviceSession} 并绑定 Channel
* <li>对 0x0001 终端通用应答 → 唤醒 {@link Jt808PendingRequests} 里等待的 future
* <li>对 0x0002 心跳 → 下发 0x8001 通用应答(若平台要求)
* <li>连接断开 → 清理 Channel 绑定
* </ol>
*/
public class Jt808ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
private static final Logger log = LoggerFactory.getLogger(Jt808ChannelHandler.class);
private final Jt808MessageDecoder decoder;
private final Dispatcher dispatcher;
private final SessionStore sessions;
private final Jt808ChannelRegistry channelRegistry;
private final Jt808PendingRequests pendingRequests;
public Jt808ChannelHandler(Jt808MessageDecoder decoder, Dispatcher dispatcher,
SessionStore sessions, Jt808ChannelRegistry channelRegistry,
Jt808PendingRequests pendingRequests) {
this.decoder = decoder;
this.dispatcher = dispatcher;
this.sessions = sessions;
this.channelRegistry = channelRegistry;
this.pendingRequests = pendingRequests;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, byte[] frame) {
Jt808Message msg;
try {
msg = decoder.decode(ByteBuffer.wrap(frame));
} catch (Exception e) {
log.warn("[jt808] decode failed peer={} len={}", addr(ctx), frame.length, e);
return;
}
// ====== 会话维护 & 同步应答匹配 ======
String phone = msg.header().phone();
switch (msg.header().messageId()) {
case Jt808MessageId.TERMINAL_REGISTER -> {
DeviceSession session = upsertSession(ctx, msg);
channelRegistry.bind(phone, ctx.channel());
byte[] registerAck = Jt808FrameEncoder.encode(
Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).messageId(),
phone,
channelRegistry.nextSerial(phone),
Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).body());
ctx.writeAndFlush(Unpooled.wrappedBuffer(registerAck));
}
case Jt808MessageId.TERMINAL_AUTH -> {
upsertSession(ctx, msg);
channelRegistry.bind(phone, ctx.channel());
ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0);
}
case Jt808MessageId.TERMINAL_HEARTBEAT -> {
channelRegistry.bind(phone, ctx.channel());
ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0);
}
case Jt808MessageId.TERMINAL_GENERAL_RESPONSE -> {
// body: ackSerial(2) + ackMsgId(2) + result(1)
if (msg.body() instanceof Jt808Body.Raw raw && raw.bytes().length >= 5) {
int ackSerial = ((raw.bytes()[0] & 0xFF) << 8) | (raw.bytes()[1] & 0xFF);
pendingRequests.complete(phone, ackSerial, msg);
}
}
default -> {
// 定位/其他上行:也可能是对同步命令的应答
pendingRequests.complete(phone, msg.header().serialNo(), msg);
}
}
// ====== 派发到 Dispatcher ======
Map<String, String> meta = new HashMap<>(4);
meta.put("vin", phone);
meta.put("seq", Integer.toString(msg.header().serialNo()));
meta.put("peer", addr(ctx));
RawFrame rf = new RawFrame(
ProtocolId.JT808,
msg.header().messageId(),
0,
msg,
frame,
meta,
Instant.now());
dispatcher.dispatch(rf);
}
private DeviceSession upsertSession(ChannelHandlerContext ctx, Jt808Message msg) {
String phone = msg.header().phone();
String sessionId = "jt808-" + phone;
DeviceSession session = sessions.findBySessionId(sessionId)
.map(s -> s.touch(Instant.now()))
.orElseGet(() -> new DeviceSession(
sessionId, ProtocolId.JT808, null, phone,
generateToken(phone),
addr(ctx),
Instant.now(), Instant.now(),
Map.of("protocolVersion", msg.header().version().name())));
if (msg.body() instanceof Jt808Body.Register reg && reg.plate() != null) {
session = session.withVin(reg.deviceId()); // 没有 VIN 则用 deviceId 占位
}
sessions.put(session);
return session;
}
private void ackTerminal(ChannelHandlerContext ctx, String phone, int ackSerial, int ackMsgId, int result) {
var cmd = Jt808Commands.platformAck(ackSerial, ackMsgId, result);
byte[] frame = Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body());
ctx.writeAndFlush(Unpooled.wrappedBuffer(frame));
}
private static String generateToken(String phone) {
return UUID.nameUUIDFromBytes(("jt808:" + phone).getBytes()).toString().replace("-", "").substring(0, 16);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
channelRegistry.unbind(ctx.channel());
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.warn("[jt808] channel error peer={}", addr(ctx), cause);
ctx.close();
}
private static String addr(ChannelHandlerContext ctx) {
if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) {
return i.getAddress().getHostAddress() + ":" + i.getPort();
}
return "unknown";
}
}

View File

@@ -0,0 +1,95 @@
package com.lingniu.ingest.protocol.jt808.inbound;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameDecoder;
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
import com.lingniu.ingest.session.SessionStore;
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 class Jt808NettyServer implements InitializingBean, DisposableBean {
private static final Logger log = LoggerFactory.getLogger(Jt808NettyServer.class);
private final int port;
private final int workerThreads;
private final Jt808MessageDecoder messageDecoder;
private final Dispatcher dispatcher;
private final SessionStore sessions;
private final Jt808ChannelRegistry channelRegistry;
private final Jt808PendingRequests pendingRequests;
private EventLoopGroup boss;
private EventLoopGroup workers;
private Channel serverChannel;
public Jt808NettyServer(int port, int workerThreads,
Jt808MessageDecoder messageDecoder, Dispatcher dispatcher,
SessionStore sessions,
Jt808ChannelRegistry channelRegistry,
Jt808PendingRequests pendingRequests) {
this.port = port;
this.workerThreads = workerThreads;
this.messageDecoder = messageDecoder;
this.dispatcher = dispatcher;
this.sessions = sessions;
this.channelRegistry = channelRegistry;
this.pendingRequests = pendingRequests;
}
@Override
public void afterPropertiesSet() throws Exception {
ThreadFactory bossTf = r -> new Thread(r, "jt808-boss");
ThreadFactory wkTf = r -> new Thread(r, "jt808-worker");
this.boss = new NioEventLoopGroup(1, bossTf);
this.workers = new NioEventLoopGroup(
workerThreads == 0 ? Runtime.getRuntime().availableProcessors() * 2 : 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("frame", new Jt808FrameDecoder())
.addLast("dispatch", new Jt808ChannelHandler(
messageDecoder, dispatcher, sessions,
channelRegistry, pendingRequests));
}
});
this.serverChannel = b.bind(port).sync().channel();
log.info("[jt808] Netty 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("[jt808] Netty server stopped");
}
}
}

View File

@@ -0,0 +1,58 @@
package com.lingniu.ingest.protocol.jt808.mapper;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.LocationPayload;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.spi.EventMapper;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* JT808 消息 → 领域事件映射。sealed body 允许 switch 穷尽处理。
*
* <p>备注JT808 里没有强制的 VIN 字段 —— 我们用 {@code phone}(终端手机号/设备号)
* 作为 VIN 占位,真实 VIN 需要通过后续的 session-core 从注册消息里补齐。
*/
public final class Jt808EventMapper implements EventMapper<Jt808Message> {
@Override
public List<VehicleEvent> toEvents(Jt808Message message) {
var header = message.header();
Instant ingestTime = Instant.now();
Map<String, String> meta = Map.of(
"messageId", "0x" + Integer.toHexString(header.messageId()),
"protocolVersion", header.version().name(),
"phone", header.phone());
String vin = header.phone(); // TODO: 由 session-core 注入真实 VIN
return switch (message.body()) {
case Jt808Body.Location loc -> List.of(new VehicleEvent.Location(
UUID.randomUUID().toString(),
vin, ProtocolId.JT808, loc.deviceTime(), ingestTime, null, meta,
new LocationPayload(loc.longitude(), loc.latitude(),
loc.altitudeM(), loc.speedKmh(), loc.directionDeg(),
loc.alarmFlag(), loc.statusFlag())));
case Jt808Body.Register reg -> List.of(new VehicleEvent.Login(
UUID.randomUUID().toString(),
vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta,
reg.deviceId(), header.version().name()));
case Jt808Body.Auth auth -> List.of(new VehicleEvent.Login(
UUID.randomUUID().toString(),
vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta,
auth.imei() == null ? "" : auth.imei(), header.version().name()));
case Jt808Body.Heartbeat _ -> List.of(new VehicleEvent.Heartbeat(
UUID.randomUUID().toString(),
vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta));
case Jt808Body.Raw _ -> List.of(); // 未解析的消息体:暂不产出事件
};
}
}

View File

@@ -0,0 +1,43 @@
package com.lingniu.ingest.protocol.jt808.model;
import java.time.Instant;
/**
* 已解析的消息体。sealed新增消息体时必须同步新增子类型与 Parser。
*/
public sealed interface Jt808Body
permits Jt808Body.Register, Jt808Body.Auth, Jt808Body.Location,
Jt808Body.Heartbeat, Jt808Body.Raw {
/** 终端注册 0x0100。 */
record Register(
int province,
int city,
String maker,
String deviceType,
String deviceId,
int plateColor,
String plate
) implements Jt808Body {}
/** 终端鉴权 0x0102。 */
record Auth(String token, String imei, String softwareVersion) implements Jt808Body {}
/** 位置信息汇报 0x0200。 */
record Location(
long alarmFlag,
long statusFlag,
double longitude,
double latitude,
int altitudeM,
double speedKmh,
int directionDeg,
Instant deviceTime
) implements Jt808Body {}
/** 心跳 0x0002无 body。 */
record Heartbeat() implements Jt808Body {}
/** 未实现 Parser 的消息体,透传原始字节。 */
record Raw(int messageId, byte[] bytes) implements Jt808Body {}
}

View File

@@ -0,0 +1,28 @@
package com.lingniu.ingest.protocol.jt808.model;
/**
* JT/T 808 消息头。
*
* @param messageId 消息 ID
* @param bodyLength 消息体长度(从 attrs 低 10 位解出)
* @param encryptType 加密类型0=不加密 / 1=RSA
* @param subpacket 是否分包
* @param version 协议版本2013 或 2019
* @param phone 终端手机号 / 设备识别号2013:12 位2019:20 位)
* @param serialNo 消息流水号
* @param totalPackets 分包总数(不分包时为 0
* @param packetSeq 分包序号(从 1 开始)
*/
public record Jt808Header(
int messageId,
int bodyLength,
int encryptType,
boolean subpacket,
ProtocolVersion version,
String phone,
int serialNo,
int totalPackets,
int packetSeq
) {
public enum ProtocolVersion { V2013, V2019 }
}

View File

@@ -0,0 +1,6 @@
package com.lingniu.ingest.protocol.jt808.model;
/**
* 完整解析后的 JT808 消息。
*/
public record Jt808Message(Jt808Header header, Jt808Body body) {}

View File

@@ -0,0 +1,36 @@
package com.lingniu.ingest.protocol.jt808.model;
/**
* JT/T 808 消息 ID 常量集(节选核心消息,按需继续扩展)。
*
* <p>使用常量类而非 enum808 消息种类多(超过 80 个),新增高频,常量类比 enum 维护成本更低。
*/
public final class Jt808MessageId {
private Jt808MessageId() {}
// ===== 终端上行 =====
public static final int TERMINAL_GENERAL_RESPONSE = 0x0001;
public static final int TERMINAL_HEARTBEAT = 0x0002;
public static final int TERMINAL_UNREGISTER = 0x0003;
public static final int TERMINAL_REGISTER = 0x0100;
public static final int TERMINAL_AUTH = 0x0102;
public static final int TERMINAL_PARAMS_REPORT = 0x0104;
public static final int TERMINAL_ATTRS_REPORT = 0x0107;
public static final int TERMINAL_LOCATION = 0x0200;
public static final int TERMINAL_LOCATION_BATCH = 0x0704;
public static final int TERMINAL_EVENT_REPORT = 0x0301;
public static final int TERMINAL_CAN_DATA_REPORT = 0x0705;
public static final int TERMINAL_MEDIA_EVENT = 0x0800;
public static final int TERMINAL_MEDIA_UPLOAD = 0x0801;
public static final int TERMINAL_PASSTHROUGH = 0x0900;
// ===== 平台下行 =====
public static final int PLATFORM_GENERAL_RESPONSE = 0x8001;
public static final int PLATFORM_REGISTER_ACK = 0x8100;
public static final int PLATFORM_SET_PARAMS = 0x8103;
public static final int PLATFORM_QUERY_PARAMS = 0x8104;
public static final int PLATFORM_TERMINAL_CONTROL = 0x8105;
public static final int PLATFORM_QUERY_LOCATION = 0x8201;
public static final int PLATFORM_TRACK_CONTROL = 0x8202;
}

View File

@@ -0,0 +1,62 @@
package com.lingniu.ingest.protocol.jt808.session;
import io.netty.channel.Channel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* JT808 终端 Channel 注册表。按 {@code phone} 索引,一个 phone 对应一个活跃 Channel。
*
* <p>{@link Jt808CommandDispatcher} 通过本类查 Channel{@code Jt808ChannelHandler} 在
* 连接建立 / 断开 / 鉴权完成时维护映射。
*
* <p>线程安全:{@link ConcurrentHashMap}。
*/
public final class Jt808ChannelRegistry {
private static final Logger log = LoggerFactory.getLogger(Jt808ChannelRegistry.class);
private final ConcurrentMap<String, Channel> byPhone = new ConcurrentHashMap<>();
private final ConcurrentMap<Channel, String> reverse = new ConcurrentHashMap<>();
private final ConcurrentMap<String, AtomicInteger> serials = new ConcurrentHashMap<>();
public void bind(String phone, Channel channel) {
if (phone == null || phone.isBlank() || channel == null) return;
Channel old = byPhone.put(phone, channel);
reverse.put(channel, phone);
if (old != null && old != channel) {
reverse.remove(old);
old.close();
log.info("channel rebound phone={} oldChannel={} newChannel={}", phone, old.id(), channel.id());
}
}
public Optional<Channel> find(String phone) {
if (phone == null) return Optional.empty();
Channel c = byPhone.get(phone);
if (c == null || !c.isActive()) {
if (c != null) unbind(c);
return Optional.empty();
}
return Optional.of(c);
}
public void unbind(Channel channel) {
String phone = reverse.remove(channel);
if (phone != null) byPhone.remove(phone, channel);
}
public int nextSerial(String phone) {
return serials.computeIfAbsent(phone, k -> new AtomicInteger())
.updateAndGet(i -> (i + 1) & 0xFFFF);
}
public int size() {
return byPhone.size();
}
}

View File

@@ -0,0 +1,51 @@
package com.lingniu.ingest.protocol.jt808.session;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* 下行命令的请求/应答同步匹配表。
*
* <p>调用 {@link #await(String, int)} 时注册一个 {@link CompletableFuture}
* 对应 key = {@code phone + ':' + serial};当 {@code Jt808ChannelHandler} 收到 0x0001 通用应答
* 或其它同步应答消息时调用 {@link #complete(String, int, Jt808Message)} 触发 future 完成。
*/
public final class Jt808PendingRequests {
private static final Logger log = LoggerFactory.getLogger(Jt808PendingRequests.class);
private final ConcurrentMap<String, CompletableFuture<Jt808Message>> pending = new ConcurrentHashMap<>();
public CompletableFuture<Jt808Message> await(String phone, int serial) {
CompletableFuture<Jt808Message> cf = new CompletableFuture<>();
pending.put(key(phone, serial), cf);
return cf;
}
public void complete(String phone, int serial, Jt808Message response) {
CompletableFuture<Jt808Message> cf = pending.remove(key(phone, serial));
if (cf != null) {
cf.complete(response);
} else {
log.debug("no pending request for phone={} serial={}", phone, serial);
}
}
public void cancel(String phone, int serial) {
CompletableFuture<Jt808Message> cf = pending.remove(key(phone, serial));
if (cf != null) cf.cancel(true);
}
public int size() {
return pending.size();
}
private static String key(String phone, int serial) {
return phone + ":" + serial;
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration

View File

@@ -0,0 +1,79 @@
package com.lingniu.ingest.protocol.jt808.codec;
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* JT808 黄金样本集回放测试:遍历 {@code src/test/resources/samples/jt808/*.hex}。
*
* <p>样本文件格式:一行十六进制(完整 0x7e 包裹帧),脚本脱敏产出。
* 测试会去掉首尾 0x7e执行反转义后调用 {@link Jt808MessageDecoder}。
*/
class Jt808DecoderGoldenTest {
private final Jt808MessageDecoder decoder = new Jt808MessageDecoder(
new BodyParserRegistry(List.of(
new RegisterBodyParser(),
new LocationBodyParser(),
new HeartbeatBodyParser())));
@TestFactory
Collection<DynamicTest> replaySamples() throws URISyntaxException, IOException {
var url = getClass().getClassLoader().getResource("samples/jt808");
if (url == null) return List.of();
Path dir = Paths.get(url.toURI());
try (Stream<Path> s = Files.list(dir)) {
return s.filter(p -> p.toString().endsWith(".hex"))
.sorted()
.map(this::toTest)
.collect(Collectors.toList());
}
}
private DynamicTest toTest(Path sample) {
return DynamicTest.dynamicTest(sample.getFileName().toString(), () -> {
byte[] framed = readHex(sample);
assertThat(framed[0]).as("must start with 0x7e").isEqualTo((byte) 0x7e);
assertThat(framed[framed.length - 1]).as("must end with 0x7e").isEqualTo((byte) 0x7e);
byte[] unescaped = Jt808Escape.unescape(framed, 1, framed.length - 2);
Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped));
assertThat(msg.header().messageId()).isNotNegative();
assertThat(msg.header().phone()).isNotEmpty();
});
}
private static byte[] readHex(Path path) throws IOException {
StringBuilder sb = new StringBuilder();
for (String line : Files.readAllLines(path)) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue;
sb.append(trimmed);
}
String hex = sb.toString();
int len = hex.length() / 2;
byte[] out = new byte[len];
for (int i = 0; i < len; i++) {
out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}

View File

@@ -0,0 +1,98 @@
package com.lingniu.ingest.protocol.jt808.codec;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.codec.BcdCodec;
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Jt808DecoderTest {
private final Jt808MessageDecoder decoder = new Jt808MessageDecoder(
new BodyParserRegistry(List.of(
new RegisterBodyParser(),
new LocationBodyParser(),
new HeartbeatBodyParser())));
@Test
void decodesSyntheticLocationFrame() {
byte[] body = buildLocationBody();
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 0x0001, body);
Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame));
assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION);
assertThat(msg.header().phone()).isEqualTo("123456789012");
assertThat(msg.header().serialNo()).isEqualTo(1);
assertThat(msg.body()).isInstanceOf(Jt808Body.Location.class);
Jt808Body.Location loc = (Jt808Body.Location) msg.body();
assertThat(loc.longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001));
assertThat(loc.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001));
assertThat(loc.speedKmh()).isEqualTo(52.3, org.assertj.core.data.Offset.offset(0.01));
}
@Test
void decodesSyntheticHeartbeatFrame() {
byte[] frame = buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 0x0002, new byte[0]);
Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame));
assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT);
assertThat(msg.body()).isInstanceOf(Jt808Body.Heartbeat.class);
}
// ===== helpers =====
private static byte[] buildLocationBody() {
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeU32(os, 0); // alarmFlag
writeU32(os, 0x00030000); // statusFlag
writeU32(os, 39_916_527L); // lat
writeU32(os, 116_397_128L); // lon
writeU16(os, 50); // altitude
writeU16(os, 523); // speed 52.3
writeU16(os, 90); // direction
byte[] bcd = BcdCodec.encode("240102030405");
os.write(bcd, 0, 6);
return os.toByteArray();
}
private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) {
// header: msgId(2) attrs(2) phone(6 BCD) serial(2)
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeU16(os, msgId);
int attrs = body.length & 0x03FF; // 2013 版,无子包、无加密
writeU16(os, attrs);
byte[] phoneBcd = BcdCodec.encode(phone);
os.write(phoneBcd, 0, 6);
writeU16(os, serial);
os.write(body, 0, body.length);
byte[] raw = os.toByteArray();
byte bcc = BccChecksum.compute(raw, 0, raw.length);
ByteArrayOutputStream framed = new ByteArrayOutputStream();
framed.write(raw, 0, raw.length);
framed.write(bcc & 0xFF);
return framed.toByteArray();
}
private static void writeU16(ByteArrayOutputStream os, int v) {
os.write((v >> 8) & 0xFF);
os.write(v & 0xFF);
}
private static void writeU32(ByteArrayOutputStream os, long v) {
os.write((int) ((v >> 24) & 0xFF));
os.write((int) ((v >> 16) & 0xFF));
os.write((int) ((v >> 8) & 0xFF));
os.write((int) (v & 0xFF));
}
}

View File

@@ -0,0 +1,25 @@
package com.lingniu.ingest.protocol.jt808.codec;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class Jt808EscapeTest {
@Test
void roundTrip() {
byte[] raw = {0x30, 0x7e, 0x08, 0x7d, (byte) 0xFF};
byte[] escaped = Jt808Escape.escape(raw);
assertThat(escaped).containsExactly(0x30, 0x7d, 0x02, 0x08, 0x7d, 0x01, 0xFF);
byte[] back = Jt808Escape.unescape(escaped, 0, escaped.length);
assertThat(back).containsExactly(raw[0], raw[1], raw[2], raw[3], raw[4]);
}
@Test
void noEscapeNeeded() {
byte[] raw = {0x01, 0x02, 0x03};
assertThat(Jt808Escape.escape(raw)).containsExactly(0x01, 0x02, 0x03);
assertThat(Jt808Escape.unescape(raw, 0, raw.length)).containsExactly(0x01, 0x02, 0x03);
}
}

View File

@@ -0,0 +1,57 @@
package com.lingniu.ingest.protocol.jt808.codec;
import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import org.junit.jupiter.api.Test;
import java.nio.ByteBuffer;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 端到端验证encoder 生成的下行帧可以被 decoder 反向解析,且字段对称。
*/
class Jt808FrameEncoderTest {
private final Jt808MessageDecoder decoder = new Jt808MessageDecoder(
new BodyParserRegistry(List.of(
new RegisterBodyParser(),
new LocationBodyParser(),
new HeartbeatBodyParser())));
@Test
void encodeThenDecodePlatformAck() {
var cmd = Jt808Commands.platformAck(0x1234, 0x0200, 0);
byte[] framed = Jt808FrameEncoder.encode(cmd.messageId(), "123456789012", 7, cmd.body());
// strip outer 0x7e and unescape
byte[] unescaped = Jt808Escape.unescape(framed, 1, framed.length - 2);
Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped));
assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.PLATFORM_GENERAL_RESPONSE);
assertThat(msg.header().phone()).isEqualTo("123456789012");
assertThat(msg.header().serialNo()).isEqualTo(7);
assertThat(msg.body()).isInstanceOf(Jt808Body.Raw.class);
Jt808Body.Raw raw = (Jt808Body.Raw) msg.body();
// body: ackSerial(2) + ackMsgId(2) + result(1) = 5 bytes
assertThat(raw.bytes()).hasSize(5);
}
@Test
void encodeQueryLocationProducesFramedWith7eBoundary() {
byte[] framed = Jt808FrameEncoder.encode(
Jt808MessageId.PLATFORM_QUERY_LOCATION, "123456789012", 1, new byte[0]);
assertThat(framed[0]).isEqualTo((byte) 0x7e);
assertThat(framed[framed.length - 1]).isEqualTo((byte) 0x7e);
// 中间不应出现裸 0x7e
for (int i = 1; i < framed.length - 1; i++) {
assertThat(framed[i]).isNotEqualTo((byte) 0x7e);
}
}
}

View File

@@ -0,0 +1,46 @@
package com.lingniu.ingest.protocol.jt808.mapper;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.VehicleEvent;
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 com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Jt808EventMapperTest {
private final Jt808EventMapper mapper = new Jt808EventMapper();
@Test
void locationBodyProducesLocationEvent() {
var header = new Jt808Header(
Jt808MessageId.TERMINAL_LOCATION, 28, 0, false,
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
var body = new Jt808Body.Location(
0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z"));
var msg = new Jt808Message(header, body);
List<VehicleEvent> events = mapper.toEvents(msg);
assertThat(events).hasSize(1);
assertThat(events.get(0)).isInstanceOf(VehicleEvent.Location.class);
assertThat(events.get(0).source()).isEqualTo(ProtocolId.JT808);
assertThat(events.get(0).vin()).isEqualTo("123456789012");
}
@Test
void heartbeatBodyProducesHeartbeatEvent() {
var header = new Jt808Header(
Jt808MessageId.TERMINAL_HEARTBEAT, 0, 0, false,
Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0);
var msg = new Jt808Message(header, new Jt808Body.Heartbeat());
assertThat(mapper.toEvents(msg)).hasSize(1)
.first().isInstanceOf(VehicleEvent.Heartbeat.class);
}
}

View File

@@ -0,0 +1 @@
7e010200070000000000230000687569746f6e67417e

View File

@@ -0,0 +1 @@
7e0102001800000000006500016e65382f51525a50664738596f7145575155577236513d3d607e

View File

@@ -0,0 +1 @@
7e02000036000000000001072d000000000008000301ceb03b0732f26e000d02af0010260413123353010400052425020200000302000025040000000030011f3101192c7e

View File

@@ -0,0 +1 @@
7e020040460100000000000000000002002c00000000000c000301bb0e2107213304003f0000010d26041312335514040000000017020000010400006722030200002504000000002a020000300115310118ea0402008000fb7e

View File

@@ -0,0 +1 @@
7e020000360000000000100278000000000008000301d596aa0735b9910001004b01472604131234030104000a42c10202000003020000250400000000300115310117107e

View File

@@ -0,0 +1 @@
7e020000360000000001020443000000000008000301d2d74907376cde000f00000000260413123354010400078789020200000302000025040000000030011b310112db7e

View File

@@ -0,0 +1 @@
7e02004048010000000000000000020204d2000000000048000301cc1d4f073ed6640010033301492604131234001404000000001702000001040000f0542504000000002a02000030011f310110ea04020c8300ef0400000000d67e

View File

@@ -0,0 +1 @@
7e02000036000000000413013d000000000008000201d392280736f1e30007000000002604131311220104000f334a020200000302000025040000000030011931010a097e

View File

@@ -0,0 +1 @@
7e0704003b000000000058034e0001010036000000000008000301e26c120725c15100110000000726041313111301040006ffc6030200002504000000002a02000030010a31010c3c7e