feat: make gb32960 archive history query production ready

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

View File

@@ -0,0 +1,59 @@
<?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-gb32960</artifactId>
<name>protocol-gb32960</name>
<description>GB/T 32960 新能源车国标协议接入Netty Server默认 TCP 9000</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>ingest-codec-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,94 @@
package com.lingniu.ingest.protocol.gb32960.auth;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 平台登入0x05账号白名单校验器。
*
* <p>与 {@link Gb32960VinAuthorizer} 区别:
* <ul>
* <li>车辆登入靠 VIN 白名单(无密码)
* <li>平台登入有 12B 用户名 + 20B 密码 + 可选 IP 白名单
* </ul>
*
* <p>匹配规则:
* <ol>
* <li>配置列表为空 → 返回 {@link Result#ALLOW_NO_POLICY}(兼容老行为,放行但标记未鉴权)
* <li>用户名不存在 → {@link Result#DENY_UNKNOWN_USER}
* <li>密码不匹配 → {@link Result#DENY_BAD_PASSWORD}
* <li>IP 白名单非空且 peer IP 不在其中 → {@link Result#DENY_IP_NOT_ALLOWED}
* <li>全部通过 → {@link Result#ALLOW}
* </ol>
*/
public final class Gb32960PlatformAuthorizer {
private static final Logger log = LoggerFactory.getLogger(Gb32960PlatformAuthorizer.class);
private final Map<String, Gb32960Properties.Auth.Platform> byUsername;
public Gb32960PlatformAuthorizer(List<Gb32960Properties.Auth.Platform> platforms) {
Map<String, Gb32960Properties.Auth.Platform> map = new HashMap<>();
for (Gb32960Properties.Auth.Platform p : platforms) {
if (p.getUsername() == null || p.getUsername().isBlank()) continue;
map.put(p.getUsername().trim(), p);
}
this.byUsername = Collections.unmodifiableMap(map);
log.info("[gb32960] platform authorizer loaded {} platform(s)", this.byUsername.size());
}
/**
* 鉴权一次平台登入请求。
*
* @param username 终端发来的用户名
* @param password 终端发来的密码
* @param peerIp 连接对端 IP纯 IPv4/IPv6不含端口
*/
public Result authenticate(String username, String password, String peerIp) {
if (byUsername.isEmpty()) return Result.ALLOW_NO_POLICY;
Gb32960Properties.Auth.Platform entry = byUsername.get(username == null ? "" : username.trim());
if (entry == null) return Result.DENY_UNKNOWN_USER;
if (!Objects.equals(entry.getPassword(), password)) return Result.DENY_BAD_PASSWORD;
Set<String> allowed = entry.getAllowedIps() == null
? Set.of()
: Set.copyOf(entry.getAllowedIps());
if (!allowed.isEmpty() && (peerIp == null || !allowed.contains(peerIp))) {
return Result.DENY_IP_NOT_ALLOWED;
}
return Result.ALLOW;
}
public int size() {
return byUsername.size();
}
public boolean isEnforcing() {
return !byUsername.isEmpty();
}
/** 鉴权结果枚举,调用方根据结果选择应答标志和日志级别。 */
public enum Result {
/** 匹配成功。 */
ALLOW(true),
/** 配置列表为空,放行但标记未鉴权。 */
ALLOW_NO_POLICY(true),
/** 用户名不存在。 */
DENY_UNKNOWN_USER(false),
/** 密码错误。 */
DENY_BAD_PASSWORD(false),
/** IP 白名单拒绝。 */
DENY_IP_NOT_ALLOWED(false);
private final boolean accepted;
Result(boolean accepted) { this.accepted = accepted; }
public boolean accepted() { return accepted; }
}
}

View File

@@ -0,0 +1,63 @@
package com.lingniu.ingest.protocol.gb32960.auth;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
/**
* GB/T 32960 VIN 白名单校验器。
*
* <p>线程安全:内部持有 {@code AtomicReference<Set>},支持运行时热替换白名单
* (未来可扩展为从 Nacos / 数据库定时刷新)。
*/
public final class Gb32960VinAuthorizer {
private static final Logger log = LoggerFactory.getLogger(Gb32960VinAuthorizer.class);
private final boolean enabled;
private final boolean caseSensitive;
private final AtomicReference<Set<String>> whitelist;
public Gb32960VinAuthorizer(Gb32960Properties.Auth cfg) {
this.enabled = cfg.isEnabled();
this.caseSensitive = cfg.isCaseSensitive();
this.whitelist = new AtomicReference<>(cfg.normalizedWhitelist());
log.info("[gb32960] VIN authorizer enabled={} caseSensitive={} whitelistSize={}",
enabled, caseSensitive, this.whitelist.get().size());
}
/**
* 鉴权。
*
* @return true 通过false 拒绝(调用方应发送 0x04 VIN_NOT_EXIST 应答并关闭连接)
*/
public boolean isAllowed(String vin) {
if (!enabled) return true;
if (vin == null || vin.isBlank()) return false;
String key = caseSensitive ? vin.trim() : vin.trim().toUpperCase();
return whitelist.get().contains(key);
}
public boolean isEnforcing() {
return enabled;
}
public int whitelistSize() {
return whitelist.get().size();
}
/** 运行时热更新。接入 Nacos/DB 监听后调用。 */
public void replaceWhitelist(Set<String> newVins) {
Set<String> norm = Collections.newSetFromMap(new java.util.concurrent.ConcurrentHashMap<>(newVins.size()));
for (String v : newVins) {
if (v == null || v.isBlank()) continue;
norm.add(caseSensitive ? v.trim() : v.trim().toUpperCase());
}
this.whitelist.set(norm);
log.info("[gb32960] VIN whitelist replaced size={}", norm.size());
}
}

View File

@@ -0,0 +1,236 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.api.spi.DecodeException;
import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionSelector;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 实时/补发上报数据单元循环解析器。
*
* <p>数据单元布局(表 7{@code timestamp(6B) | (typeFlag(1B) + infoBody)* | [0xFF 签名信息]}。
* 其中时间戳由 {@link Gb32960MessageDecoder} 负责剥离,本 parser 只处理信息体循环。
*
* <p>支持的信息类型标志(表 9
* <table>
* <tr><th>2016</th><th>2025</th><th>含义</th></tr>
* <tr><td>0x01</td><td>0x01</td><td>整车数据</td></tr>
* <tr><td>0x02</td><td>0x02</td><td>驱动电机数据</td></tr>
* <tr><td>0x03</td><td>0x03</td><td>燃料电池发动机及车载氢系统数据</td></tr>
* <tr><td>0x04</td><td>0x04</td><td>发动机数据</td></tr>
* <tr><td>0x05</td><td>0x05</td><td>车辆位置数据2025 版增加坐标系字段)</td></tr>
* <tr><td>0x06 极值</td><td>0x06 报警</td><td>&nbsp;</td></tr>
* <tr><td>0x07 报警</td><td>0x07 动力蓄电池最小并联单元电压</td><td>&nbsp;</td></tr>
* <tr><td>0x08 储能电压</td><td>0x08 动力蓄电池温度</td><td>&nbsp;</td></tr>
* <tr><td>0x09 储能温度</td><td>—</td><td>&nbsp;</td></tr>
* <tr><td>—</td><td>0x30</td><td>燃料电池电堆</td></tr>
* <tr><td>—</td><td>0x31</td><td>超级电容器</td></tr>
* <tr><td>—</td><td>0x32</td><td>超级电容器极值</td></tr>
* <tr><td>—</td><td>0xFF</td><td>签名数据开始标识2025 版新增)</td></tr>
* </table>
*
* <p>处理策略:
* <ul>
* <li>遇到 {@code 0xFF}2025 版签名起始标识):停止循环,剩余字节作为签名原始字节返回
* <li>遇到未注册的类型:作为单个 {@link InfoBlock.Raw} 包装剩余字节并终止循环,避免丢失整帧
* </ul>
*/
public final class Gb32960BodyParser {
private static final Logger log = LoggerFactory.getLogger(Gb32960BodyParser.class);
/** 2025 版实时上报尾部签名标识(表 9。 */
private static final int SIGNATURE_MARKER = 0xFF;
private final Gb32960ProfileRegistry profileRegistry;
private final VendorExtensionSelector selector;
/**
* 单块解析异常时是否兜底为 {@link InfoBlock.Raw} 后继续。由
* {@link com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties.Parse#isLenientBlockFailure()}
* 注入;默认 true。实际行为在
* {@link #parse(Gb32960ParserContext, ByteBuffer)} 主循环里实现。
*/
private boolean lenientBlockFailure = true;
public void setLenientBlockFailure(boolean lenientBlockFailure) {
this.lenientBlockFailure = lenientBlockFailure;
}
/**
* 单 registry 构造(向后兼容旧测试)。等价于 default profile + 无 vendor 扩展。
*/
public Gb32960BodyParser(InfoBlockParserRegistry registry) {
this.profileRegistry = new Gb32960ProfileRegistry(
new java.util.ArrayList<>(), // ignored, defaultRegistry overridden via wrapper below
new com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog(java.util.Map.of()),
java.util.Set.of()) {
@Override public InfoBlockParserRegistry forProfile(String name) { return registry; }
@Override public InfoBlockParserRegistry defaultRegistry() { return registry; }
};
this.selector = VendorExtensionSelector.NONE;
}
/**
* 标准构造profile 注册表 + vendor 选择器。运行期由
* {@link Gb32960MessageDecoder} 调用 {@link #parse(Gb32960ParserContext, ByteBuffer)}
* 携带上下文,按 selector 路由到具体 profile 的 registry。
*/
public Gb32960BodyParser(Gb32960ProfileRegistry profileRegistry, VendorExtensionSelector selector) {
this.profileRegistry = profileRegistry;
this.selector = selector;
}
/** 旧入口:无上下文 → 走默认 profile。保留用于不需要 vendor 路由的单测。 */
public Gb32960MessageDecoder.BodyParseResult parse(ProtocolVersion version, ByteBuffer body) {
return parse(Gb32960ParserContext.minimal(version), body);
}
/** 主入口:根据上下文按 selector 选 vendor profile然后照旧解析循环。 */
public Gb32960MessageDecoder.BodyParseResult parse(Gb32960ParserContext ctx, ByteBuffer body) {
ProtocolVersion version = ctx.version();
InfoBlockParserRegistry registry = profileRegistry.forProfile(selector.select(ctx));
List<InfoBlock> blocks = new ArrayList<>(8);
byte[] signature = null;
// 捕获 info-block 区间的起止位置,用于未知块出现时打 dump 帮助手动定位漂移点。
// body 由 Gb32960MessageDecoder 通过 ByteBuffer.wrap(frame, bodyStart+6, dataLength-6) 构造,
// arrayOffset()=0position() = bodyStart+6limit() = bodyStart + dataLength。
final int infoBlocksStart = body.position();
final int infoBlocksEnd = body.limit();
while (body.hasRemaining()) {
int typeStartPos = body.position();
int typeCode = body.get() & 0xFF;
if (typeCode == SIGNATURE_MARKER && version == ProtocolVersion.V2025) {
// 剩余全部字节即为签名内容(表 8
signature = new byte[body.remaining()];
body.get(signature);
break;
}
InfoBlockParser parser = registry.find(version, typeCode);
if (parser == null) {
// lenient未知类型无法安全跳过吞剩余字节作为 Raw 后终止。
// 真实 typeCode 透传到 Raw 字段,便于在日志/JSON 里直接看到漂移落点。
int remaining = body.remaining();
byte[] rest = new byte[remaining];
body.get(rest);
log.warn("[gb32960] unknown info block typeCode=0x{} version={} bodyPos={} parsedBlocks={} remaining={} preview={}",
Integer.toHexString(typeCode), version, typeStartPos,
blocks.size(), remaining, hexPreview(rest, 16));
// 一次性把整段 info-block 区间的原始字节打出来,并在 typeStartPos 处插入标记,
// 帮助人工对比规范字段长度、定位是哪个早期块对齐错。
if (body.hasArray()) {
log.warn("[gb32960] body dump (info-block region) infoBlocksStart={} infoBlocksEnd={} typeStartPos={} totalLen={} alreadyParsed={}\n{}",
infoBlocksStart, infoBlocksEnd, typeStartPos,
infoBlocksEnd - infoBlocksStart, blocks.size(),
hexDumpWithMarker(body.array(), infoBlocksStart, infoBlocksEnd, typeStartPos));
}
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, rest));
break;
}
int fixedLen = parser.fixedLength();
int posBefore = body.position();
// 预检固定长度块在剩余字节不足时lenient 模式兜 Raw 后 break严格模式保持旧行为抛异常。
if (fixedLen >= 0 && body.remaining() < fixedLen) {
if (!lenientBlockFailure) {
throw new DecodeException(
"info block 0x" + Integer.toHexString(typeCode)
+ " needs " + fixedLen + " bytes but got " + body.remaining());
}
int remaining = body.remaining();
byte[] tail = new byte[remaining];
body.get(tail);
log.warn("[gb32960] truncated block typeCode=0x{} declaredFixedLen={} remaining={} — wrapping tail as Raw",
Integer.toHexString(typeCode), fixedLen, remaining);
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail));
break;
}
InfoBlock block;
try {
block = parser.parse(body);
} catch (DecodeException | java.nio.BufferUnderflowException | IndexOutOfBoundsException e) {
if (!lenientBlockFailure) {
if (e instanceof DecodeException de) throw de;
throw new DecodeException(
"parser " + parser.getClass().getSimpleName() + " failed: " + e.getMessage(), e);
}
// 回滚到 parser.parse 入口,按块类型分支处理。
body.position(posBefore);
if (fixedLen >= 0) {
// 固定长度块:按 fixedLen 截取 → 兜 Raw → continue 循环
byte[] corrupt = new byte[fixedLen];
body.get(corrupt);
log.warn("[gb32960] block parse failed typeCode=0x{} parser={} fixedLen={} — isolated as Raw, continuing",
Integer.toHexString(typeCode), parser.getClass().getSimpleName(), fixedLen, e);
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, corrupt));
continue;
}
// 变长块:无法安全找下一块边界 → 剩余整段兜 Raw + break
int remaining = body.remaining();
byte[] tail = new byte[remaining];
body.get(tail);
log.warn("[gb32960] variable-length block parse failed typeCode=0x{} parser={} remaining={} — wrapping remainder as Raw, stopping loop",
Integer.toHexString(typeCode), parser.getClass().getSimpleName(), remaining, e);
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail));
break;
}
int consumed = body.position() - posBefore;
if (fixedLen >= 0 && consumed != fixedLen) {
// parser 契约违反(声明 fixedLen=X 但实际读了 Y保持抛异常以暴露 parser bug
// 不走 lenient 兜底,避免静默误解析长期误判。
throw new DecodeException(
"parser " + parser.getClass().getSimpleName()
+ " consumed " + consumed
+ " bytes but declared " + fixedLen);
}
if (log.isDebugEnabled()) {
log.debug("[gb32960] block typeCode=0x{} parser={} pos {}→{} consumed={} declaredFixed={}",
Integer.toHexString(typeCode), parser.getClass().getSimpleName(),
typeStartPos, body.position(), consumed, fixedLen);
}
blocks.add(block);
}
return new Gb32960MessageDecoder.BodyParseResult(blocks, signature);
}
private static String hexPreview(byte[] bytes, int max) {
int n = Math.min(bytes.length, max);
StringBuilder sb = new StringBuilder(n * 2);
for (int i = 0; i < n; i++) sb.append(String.format("%02x", bytes[i]));
if (bytes.length > max) sb.append("..");
return sb.toString();
}
/**
* 16 字节/行的 hex dump遇到 typeStartPos 时在该字节后插入 {@code |<<<HERE} 标记。
* 行首打印绝对偏移(与 BodyParser 上下文里的 bodyPos 字段一致),方便逐字段倒查。
*/
private static String hexDumpWithMarker(byte[] arr, int from, int toExclusive, int markerAbs) {
StringBuilder sb = new StringBuilder((toExclusive - from) * 4);
for (int i = from; i < toExclusive; i += 16) {
sb.append(String.format("%04d:", i));
for (int j = 0; j < 16 && i + j < toExclusive; j++) {
int p = i + j;
sb.append(' ').append(String.format("%02x", arr[p]));
if (p == markerAbs) sb.append("<");
}
sb.append('\n');
}
return sb.toString();
}
}

View File

@@ -0,0 +1,294 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.api.spi.DecodeException;
import com.lingniu.ingest.protocol.gb32960.model.CommandBody;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 非实时上报的命令数据单元解析器。覆盖:
* <ul>
* <li>0x01 车辆登入(表 6
* <li>0x04 车辆登出(表 28
* <li>0x05 平台登入(表 29
* <li>0x06 平台登出(表 30
* <li>0x07 心跳(空)
* <li>0x08 终端校时(空)
* <li>0x09 激活(表 B.3
* <li>0x0A 激活应答(表 B.4
* <li>0x0B 密钥交换(表 31
* </ul>
*
* <p>其它命令(查询/设置/控制/自定义)保留为 {@link CommandBody.RawCommand}。
*/
public final class Gb32960CommandParser {
private static final ZoneId ZONE_GMT8 = ZoneId.of("Asia/Shanghai");
public CommandBody parse(CommandType command, ByteBuffer buf) {
return switch (command) {
case VEHICLE_LOGIN -> parseVehicleLogin(buf);
case VEHICLE_LOGOUT -> parseVehicleLogout(buf);
case PLATFORM_LOGIN -> parsePlatformLogin(buf);
case PLATFORM_LOGOUT -> parsePlatformLogout(buf);
case HEARTBEAT -> new CommandBody.Heartbeat();
case TIME_CALIBRATION -> new CommandBody.TimeCalibration();
case ACTIVATION -> parseActivation(buf);
case ACTIVATION_RESPONSE -> parseActivationResponse(buf);
case KEY_EXCHANGE -> parseKeyExchange(buf);
case QUERY -> parseQueryParams(buf);
case SET_PARAMS -> parseSetParams(buf);
case TERMINAL_CONTROL -> parseTerminalControl(buf);
default -> captureRaw(command, buf);
};
}
/** 表 6车辆登入。 */
private CommandBody.VehicleLogin parseVehicleLogin(ByteBuffer buf) {
Instant time = readTime(buf);
int serialNo = buf.getShort() & 0xFFFF;
String iccid = readAscii(buf, 20);
// 2025 版新增:电池管理系统数 n + 各系统包数 + 编码列表
int batterySystemCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
List<Integer> packCounts = new ArrayList<>();
int totalPacks = 0;
for (int i = 0; i < batterySystemCount && buf.hasRemaining(); i++) {
int c = buf.get() & 0xFF;
packCounts.add(c);
totalPacks += c;
}
List<String> codes = new ArrayList<>(totalPacks);
for (int i = 0; i < totalPacks && buf.remaining() >= 24; i++) {
codes.add(readAscii(buf, 24));
}
return new CommandBody.VehicleLogin(time, serialNo, iccid, batterySystemCount, packCounts, codes);
}
private CommandBody.VehicleLogout parseVehicleLogout(ByteBuffer buf) {
return new CommandBody.VehicleLogout(readTime(buf), buf.getShort() & 0xFFFF);
}
private CommandBody.PlatformLogin parsePlatformLogin(ByteBuffer buf) {
Instant time = readTime(buf);
int serial = buf.getShort() & 0xFFFF;
String user = readAscii(buf, 12);
int passwordLength = Math.max(0, buf.remaining() - 1);
String pwd = readAscii(buf, passwordLength);
EncryptType enc = EncryptType.of(buf.get() & 0xFF);
return new CommandBody.PlatformLogin(time, serial, user, pwd, enc);
}
private CommandBody.PlatformLogout parsePlatformLogout(ByteBuffer buf) {
return new CommandBody.PlatformLogout(readTime(buf), buf.getShort() & 0xFFFF);
}
private CommandBody.Activation parseActivation(ByteBuffer buf) {
Instant time = readTime(buf);
String chipId = readAscii(buf, 16);
int keyLen = buf.getShort() & 0xFFFF;
byte[] pubKey = new byte[Math.min(keyLen, buf.remaining())];
buf.get(pubKey);
String vin = readAscii(buf, 17);
byte[] signature = new byte[buf.remaining()];
buf.get(signature);
return new CommandBody.Activation(time, chipId, pubKey, vin, signature);
}
private CommandBody.ActivationResponse parseActivationResponse(ByteBuffer buf) {
return new CommandBody.ActivationResponse(buf.get() & 0xFF, buf.get() & 0xFF);
}
private CommandBody.KeyExchange parseKeyExchange(ByteBuffer buf) {
EncryptType keyType = EncryptType.of(buf.get() & 0xFF);
int keyLen = buf.getShort() & 0xFFFF;
byte[] key = new byte[Math.min(keyLen, buf.remaining() - 12)];
buf.get(key);
Instant activateAt = readTime(buf);
Instant expireAt = readTime(buf);
return new CommandBody.KeyExchange(keyType, key, activateAt, expireAt);
}
private CommandBody.RawCommand captureRaw(CommandType command, ByteBuffer buf) {
byte[] bytes = new byte[buf.remaining()];
buf.get(bytes);
return new CommandBody.RawCommand(command.code(), bytes);
}
// =========================================================================
// 表 B.5-B.13 下行命令
// =========================================================================
/**
* 参数查询 0x80表 B.5{@code time(6) + count(1) + paramId(count)}。
* 如果 count > 0则是查询命令如果 count == 0 可能是终端应答(表 B.6
* 应答带参数项列表(表 B.7paramValue 长度根据 paramId 查表 B.8 决定。
*/
private CommandBody parseQueryParams(ByteBuffer buf) {
Instant time = readTime(buf);
int count = buf.get() & 0xFF;
// 判定是查询命令还是应答:命令方向下 count 后紧跟 paramId 列表(每项 1 字节);
// 应答方向下 count 后是完整的 paramId + paramValue 列表(表 B.7)。
// 通过剩余字节数判断:命令形态 remaining == count应答形态 remaining > count
if (buf.remaining() == count) {
List<Integer> ids = new ArrayList<>(count);
for (int i = 0; i < count; i++) ids.add(buf.get() & 0xFF);
return new CommandBody.QueryParams(time, ids);
}
// 作为应答处理
Map<Integer, byte[]> params = readParamItems(buf, count);
return new CommandBody.QueryParamsResponse(time, params);
}
/** 参数设置 0x81表 B.9{@code time(6) + count(1) + paramItems}。 */
private CommandBody.SetParams parseSetParams(ByteBuffer buf) {
Instant time = readTime(buf);
int count = buf.get() & 0xFF;
Map<Integer, byte[]> params = readParamItems(buf, count);
return new CommandBody.SetParams(time, params);
}
/** 读取 N 个参数项(表 B.7{@code paramId(1) + paramValue(按表 B.8 解释)}。 */
private Map<Integer, byte[]> readParamItems(ByteBuffer buf, int count) {
Map<Integer, byte[]> out = new HashMap<>(count);
for (int i = 0; i < count && buf.hasRemaining(); i++) {
int paramId = buf.get() & 0xFF;
int len = paramValueLength(paramId, buf);
if (len < 0 || len > buf.remaining()) {
// 长度未知或越界:吞剩余字节作为该参数值后终止
byte[] rest = new byte[buf.remaining()];
buf.get(rest);
out.put(paramId, rest);
break;
}
byte[] value = new byte[len];
buf.get(value);
out.put(paramId, value);
}
return out;
}
/**
* 按表 B.8 返回参数值长度。
*
* <p>对于变长参数0x05 域名 / 0x0E 公共平台域名前一个参数0x04/0x0D给出长度
* 这里简化处理:变长参数使用 {@code buf.get() & 0xFF + 1} 预读 + 取值。
*/
private int paramValueLength(int paramId, ByteBuffer buf) {
return switch (paramId) {
case 0x01 -> 2; // 本地存储时间周期 WORD
case 0x02 -> 1; // 正常信息上报时间周期
case 0x03 -> 1; // 报警信息上报时间周期
case 0x04 -> 1; // 平台域名长度 M
case 0x05 -> {
// 平台域名 M 字节M 由前一个参数的值给出。
// 安全起见这里一次吞掉剩余字节(实际场景参数 0x04 和 0x05 通常一起下发)
yield buf.remaining();
}
case 0x06 -> 2; // 平台端口 WORD
case 0x07, 0x08 -> 5; // 硬件/固件版本 5 字节
case 0x09 -> 1; // 心跳发送周期
case 0x0A -> 2; // 终端应答超时时间
case 0x0B -> 2; // 平台应答超时时间
case 0x0C -> 1; // 登入失败后下次登入间隔
case 0x0D -> 1; // 公共平台域名长度 N
case 0x0E -> buf.remaining(); // 公共平台域名
case 0x0F -> 2; // 公共平台端口
case 0x10 -> 1; // 是否抽样监测中
default -> -1; // 未知参数:无法决定长度
};
}
/** 车载终端控制 0x82表 B.10)。 */
private CommandBody.TerminalControl parseTerminalControl(ByteBuffer buf) {
Instant time = readTime(buf);
int cmdId = buf.get() & 0xFF;
CommandBody.ControlCommand commandId = CommandBody.ControlCommand.of(cmdId);
CommandBody.ControlPayload payload = switch (commandId) {
case REMOTE_UPGRADE -> parseRemoteUpgrade(buf);
case ALARM_COMMAND -> parseAlarmCommand(buf);
case SHUTDOWN, RESET, FACTORY_RESET, DISCONNECT, SAMPLING_LINK_ON -> new CommandBody.ControlPayload.None();
case UNKNOWN -> captureRawPayload(buf);
};
return new CommandBody.TerminalControl(time, commandId, payload);
}
/**
* 表 B.12 远程升级参数。字段之间用半角分号 {@code ;} 分隔。
*
* <p>字段顺序URL;apn;dialUser;dialPwd;serverAddress;serverPort;manufacturerCode;hwVer;fwVer;timeoutMin
*/
private CommandBody.ControlPayload.RemoteUpgrade parseRemoteUpgrade(ByteBuffer buf) {
byte[] all = new byte[buf.remaining()];
buf.get(all);
String s = new String(all, Charset.forName("GBK"));
String[] parts = s.split(";", -1);
String url = parts.length > 0 ? parts[0] : "";
String apn = parts.length > 1 ? parts[1] : "";
String user = parts.length > 2 ? parts[2] : "";
String pwd = parts.length > 3 ? parts[3] : "";
byte[] addr = parts.length > 4 ? parts[4].getBytes(StandardCharsets.US_ASCII) : new byte[0];
int port = parts.length > 5 ? parseIntSafe(parts[5]) : 0;
String manu = parts.length > 6 ? parts[6] : "";
String hw = parts.length > 7 ? parts[7] : "";
String fw = parts.length > 8 ? parts[8] : "";
int timeoutMin = parts.length > 9 ? parseIntSafe(parts[9]) : 0;
return new CommandBody.ControlPayload.RemoteUpgrade(
apn, user, pwd, addr, port, manu, hw, fw, url, timeoutMin);
}
/** 表 B.13 报警/预警命令。 */
private CommandBody.ControlPayload.AlarmCommand parseAlarmCommand(ByteBuffer buf) {
int warningLevel = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
byte[] info = new byte[buf.remaining()];
buf.get(info);
return new CommandBody.ControlPayload.AlarmCommand(warningLevel, info);
}
private CommandBody.ControlPayload.RawPayload captureRawPayload(ByteBuffer buf) {
byte[] bytes = new byte[buf.remaining()];
buf.get(bytes);
return new CommandBody.ControlPayload.RawPayload(bytes);
}
private static int parseIntSafe(String s) {
try { return Integer.parseInt(s.trim()); } catch (Exception e) { return 0; }
}
/** 表 5时间定义 YY MM DD HH MM SSGMT+8。 */
private Instant readTime(ByteBuffer buf) {
if (buf.remaining() < 6) {
throw new DecodeException("time field requires 6 bytes, got " + buf.remaining());
}
int year = 2000 + (buf.get() & 0xFF);
int month = buf.get() & 0xFF;
int day = buf.get() & 0xFF;
int hour = buf.get() & 0xFF;
int minute = buf.get() & 0xFF;
int second = buf.get() & 0xFF;
try {
return LocalDateTime.of(year, month, day, hour, minute, second).atZone(ZONE_GMT8).toInstant();
} catch (Exception e) {
throw new DecodeException("invalid time: " + year + "-" + month + "-" + day
+ " " + hour + ":" + minute + ":" + second, e);
}
}
private String readAscii(ByteBuffer buf, int len) {
int safeLen = Math.min(len, buf.remaining());
byte[] b = new byte[safeLen];
buf.get(b);
return new String(b, StandardCharsets.US_ASCII).trim();
}
}

View File

@@ -0,0 +1,102 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.lingniu.ingest.codec.BccChecksum;
import java.util.List;
/**
* Netty 层的帧分帧器:从字节流里提取一条完整的 32960 帧。
*
* <p>支持两种起始符:{@code 0x23 0x23 (##)} 2016 版,{@code 0x24 0x24 ($$)} 2025 版。
* 只负责拆包粘包,不做业务解析。具体字段解析交给 {@link Gb32960MessageDecoder}。
*
* <p>开启 DEBUG 日志可以看到每次读到的字节、每条产出的完整帧以及因长度/起始符不匹配被丢弃的字节数。
*/
public class Gb32960FrameDecoder extends ByteToMessageDecoder {
private static final Logger log = LoggerFactory.getLogger(Gb32960FrameDecoder.class);
private static final int HEADER_LEN = 24; // 起始(2) + cmd(1) + rsp(1) + vin(17) + enc(1) + len(2)
private static final int MAX_FRAME = 8 * 1024;
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (log.isDebugEnabled() && in.readableBytes() > 0) {
int peek = Math.min(in.readableBytes(), 64);
log.debug("[gb32960] frame-decoder rx bytes={} head={}",
in.readableBytes(),
ByteBufUtil.hexDump(in, in.readerIndex(), peek));
}
while (in.readableBytes() >= HEADER_LEN) {
int startIdx = findStart(in);
if (startIdx < 0 || startIdx > in.writerIndex() - HEADER_LEN) {
int drop = Math.max(0, in.readableBytes() - 1);
if (drop > 0) {
log.debug("[gb32960] frame-decoder skip {} bytes (no start symbol)", drop);
in.skipBytes(drop);
}
return;
}
if (startIdx != in.readerIndex()) {
int drop = startIdx - in.readerIndex();
log.debug("[gb32960] frame-decoder skip {} leading bytes before start", drop);
in.skipBytes(drop);
}
int dataLen = in.getUnsignedShort(in.readerIndex() + 22);
int frameLen = frameLength(in, in.readerIndex(), dataLen);
if (frameLen > MAX_FRAME) {
log.warn("[gb32960] frame-decoder oversized frame len={} peer={}, skip start",
frameLen, ctx.channel().remoteAddress());
in.skipBytes(2);
continue;
}
if (in.readableBytes() < frameLen) return;
byte[] frame = new byte[frameLen];
in.readBytes(frame);
if (log.isDebugEnabled()) {
log.debug("[gb32960] frame-decoder emit len={} start={} peer={}",
frameLen,
String.format("%02x%02x", frame[0], frame[1]),
ctx.channel().remoteAddress());
}
out.add(frame);
}
}
private static int frameLength(ByteBuf in, int readerIndex, int dataLen) {
int standardLen = HEADER_LEN + dataLen + 1;
int command = in.getUnsignedByte(readerIndex + 2);
if (command != 0x05 || dataLen != 41) {
return standardLen;
}
int extendedLen = HEADER_LEN + 53 + 1;
if (in.readableBytes() < extendedLen) {
return standardLen;
}
byte[] extended = new byte[extendedLen];
in.getBytes(readerIndex, extended);
if (BccChecksum.verify(extended, 2, extended.length - 3, extended[extended.length - 1])) {
return extendedLen;
}
return standardLen;
}
/** 查找下一个合法起始位置:两字节连续 0x23 0x23 或 0x24 0x24。 */
private static int findStart(ByteBuf in) {
int limit = in.writerIndex() - 1;
for (int i = in.readerIndex(); i < limit; i++) {
byte b = in.getByte(i);
if ((b == 0x23 || b == 0x24) && in.getByte(i + 1) == b) {
return i;
}
}
return -1;
}
}

View File

@@ -0,0 +1,111 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
/**
* GB/T 32960.3 下行帧编码器。主要用于应答场景:平台收到终端上行命令后发送应答。
*
* <p>应答规则§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、
* 重新计算校验位。
*/
public final class Gb32960FrameEncoder {
private static final ZoneId ZONE_GMT8 = ZoneId.of("Asia/Shanghai");
private Gb32960FrameEncoder() {}
/**
* 构造简易应答帧:复用原命令的 command/vin/encryptType修改应答标志
* 可选携带数据单元(通常只带 6 字节采集时间)。
*
* @param version 协议版本(决定起始符)
* @param command 命令标识(与原命令相同)
* @param responseFlag 应答标志:{@link ResponseFlag#SUCCESS}/{@link ResponseFlag#VIN_NOT_EXIST} 等
* @param vin 17 字节 VIN
* @param collectTime 数据采集时间(保留原帧时间,若无则传 {@code Instant.now()}
* @param extraBody 附加数据(可为 null
*/
public static byte[] buildResponse(ProtocolVersion version,
CommandType command,
ResponseFlag responseFlag,
String vin,
Instant collectTime,
byte[] extraBody) {
return buildResponse(version, command, responseFlag, vinPadded(vin), collectTime, extraBody);
}
/**
* 同 {@link #buildResponse(ProtocolVersion, CommandType, ResponseFlag, String, Instant, byte[])}
* 但直接接收 17 字节原始 VIN。用于回执时原样回显请求帧的 VIN 字节,避免
* 平台登入这类 VIN 全 0 的场景被我们用空格重写后导致对端判定 VIN 不匹配。
*/
public static byte[] buildResponse(ProtocolVersion version,
CommandType command,
ResponseFlag responseFlag,
byte[] vin17,
Instant collectTime,
byte[] extraBody) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
// 数据采集时间 6 字节(表 5
if (collectTime != null) {
LocalDateTime ldt = LocalDateTime.ofInstant(collectTime, ZONE_GMT8);
body.write(ldt.getYear() - 2000);
body.write(ldt.getMonthValue());
body.write(ldt.getDayOfMonth());
body.write(ldt.getHour());
body.write(ldt.getMinute());
body.write(ldt.getSecond());
}
if (extraBody != null && extraBody.length > 0) {
body.write(extraBody, 0, extraBody.length);
}
byte[] bodyBytes = body.toByteArray();
int dataLength = bodyBytes.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
// 起始符
if (version == ProtocolVersion.V2025) {
out.write(0x24); out.write(0x24);
} else {
out.write(0x23); out.write(0x23);
}
out.write(command.code());
out.write(responseFlag.code());
// VIN17 字节,由调用方给出;平台登入等场景直接回显请求的原始字节)
byte[] vinBytes = vin17 != null && vin17.length == 17 ? vin17 : vinPadded(null);
out.write(vinBytes, 0, 17);
// 加密方式:应答不加密
out.write(EncryptType.UNENCRYPTED.code());
// 数据单元长度 WORD 大端
out.write((dataLength >> 8) & 0xFF);
out.write(dataLength & 0xFF);
out.write(bodyBytes, 0, bodyBytes.length);
// BCC从 cmd(offset 2) 到数据单元结尾
byte[] almost = out.toByteArray();
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
out.write(bcc & 0xFF);
return out.toByteArray();
}
private static byte[] vinPadded(String vin) {
byte[] out = new byte[17];
// 填充空格
java.util.Arrays.fill(out, (byte) ' ');
if (vin == null) return out;
byte[] src = vin.getBytes(StandardCharsets.US_ASCII);
int len = Math.min(src.length, 17);
System.arraycopy(src, 0, out, 0, len);
return out;
}
}

View File

@@ -0,0 +1,179 @@
package com.lingniu.ingest.protocol.gb32960.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.protocol.gb32960.model.CommandBody;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Header;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Collections;
import java.util.List;
/**
* 完整帧 → {@link Gb32960Message} 的解码器。
*
* <p>支持 GB/T 32960.3 两个版本:
* <ul>
* <li>{@code 0x23 0x23 (##)} → 2016 版({@link ProtocolVersion#V2016}
* <li>{@code 0x24 0x24 ($$)} → 2025 版({@link ProtocolVersion#V2025}
* </ul>
*
* <p>完整数据包结构(表 2 / 表 B.1
* <pre>
* offset 0: 起始符 (2B)
* offset 2: 命令标识 (1B)
* offset 3: 应答标志 (1B)
* offset 4: 唯一识别码 VIN (17B ASCII)
* offset 21: 数据单元加密方式 (1B)
* offset 22: 数据单元长度 (2B WORD 大端)
* offset 24: 数据单元 (dataLength 字节)
* last byte: 校验码 (1B BCC)
* </pre>
*
* <p>BCC 校验范围从命令单元的第一个字节开始offset 2同后一字节异或
* 直到校验码前一字节为止。注意:当数据单元存在加密时,应先加密后校验,解析时先校验后解密。
*
* <p>上游 Netty 的 frame handler 已经保证 {@code buffer} 内是一条完整帧。
*/
public final class Gb32960MessageDecoder implements FrameDecoder<Gb32960Message> {
/** 最小帧长2(起始符)+1(cmd)+1(rsp)+17(vin)+1(enc)+2(len)+0(body)+1(bcc)。 */
private static final int MIN_FRAME = 25;
private static final ZoneId ZONE_GMT8 = ZoneId.of("Asia/Shanghai");
private final Gb32960BodyParser bodyParser;
private final Gb32960CommandParser commandParser;
public Gb32960MessageDecoder(Gb32960BodyParser bodyParser) {
this(bodyParser, new Gb32960CommandParser());
}
public Gb32960MessageDecoder(Gb32960BodyParser bodyParser, Gb32960CommandParser commandParser) {
this.bodyParser = bodyParser;
this.commandParser = commandParser;
}
@Override
public Gb32960Message decode(ByteBuffer buffer) {
return decode(buffer, null);
}
/**
* 带 platformAccount 提示的解码。Handler 在已经完成 0x05 PLATFORM_LOGIN 鉴权后通过
* channel attribute 取到账号名再传进来;以便 BodyParser 内部的 vendor profile selector
* 路由。其他命令传 {@code null} 即可。
*/
public Gb32960Message decode(ByteBuffer buffer, String platformAccount) {
int frameLen = buffer.remaining();
if (frameLen < MIN_FRAME) {
throw new DecodeException("frame too short: " + frameLen);
}
byte[] frame = new byte[frameLen];
buffer.get(frame);
ProtocolVersion version = detectVersion(frame);
// BCC 校验:从 cmd(offset 2) 开始到 BCC 前一字节
byte expectedBcc = frame[frame.length - 1];
if (!BccChecksum.verify(frame, 2, frame.length - 3, expectedBcc)) {
throw new DecodeException("BCC mismatch");
}
int commandCode = frame[2] & 0xFF;
int responseCode = frame[3] & 0xFF;
String vin = new String(frame, 4, 17, StandardCharsets.US_ASCII).trim();
int encryptCode = frame[21] & 0xFF;
int dataLength = ((frame[22] & 0xFF) << 8) | (frame[23] & 0xFF);
int actualDataLength = frame.length - 25;
int bodyStart = 24;
if (bodyStart + dataLength + 1 != frame.length
&& !isExtendedPlatformLogin(commandCode, dataLength, actualDataLength)) {
throw new DecodeException("declared body length " + dataLength
+ " does not match frame remainder " + (frame.length - bodyStart - 1));
}
CommandType cmdType = CommandType.of(commandCode);
ResponseFlag rspFlag = ResponseFlag.of(responseCode);
EncryptType encType = EncryptType.of(encryptCode);
// 实时 / 补发上报:解析信息体(和 2025 版尾部签名)
if (cmdType == CommandType.REALTIME_REPORT || cmdType == CommandType.RESEND_REPORT) {
if (dataLength < 6) throw new DecodeException("report body missing timestamp");
Instant eventTime = parseTimestamp(frame, bodyStart);
ByteBuffer body = ByteBuffer.wrap(frame, bodyStart + 6, dataLength - 6);
Gb32960ParserContext ctx = new Gb32960ParserContext(version, vin, platformAccount);
BodyParseResult result = bodyParser.parse(ctx, body);
Gb32960Header header = new Gb32960Header(
version, cmdType, rspFlag, vin, encType, dataLength, eventTime);
return new Gb32960Message(header, result.blocks(), null, result.signature());
}
// 其他命令:解析 command body
// 车辆/平台登入登出 body 首 6B 为采集时间(表 5预解析并写入 header.eventTime
// 以便 handler 构造应答帧时按 §6.3.2 保留原报文时间。
Instant eventTime = null;
if (hasLeadingTimestamp(cmdType) && dataLength >= 6) {
eventTime = parseTimestamp(frame, bodyStart);
}
Gb32960Header headerStub = new Gb32960Header(
version, cmdType, rspFlag, vin, encType, dataLength, eventTime);
int commandBodyLength = isExtendedPlatformLogin(commandCode, dataLength, actualDataLength)
? actualDataLength
: dataLength;
ByteBuffer body = ByteBuffer.wrap(frame, bodyStart, commandBodyLength);
CommandBody cmdBody = commandParser.parse(cmdType, body);
return new Gb32960Message(headerStub, Collections.emptyList(), cmdBody, null);
}
private static boolean isExtendedPlatformLogin(int commandCode, int declaredDataLength, int actualDataLength) {
return commandCode == 0x05 && declaredDataLength == 41 && actualDataLength == 53;
}
private static ProtocolVersion detectVersion(byte[] frame) {
if (frame[0] == 0x23 && frame[1] == 0x23) return ProtocolVersion.V2016;
if (frame[0] == 0x24 && frame[1] == 0x24) return ProtocolVersion.V2025;
throw new DecodeException("unknown start symbol: "
+ String.format("%02x %02x", frame[0] & 0xFF, frame[1] & 0xFF));
}
private static boolean hasLeadingTimestamp(CommandType cmd) {
return cmd == CommandType.VEHICLE_LOGIN
|| cmd == CommandType.VEHICLE_LOGOUT
|| cmd == CommandType.PLATFORM_LOGIN
|| cmd == CommandType.PLATFORM_LOGOUT;
}
/** 时间定义(表 5YY MM DD HH MM SS各 1 字节GMT+8。 */
private static Instant parseTimestamp(byte[] frame, int offset) {
int year = 2000 + (frame[offset] & 0xFF);
int month = frame[offset + 1] & 0xFF;
int day = frame[offset + 2] & 0xFF;
int hour = frame[offset + 3] & 0xFF;
int minute = frame[offset + 4] & 0xFF;
int second = frame[offset + 5] & 0xFF;
try {
return LocalDateTime.of(year, month, day, hour, minute, second)
.atZone(ZONE_GMT8)
.toInstant();
} catch (Exception e) {
throw new DecodeException("invalid timestamp " + year + "-" + month + "-" + day
+ " " + hour + ":" + minute + ":" + second, e);
}
}
/** 实时上报 body 解析结果:信息体列表 + 可选签名字节。 */
public record BodyParseResult(List<InfoBlock> blocks, byte[] signature) {}
}

View File

@@ -0,0 +1,21 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
/**
* Body parser 调用上下文:携带选 profile 时需要的三元组。
*
* <p>由 {@link Gb32960MessageDecoder} 在解析帧时构造vin / version 来自帧字节,
* platformAccount 由 handler 通过 channel attribute 传入)。
*
* @param version 协议版本(来自帧起始符)
* @param vin 17 字节 VINtrim 后),平台登入 0x05 等帧 VIN 全 0 时为空串
* @param platformAccount 平台登入用户名;非平台对接场景为 null
*/
public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) {
/** 占位上下文版本不可缺vin/account 允许为 null便于内部构造和单测。 */
public static Gb32960ParserContext minimal(ProtocolVersion version) {
return new Gb32960ParserContext(version, null, null);
}
}

View File

@@ -0,0 +1,34 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 单一信息体解析器 SPI。
*
* <p>替代旧代码里 2500 行 {@code informationReporting()} 巨方法。每个
* {@code (ProtocolVersion, typeCode)} 组合对应一个独立实现。同一个协议 typeCode
* 在 2016 / 2025 可能语义不同(如 0x07因此需要两个 Parser 分别声明 version。
*
* <p>实现约定:
* <ul>
* <li>{@link #parse(ByteBuffer)} 被调用时 buffer 的 position 已指向信息体第一个字节(不含 1 字节类型前缀)
* <li>解析完成后 position 应恰好移动到信息体末尾
* <li>实现应无状态,可被单例复用
* </ul>
*/
public interface InfoBlockParser {
/** 本 Parser 适用的协议版本({@link ProtocolVersion#V2016} 或 {@link ProtocolVersion#V2025})。 */
ProtocolVersion version();
/** 本 Parser 对应的协议信息类型标志字节(表 9。 */
int typeCode();
/** 信息体固定长度;变长类型返回 -1 并自行在 parse 中读取长度字段。 */
int fixedLength();
InfoBlock parse(ByteBuffer buffer);
}

View File

@@ -0,0 +1,34 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 双键策略注册中心:按 {@code (ProtocolVersion, typeCode)} 索引解析器。
*
* <p>同一 typeCode 在 2016 / 2025 版本可能语义不同(如 0x07 在 2016 是报警,在 2025 是
* 动力蓄电池最小并联单元电压),必须按版本分派。
*/
public final class InfoBlockParserRegistry {
private final Map<Key, InfoBlockParser> parsers = new HashMap<>();
public InfoBlockParserRegistry(List<InfoBlockParser> list) {
for (InfoBlockParser p : list) {
parsers.put(new Key(p.version(), p.typeCode()), p);
}
}
public InfoBlockParser find(ProtocolVersion version, int typeCode) {
return parsers.get(new Key(version, typeCode));
}
public int size() {
return parsers.size();
}
private record Key(ProtocolVersion version, int typeCode) {}
}

View File

@@ -0,0 +1,80 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import java.nio.ByteBuffer;
/**
* 规范异常值处理辅助GB/T 32960.3 约定 {@code 0xFE}/{@code 0xFFFE}/{@code 0xFFFFFFFE} 为异常,
* {@code 0xFF}/{@code 0xFFFF}/{@code 0xFFFFFFFF} 为无效。本类把字段读取 + 异常检查封装起来,
* 异常或无效时返回 {@code null},让 record 字段保持 {@link Integer}/{@link Double} 装箱类型。
*/
public final class ValueDecoder {
private ValueDecoder() {}
// ------------ 1 字节无符号 ------------
public static Integer u8(ByteBuffer buf) {
int v = buf.get() & 0xFF;
if (v == 0xFE || v == 0xFF) return null;
return v;
}
/** 原始值,不做异常处理(状态位等不能以 null 表达时用此方法)。 */
public static int u8raw(ByteBuffer buf) {
return buf.get() & 0xFF;
}
// ------------ 2 字节无符号大端 ------------
public static Integer u16(ByteBuffer buf) {
int v = buf.getShort() & 0xFFFF;
if (v == 0xFFFE || v == 0xFFFF) return null;
return v;
}
public static int u16raw(ByteBuffer buf) {
return buf.getShort() & 0xFFFF;
}
// ------------ 4 字节无符号大端 ------------
public static Long u32(ByteBuffer buf) {
long v = buf.getInt() & 0xFFFFFFFFL;
if (v == 0xFFFFFFFEL || v == 0xFFFFFFFFL) return null;
return v;
}
public static long u32raw(ByteBuffer buf) {
return buf.getInt() & 0xFFFFFFFFL;
}
// ------------ 业务标度辅助 ------------
/** 读 u16scale 后返回(例如 0.1 转换)。异常/无效返回 null。 */
public static Double scaledU16(ByteBuffer buf, double scale) {
Integer raw = u16(buf);
return raw == null ? null : raw * scale;
}
/** 读 u16 + 偏移 + scale。例读总电流 -> scaledU16WithOffset(buf, 0.1, -1000) */
public static Double scaledU16WithOffset(ByteBuffer buf, double scale, double offset) {
Integer raw = u16(buf);
return raw == null ? null : raw * scale + offset;
}
public static Double scaledU32(ByteBuffer buf, double scale) {
Long raw = u32(buf);
return raw == null ? null : raw * scale;
}
public static Double scaledU32WithOffset(ByteBuffer buf, double scale, double offset) {
Long raw = u32(buf);
return raw == null ? null : raw * scale + offset;
}
/** 温度字段:原始值 - 40 ℃,范围 -40~+210。 */
public static Integer tempOffset40(ByteBuffer buf) {
Integer raw = u8(buf);
return raw == null ? null : raw - 40;
}
}

View File

@@ -0,0 +1,45 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
// ProtocolVersion still used by version() override below
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 报警数据 (0x07) —— GB/T 32960.3-2016 附录 B 表 B.17。变长。
* maxAlarmLevel(1) + generalAlarmFlag(4) + 4 组故障列表(count(1)+entries(4*N))。
*
* <p>2025 版报警数据 typeCode 改为 0x06 且尾部多 N5 个 (flagBit,level) —— 见 v2025 包。
*/
public final class AlarmV2016BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x07; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer maxLevel = ValueDecoder.u8(buf);
long generalFlag = ValueDecoder.u32raw(buf);
List<Long> battery = readFaultList(buf);
List<Long> motor = readFaultList(buf);
List<Long> engine = readFaultList(buf);
List<Long> other = readFaultList(buf);
return new InfoBlock.Gb32960V2016.Alarm(
maxLevel, generalFlag, battery, motor, engine, other);
}
static List<Long> readFaultList(ByteBuffer buf) {
int count = buf.get() & 0xFF;
List<Long> out = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
out.add(buf.getInt() & 0xFFFFFFFFL);
}
return out;
}
}

View File

@@ -0,0 +1,45 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 驱动电机数据 (0x02) —— GB/T 32960.3-2016 表 B.16。变长。
* 1 字节电机个数 + N × 12 字节电机:
* serialNo(1) state(1) ctrlTempC(1) rpm(2,offset -20000)
* torque(2,offset -20000,0.1 N·m) motorTempC(1)
* ctrlVoltageV(2,0.1V) ctrlCurrentA(2,0.1A,offset -1000)。
*/
public final class DriveMotorV2016BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x02; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int count = buf.get() & 0xFF;
List<InfoBlock.Gb32960V2016.DriveMotor.Motor> motors = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
Integer serialNo = ValueDecoder.u8(buf);
Integer state = ValueDecoder.u8(buf);
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);
Integer rawRpm = ValueDecoder.u16(buf);
Integer rpm = rawRpm == null ? null : rawRpm - 20_000;
Integer rawTq = ValueDecoder.u16(buf);
Double torque = rawTq == null ? null : (rawTq - 20_000) * 0.1;
Integer motorTempC = ValueDecoder.tempOffset40(buf);
Double ctrlVoltage = ValueDecoder.scaledU16(buf, 0.1);
Double ctrlCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
motors.add(new InfoBlock.Gb32960V2016.DriveMotor.Motor(
serialNo, state, ctrlTempC, rpm, torque, motorTempC, ctrlVoltage, ctrlCurrent));
}
return new InfoBlock.Gb32960V2016.DriveMotor(motors);
}
}

View File

@@ -0,0 +1,28 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 发动机数据 (0x04) —— GB/T 32960.3-2016 表 20。固定 5 字节:
* engineState(1) + crankshaftRpm(2) + fuelConsumptionRate(2,0.01 L/100km)。
* 停车充电过程和增程式车辆纯电模式无需传输。
*/
public final class EngineV2016BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x04; }
@Override public int fixedLength() { return 5; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer engineState = ValueDecoder.u8(buf);
Integer crankshaftRpm = ValueDecoder.u16(buf);
Double fuelRate = ValueDecoder.scaledU16(buf, 0.01);
return new InfoBlock.Gb32960V2016.Engine(engineState, crankshaftRpm, fuelRate);
}
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 动力蓄电池极值数据 (0x06) —— GB/T 32960.3-2016 表 B.16。固定 14 字节。
* 2025 版 0x06 语义已变更为报警数据。
*/
public final class ExtremeValueV2016BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x06; }
@Override public int fixedLength() { return 14; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer maxVSub = ValueDecoder.u8(buf);
Integer maxVCell = ValueDecoder.u8(buf);
Double maxV = ValueDecoder.scaledU16(buf, 0.001);
Integer minVSub = ValueDecoder.u8(buf);
Integer minVCell = ValueDecoder.u8(buf);
Double minV = ValueDecoder.scaledU16(buf, 0.001);
Integer maxTSub = ValueDecoder.u8(buf);
Integer maxTProbe = ValueDecoder.u8(buf);
Integer maxT = ValueDecoder.tempOffset40(buf);
Integer minTSub = ValueDecoder.u8(buf);
Integer minTProbe = ValueDecoder.u8(buf);
Integer minT = ValueDecoder.tempOffset40(buf);
return new InfoBlock.Gb32960V2016.Extreme(
maxVSub, maxVCell, maxV,
minVSub, minVCell, minV,
maxTSub, maxTProbe, maxT,
minTSub, minTProbe, minT);
}
}

View File

@@ -0,0 +1,54 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 燃料电池数据 (0x03) —— GB/T 32960.3-2016 表 B.13。变长。
* fcVoltageV(2) + fcCurrentA(2) + hydrogenConsumption(2,0.01 kg/100km)
* + probeCount N(2) + probes[N](1B,-40 offset) + 后续氢系统 10 字节固定段。
*/
public final class FuelCellV2016BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x03; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Double fcVoltage = ValueDecoder.scaledU16(buf, 0.1);
Double fcCurrent = ValueDecoder.scaledU16(buf, 0.1);
Double consumption = ValueDecoder.scaledU16(buf, 0.01);
int probeCount = ValueDecoder.u16raw(buf);
// 防御probeCount 异常时回退为空列表避免吞 buffer
List<Integer> probes = new ArrayList<>();
int tailFixed = 2 + 1 + 2 + 1 + 2 + 1 + 1; // 尾部固定 10 字节
if (probeCount >= 0 && probeCount <= buf.remaining() - tailFixed) {
for (int i = 0; i < probeCount; i++) {
Integer t = ValueDecoder.tempOffset40(buf);
if (t != null) probes.add(t);
}
}
Double maxHydrogenTempC = ValueDecoder.scaledU16WithOffset(buf, 0.1, -40.0);
Integer maxHydrogenTempProbeId = ValueDecoder.u8(buf);
Double maxHydrogenConcentration = ValueDecoder.scaledU16(buf, 0.000001);
Integer maxHydrogenConcentrationProbeId = ValueDecoder.u8(buf);
Double maxHydrogenPressure = ValueDecoder.scaledU16(buf, 0.1);
Integer maxHydrogenPressureProbeId = ValueDecoder.u8(buf);
Integer hvDcDcStatus = ValueDecoder.u8(buf);
return new InfoBlock.Gb32960V2016.FuelCell(
fcVoltage, fcCurrent, consumption, probes,
maxHydrogenTempC, maxHydrogenTempProbeId,
maxHydrogenConcentration, maxHydrogenConcentrationProbeId,
maxHydrogenPressure, maxHydrogenPressureProbeId,
hvDcDcStatus);
}
}

View File

@@ -0,0 +1,32 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 车辆位置数据 (0x05) —— GB/T 32960.3-2016 表 22。固定 9 字节:
* 状态位(1) + 经度(4,1e-6 度) + 纬度(4,1e-6 度)。
*
* <p>状态位 bit0 0=有效定位 / bit1 0=北纬 1=南纬 / bit2 0=东经 1=西经。
*/
public final class PositionV2016BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x05; }
@Override public int fixedLength() { return 9; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int statusFlag = buf.get() & 0xFF;
long lonRaw = buf.getInt() & 0xFFFFFFFFL;
long latRaw = buf.getInt() & 0xFFFFFFFFL;
double longitude = lonRaw / 1_000_000.0;
double latitude = latRaw / 1_000_000.0;
if ((statusFlag & 0b100) != 0) longitude = -longitude;
if ((statusFlag & 0b010) != 0) latitude = -latitude;
return new InfoBlock.Gb32960V2016.Position(statusFlag, longitude, latitude);
}
}

View File

@@ -0,0 +1,39 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 可充电储能装置温度数据 (0x09) —— GB/T 32960.3-2016 表 B.20。变长。
* subSystemCount(1) + 每子系统 [batteryIndex(1) + probeCount(2) + probes(probeCount*1,-40 offset)]。
*
* <p>2025 版已删除该 typeCode动力蓄电池温度改用 0x08
*/
public final class TemperatureV2016BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x09; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int subCount = buf.get() & 0xFF;
int maxT = Integer.MIN_VALUE;
int minT = Integer.MAX_VALUE;
for (int i = 0; i < subCount; i++) {
buf.get(); // batteryIndex
int probes = buf.getShort() & 0xFFFF;
for (int p = 0; p < probes; p++) {
int t = (buf.get() & 0xFF) - 40;
if (t > maxT) maxT = t;
if (t < minT) minT = t;
}
}
if (maxT == Integer.MIN_VALUE) maxT = 0;
if (minT == Integer.MAX_VALUE) minT = 0;
return new InfoBlock.Gb32960V2016.Temperature(subCount, maxT, minT);
}
}

View File

@@ -0,0 +1,42 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 整车数据 (0x01) —— GB/T 32960.3-2016 表 B.10。固定 20 字节:
* vehicleState(1) + chargingState(1) + runningMode(1) + speedKmh(2)
* + totalMileageKm(4) + totalVoltageV(2) + totalCurrentA(2)
* + socPercent(1) + dcDcStatus(1) + gearRaw(1) + insulationKohm(2)
* + accelPedal(1) + brakePedal(1)。
*/
public final class VehicleV2016BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x01; }
@Override public int fixedLength() { return 20; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer vehicleState = ValueDecoder.u8(buf);
Integer chargingState = ValueDecoder.u8(buf);
Integer runningMode = ValueDecoder.u8(buf);
Double speedKmh = ValueDecoder.scaledU16(buf, 0.1);
Double mileageKm = ValueDecoder.scaledU32(buf, 0.1);
Double totalVoltageV = ValueDecoder.scaledU16(buf, 0.1);
Double totalCurrentA = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
Integer soc = ValueDecoder.u8(buf);
Integer dcDcStatus = ValueDecoder.u8(buf);
Integer gearRaw = ValueDecoder.u8raw(buf);
Integer insulation = ValueDecoder.u16(buf);
Integer accelPedal = ValueDecoder.u8(buf);
Integer brakePedal = ValueDecoder.u8(buf);
return new InfoBlock.Gb32960V2016.Vehicle(vehicleState, chargingState, runningMode,
speedKmh, mileageKm, totalVoltageV, totalCurrentA, soc, dcDcStatus, gearRaw,
insulation, accelPedal, brakePedal);
}
}

View File

@@ -0,0 +1,49 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 可充电储能装置电压数据 (0x08) —— GB/T 32960.3-2016 表 B.18。变长。
* subSystemCount(1) + 每子系统 [batteryIndex(1) + voltage(2,0.1V) + current(2,0.1A,offset -1000)
* + cellCount(2) + frameCellStart(2) + frameCellCount(1) + cellVoltages(frameCellCount*2,0.001V)]。
*
* <p>PoC 汇总:保留子系统数 + 总电压 + 单体最高/最低值。
* 2025 版 0x08 语义已变更为动力蓄电池温度。
*/
public final class VoltageV2016BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x08; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int subCount = buf.get() & 0xFF;
double totalV = 0;
double maxCell = Double.MIN_VALUE;
double minCell = Double.MAX_VALUE;
for (int i = 0; i < subCount; i++) {
buf.get(); // batteryIndex
double voltage = (buf.getShort() & 0xFFFF) * 0.1;
totalV += voltage;
buf.getShort(); // current (skip)
int cellCount = buf.getShort() & 0xFFFF;
buf.getShort(); // frameCellStart
int frameCellCount = buf.get() & 0xFF;
int actual = Math.min(frameCellCount, cellCount);
for (int c = 0; c < actual; c++) {
double cellV = (buf.getShort() & 0xFFFF) * 0.001;
if (cellV > maxCell) maxCell = cellV;
if (cellV < minCell) minCell = cellV;
}
for (int c = actual; c < frameCellCount; c++) buf.getShort();
}
if (maxCell == Double.MIN_VALUE) maxCell = 0;
if (minCell == Double.MAX_VALUE) minCell = 0;
return new InfoBlock.Gb32960V2016.Voltage(subCount, maxCell, minCell, totalV);
}
}

View File

@@ -0,0 +1,51 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 报警数据 (0x06) —— GB/T 32960.3-2025 §7.2.4.9 表 23。变长。
* 前半部分与 2016 版相同,尾部新增"通用报警故障总数(1) + N5×(flagBit(1)+level(1))"。
*/
public final class AlarmV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x06; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer maxLevel = ValueDecoder.u8(buf);
long generalFlag = ValueDecoder.u32raw(buf);
List<Long> battery = readFaultList(buf);
List<Long> motor = readFaultList(buf);
List<Long> engine = readFaultList(buf);
List<Long> other = readFaultList(buf);
int generalAlarmCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
List<InfoBlock.Gb32960V2025.Alarm.GeneralAlarmEntry> levels = new ArrayList<>(generalAlarmCount);
for (int i = 0; i < generalAlarmCount && buf.remaining() >= 2; i++) {
int bit = buf.get() & 0xFF;
int level = buf.get() & 0xFF;
levels.add(new InfoBlock.Gb32960V2025.Alarm.GeneralAlarmEntry(bit, level));
}
return new InfoBlock.Gb32960V2025.Alarm(
maxLevel, generalFlag, battery, motor, engine, other, levels);
}
private static List<Long> readFaultList(ByteBuffer buf) {
int count = buf.get() & 0xFF;
List<Long> out = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
out.add(buf.getInt() & 0xFFFFFFFFL);
}
return out;
}
}

View File

@@ -0,0 +1,37 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 动力蓄电池温度数据 (0x08) —— GB/T 32960.3-2025 §7.2.4.3。变长。
* 2016 版 0x08 为可充电储能装置电压,由 v2016 包处理。
*/
public final class BatteryTemperatureV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x08; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int subCount = buf.get() & 0xFF;
List<InfoBlock.Gb32960V2025.BatteryTemperature.Pack> packs = new ArrayList<>(subCount);
for (int i = 0; i < subCount; i++) {
int packNo = buf.get() & 0xFF;
int probeCount = buf.getShort() & 0xFFFF;
List<Integer> temps = new ArrayList<>(probeCount);
int safeCount = Math.min(probeCount, buf.remaining());
for (int k = 0; k < safeCount; k++) {
temps.add((buf.get() & 0xFF) - 40);
}
packs.add(new InfoBlock.Gb32960V2025.BatteryTemperature.Pack(packNo, probeCount, temps));
}
return new InfoBlock.Gb32960V2025.BatteryTemperature(subCount, packs);
}
}

View File

@@ -0,0 +1,47 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 驱动电机数据 (0x02) —— GB/T 32960.3-2025 表 16。变长。
* 1 字节电机个数 + N × 14 字节电机:
* serialNo(1) state(1) ctrlTempC(1) rpm(2,offset -32000)
* torque(4,offset -20000,0.1 N·m) motorTempC(1)
* ctrlVoltageV(2,0.1V) ctrlCurrentA(2,0.1A,offset -1000)。
*
* <p>相比 2016 版rpm 偏移变为 -32000torque 由 2 字节扩展为 4 字节。
*/
public final class DriveMotorV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x02; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int count = buf.get() & 0xFF;
List<InfoBlock.Gb32960V2025.DriveMotor.Motor> motors = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
Integer serialNo = ValueDecoder.u8(buf);
Integer state = ValueDecoder.u8(buf);
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);
Integer rawRpm = ValueDecoder.u16(buf);
Integer rpm = rawRpm == null ? null : rawRpm - 32_000;
Long rawTq = ValueDecoder.u32(buf);
Double torque = rawTq == null ? null : (rawTq - 20_000L) * 0.1;
Integer motorTempC = ValueDecoder.tempOffset40(buf);
Double ctrlVoltage = ValueDecoder.scaledU16(buf, 0.1);
Double ctrlCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
motors.add(new InfoBlock.Gb32960V2025.DriveMotor.Motor(
serialNo, state, ctrlTempC, rpm, torque, motorTempC, ctrlVoltage, ctrlCurrent));
}
return new InfoBlock.Gb32960V2025.DriveMotor(motors);
}
}

View File

@@ -0,0 +1,27 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 发动机数据 (0x04) —— GB/T 32960.3-2025。固定 5 字节。
* 字段与 2016 版完全一致;保留独立类只为遵守"双版本不共用"原则。
*/
public final class EngineV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x04; }
@Override public int fixedLength() { return 5; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer engineState = ValueDecoder.u8(buf);
Integer crankshaftRpm = ValueDecoder.u16(buf);
Double fuelRate = ValueDecoder.scaledU16(buf, 0.01);
return new InfoBlock.Gb32960V2025.Engine(engineState, crankshaftRpm, fuelRate);
}
}

View File

@@ -0,0 +1,45 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 燃料电池电堆数据 (0x30) —— GB/T 32960.3-2025 §7.2.4.6 表 18/19。变长。
*
* <p>V2016 的同 typeCode 厂商扩展由 v2016 包独立处理。
*/
public final class FuelCellStackV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x30; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int stackCount = buf.get() & 0xFF;
List<InfoBlock.Gb32960V2025.FuelCellStack.Stack> stacks = new ArrayList<>(stackCount);
for (int i = 0; i < stackCount; i++) {
int stackNo = buf.get() & 0xFF;
Double voltage = ValueDecoder.scaledU16(buf, 0.1);
Double current = ValueDecoder.scaledU16(buf, 0.1);
Double h2InletPressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
Double airInletPressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
Integer airInletTemp = ValueDecoder.tempOffset40(buf);
int probeCount = buf.getShort() & 0xFFFF;
List<Integer> temps = new ArrayList<>(probeCount);
int safeCount = Math.min(probeCount, buf.remaining());
for (int k = 0; k < safeCount; k++) {
temps.add((buf.get() & 0xFF) - 40);
}
stacks.add(new InfoBlock.Gb32960V2025.FuelCellStack.Stack(
stackNo, voltage, current, h2InletPressure, airInletPressure, airInletTemp, temps));
}
return new InfoBlock.Gb32960V2025.FuelCellStack(stackCount, stacks);
}
}

View File

@@ -0,0 +1,36 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 燃料电池发动机及车载氢系统数据 (0x03) —— GB/T 32960.3-2025 §7.2.4.5 表 17。固定 13 字节。
*/
public final class FuelCellV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x03; }
@Override public int fixedLength() { return 13; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Double maxTempC = ValueDecoder.scaledU16WithOffset(buf, 0.1, -40.0);
Integer maxTempProbeId = ValueDecoder.u8(buf);
Double maxConcentration = ValueDecoder.scaledU16(buf, 0.000001);
Integer maxConcentrationProbeId = ValueDecoder.u8(buf);
Double maxPressure = ValueDecoder.scaledU16(buf, 0.1);
Integer maxPressureProbeId = ValueDecoder.u8(buf);
Integer hvDcDcStatus = ValueDecoder.u8(buf);
Integer remainingH2Percent = ValueDecoder.u8(buf);
Integer hvDcDcControllerTempC = ValueDecoder.tempOffset40(buf);
return new InfoBlock.Gb32960V2025.FuelCell(
maxTempC, maxTempProbeId,
maxConcentration, maxConcentrationProbeId,
maxPressure, maxPressureProbeId,
hvDcDcStatus, remainingH2Percent, hvDcDcControllerTempC);
}
}

View File

@@ -0,0 +1,41 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 动力蓄电池最小并联单元电压数据 (0x07) —— GB/T 32960.3-2025 §7.2.4.2。变长。
* 2016 版 0x07 为报警,由 v2016 包处理。
*/
public final class MinParallelVoltageV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x07; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int subCount = buf.get() & 0xFF;
List<InfoBlock.Gb32960V2025.MinParallelVoltage.Pack> packs = new ArrayList<>(subCount);
for (int i = 0; i < subCount; i++) {
int packNo = buf.get() & 0xFF;
Double voltage = ValueDecoder.scaledU16(buf, 0.1);
Double current = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
int totalUnits = buf.getShort() & 0xFFFF;
List<Double> frameVoltages = new ArrayList<>(totalUnits);
int safeCount = Math.min(totalUnits, buf.remaining() / 2);
for (int k = 0; k < safeCount; k++) {
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);
}
packs.add(new InfoBlock.Gb32960V2025.MinParallelVoltage.Pack(
packNo, voltage, current, totalUnits, frameVoltages));
}
return new InfoBlock.Gb32960V2025.MinParallelVoltage(subCount, packs);
}
}

View File

@@ -0,0 +1,34 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 车辆位置数据 (0x05) —— GB/T 32960.3-2025。固定 10 字节,
* 比 2016 版多一个坐标系字节:
* 状态位(1) + 坐标系(1) + 经度(4) + 纬度(4)。
*
* <p>坐标系0x01=WGS84 / 0x02=GCJ-02 / 0x03=其他。
*/
public final class PositionV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x05; }
@Override public int fixedLength() { return 10; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int statusFlag = buf.get() & 0xFF;
int coordSystem = buf.get() & 0xFF;
long lonRaw = buf.getInt() & 0xFFFFFFFFL;
long latRaw = buf.getInt() & 0xFFFFFFFFL;
double longitude = lonRaw / 1_000_000.0;
double latitude = latRaw / 1_000_000.0;
if ((statusFlag & 0b100) != 0) longitude = -longitude;
if ((statusFlag & 0b010) != 0) latitude = -latitude;
return new InfoBlock.Gb32960V2025.Position(statusFlag, coordSystem, longitude, latitude);
}
}

View File

@@ -0,0 +1,39 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 超级电容器极值数据 (0x32) —— GB/T 32960.3-2025 §7.2.4.11 表 26。固定 18 字节。
*/
public final class SuperCapacitorExtremeV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x32; }
@Override public int fixedLength() { return 18; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer maxVSys = ValueDecoder.u8(buf);
Integer maxVCell = ValueDecoder.u16(buf);
Double maxV = ValueDecoder.scaledU16(buf, 0.001);
Integer minVSys = ValueDecoder.u8(buf);
Integer minVCell = ValueDecoder.u16(buf);
Double minV = ValueDecoder.scaledU16(buf, 0.001);
Integer maxTSys = ValueDecoder.u8(buf);
Integer maxTProbe = ValueDecoder.u16(buf);
Integer maxT = ValueDecoder.tempOffset40(buf);
Integer minTSys = ValueDecoder.u8(buf);
Integer minTProbe = ValueDecoder.u16(buf);
Integer minT = ValueDecoder.tempOffset40(buf);
return new InfoBlock.Gb32960V2025.SuperCapacitorExtreme(
maxVSys, maxVCell, maxV,
minVSys, minVCell, minV,
maxTSys, maxTProbe, maxT,
minTSys, minTProbe, minT);
}
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 超级电容器数据 (0x31) —— GB/T 32960.3-2025 §7.2.4.10 表 25。变长。
*/
public final class SuperCapacitorV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x31; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int systemNo = buf.get() & 0xFF;
Double totalVoltage = ValueDecoder.scaledU16(buf, 0.1);
Double totalCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
int cellCount = buf.getShort() & 0xFFFF;
List<Double> cells = new ArrayList<>(cellCount);
int safeCellCount = Math.min(cellCount, buf.remaining() / 2);
for (int i = 0; i < safeCellCount; i++) {
cells.add((buf.getShort() & 0xFFFF) * 0.001);
}
int probeCount = buf.hasRemaining() ? (buf.getShort() & 0xFFFF) : 0;
List<Integer> temps = new ArrayList<>(probeCount);
int safeProbeCount = Math.min(probeCount, buf.remaining());
for (int i = 0; i < safeProbeCount; i++) {
temps.add((buf.get() & 0xFF) - 40);
}
return new InfoBlock.Gb32960V2025.SuperCapacitor(systemNo, totalVoltage, totalCurrent, cells, temps);
}
}

View File

@@ -0,0 +1,37 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 整车数据 (0x01) —— GB/T 32960.3-2025。固定 18 字节。
* 相比 2016 版删除加速踏板和制动踏板。
*/
public final class VehicleV2025BlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
@Override public int typeCode() { return 0x01; }
@Override public int fixedLength() { return 18; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer vehicleState = ValueDecoder.u8(buf);
Integer chargingState = ValueDecoder.u8(buf);
Integer runningMode = ValueDecoder.u8(buf);
Double speedKmh = ValueDecoder.scaledU16(buf, 0.1);
Double mileageKm = ValueDecoder.scaledU32(buf, 0.1);
Double totalVoltageV = ValueDecoder.scaledU16(buf, 0.1);
Double totalCurrentA = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
Integer soc = ValueDecoder.u8(buf);
Integer dcDcStatus = ValueDecoder.u8(buf);
Integer gearRaw = ValueDecoder.u8raw(buf);
Integer insulation = ValueDecoder.u16(buf);
return new InfoBlock.Gb32960V2025.Vehicle(vehicleState, chargingState, runningMode,
speedKmh, mileageKm, totalVoltageV, totalCurrentA, soc, dcDcStatus, gearRaw,
insulation);
}
}

View File

@@ -0,0 +1,34 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池规范 §7.2.3.7 表 18空调数据。typeCode {@code 0x33}。
*
* <p>固定 5 字节:
* <pre>
* status(1) 0 关闭 / 1 启动
* powerKw(2, offset -5, 1 kw, range -5~+30)
* compressorInputVoltageV(2, 0.1V, 0~1000V)
* </pre>
*/
public final class GdFcAirConditionerBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x33; }
@Override public int fixedLength() { return 5; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer status = ValueDecoder.u8raw(buf);
int rawPower = buf.getShort() & 0xFFFF;
Integer power = (rawPower == 0xFFFE || rawPower == 0xFFFF) ? null : rawPower - 5;
Double inputV = ValueDecoder.scaledU16(buf, 0.1);
return new InfoBlock.GuangdongFc.AirConditioner(status, power, inputV);
}
}

View File

@@ -0,0 +1,61 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 广东燃料电池规范 §7.2.3.5 表 15/16辅助系统数据。typeCode {@code 0x31}。变长。
*
* <pre>
* subSystemCount(1)
* For each:
* airCompressorMotorVoltageV(2, 0.1V, 0~1000V)
* airCompressorPowerKw(2, offset -5, 1 kw, range -5~+30)
* hydrogenPumpMotorVoltageV(2, 0.2V, 0~50V)
* hydrogenPumpPowerKw(2, offset -5, 1 kw)
* waterPumpVoltageV(2, 0.1V)
* ptcVoltageV(2, 0.1V)
* ptcPowerKw(2, offset -5, 1 kw)
* lowVoltageBatteryVoltageV(2, 0.1V, 0~32V)
* </pre>
*
* 每子系统 16 字节。
*/
public final class GdFcAuxiliaryBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x31; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int count = buf.get() & 0xFF;
List<InfoBlock.GuangdongFc.Auxiliary.Subsystem> subs = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
Double airCompV = ValueDecoder.scaledU16(buf, 0.1);
Integer airCompP = readPowerOffsetMinus5(buf);
Double h2PumpV = ValueDecoder.scaledU16(buf, 0.2);
Integer h2PumpP = readPowerOffsetMinus5(buf);
Double waterPumpV = ValueDecoder.scaledU16(buf, 0.1);
Double ptcV = ValueDecoder.scaledU16(buf, 0.1);
Integer ptcP = readPowerOffsetMinus5(buf);
Double lvBattV = ValueDecoder.scaledU16(buf, 0.1);
subs.add(new InfoBlock.GuangdongFc.Auxiliary.Subsystem(
airCompV, airCompP, h2PumpV, h2PumpP, waterPumpV, ptcV, ptcP, lvBattV));
}
return new InfoBlock.GuangdongFc.Auxiliary(count, subs);
}
/** 功率字段raw(2B) - 50xFFFE/0xFFFF 异常/无效。 */
private static Integer readPowerOffsetMinus5(ByteBuffer buf) {
int raw = buf.getShort() & 0xFFFF;
if (raw == 0xFFFE || raw == 0xFFFF) return null;
return raw - 5;
}
}

View File

@@ -0,0 +1,37 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池规范 §7.2.3.6 表 17DC/DC燃料电池系统数据。typeCode {@code 0x32}。
*
* <p>固定 9 字节:
* <pre>
* inputVoltageV(2, 0.1V, 0~400V)
* inputCurrentA(2, 0.1A, 0~600A)
* outputVoltageV(2, 0.1V, 0~700V)
* outputCurrentA(2, 0.1A, 0~600A)
* controllerTempC(1, -40 offset)
* </pre>
*/
public final class GdFcDcDcBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x32; }
@Override public int fixedLength() { return 9; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Double inV = ValueDecoder.scaledU16(buf, 0.1);
Double inC = ValueDecoder.scaledU16(buf, 0.1);
Double outV = ValueDecoder.scaledU16(buf, 0.1);
Double outC = ValueDecoder.scaledU16(buf, 0.1);
Integer ctrlTemp = ValueDecoder.tempOffset40(buf);
return new InfoBlock.GuangdongFc.DcDc(inV, inC, outV, outC, ctrlTemp);
}
}

View File

@@ -0,0 +1,47 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池规范 §7.2.3.14 表 27燃料电池汽车示范应用数据扩展。typeCode {@code 0x80}。
*
* <p>结构typeCode 由外层消费):
* <pre>
* dataLength (2B WORD) = 9 表示后续扩展数据长度
* stackTempC (1B, -40 offset, -40~+210°C)
* airCompressorVoltageV (2B, 0.1V, 0~2000V)
* airCompressorCurrentA (2B, 0.1A, -1000 offset, -1000~+1000A)
* hydrogenPumpVoltageV (2B, 0.1V, 0~2000V)
* hydrogenPumpCurrentA (2B, 0.1A, -1000 offset, -1000~+1000A)
* </pre>
*
* 含 length 字段共 11 字节 body不含外层 1B typeCode
*/
public final class GdFcDemoExtensionBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x80; }
@Override public int fixedLength() { return 11; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int declaredLen = buf.getShort() & 0xFFFF;
// declaredLen 通常 = 9但即便不是也按 9 字节固定布局解,避免无谓的失败。
Integer stackTemp = ValueDecoder.tempOffset40(buf);
Double acV = ValueDecoder.scaledU16(buf, 0.1);
Double acC = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
Double h2V = ValueDecoder.scaledU16(buf, 0.1);
Double h2C = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
// 防御:若 declaredLen 与实际不一致,仅在 DEBUG 留痕。
if (declaredLen != 9 && org.slf4j.LoggerFactory.getLogger(GdFcDemoExtensionBlockParser.class).isDebugEnabled()) {
org.slf4j.LoggerFactory.getLogger(GdFcDemoExtensionBlockParser.class)
.debug("[gb32960/gd-fc/0x80] declaredLen={} (expected 9), parsed by fixed layout", declaredLen);
}
return new InfoBlock.GuangdongFc.DemoExtension(stackTemp, acV, acC, h2V, h2C);
}
}

View File

@@ -0,0 +1,75 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 广东燃料电池汽车示范应用城市群综合监管平台 §7.2.3.4 表 13/14燃料电池电堆数据。
*
* <p>typeCode {@code 0x30},工程上观察到对端在 GB/T 32960.3-<b>2016</b> 帧({@code 2323}
* 起始符)里下发该块。本规范在 GB/T 32960.3-2016 标准里属于 {@code 0x30~0x7F} 预留区段,
* 因此只有当对端被 vendor profile 选中后才启用此 parser。
*
* <p>结构:
* <pre>
* stackCount (1B)
* For each stack:
* engineWorkState (1B) 00 关闭 / 01 打开 / FE 异常 / FF 无效
* stackWaterOutletTempC (1B, -40 offset)
* hydrogenInletPressureKpa (2B WORD, -100 offset, 0.1 kPa)
* airInletPressureKpa (2B WORD, -100 offset, 0.1 kPa)
* airInletTempC (1B, -40 offset)
* maxCellVoltageId (2B WORD)
* minCellVoltageId (2B WORD)
* maxCellVoltageV (2B WORD, 0.001 V)
* minCellVoltageV (2B WORD, 0.001 V)
* avgCellVoltageV (2B WORD, 0.001 V)
* cellCount (2B WORD)
* frameCellStart (2B WORD)
* frameCellCount (1B)
* frameCellVoltagesV (2 * frameCellCount, 0.001 V)
* </pre>
*/
public final class GdFcStackBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x30; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int stackCount = buf.get() & 0xFF;
List<InfoBlock.GuangdongFc.Stack.Item> stacks = new ArrayList<>(stackCount);
for (int i = 0; i < stackCount; i++) {
Integer workState = ValueDecoder.u8raw(buf);
Integer waterTemp = ValueDecoder.tempOffset40(buf);
Double h2InletP = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
Double airInletP = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
Integer airInletTemp = ValueDecoder.tempOffset40(buf);
Integer maxId = ValueDecoder.u16(buf);
Integer minId = ValueDecoder.u16(buf);
Double maxV = ValueDecoder.scaledU16(buf, 0.001);
Double minV = ValueDecoder.scaledU16(buf, 0.001);
Double avgV = ValueDecoder.scaledU16(buf, 0.001);
Integer cellCount = ValueDecoder.u16(buf);
Integer frameStart = ValueDecoder.u16(buf);
int frameCells = buf.get() & 0xFF;
List<Double> frameVoltages = new ArrayList<>(frameCells);
int safe = Math.min(frameCells, buf.remaining() / 2);
for (int k = 0; k < safe; k++) {
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);
}
stacks.add(new InfoBlock.GuangdongFc.Stack.Item(
workState, waterTemp, h2InletP, airInletP, airInletTemp,
maxId, minId, maxV, minV, avgV,
cellCount, frameStart, frameCells, frameVoltages));
}
return new InfoBlock.GuangdongFc.Stack(stackCount, stacks);
}
}

View File

@@ -0,0 +1,36 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池规范 §7.2.3.8 表 19车辆信息补充。typeCode {@code 0x34}。
*
* <p>固定 7 字节:
* <pre>
* collisionAlarm(1) 0 无 / 1 有
* ambientTempC(2, -40 offset, 1°C)
* ambientPressureKpa(2, -100 offset, 0.1 kPa)
* hydrogenMassKg(2, 0.1 kg)
* </pre>
*/
public final class GdFcVehicleInfoBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x34; }
@Override public int fixedLength() { return 7; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer collision = ValueDecoder.u8raw(buf);
int rawTemp = buf.getShort() & 0xFFFF;
Integer tempC = (rawTemp == 0xFFFE || rawTemp == 0xFFFF) ? null : rawTemp - 40;
Double pressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
Double h2Mass = ValueDecoder.scaledU16(buf, 0.1);
return new InfoBlock.GuangdongFc.VehicleInfo(collision, tempC, pressure, h2Mass);
}
}

View File

@@ -0,0 +1,56 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池 peer 的厂商私有 TLV 块兜底 parser。
*
* <p>同一 peer 在广东规范 v1.0 表 8 列出的 0x30~0x34 / 0x80 之外,**还会下发**一些
* 在该规范里完全未定义的扩展 typeCode线上首先观察到 {@code 0x83},未来不排除有
* 0x84 等)。这些块经验上是 **TLV 格式**
*
* <pre>
* typeCode (1B, 由 BodyParser 外层消费)
* length (2B WORD, 大端)
* payload (length 字节)
* </pre>
*
* <p>本 parser 严格按 length 消费字节,<b>不</b>贪心读到 buffer 末尾,保证 BodyParser
* 主循环在该块之后仍能继续解析后面的标准/扩展块。
*
* <p>typeCode 通过构造器注入,未来若发现新的 vendor typeCode只需在 catalog 里多注册
* 一个 {@code new GdFcVendorTlvBlockParser(0x84)} 即可,无需新写 parser 类。
*
* <p>length 字段宽度为 2 字节是从 {@code 0x83} 实际字节序列推断而来0x0024=36 与
* payload 实际长度匹配);如未来对端的某个 typeCode 用 1B length 而非 2B将出现
* 字节漂移并被 BodyParser 的 hex dump 立刻暴露,可针对性新增独立 parser。
*/
public final class GdFcVendorTlvBlockParser implements InfoBlockParser {
private final int typeCode;
public GdFcVendorTlvBlockParser(int typeCode) {
if (typeCode < 0 || typeCode > 0xFF) {
throw new IllegalArgumentException("typeCode out of range: " + typeCode);
}
this.typeCode = typeCode;
}
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return typeCode; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int len = buf.getShort() & 0xFFFF;
// 防御:若 length 大于剩余可读字节,截短到剩余长度(避免 BufferUnderflowException
int safe = Math.min(len, buf.remaining());
byte[] payload = new byte[safe];
buf.get(payload);
return new InfoBlock.GuangdongFc.VendorTlv(typeCode, len, payload);
}
}

View File

@@ -0,0 +1,64 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 命名 profile → {@link InfoBlockParserRegistry} 映射。
*
* <p>构造时接收:
* <ul>
* <li>{@code defaultParsers} —— 标准 GB/T 32960 parser 集合v2016 + v2025
* <li>{@code catalog} —— vendor 扩展套件目录
* <li>{@code enabledExtensionNames} —— 在配置文件里被引用过的 vendor 套件名集合
* </ul>
*
* 构造内部为 default profile 和每个 enabled 扩展套件分别构建一个 registry
* 套件 registry = default parsers + catalog 里该套件的 parsers。运行期通过
* {@link #forProfile(String)} 按名字查找;查不到或传 null 时返回 default。
*/
public class Gb32960ProfileRegistry {
/** 约定的默认 profile 名(不会出现在配置里,仅作为 forProfile(null) 的 fallback 标识)。 */
public static final String DEFAULT_PROFILE = "__default__";
private final InfoBlockParserRegistry defaultRegistry;
private final Map<String, InfoBlockParserRegistry> registriesByProfile;
public Gb32960ProfileRegistry(List<InfoBlockParser> defaultParsers,
VendorExtensionCatalog catalog,
Set<String> enabledExtensionNames) {
this.defaultRegistry = new InfoBlockParserRegistry(defaultParsers);
Map<String, InfoBlockParserRegistry> map = new HashMap<>();
for (String name : enabledExtensionNames) {
List<InfoBlockParser> vendorParsers = catalog.parsersFor(name);
if (vendorParsers.isEmpty()) {
throw new IllegalStateException(
"vendor extension '" + name + "' referenced in config but not present in catalog. "
+ "Available: " + catalog.names());
}
List<InfoBlockParser> combined = new ArrayList<>(defaultParsers.size() + vendorParsers.size());
combined.addAll(defaultParsers);
combined.addAll(vendorParsers);
map.put(name, new InfoBlockParserRegistry(combined));
}
this.registriesByProfile = Map.copyOf(map);
}
/** 按 profile 名取 registrynull 或未注册的名字返回默认 registry。 */
public InfoBlockParserRegistry forProfile(String profileName) {
if (profileName == null) return defaultRegistry;
InfoBlockParserRegistry r = registriesByProfile.get(profileName);
return r != null ? r : defaultRegistry;
}
public InfoBlockParserRegistry defaultRegistry() {
return defaultRegistry;
}
}

View File

@@ -0,0 +1,117 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 配置驱动的 vendor 扩展选择器。按 {@link Gb32960Properties#getVendorExtensions()} 配置的
* 顺序自上而下首匹first-match-wins命中的 entry 的 {@code name} 即返回值。
*
* <p>命中字段:{@code platformAccount} / {@code vin} / {@code vinPrefix}(任一命中即整条
* entry 命中)。比较为 case-insensitive。
*
* <p>结果按 {@code (account, vin)} 缓存以避免每帧重复扫描;约定 cache key 不含 version
* 因为版本不参与匹配。Cache 用 {@link Optional} 包裹以同时缓存 "no match"。
*/
public final class RuleBasedVendorExtensionSelector implements VendorExtensionSelector {
private final List<CompiledRule> rules;
private final ConcurrentHashMap<CacheKey, Optional<String>> cache = new ConcurrentHashMap<>();
public RuleBasedVendorExtensionSelector(List<Gb32960Properties.VendorExtension> entries,
Set<String> validNames) {
List<CompiledRule> compiled = new ArrayList<>(entries.size());
for (Gb32960Properties.VendorExtension e : entries) {
if (e.getName() == null || e.getName().isBlank()) {
throw new IllegalStateException("vendor extension entry missing 'name'");
}
if (!validNames.contains(e.getName())) {
throw new IllegalStateException(
"vendor extension '" + e.getName() + "' not registered in catalog. "
+ "Available: " + validNames);
}
compiled.add(CompiledRule.from(e));
}
this.rules = List.copyOf(compiled);
}
@Override
public String select(Gb32960ParserContext ctx) {
if (rules.isEmpty()) return null;
CacheKey key = new CacheKey(normalize(ctx.platformAccount()), normalize(ctx.vin()));
Optional<String> cached = cache.get(key);
if (cached != null) return cached.orElse(null);
String hit = scan(key);
cache.put(key, Optional.ofNullable(hit));
return hit;
}
private String scan(CacheKey key) {
for (CompiledRule r : rules) {
if (r.matches(key.account, key.vin)) return r.name;
}
return null;
}
private static String normalize(String s) {
return s == null || s.isEmpty() ? null : s.toUpperCase(Locale.ROOT);
}
/** 暴露给测试和 actuator 端点(如果以后加)。 */
public int cacheSize() { return cache.size(); }
private record CacheKey(String account, String vin) {}
/** 预编译后的单条规则:所有列表已 normalize 成大写 set。 */
private static final class CompiledRule {
final String name;
final Set<String> accounts;
final Set<String> vins;
final List<String> vinPrefixes;
CompiledRule(String name, Set<String> accounts, Set<String> vins, List<String> vinPrefixes) {
this.name = name;
this.accounts = accounts;
this.vins = vins;
this.vinPrefixes = vinPrefixes;
}
static CompiledRule from(Gb32960Properties.VendorExtension e) {
Gb32960Properties.VendorExtension.Match m = e.getMatch();
return new CompiledRule(
e.getName(),
Set.copyOf(toUpper(m.getPlatformAccounts())),
Set.copyOf(toUpper(m.getVins())),
List.copyOf(toUpper(m.getVinPrefixes())));
}
boolean matches(String account, String vin) {
if (account != null && accounts.contains(account)) return true;
if (vin != null && vins.contains(vin)) return true;
if (vin != null) {
for (String prefix : vinPrefixes) {
if (vin.startsWith(prefix)) return true;
}
}
return false;
}
private static List<String> toUpper(List<String> list) {
if (list == null || list.isEmpty()) return List.of();
List<String> out = new ArrayList<>(list.size());
for (String s : list) {
if (s == null || s.isBlank()) continue;
out.add(s.trim().toUpperCase(Locale.ROOT));
}
return out;
}
}
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Vendor 扩展套件目录:从套件名映射到该套件包含的 {@link InfoBlockParser} 列表。
*
* <p>Catalog 在启动时由 {@code Gb32960AutoConfiguration} 一次性构建并注入到
* {@link Gb32960ProfileRegistry},运行期不可变。每个 entry 代表一个**已实现的**厂商扩展
* 协议(如广东燃料电池汽车示范规范),其 parser 集合会在选中时**追加**到默认标准 parser
* 集合之上,组成完整的 profile registry。
*
* <p>当前内置 key
* <ul>
* <li>{@code guangdong-fc} —— 广东燃料电池汽车示范规范 0x30~0x34、0x80
* </ul>
*/
public final class VendorExtensionCatalog {
private final Map<String, List<InfoBlockParser>> extensions;
public VendorExtensionCatalog(Map<String, List<InfoBlockParser>> extensions) {
this.extensions = Map.copyOf(extensions);
}
/** 已注册的全部 vendor 扩展名集合。 */
public java.util.Set<String> names() {
return extensions.keySet();
}
/** 取指定 vendor 扩展套件的 parser 列表;不存在返回空列表。 */
public List<InfoBlockParser> parsersFor(String name) {
List<InfoBlockParser> list = extensions.get(name);
return list == null ? Collections.emptyList() : list;
}
}

View File

@@ -0,0 +1,20 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
/**
* 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile
*
* <p>实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。
*/
public interface VendorExtensionSelector {
/**
* @return 命中的扩展套件名(与 {@code Gb32960ProfileRegistry} 注册名一致),
* 未命中返回 {@code null}。
*/
String select(Gb32960ParserContext ctx);
/** 永远返回 null 的实现,用于不启用任何 vendor 扩展的默认场景。 */
VendorExtensionSelector NONE = ctx -> null;
}

View File

@@ -0,0 +1,236 @@
package com.lingniu.ingest.protocol.gb32960.config;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer;
import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960CommandParser;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.EngineV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.ExtremeValueV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.FuelCellV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.AlarmV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.BatteryTemperatureV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.DriveMotorV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.EngineV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.FuelCellStackV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.FuelCellV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.MinParallelVoltageV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.PositionV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.SuperCapacitorExtremeV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.SuperCapacitorV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.VehicleV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAirConditionerBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAuxiliaryBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDcDcBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDemoExtensionBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcStackBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcVehicleInfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcVendorTlvBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.profile.RuleBasedVendorExtensionSelector;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionSelector;
import com.lingniu.ingest.protocol.gb32960.handler.Gb32960RealtimeHandler;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960AccessService;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960AckService;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960FrameDiagnostics;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer;
import com.lingniu.ingest.protocol.gb32960.mapper.Gb32960EventMapper;
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;
/**
* GB/T 32960.3 协议接入模块的自动装配。
*
* <p>每个 Parser Bean 都会被 {@link InfoBlockParserRegistry} 按
* {@code (ProtocolVersion, typeCode)} 注册。2016/2025 可同时加载。
*/
@AutoConfiguration
@EnableConfigurationProperties(Gb32960Properties.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.gb32960", name = "enabled", havingValue = "true")
public class Gb32960AutoConfiguration {
// ================= 2016 版信息体 Parsercom...parser.v2016 包) =================
@Bean public InfoBlockParser gb32960V2016Vehicle() { return new VehicleV2016BlockParser(); }
@Bean public InfoBlockParser gb32960V2016DriveMotor() { return new DriveMotorV2016BlockParser(); }
@Bean public InfoBlockParser gb32960V2016FuelCell() { return new FuelCellV2016BlockParser(); }
@Bean public InfoBlockParser gb32960V2016Engine() { return new EngineV2016BlockParser(); }
@Bean public InfoBlockParser gb32960V2016Position() { return new PositionV2016BlockParser(); }
@Bean public InfoBlockParser gb32960V2016Extreme() { return new ExtremeValueV2016BlockParser(); }
@Bean public InfoBlockParser gb32960V2016Alarm() { return new AlarmV2016BlockParser(); }
@Bean public InfoBlockParser gb32960V2016Voltage() { return new VoltageV2016BlockParser(); }
@Bean public InfoBlockParser gb32960V2016Temperature() { return new TemperatureV2016BlockParser(); }
// 注意0x30~0x7F 在 GB/T 32960.3-2016 附录 B 表 B.3 是"预留"区段,没有任何字段定义。
// peer 在 V2016 帧里发 0x30/0x31/0x32 是越界使用,强行用 2025 字段布局解读会得到非物理值。
// 故 V2016 不注册任何 0x30+ parser遇到时由 Gb32960BodyParser 走 Raw 兜底并打 WARN。
// ================= 2025 版信息体 Parsercom...parser.v2025 包) =================
@Bean public InfoBlockParser gb32960V2025Vehicle() { return new VehicleV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025DriveMotor() { return new DriveMotorV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025FuelCell() { return new FuelCellV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025Engine() { return new EngineV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025Position() { return new PositionV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025Alarm() { return new AlarmV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025MinParallelVoltage() { return new MinParallelVoltageV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025BatteryTemp() { return new BatteryTemperatureV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025FuelCellStack() { return new FuelCellStackV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025SuperCap() { return new SuperCapacitorV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025SuperCapExtreme() { return new SuperCapacitorExtremeV2025BlockParser(); }
// ================= Vendor 扩展套件目录 =================
/**
* 内置 vendor 扩展套件目录。当前注册:
* <ul>
* <li>{@code guangdong-fc} —— 广东燃料电池汽车示范规范 0x30~0x34、0x80
* </ul>
* 未来新增其他厂商扩展时直接在这里加 entry。
*/
@Bean
@ConditionalOnMissingBean
public VendorExtensionCatalog gb32960VendorExtensionCatalog() {
return new VendorExtensionCatalog(java.util.Map.of(
"guangdong-fc", List.of(
new GdFcStackBlockParser(),
new GdFcAuxiliaryBlockParser(),
new GdFcDcDcBlockParser(),
new GdFcAirConditionerBlockParser(),
new GdFcVehicleInfoBlockParser(),
new GdFcDemoExtensionBlockParser(),
// 0x83 厂商私有 TLV 兜底(同 peer 在广东规范之外又下发的未公开扩展)
new GdFcVendorTlvBlockParser(0x83))));
}
// ================= Core components =================
/**
* Profile 注册表:把全部已注入的标准 parser 作为 default profile
* 同时按配置 {@code lingniu.ingest.gb32960.vendor-extensions} 里被引用的 vendor 套件名
* 分别构建一份 "default + vendor" 的 registry。
*/
@Bean
@ConditionalOnMissingBean
public Gb32960ProfileRegistry gb32960ProfileRegistry(List<InfoBlockParser> parsers,
VendorExtensionCatalog catalog,
Gb32960Properties props) {
java.util.Set<String> enabled = new java.util.LinkedHashSet<>();
for (Gb32960Properties.VendorExtension e : props.getVendorExtensions()) {
if (e.getName() != null && !e.getName().isBlank()) enabled.add(e.getName());
}
return new Gb32960ProfileRegistry(parsers, catalog, enabled);
}
/** Vendor 扩展选择器:按配置 list 顺序首匹。 */
@Bean
@ConditionalOnMissingBean
public VendorExtensionSelector gb32960VendorExtensionSelector(Gb32960Properties props,
VendorExtensionCatalog catalog) {
if (props.getVendorExtensions() == null || props.getVendorExtensions().isEmpty()) {
return VendorExtensionSelector.NONE;
}
return new RuleBasedVendorExtensionSelector(props.getVendorExtensions(), catalog.names());
}
@Bean
@ConditionalOnMissingBean
public Gb32960BodyParser gb32960BodyParser(Gb32960ProfileRegistry profileRegistry,
VendorExtensionSelector selector,
Gb32960Properties props) {
Gb32960BodyParser parser = new Gb32960BodyParser(profileRegistry, selector);
parser.setLenientBlockFailure(props.getParse().isLenientBlockFailure());
return parser;
}
@Bean
@ConditionalOnMissingBean
public Gb32960CommandParser gb32960CommandParser() {
return new Gb32960CommandParser();
}
@Bean
@ConditionalOnMissingBean
public Gb32960MessageDecoder gb32960MessageDecoder(Gb32960BodyParser bodyParser,
Gb32960CommandParser commandParser) {
return new Gb32960MessageDecoder(bodyParser, commandParser);
}
@Bean
@ConditionalOnMissingBean
public Gb32960EventMapper gb32960EventMapper() {
return new Gb32960EventMapper();
}
@Bean
@ConditionalOnMissingBean
public Gb32960RealtimeHandler gb32960RealtimeHandler(Gb32960EventMapper mapper) {
return new Gb32960RealtimeHandler(mapper);
}
@Bean
@ConditionalOnMissingBean
public Gb32960VinAuthorizer gb32960VinAuthorizer(Gb32960Properties props) {
return new Gb32960VinAuthorizer(props.getAuth());
}
@Bean
@ConditionalOnMissingBean
public Gb32960PlatformAuthorizer gb32960PlatformAuthorizer(Gb32960Properties props) {
return new Gb32960PlatformAuthorizer(props.getAuth().getPlatforms());
}
@Bean
@ConditionalOnMissingBean
public Gb32960AccessService gb32960AccessService(Gb32960VinAuthorizer authorizer,
Gb32960PlatformAuthorizer platformAuthorizer) {
return new Gb32960AccessService(authorizer, platformAuthorizer);
}
@Bean
@ConditionalOnMissingBean
public Gb32960AckService gb32960AckService() {
return new Gb32960AckService();
}
@Bean
@ConditionalOnMissingBean
public Gb32960FrameDiagnostics gb32960FrameDiagnostics(Gb32960Properties props) {
return new Gb32960FrameDiagnostics(
props.getDiagnostics().getMaxLoggedFrameKeysPerChannel());
}
@Bean
@ConditionalOnMissingBean
public Gb32960NettyServer gb32960NettyServer(Gb32960Properties props,
Gb32960MessageDecoder decoder,
Dispatcher dispatcher,
Gb32960AccessService accessService,
Gb32960AckService ackService,
Gb32960FrameDiagnostics diagnostics) {
return new Gb32960NettyServer(
props.getPort(),
props.getWorkerThreads(),
props.getIdleReadSeconds(),
decoder,
dispatcher,
accessService,
ackService,
diagnostics,
props.getTls());
}
}

View File

@@ -0,0 +1,263 @@
package com.lingniu.ingest.protocol.gb32960.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@ConfigurationProperties(prefix = "lingniu.ingest.gb32960")
public class Gb32960Properties {
private boolean enabled = false;
private int port = 9000;
private int workerThreads = 0;
/**
* 读空闲告警阈值(秒)。连接在该时长内没有任何上行帧则打一条 WARN 日志,方便排查
* "对端登入成功但不推数据"之类的静默场景。仅告警不断链0 或负数禁用。
*/
private int idleReadSeconds = 60;
/** VIN 白名单认证。关闭时任何 VIN 都能登入。 */
private Auth auth = new Auth();
/** TLS 双向认证。 */
private Tls tls = new Tls();
/**
* 报文解析行为配置。
*
* <p>默认 {@code lenientBlockFailure=true}:单个信息块解析异常时兜成
* {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw} 继续解析,
* 不再整帧放弃。关闭后恢复旧行为(任意异常直接抛 {@code DecodeException}
* 放弃整帧),仅在灰度回滚时使用。
*/
private Parse parse = new Parse();
/**
* 高频入站诊断配置。用于限制每个连接保存的"首次见到帧形态"去重 key 数量,
* 避免异常设备制造大量不同 Raw 组合时造成 channel attribute 无界增长。
*/
private Diagnostics diagnostics = new Diagnostics();
/**
* 厂商扩展协议vendor extensions路由配置。
*
* <p>每个 entry 声明一个**已被实现**的 vendor 扩展套件名({@code name})以及该套件
* 的命中规则({@link VendorExtension.Match})。命中规则由
* {@code RuleBasedVendorExtensionSelector} 在收到帧时按上下文platformAccount /
* vin / vin-prefix逐条按 list 顺序首匹,命中后用对应套件的 parser 集合替换默认的
* 标准解析器集合(仍包含 0x01..0x09 标准块)。
*
* <p>套件名必须与 {@code VendorExtensionCatalog} 里注册的 key 一致;
* 当前内置:{@code guangdong-fc}(广东燃料电池汽车示范规范 0x30~0x34 + 0x80
*
* <p>未匹配任何规则时回退到默认 profile仅保留 GB/T 32960 标准块。
*/
private List<VendorExtension> vendorExtensions = new ArrayList<>();
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
public int getWorkerThreads() { return workerThreads; }
public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; }
public int getIdleReadSeconds() { return idleReadSeconds; }
public void setIdleReadSeconds(int idleReadSeconds) { this.idleReadSeconds = idleReadSeconds; }
public Auth getAuth() { return auth; }
public void setAuth(Auth auth) { this.auth = auth; }
public Tls getTls() { return tls; }
public void setTls(Tls tls) { this.tls = tls; }
public Parse getParse() { return parse; }
public void setParse(Parse parse) { this.parse = parse; }
public Diagnostics getDiagnostics() { return diagnostics; }
public void setDiagnostics(Diagnostics diagnostics) { this.diagnostics = diagnostics; }
public List<VendorExtension> getVendorExtensions() { return vendorExtensions; }
public void setVendorExtensions(List<VendorExtension> vendorExtensions) {
this.vendorExtensions = vendorExtensions;
}
/**
* VIN 白名单认证。
*
* <p>GB/T 32960 协议没有设备账号密码,身份由报文头的 17 字节 VIN + 数据单元里的 ICCID
* 决定。此处通过内存白名单实现最小可用的接入控制:
* <ul>
* <li>{@code enabled=false}(默认):接受任意 VIN 接入,兼容未注册的测试终端
* <li>{@code enabled=true}
* <ul>
* <li>车辆登入 0x01VIN 不在白名单时返回 {@code 0x04 VIN_NOT_EXIST} 应答,关闭连接
* <li>实时上报 0x02 / 补发 0x03 / 登出 0x04 / 心跳 0x07VIN 不在白名单时丢弃,关闭连接
* </ul>
* </ul>
*/
public static class Auth {
private boolean enabled = false;
/** 是否大小写敏感比较 VIN。GB 16735 要求 VIN 大写,默认大小写不敏感以容错。 */
private boolean caseSensitive = false;
private List<String> whitelist = new ArrayList<>();
/**
* 平台登入 (0x05) 账号列表。GB/T 32960.3 表 29 规定平台登入携带
* 12B 用户名 + 20B 密码 + 1B 加密规则。此处维护允许登入的账号集合,
* 收到 0x05 时比对 username / password
* <ul>
* <li>匹配且可选IP 在 {@code allowedIps} 内 → 返回 {@code SUCCESS} 并产出登入事件
* <li>不匹配 → 返回 {@code 0x02 OTHER_ERROR} 并关闭连接
* </ul>
* 列表为空时默认放行所有平台登入(兼容旧行为)。
*/
private List<Platform> platforms = new ArrayList<>();
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public boolean isCaseSensitive() { return caseSensitive; }
public void setCaseSensitive(boolean caseSensitive) { this.caseSensitive = caseSensitive; }
public List<String> getWhitelist() { return whitelist; }
public void setWhitelist(List<String> whitelist) { this.whitelist = whitelist; }
public List<Platform> getPlatforms() { return platforms; }
public void setPlatforms(List<Platform> platforms) { this.platforms = platforms; }
/** 转换为规范化 Set 供 O(1) 查找。 */
public Set<String> normalizedWhitelist() {
Set<String> out = new LinkedHashSet<>(whitelist.size());
for (String v : whitelist) {
if (v == null || v.isBlank()) continue;
out.add(caseSensitive ? v.trim() : v.trim().toUpperCase());
}
return out;
}
/**
* 平台账号定义。对应 GB/T 32960.3 表 29 的字段。
*
* @param username 12 字节以内的用户名
* @param password 20 字节以内的密码(明文配置;推荐通过 env var 注入避免入库代码)
* @param allowedIps 可选:允许的客户端 IP 列表,为空不做 IP 限制
* @param description 可选:平台描述(仅日志用)
*/
public static class Platform {
private String username;
private String password;
private List<String> allowedIps = new ArrayList<>();
private String description;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public List<String> getAllowedIps() { return allowedIps; }
public void setAllowedIps(List<String> allowedIps) { this.allowedIps = allowedIps; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
}
/**
* TLS 双向认证。
*
* <p>启用后 Netty pipeline 前置 {@code SslHandler},服务端加载自身证书+私钥,
* 强制要求客户端提供证书({@code NEED_CLIENT_AUTH})并使用 CA 证书验证。
*/
public static class Tls {
private boolean enabled = false;
/** 服务端证书 PEM 路径。文件路径或 classpath 资源。 */
private String certPath;
/** 服务端私钥 PEM 路径PKCS#8 未加密)。 */
private String keyPath;
/** 受信任的 CA 证书 PEM 路径。用于验证客户端证书。 */
private String trustCertPath;
/** 是否要求客户端证书。默认 true双向false 则变成单向 TLS。 */
private boolean requireClientAuth = true;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getCertPath() { return certPath; }
public void setCertPath(String certPath) { this.certPath = certPath; }
public String getKeyPath() { return keyPath; }
public void setKeyPath(String keyPath) { this.keyPath = keyPath; }
public String getTrustCertPath() { return trustCertPath; }
public void setTrustCertPath(String trustCertPath) { this.trustCertPath = trustCertPath; }
public boolean isRequireClientAuth() { return requireClientAuth; }
public void setRequireClientAuth(boolean requireClientAuth) { this.requireClientAuth = requireClientAuth; }
}
/**
* 报文解析容错策略。
*/
public static class Parse {
/**
* 单块解析异常时是否兜底为 {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw}
* 继续解析。默认开启。
*
* <ul>
* <li>{@code true}默认parser 抛 DecodeException / BufferUnderflowException /
* IndexOutOfBoundsException 时,固定长度块按 fixedLen 截取 Raw 后 continue
* 变长块或剩余字节不足时,剩余全部兜成 Raw 后 break 循环。
* <li>{@code false}:保留旧行为,任意异常直接抛出 DecodeException 放弃整帧。
* </ul>
*/
private boolean lenientBlockFailure = true;
public boolean isLenientBlockFailure() { return lenientBlockFailure; }
public void setLenientBlockFailure(boolean lenientBlockFailure) {
this.lenientBlockFailure = lenientBlockFailure;
}
}
/**
* 入站诊断限流/限内存策略。
*/
public static class Diagnostics {
/** 每个 TCP channel 最多保存的首次帧诊断 key 数量。 */
private int maxLoggedFrameKeysPerChannel = 128;
public int getMaxLoggedFrameKeysPerChannel() {
return maxLoggedFrameKeysPerChannel;
}
public void setMaxLoggedFrameKeysPerChannel(int maxLoggedFrameKeysPerChannel) {
this.maxLoggedFrameKeysPerChannel = maxLoggedFrameKeysPerChannel;
}
}
/**
* 单条 vendor 扩展路由配置。{@code name} 必须与
* {@code VendorExtensionCatalog} 注册的 key 一致。
*/
public static class VendorExtension {
/** 扩展套件名,必须在 catalog 中已注册(如 {@code guangdong-fc})。 */
private String name;
/** 命中规则。任一字段命中即视为整条 entry 命中。 */
private Match match = new Match();
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Match getMatch() { return match; }
public void setMatch(Match match) { this.match = match; }
/**
* 命中规则。所有字段为 OR 关系:任意一个命中即匹配。
* 字段内部为 case-insensitive 比较;字段为空 list 视为不参与匹配(不会主动命中)。
*/
public static class Match {
/** 平台登入用户名精确匹配列表(来自 0x05 PlatformLogin 的 username 字段)。 */
private List<String> platformAccounts = new ArrayList<>();
/** VIN 精确匹配列表。 */
private List<String> vins = new ArrayList<>();
/** VIN 前缀匹配列表startsWith。 */
private List<String> vinPrefixes = new ArrayList<>();
public List<String> getPlatformAccounts() { return platformAccounts; }
public void setPlatformAccounts(List<String> platformAccounts) {
this.platformAccounts = platformAccounts;
}
public List<String> getVins() { return vins; }
public void setVins(List<String> vins) { this.vins = vins; }
public List<String> getVinPrefixes() { return vinPrefixes; }
public void setVinPrefixes(List<String> vinPrefixes) { this.vinPrefixes = vinPrefixes; }
}
}
}

View File

@@ -0,0 +1,57 @@
package com.lingniu.ingest.protocol.gb32960.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.annotation.RateLimited;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.protocol.gb32960.mapper.Gb32960EventMapper;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import java.util.List;
/**
* GB/T 32960 协议 HandlerDispatcher 路由到此的所有 0x01~0x07 命令都委托给
* {@link Gb32960EventMapper} 转成 {@link VehicleEvent}。
*
* <p>这是 GB32960 协议在通用 Dispatcher 管线里的**唯一入口** Bean——移除将导致所有
* GB32960 帧产出的事件丢失。构造器注入 {@link Gb32960EventMapper},全部业务逻辑限制
* 在纯函数里,不触碰数据库 / 不做 IO。产出的事件由 Dispatcher 统一投递到 Disruptor → Kafka。
*/
@ProtocolHandler(protocol = ProtocolId.GB32960)
public class Gb32960RealtimeHandler {
private final Gb32960EventMapper mapper;
public Gb32960RealtimeHandler(Gb32960EventMapper mapper) {
this.mapper = mapper;
}
/** 实时 + 补发上报。 */
@MessageMapping(command = {0x02, 0x03}, desc = "实时/补发上报")
@RateLimited(perVin = 50)
@EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class})
public List<VehicleEvent> onReport(Gb32960Message msg) {
return mapper.toEvents(msg);
}
/** 车辆登入 / 登出 / 心跳:统一映射为 session 事件。 */
@MessageMapping(command = {0x01, 0x04, 0x07}, desc = "登入/登出/心跳")
@EventEmit({VehicleEvent.Login.class, VehicleEvent.Logout.class, VehicleEvent.Heartbeat.class})
public List<VehicleEvent> onSession(Gb32960Message msg) {
return mapper.toEvents(msg);
}
/**
* 平台登入 / 登出0x05 / 0x06外部下级平台握手产出
* {@link VehicleEvent.Login} / {@link VehicleEvent.Logout}
* vin 字段形如 {@code platform:<username>}metadata 带 {@code kind=platform}
* 下游消费者可据此区分车辆会话与平台会话。
*/
@MessageMapping(command = {0x05, 0x06}, desc = "平台登入/登出")
@EventEmit({VehicleEvent.Login.class, VehicleEvent.Logout.class})
public List<VehicleEvent> onPlatform(Gb32960Message msg) {
return mapper.toEvents(msg);
}
}

View File

@@ -0,0 +1,72 @@
package com.lingniu.ingest.protocol.gb32960.inbound;
import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer;
import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer;
import io.netty.channel.Channel;
import io.netty.util.AttributeKey;
import java.net.InetSocketAddress;
/**
* Access boundary for GB/T 32960 inbound channels.
*
* <p>Keeps authentication and channel-scoped platform identity out of the Netty
* handler so the hot path can stay small and testable.
*/
public final class Gb32960AccessService {
public static final AttributeKey<String> PLATFORM_ACCOUNT_ATTR =
AttributeKey.valueOf("gb32960.platformAccount");
private final Gb32960VinAuthorizer vinAuthorizer;
private final Gb32960PlatformAuthorizer platformAuthorizer;
public Gb32960AccessService(Gb32960VinAuthorizer vinAuthorizer,
Gb32960PlatformAuthorizer platformAuthorizer) {
this.vinAuthorizer = vinAuthorizer;
this.platformAuthorizer = platformAuthorizer;
}
public boolean isVinAllowed(String vin) {
return vinAuthorizer.isAllowed(vin);
}
public boolean isVinWhitelistEnforcing() {
return vinAuthorizer.isEnforcing();
}
public int vinWhitelistSize() {
return vinAuthorizer.whitelistSize();
}
public boolean isPlatformAuthEnforcing() {
return platformAuthorizer.isEnforcing();
}
public int platformPolicySize() {
return platformAuthorizer.size();
}
public Gb32960PlatformAuthorizer.Result authenticatePlatformLogin(String username,
String password,
Channel channel) {
return platformAuthorizer.authenticate(username, password, peerIp(channel));
}
public void bindPlatformAccount(Channel channel, String username) {
channel.attr(PLATFORM_ACCOUNT_ATTR).set(username);
}
public String platformAccount(Channel channel) {
return channel.hasAttr(PLATFORM_ACCOUNT_ATTR)
? channel.attr(PLATFORM_ACCOUNT_ATTR).get()
: null;
}
private static String peerIp(Channel channel) {
if (channel.remoteAddress() instanceof InetSocketAddress i) {
return i.getAddress().getHostAddress();
}
return null;
}
}

View File

@@ -0,0 +1,116 @@
package com.lingniu.ingest.protocol.gb32960.inbound;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameEncoder;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.time.Instant;
/**
* Builds and writes GB/T 32960 response frames.
*/
public final class Gb32960AckService {
private static final Logger log = LoggerFactory.getLogger(Gb32960AckService.class);
public void writeResponse(ChannelHandlerContext ctx,
ProtocolVersion version,
CommandType command,
ResponseFlag responseFlag,
byte[] rawVin,
Instant eventTime,
byte[] data,
String tag) {
writeResponse(ctx.channel(), version, command, responseFlag, rawVin, eventTime, data, tag);
}
public void writeResponse(Channel channel,
ProtocolVersion version,
CommandType command,
ResponseFlag responseFlag,
byte[] rawVin,
Instant eventTime,
byte[] data,
String tag) {
byte[] ack = Gb32960FrameEncoder.buildResponse(
version, command, responseFlag, rawVin, eventTime, data);
write(channel, ack, tag);
}
public void writeAndClose(ChannelHandlerContext ctx,
ProtocolVersion version,
CommandType command,
ResponseFlag responseFlag,
String vin,
Instant eventTime,
byte[] data,
String tag) {
byte[] ack = Gb32960FrameEncoder.buildResponse(
version, command, responseFlag, vin, eventTime, data);
ctx.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> ctx.close());
}
public void writeAndClose(ChannelHandlerContext ctx,
ProtocolVersion version,
CommandType command,
ResponseFlag responseFlag,
byte[] rawVin,
Instant eventTime,
byte[] data,
String tag) {
byte[] ack = Gb32960FrameEncoder.buildResponse(
version, command, responseFlag, rawVin, eventTime, data);
String hex = hex(ack);
ctx.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> {
if (f.isSuccess()) {
log.info("[gb32960] {} flushed peer={} len={} hex={}",
tag, addr(ctx.channel()), ack.length, hex);
} else {
log.warn("[gb32960] {} flush FAILED peer={} len={} hex={}",
tag, addr(ctx.channel()), ack.length, hex, f.cause());
}
ctx.close();
});
}
private static void write(Channel channel, byte[] ack, String tag) {
String hex = hex(ack);
boolean highFrequency = tag.startsWith("report-ack-") || tag.startsWith("heartbeat-ack");
channel.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> {
if (f.isSuccess()) {
if (highFrequency) {
if (log.isDebugEnabled()) {
log.debug("[gb32960] {} flushed peer={} len={} hex={}",
tag, addr(channel), ack.length, hex);
}
} else {
log.info("[gb32960] {} flushed peer={} len={} hex={}",
tag, addr(channel), ack.length, hex);
}
} else {
log.warn("[gb32960] {} flush FAILED peer={} len={} hex={}",
tag, addr(channel), ack.length, hex, f.cause());
}
});
}
private static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
private static String addr(Channel channel) {
if (channel.remoteAddress() instanceof InetSocketAddress i) {
return i.getAddress().getHostAddress() + ":" + i.getPort();
}
return "unknown";
}
}

View File

@@ -0,0 +1,359 @@
package com.lingniu.ingest.protocol.gb32960.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.gb32960.auth.Gb32960PlatformAuthorizer;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.model.CommandBody;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.util.AttributeKey;
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.List;
import java.util.Map;
/**
* Netty 入站处理器:字节 → Gb32960Message → 认证 → RawFrame → Dispatcher。
*
* <p>认证逻辑:
* <ul>
* <li>{@link Gb32960VinAuthorizer#isEnforcing()} = false放行所有 VIN
* <li>{@link Gb32960VinAuthorizer#isEnforcing()} = true
* <ul>
* <li>车辆登入 {@code 0x01}VIN 在白名单则返回成功应答(保留原帧时间)并派发;
* 不在则返回 {@code 0x04 VIN_NOT_EXIST} 应答并关闭连接
* <li>其他命令VIN 在白名单则派发;不在则 DEBUG 日志后关闭连接
* </ul>
* </ul>
* 注意应答帧由 {@link Gb32960AckService} 构造并写回 Channel。
*/
public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
private static final Logger log = LoggerFactory.getLogger(Gb32960ChannelHandler.class);
/**
* 0x05 PLATFORM_LOGIN 鉴权通过后写入;后续 0x02/0x03 实时上报帧从 channel attribute 取出
* 作为 {@code Gb32960ParserContext.platformAccount},供 vendor profile selector 路由。
* 普通车端直连场景0x01 VEHICLE_LOGIN此 attribute 始终为 null。
*/
public static final AttributeKey<String> PLATFORM_ACCOUNT_ATTR =
Gb32960AccessService.PLATFORM_ACCOUNT_ATTR;
private final Gb32960MessageDecoder decoder;
private final Dispatcher dispatcher;
private final Gb32960AccessService accessService;
private final Gb32960AckService ackService;
private final Gb32960FrameDiagnostics diagnostics;
public Gb32960ChannelHandler(Gb32960MessageDecoder decoder,
Dispatcher dispatcher,
Gb32960AccessService accessService,
Gb32960AckService ackService,
Gb32960FrameDiagnostics diagnostics) {
this.decoder = decoder;
this.dispatcher = dispatcher;
this.accessService = accessService;
this.ackService = ackService;
this.diagnostics = diagnostics;
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
log.info("[gb32960] connection opened peer={}", addr(ctx));
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
log.info("[gb32960] connection closed peer={}", addr(ctx));
}
/**
* 读空闲告警:仅记录日志,不断链。用于排查"对端登入成功后长时间不推数据"场景。
* 由 pipeline 中的 {@link io.netty.handler.timeout.IdleStateHandler}(可选)触发。
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent idle && idle.state() == IdleState.READER_IDLE) {
log.warn("[gb32960] read idle peer={} noInboundFrameFor>=thresholdSeconds (see idleReadSeconds)",
addr(ctx));
}
super.userEventTriggered(ctx, evt);
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, byte[] frame) {
Gb32960Message msg;
try {
String platformAccount = accessService.platformAccount(ctx.channel());
msg = decoder.decode(ByteBuffer.wrap(frame), platformAccount);
} catch (Exception e) {
log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e);
return;
}
String vin = msg.header().vin();
CommandType cmd = msg.header().command();
// 原样回显请求帧的 17 字节 VINframe[4..20]),避免平台登入 VIN 全 0
// 被 vinPadded 重写为空格导致对端判定 VIN 不匹配。
byte[] rawVin = new byte[17];
System.arraycopy(frame, 4, rawVin, 0, 17);
// 平台登入/登出0x05/0x06没有 VIN不进 VIN 白名单校验
boolean isPlatformCommand = cmd == CommandType.PLATFORM_LOGIN || cmd == CommandType.PLATFORM_LOGOUT;
if (!isPlatformCommand && !accessService.isVinAllowed(vin)) {
handleUnauthorized(ctx, msg);
return;
}
// 鉴权通过:按命令类型回 ACK写成功后统一 dispatch 到通用管线。
// 平台登入鉴权失败会短路 return不进 dispatch。
switch (cmd) {
case VEHICLE_LOGIN -> handleVehicleLogin(ctx, msg, rawVin);
case VEHICLE_LOGOUT -> handleVehicleLogout(ctx, msg, rawVin);
case PLATFORM_LOGIN -> {
if (!handlePlatformLogin(ctx, msg, rawVin)) return;
}
case PLATFORM_LOGOUT -> handlePlatformLogout(ctx, msg, rawVin);
case REALTIME_REPORT, RESEND_REPORT, HEARTBEAT -> handleReportOrHeartbeat(ctx, msg, rawVin);
case TIME_CALIBRATION -> handleTimeCalibration(ctx, msg, rawVin);
default -> logOtherFrame(ctx, msg);
}
Map<String, String> sourceMeta = new HashMap<>(4);
sourceMeta.put("vin", vin);
sourceMeta.put("peer", addr(ctx));
String platformAccount = accessService.platformAccount(ctx.channel());
if (platformAccount != null && !platformAccount.isBlank()) {
sourceMeta.put("platformAccount", platformAccount);
}
RawFrame rf = new RawFrame(
ProtocolId.GB32960,
cmd.code(),
0,
msg,
frame,
sourceMeta,
Instant.now());
dispatcher.dispatch(rf);
}
/** 0x01 车辆登入:按 §6.3.2 带原采集时间回成功 ACK + INFO 日志。 */
private void handleVehicleLogin(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) {
ackService.writeResponse(
ctx,
msg.header().protocolVersion(),
CommandType.VEHICLE_LOGIN,
ResponseFlag.SUCCESS,
rawVin,
eventOrNow(msg),
null,
"vehicle-login-ack");
log.info("[gb32960] vehicle login peer={} vin={} protocolVersion={}",
addr(ctx), msg.header().vin(), msg.header().protocolVersion());
}
/** 0x04 车辆登出INFO 日志 + 带原采集时间回成功 ACK。 */
private void handleVehicleLogout(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) {
log.info("[gb32960] vehicle logout peer={} vin={}", addr(ctx), msg.header().vin());
ackService.writeResponse(ctx,
msg.header().protocolVersion(),
CommandType.VEHICLE_LOGOUT,
ResponseFlag.SUCCESS,
rawVin,
eventOrNow(msg),
null,
"vehicle-logout-ack");
}
/**
* 0x05 平台登入(表 29含 12B username + 20B password + 加密规则。
*
* <ul>
* <li>消息体解析缺失:关闭连接,返回 false 短路
* <li>鉴权失败:写 {@code 0x02 OTHER_ERROR} NACK + 关闭连接,返回 false 短路
* <li>鉴权通过:把 username 钉到 channel attribute供后续 vendor profile 路由),
* 写成功 ACK + INFO 日志,返回 true 继续 dispatch
* </ul>
*
* @return true 表示鉴权通过应继续 dispatchfalse 表示调用方应 short-circuit return
*/
private boolean handlePlatformLogin(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) {
if (!(msg.commandBody() instanceof CommandBody.PlatformLogin pl)) {
log.warn("[gb32960] platform login peer={} body unparsed, closing", addr(ctx));
ctx.close();
return false;
}
Gb32960PlatformAuthorizer.Result result = accessService.authenticatePlatformLogin(
pl.username(), pl.password(), ctx.channel());
if (!result.accepted()) {
log.warn("[gb32960] platform login REJECTED peer={} username={} reason={}",
addr(ctx), pl.username(), result);
ackService.writeAndClose(
ctx,
msg.header().protocolVersion(),
CommandType.PLATFORM_LOGIN,
ResponseFlag.OTHER_ERROR,
rawVin,
eventOrNow(msg),
null,
"platform-login-nack");
return false;
}
log.info("[gb32960] platform login peer={} username={} encryptRule={} serial={} time={} policy={}",
addr(ctx), pl.username(), pl.encryptRule(), pl.serialNo(), pl.loginTime(), result);
// 把 username 钉到 channel attribute供后续 0x02/0x03 帧的 vendor profile 路由
accessService.bindPlatformAccount(ctx.channel(), pl.username());
ackService.writeResponse(
ctx,
msg.header().protocolVersion(),
CommandType.PLATFORM_LOGIN,
ResponseFlag.SUCCESS,
rawVin,
eventOrNow(msg),
null,
"platform-login-ack");
return true;
}
/** 0x06 平台登出INFO 日志 + 带原采集时间回成功 ACK。 */
private void handlePlatformLogout(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) {
log.info("[gb32960] platform logout peer={}", addr(ctx));
ackService.writeResponse(ctx,
msg.header().protocolVersion(),
CommandType.PLATFORM_LOGOUT,
ResponseFlag.SUCCESS,
rawVin,
eventOrNow(msg),
null,
"platform-logout-ack");
}
/**
* 0x02/0x03/0x07 实时 / 补发 / 心跳:按 §6.3.2 回应答帧,保留原帧采集时间。
* 部分车端实现严格按规范没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。
*
* <p>日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏):
* <ul>
* <li>0x02 / 0x03 首次见到某 (vin, normal) → INFO 一条,含 peer/vin/platformAccount/version/parsedBlocks。
* <li>0x02 / 0x03 首次见到某 (vin, rawTypes=…) → WARN 一条,列出 raw typeCode。提示对端可能没走预期
* vendor profilepeer 是广东燃料电池但 platformAccount 没匹配上
* {@code vendor-extensions.platform-accounts} → 0x30+ 落 Raw
* <li>同 (vin, signature) 后续帧 → DEBUG默认看不到troubleshoot 时打开 Gb32960ChannelHandler 的 DEBUG
* <li>0x07 心跳DEBUG量大且无信息体
* </ul>
* 帧内字段全量 JSON 仍保留 DEBUG。
*/
private void handleReportOrHeartbeat(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) {
CommandType cmd = msg.header().command();
String platformAccount = accessService.platformAccount(ctx.channel());
ackService.writeResponse(ctx,
msg.header().protocolVersion(),
cmd,
ResponseFlag.SUCCESS,
rawVin,
eventOrNow(msg),
null,
"report-ack-0x" + Integer.toHexString(cmd.code()));
if (cmd == CommandType.HEARTBEAT) {
if (log.isDebugEnabled()) {
log.debug("[gb32960] HEARTBEAT peer={} vin={} platformAccount={}",
addr(ctx), msg.header().vin(), platformAccount);
}
return;
}
List<String> rawTypes = Gb32960FrameDiagnostics.collectRawTypeHex(msg.infoBlocks());
int parsedBlocks = msg.infoBlocks().size() - rawTypes.size();
String vin = msg.header().vin();
boolean firstSeen = diagnostics.markFirstSeen(ctx.channel(), vin, rawTypes);
if (firstSeen) {
if (rawTypes.isEmpty()) {
log.info("[gb32960] {} (first frame for vin) peer={} vin={} platformAccount={} version={} parsedBlocks={}",
cmd.name(), addr(ctx), vin, platformAccount,
msg.header().protocolVersion(), parsedBlocks);
} else {
log.warn("[gb32960] {} peer={} vin={} platformAccount={} version={} parsedBlocks={} rawTypes={} — vendor profile likely not selected for this peer",
cmd.name(), addr(ctx), vin, platformAccount,
msg.header().protocolVersion(), parsedBlocks, rawTypes);
}
}
if (log.isDebugEnabled()) {
log.debug("[gb32960] frame peer={} vin={} cmd=0x{} parsedBlocks={} rawTypes={} json={}",
addr(ctx), vin, Integer.toHexString(cmd.code()),
parsedBlocks, rawTypes, Gb32960FrameDiagnostics.toJson(msg));
}
}
/** 0x08 终端校时:平台应答 data 段为平台当前时间 6B用 Instant.now())。 */
private void handleTimeCalibration(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) {
ackService.writeResponse(ctx,
msg.header().protocolVersion(),
CommandType.TIME_CALIBRATION,
ResponseFlag.SUCCESS,
rawVin,
Instant.now(),
null,
"time-calibration-ack");
}
/** 其它未单独处理的命令(如激活 0x09、密钥交换 0x0A仅 DEBUG 日志,仍会进 dispatcher。 */
private void logOtherFrame(ChannelHandlerContext ctx, Gb32960Message msg) {
if (log.isDebugEnabled()) {
log.debug("[gb32960] frame peer={} vin={} cmd=0x{} infoBlocks={} json={}",
addr(ctx), msg.header().vin(), Integer.toHexString(msg.header().command().code()),
msg.infoBlocks().size(), Gb32960FrameDiagnostics.toJson(msg));
}
}
/** 优先使用帧内采集时间;为 null 时回落到 {@link Instant#now()}。 */
private static Instant eventOrNow(Gb32960Message msg) {
return msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now();
}
/** VIN 未授权:对登入命令下发 VIN_NOT_EXIST 应答;其他命令静默关闭。 */
private void handleUnauthorized(ChannelHandlerContext ctx, Gb32960Message msg) {
String vin = msg.header().vin();
CommandType cmd = msg.header().command();
if (cmd == CommandType.VEHICLE_LOGIN) {
ackService.writeAndClose(
ctx,
msg.header().protocolVersion(),
CommandType.VEHICLE_LOGIN,
ResponseFlag.VIN_NOT_EXIST,
vin,
msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(),
null,
"vehicle-login-nack");
log.info("[gb32960] reject login peer={} vin={} (not in whitelist)", addr(ctx), vin);
} else {
log.debug("[gb32960] drop frame peer={} vin={} cmd=0x{} (not in whitelist)",
addr(ctx), vin, Integer.toHexString(cmd.code()));
ctx.close();
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.warn("[gb32960] 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,83 @@
package com.lingniu.ingest.protocol.gb32960.inbound;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import io.netty.channel.Channel;
import io.netty.util.AttributeKey;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Channel-scoped diagnostics for high-frequency GB/T 32960 reports.
*/
public final class Gb32960FrameDiagnostics {
private static final AttributeKey<Set<String>> LOGGED_FRAME_KEYS_ATTR =
AttributeKey.valueOf("gb32960.loggedFrameKeys");
private static final ObjectMapper DEBUG_JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private final int maxKeysPerChannel;
public Gb32960FrameDiagnostics() {
this(128);
}
public Gb32960FrameDiagnostics(int maxKeysPerChannel) {
this.maxKeysPerChannel = Math.max(1, maxKeysPerChannel);
}
public boolean markFirstSeen(Channel channel, String vin, List<String> rawTypes) {
String signature = rawTypes == null || rawTypes.isEmpty() ? "NORMAL" : "RAW:" + rawTypes;
return markFirstSeenKey(channel, vin + "|" + signature);
}
public int seenKeyCount(Channel channel) {
Set<String> seen = channel.attr(LOGGED_FRAME_KEYS_ATTR).get();
return seen == null ? 0 : seen.size();
}
public static List<String> collectRawTypeHex(List<InfoBlock> blocks) {
List<String> raws = new ArrayList<>(0);
for (InfoBlock b : blocks) {
if (b instanceof InfoBlock.Raw r) {
raws.add(String.format("0x%02x", r.typeCode()));
}
}
return raws;
}
public static String toJson(Gb32960Message msg) {
try {
return DEBUG_JSON.writeValueAsString(msg);
} catch (Exception e) {
return "<json-error:" + e.getMessage() + ">";
}
}
private boolean markFirstSeenKey(Channel channel, String key) {
Set<String> seen = channel.attr(LOGGED_FRAME_KEYS_ATTR).get();
if (seen == null) {
seen = new LinkedHashSet<>();
channel.attr(LOGGED_FRAME_KEYS_ATTR).set(seen);
}
boolean added = seen.add(key);
if (added) trimToLimit(seen);
return added;
}
private void trimToLimit(Set<String> seen) {
while (seen.size() > maxKeysPerChannel) {
String first = seen.iterator().next();
seen.remove(first);
}
}
}

View File

@@ -0,0 +1,174 @@
package com.lingniu.ingest.protocol.gb32960.inbound;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameDecoder;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
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 io.netty.handler.ssl.ClientAuth;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.timeout.IdleStateHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import java.io.File;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.concurrent.ThreadFactory;
/**
* GB/T 32960 Netty Server 启动器。
*
* <p>EventLoop 只做解码与投递 {@link Dispatcher},业务处理由 Disruptor 侧的虚拟线程承担。
*
* <p>支持可选 TLS{@link Gb32960Properties.Tls#isEnabled()} = true 时pipeline 前置
* {@code SslHandler},加载服务端证书 + 私钥 + 受信 CA默认要求客户端证书双向 TLS
*/
public class Gb32960NettyServer implements InitializingBean, DisposableBean {
private static final Logger log = LoggerFactory.getLogger(Gb32960NettyServer.class);
private final int port;
private final int workerThreads;
private final int idleReadSeconds;
private final Gb32960MessageDecoder messageDecoder;
private final Dispatcher dispatcher;
private final Gb32960AccessService accessService;
private final Gb32960AckService ackService;
private final Gb32960FrameDiagnostics diagnostics;
private final Gb32960Properties.Tls tlsConfig;
private EventLoopGroup boss;
private EventLoopGroup workers;
private Channel serverChannel;
private SslContext sslContext;
public Gb32960NettyServer(int port, int workerThreads, int idleReadSeconds,
Gb32960MessageDecoder messageDecoder,
Dispatcher dispatcher,
Gb32960AccessService accessService,
Gb32960AckService ackService,
Gb32960FrameDiagnostics diagnostics,
Gb32960Properties.Tls tlsConfig) {
this.port = port;
this.workerThreads = workerThreads;
this.idleReadSeconds = idleReadSeconds;
this.messageDecoder = messageDecoder;
this.dispatcher = dispatcher;
this.accessService = accessService;
this.ackService = ackService;
this.diagnostics = diagnostics;
this.tlsConfig = tlsConfig;
}
@Override
public void afterPropertiesSet() throws Exception {
if (tlsConfig != null && tlsConfig.isEnabled()) {
this.sslContext = buildSslContext(tlsConfig);
log.info("[gb32960] TLS enabled cert={} clientAuth={}",
tlsConfig.getCertPath(),
tlsConfig.isRequireClientAuth() ? "REQUIRED" : "OPTIONAL");
}
ThreadFactory bossTf = runnable -> new Thread(runnable, "gb32960-boss");
ThreadFactory wkTf = runnable -> new Thread(runnable, "gb32960-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) {
if (sslContext != null) {
ch.pipeline().addLast("ssl", sslContext.newHandler(ch.alloc()));
}
if (idleReadSeconds > 0) {
// 只监听读空闲readerIdle=NwriterIdle=0allIdle=0。
// 触发后由 Gb32960ChannelHandler#userEventTriggered 仅打告警日志,不断链。
ch.pipeline().addLast("idle",
new IdleStateHandler(idleReadSeconds, 0, 0));
}
ch.pipeline()
.addLast("frame", new Gb32960FrameDecoder())
.addLast("dispatch",
new Gb32960ChannelHandler(messageDecoder, dispatcher,
accessService, ackService, diagnostics));
}
});
this.serverChannel = b.bind(port).sync().channel();
log.info("[gb32960] Netty server listening on :{} (tls={}, vinWhitelist={}, platformAuth={}, idleReadSeconds={})",
getBoundPort(),
sslContext != null,
accessService.isVinWhitelistEnforcing() ? accessService.vinWhitelistSize() : "DISABLED",
accessService.isPlatformAuthEnforcing() ? accessService.platformPolicySize() : "DISABLED",
idleReadSeconds > 0 ? idleReadSeconds : "DISABLED");
}
/** 实际绑定端口(支持 port=0 随机分配后查询)。 */
public int getBoundPort() {
if (serverChannel != null && serverChannel.localAddress() instanceof InetSocketAddress a) {
return a.getPort();
}
return port;
}
private static SslContext buildSslContext(Gb32960Properties.Tls cfg) throws Exception {
if (cfg.getCertPath() == null || cfg.getKeyPath() == null) {
throw new IllegalStateException("TLS enabled but cert-path/key-path not configured");
}
SslContextBuilder builder = SslContextBuilder.forServer(
openPem(cfg.getCertPath()),
openPem(cfg.getKeyPath()));
if (cfg.getTrustCertPath() != null && !cfg.getTrustCertPath().isBlank()) {
try (InputStream trust = openPem(cfg.getTrustCertPath())) {
builder.trustManager(trust);
}
}
builder.clientAuth(cfg.isRequireClientAuth() ? ClientAuth.REQUIRE : ClientAuth.OPTIONAL);
return builder.build();
}
/** 读取 PEM 文件,支持 file:// 绝对路径或相对路径。 */
private static InputStream openPem(String path) throws Exception {
String stripped = path.startsWith("file://") ? path.substring("file://".length()) : path;
File f = new File(stripped);
if (!f.isAbsolute()) {
f = Paths.get(System.getProperty("user.dir"), stripped).toFile();
}
if (!f.exists()) {
throw new IllegalStateException("TLS PEM not found: " + f.getAbsolutePath());
}
return Files.newInputStream(f.toPath());
}
@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("[gb32960] Netty server stopped");
}
}
}

View File

@@ -0,0 +1,327 @@
package com.lingniu.ingest.protocol.gb32960.mapper;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.AlarmPayload;
import com.lingniu.ingest.api.event.LocationPayload;
import com.lingniu.ingest.api.event.RealtimePayload;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.spi.EventMapper;
import com.lingniu.ingest.protocol.gb32960.model.CommandBody;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.GeneralAlarmFlagBit;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 32960 → 领域事件 Adapter。
*
* <p>实时/补发上报产出 {@code Realtime + Location}(可选)+ {@code Alarm}(可选);
* 车辆登入/登出/心跳产出对应的会话事件。
*/
public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
@Override
public List<VehicleEvent> toEvents(Gb32960Message message) {
var header = message.header();
Instant eventTime = header.eventTime() != null ? header.eventTime() : Instant.now();
Instant ingestTime = Instant.now();
List<VehicleEvent> out = new ArrayList<>(3);
Map<String, String> baseMeta = Map.of(
"command", header.command().name(),
"protocolVersion", header.protocolVersion().name());
CommandType cmd = header.command();
if (cmd == CommandType.REALTIME_REPORT || cmd == CommandType.RESEND_REPORT) {
buildRealtime(message, header, baseMeta, eventTime, ingestTime, out);
return out;
}
if (cmd == CommandType.VEHICLE_LOGIN) {
String iccid = "";
if (message.commandBody() instanceof CommandBody.VehicleLogin vl) iccid = vl.iccid();
out.add(new VehicleEvent.Login(
UUID.randomUUID().toString(),
header.vin(), ProtocolId.GB32960, eventTime, ingestTime,
null, baseMeta, iccid, header.protocolVersion().name()));
} else if (cmd == CommandType.VEHICLE_LOGOUT) {
out.add(new VehicleEvent.Logout(
UUID.randomUUID().toString(),
header.vin(), ProtocolId.GB32960, eventTime, ingestTime,
null, baseMeta));
} else if (cmd == CommandType.HEARTBEAT) {
out.add(new VehicleEvent.Heartbeat(
UUID.randomUUID().toString(),
header.vin(), ProtocolId.GB32960, eventTime, ingestTime,
null, baseMeta));
} else if (cmd == CommandType.PLATFORM_LOGIN) {
// 平台登入 0x05映射为 VehicleEvent.Login + metadata.kind=platform。
// 使用 "platform:" + username 作为 vin 字段,既能送到 vehicle.session topic
// 下游消费者又能靠 metadata.kind 区分车辆/平台登入。
if (message.commandBody() instanceof CommandBody.PlatformLogin pl) {
Map<String, String> meta = new java.util.HashMap<>(baseMeta);
meta.put("kind", "platform");
meta.put("username", pl.username() == null ? "" : pl.username());
meta.put("encryptRule", pl.encryptRule() == null ? "" : pl.encryptRule().name());
meta.put("serial", String.valueOf(pl.serialNo()));
out.add(new VehicleEvent.Login(
UUID.randomUUID().toString(),
"platform:" + (pl.username() == null ? "" : pl.username()),
ProtocolId.GB32960,
pl.loginTime() != null ? pl.loginTime() : eventTime,
ingestTime,
null, meta, "", header.protocolVersion().name()));
}
} else if (cmd == CommandType.PLATFORM_LOGOUT) {
if (message.commandBody() instanceof CommandBody.PlatformLogout plo) {
Map<String, String> meta = new java.util.HashMap<>(baseMeta);
meta.put("kind", "platform");
meta.put("serial", String.valueOf(plo.serialNo()));
out.add(new VehicleEvent.Logout(
UUID.randomUUID().toString(),
"platform:unknown",
ProtocolId.GB32960,
plo.logoutTime() != null ? plo.logoutTime() : eventTime,
ingestTime,
null, meta));
}
}
return out;
}
private void buildRealtime(Gb32960Message message,
com.lingniu.ingest.protocol.gb32960.model.Gb32960Header header,
Map<String, String> meta,
Instant eventTime, Instant ingestTime,
List<VehicleEvent> out) {
// 整车数据:先查 V2016再 V2025每帧只会命中其一extracted 出来变成统一
// 视图VehicleView供后面 payload 构造使用
VehicleView v = extractVehicleView(message);
// 位置数据:同样两版本都查
PositionView p = extractPositionView(message);
// 燃料电池V2016 字段更全(带电压/电流V2025 字段更少
InfoBlock.Gb32960V2016.FuelCell fc16 =
message.findBlock(InfoBlock.Gb32960V2016.FuelCell.class).orElse(null);
InfoBlock.Gb32960V2025.FuelCell fc25 =
message.findBlock(InfoBlock.Gb32960V2025.FuelCell.class).orElse(null);
// 报警
AlarmView alarm = extractAlarmView(message);
if (v != null) {
Double fcVoltageV = fc16 != null ? fc16.fcVoltageV() : null;
Double fcCurrentA = fc16 != null ? fc16.fcCurrentA() : null;
Double fcTempC = fc16 != null ? fc16.maxHydrogenSystemTempC()
: (fc25 != null ? fc25.maxHydrogenSystemTempC() : null);
Double hydrogenHighPressure = fc16 != null ? fc16.maxHydrogenPressureMpa()
: (fc25 != null ? fc25.maxHydrogenPressureMpa() : null);
Double hydrogenRemaining = fc25 != null && fc25.remainingHydrogenPercent() != null
? fc25.remainingHydrogenPercent().doubleValue() : null;
RealtimePayload payload = new RealtimePayload(
v.speedKmh,
v.totalMileageKm,
v.socPercent != null ? v.socPercent.doubleValue() : null,
v.totalVoltageV,
v.totalCurrentA,
fcVoltageV,
fcCurrentA,
fcTempC,
hydrogenRemaining,
hydrogenHighPressure,
null,
mapVehicleState(v.vehicleState),
mapChargingState(v.chargingState),
mapRunningMode(v.runningMode),
extractGearLevel(v.gearRaw),
v.acceleratorPedal != null ? v.acceleratorPedal.doubleValue() : null,
v.brakePedal != null ? v.brakePedal.doubleValue() : null,
p != null ? p.longitude : null,
p != null ? p.latitude : null,
null,
null,
null,
null);
out.add(new VehicleEvent.Realtime(
UUID.randomUUID().toString(),
header.vin(), ProtocolId.GB32960, eventTime, ingestTime,
null, meta, payload));
}
if (p != null) {
LocationPayload loc = new LocationPayload(
p.longitude, p.latitude, 0.0,
v != null && v.speedKmh != null ? v.speedKmh : 0.0,
0.0, 0, p.statusFlag);
out.add(new VehicleEvent.Location(
UUID.randomUUID().toString(),
header.vin(), ProtocolId.GB32960, eventTime, ingestTime,
null, meta, loc));
}
if (alarm != null && shouldEmitAlarm(alarm)) {
List<String> faultCodes = new ArrayList<>();
addFaultCodes(faultCodes, "BAT", alarm.batteryFaults);
addFaultCodes(faultCodes, "MOT", alarm.motorFaults);
addFaultCodes(faultCodes, "ENG", alarm.engineFaults);
addFaultCodes(faultCodes, "OTH", alarm.otherFaults);
Set<GeneralAlarmFlagBit> activeBitEnums = GeneralAlarmFlagBit.parse(alarm.generalAlarmFlag);
Set<String> activeBits = activeBitEnums.stream()
.map(Enum::name)
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
boolean hydrogenLeakDetected = activeBitEnums.contains(GeneralAlarmFlagBit.HYDROGEN_LEAK);
AlarmPayload ap = new AlarmPayload(
mapAlarmLevel(alarm.maxLevel, hydrogenLeakDetected),
(int) (alarm.generalAlarmFlag & 0xFFFF),
"GB32960_ALARM",
faultCodes,
activeBits,
p != null ? p.longitude : null,
p != null ? p.latitude : null,
safetyCategory(activeBitEnums),
hydrogenLeakDetected,
hydrogenLeakDetected
? AlarmPayload.HydrogenLeakLevel.CRITICAL
: AlarmPayload.HydrogenLeakLevel.NONE,
hydrogenLeakDetected);
out.add(new VehicleEvent.Alarm(
UUID.randomUUID().toString(),
header.vin(), ProtocolId.GB32960, eventTime, ingestTime,
null, meta, ap));
}
}
/** 跨版本的整车数据视图,把 V2016 的踏板字段V2025 没有)放在一起。 */
private record VehicleView(
Integer vehicleState, Integer chargingState, Integer runningMode,
Double speedKmh, Double totalMileageKm, Double totalVoltageV, Double totalCurrentA,
Integer socPercent, Integer gearRaw,
Integer acceleratorPedal, Integer brakePedal) {}
private static VehicleView extractVehicleView(Gb32960Message m) {
var v16 = m.findBlock(InfoBlock.Gb32960V2016.Vehicle.class).orElse(null);
if (v16 != null) {
return new VehicleView(v16.vehicleState(), v16.chargingState(), v16.runningMode(),
v16.speedKmh(), v16.totalMileageKm(), v16.totalVoltageV(), v16.totalCurrentA(),
v16.socPercent(), v16.gearRaw(),
v16.acceleratorPedal(), v16.brakePedal());
}
var v25 = m.findBlock(InfoBlock.Gb32960V2025.Vehicle.class).orElse(null);
if (v25 != null) {
return new VehicleView(v25.vehicleState(), v25.chargingState(), v25.runningMode(),
v25.speedKmh(), v25.totalMileageKm(), v25.totalVoltageV(), v25.totalCurrentA(),
v25.socPercent(), v25.gearRaw(),
null, null);
}
return null;
}
/** 跨版本的位置数据视图。 */
private record PositionView(int statusFlag, double longitude, double latitude) {}
private static PositionView extractPositionView(Gb32960Message m) {
var p16 = m.findBlock(InfoBlock.Gb32960V2016.Position.class).orElse(null);
if (p16 != null) return new PositionView(p16.statusFlag(), p16.longitude(), p16.latitude());
var p25 = m.findBlock(InfoBlock.Gb32960V2025.Position.class).orElse(null);
if (p25 != null) return new PositionView(p25.statusFlag(), p25.longitude(), p25.latitude());
return null;
}
/** 跨版本的报警视图仅暴露公共字段2025 的 generalAlarmLevels 暂未上送下游)。 */
private record AlarmView(Integer maxLevel, long generalAlarmFlag,
List<Long> batteryFaults, List<Long> motorFaults,
List<Long> engineFaults, List<Long> otherFaults) {}
private static AlarmView extractAlarmView(Gb32960Message m) {
var a16 = m.findBlock(InfoBlock.Gb32960V2016.Alarm.class).orElse(null);
if (a16 != null) {
return new AlarmView(a16.maxLevel(), a16.generalAlarmFlag(),
a16.batteryFaults(), a16.motorFaults(), a16.engineFaults(), a16.otherFaults());
}
var a25 = m.findBlock(InfoBlock.Gb32960V2025.Alarm.class).orElse(null);
if (a25 != null) {
return new AlarmView(a25.maxLevel(), a25.generalAlarmFlag(),
a25.batteryFaults(), a25.motorFaults(), a25.engineFaults(), a25.otherFaults());
}
return null;
}
/** 挡位字节按附录 A.1 解析:低 4 位为挡位值 0000-1111。 */
private static Integer extractGearLevel(Integer gearRaw) {
if (gearRaw == null) return null;
if ((gearRaw & 0x80) != 0) return null; // bit7: 挡位无效
return gearRaw & 0x0F;
}
private static RealtimePayload.VehicleState mapVehicleState(Integer code) {
if (code == null) return null;
return switch (code) {
case 0x01 -> RealtimePayload.VehicleState.STARTED;
case 0x02 -> RealtimePayload.VehicleState.SHUTDOWN;
case 0x03 -> RealtimePayload.VehicleState.OTHER;
default -> RealtimePayload.VehicleState.INVALID;
};
}
private static RealtimePayload.ChargingState mapChargingState(Integer code) {
if (code == null) return null;
return switch (code) {
case 0x01 -> RealtimePayload.ChargingState.PARKED_CHARGING;
case 0x02 -> RealtimePayload.ChargingState.DRIVING_CHARGING;
case 0x03 -> RealtimePayload.ChargingState.UNCHARGED;
case 0x04 -> RealtimePayload.ChargingState.CHARGED;
default -> RealtimePayload.ChargingState.INVALID;
};
}
private static RealtimePayload.RunningMode mapRunningMode(Integer code) {
if (code == null) return null;
return switch (code) {
case 0x01 -> RealtimePayload.RunningMode.ELECTRIC;
case 0x02 -> RealtimePayload.RunningMode.HYBRID;
case 0x03 -> RealtimePayload.RunningMode.FUEL;
default -> RealtimePayload.RunningMode.INVALID;
};
}
private static boolean shouldEmitAlarm(AlarmView alarm) {
if (alarm.maxLevel != null && alarm.maxLevel > 0) return true;
return GeneralAlarmFlagBit.HYDROGEN_LEAK.isSet(alarm.generalAlarmFlag)
|| GeneralAlarmFlagBit.HYDROGEN_PRESSURE_ABNORMAL.isSet(alarm.generalAlarmFlag)
|| GeneralAlarmFlagBit.HYDROGEN_TEMP_ABNORMAL.isSet(alarm.generalAlarmFlag);
}
private static AlarmPayload.AlarmLevel mapAlarmLevel(Integer code, boolean hydrogenLeakDetected) {
if (hydrogenLeakDetected) return AlarmPayload.AlarmLevel.CRITICAL;
if (code == null) return AlarmPayload.AlarmLevel.INFO;
return switch (code) {
case 1 -> AlarmPayload.AlarmLevel.MINOR;
case 2 -> AlarmPayload.AlarmLevel.MAJOR;
case 3, 4 -> AlarmPayload.AlarmLevel.CRITICAL;
default -> AlarmPayload.AlarmLevel.INFO;
};
}
private static AlarmPayload.SafetyCategory safetyCategory(Set<GeneralAlarmFlagBit> activeBits) {
if (activeBits.contains(GeneralAlarmFlagBit.HYDROGEN_LEAK)) {
return AlarmPayload.SafetyCategory.HYDROGEN_LEAK;
}
if (activeBits.contains(GeneralAlarmFlagBit.HYDROGEN_PRESSURE_ABNORMAL)) {
return AlarmPayload.SafetyCategory.TANK_PRESSURE;
}
if (activeBits.contains(GeneralAlarmFlagBit.HYDROGEN_TEMP_ABNORMAL)) {
return AlarmPayload.SafetyCategory.TANK_TEMPERATURE;
}
return AlarmPayload.SafetyCategory.GENERAL;
}
private static void addFaultCodes(List<String> out, String prefix, List<Long> codes) {
if (codes == null || codes.isEmpty()) return;
for (Long c : codes) out.add(prefix + "-" + Long.toHexString(c));
}
}

View File

@@ -0,0 +1,225 @@
package com.lingniu.ingest.protocol.gb32960.model;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* 命令数据单元非实时上报。sealed 集合;每个子类型对应一条 {@link CommandType}。
*
* <p>实时/补发上报的数据单元不走这里,而是产出 {@link InfoBlock} 列表。
*
* <p>上行类型由车载终端发起VehicleLogin/VehicleLogout/PlatformLogin/PlatformLogout/
* Heartbeat/TimeCalibration/Activation/KeyExchange。
*
* <p>下行类型由平台发起QueryParams/SetParams/TerminalControl/ActivationResponse。
* 参见 GB/T 32960.3-2025 附录 B 表 B.5~B.13。
*/
public sealed interface CommandBody
permits CommandBody.VehicleLogin,
CommandBody.VehicleLogout,
CommandBody.PlatformLogin,
CommandBody.PlatformLogout,
CommandBody.Heartbeat,
CommandBody.TimeCalibration,
CommandBody.Activation,
CommandBody.ActivationResponse,
CommandBody.KeyExchange,
CommandBody.QueryParams,
CommandBody.QueryParamsResponse,
CommandBody.SetParams,
CommandBody.TerminalControl,
CommandBody.RawCommand {
/**
* 车辆登入 0x01表 6
*
* @param loginTime 数据采集时间(表 5
* @param serialNo 登入流水号 1~65531按天循环累加
* @param iccid 20 字节 SIM 卡 ICCID终端从 SIM 卡获取
* @param batterySystemCount 电池管理系统数 n (0~202025 版新增)
* @param batteryPackCounts 每个电池管理系统对应的动力蓄电池包个数
* @param batteryCodes 动力蓄电池包编码列表GB/T 3401424 字节/个)
*/
record VehicleLogin(
Instant loginTime,
int serialNo,
String iccid,
int batterySystemCount,
List<Integer> batteryPackCounts,
List<String> batteryCodes
) implements CommandBody {}
/** 车辆登出 0x04表 28。 */
record VehicleLogout(Instant logoutTime, int serialNo) implements CommandBody {}
/** 平台登入 0x05表 29。 */
record PlatformLogin(
Instant loginTime,
int serialNo,
String username,
String password,
EncryptType encryptRule
) implements CommandBody {}
/** 平台登出 0x06表 30。 */
record PlatformLogout(Instant logoutTime, int serialNo) implements CommandBody {}
/** 心跳 0x07数据单元为空B.3.5.10)。 */
record Heartbeat() implements CommandBody {}
/** 终端校时 0x08信息标志及信息体为空B.3.5.11)。 */
record TimeCalibration() implements CommandBody {}
/** 激活 0x09表 B.3)。 */
record Activation(
Instant collectTime,
String chipId,
byte[] publicKey,
String vin,
byte[] signature
) implements CommandBody {}
/** 激活结果应答 0x0A表 B.4)。 */
record ActivationResponse(int status, int info) implements CommandBody {}
/**
* 数据单元加密密钥交换 0x0B表 31
*
* @param keyType 密钥类型({@link EncryptType}
* @param key 密钥字节
* @param activateAt 启用时间
* @param expireAt 失效时间
*/
record KeyExchange(
EncryptType keyType,
byte[] key,
Instant activateAt,
Instant expireAt
) implements CommandBody {}
// ========================================================================
// 下行命令 0x80 / 0x81 / 0x82 —— 附录 B 表 B.5 ~ B.13
// ========================================================================
/**
* 查询命令 0x80表 B.5)。平台向车载终端发送,请求查询指定参数。
*
* @param queryTime 参数查询时间
* @param paramIds 待查询的参数 ID 列表(表 B.80x01-0x102025 版新增 0x0D-0x10
*/
record QueryParams(Instant queryTime, List<Integer> paramIds) implements CommandBody {}
/**
* 参数查询返回 0x80 应答(表 B.6/B.7/B.8)。车载终端响应平台。
*
* @param respondTime 返回查询参数时间
* @param params 参数项键值对key = paramIdvalue = bytes需按表 B.8 解释)
*/
record QueryParamsResponse(Instant respondTime, Map<Integer, byte[]> params) implements CommandBody {}
/**
* 设置命令 0x81表 B.9)。平台向车载终端发送,修改车载终端参数。
*
* <p>参数 ID 范围见表 B.8,参数值定义见表 B.8 各字段说明。
* 其中 0x07 硬件版本、0x08 固件版本为只读,不在 set 命令中出现。
*
* @param setTime 参数设置时间
* @param params 参数项键值对
*/
record SetParams(Instant setTime, Map<Integer, byte[]> params) implements CommandBody {}
/**
* 车载终端控制命令 0x82表 B.10)。
*
* @param time 时间
* @param commandId 控制命令({@link ControlCommand}
* @param payload 根据不同控制命令的负载
*/
record TerminalControl(
Instant time,
ControlCommand commandId,
ControlPayload payload
) implements CommandBody {}
/** 控制命令枚举(表 B.11)。 */
enum ControlCommand {
/** 0x01远程升级参数见表 B.12。 */
REMOTE_UPGRADE(0x01),
/** 0x02车载终端关机。 */
SHUTDOWN(0x02),
/** 0x03车载终端复位。 */
RESET(0x03),
/** 0x04车载终端恢复出厂设置。 */
FACTORY_RESET(0x04),
/** 0x05断开数据通信链路。 */
DISCONNECT(0x05),
/** 0x06车载终端报警/预警,参数见表 B.13。 */
ALARM_COMMAND(0x06),
/** 0x07开启抽样监测链路。 */
SAMPLING_LINK_ON(0x07),
/** 未知命令 / 用户自定义 0x80-0xFE。 */
UNKNOWN(-1);
private final int code;
ControlCommand(int code) { this.code = code; }
public int code() { return code; }
public static ControlCommand of(int code) {
for (ControlCommand c : values()) if (c.code == code) return c;
return UNKNOWN;
}
}
/** 控制命令的负载。sealed每种控制命令对应一种 payload 类型。 */
sealed interface ControlPayload
permits ControlPayload.None, ControlPayload.RemoteUpgrade, ControlPayload.AlarmCommand, ControlPayload.RawPayload {
/** 无参数SHUTDOWN / RESET / FACTORY_RESET / DISCONNECT / SAMPLING_LINK_ON。 */
record None() implements ControlPayload {}
/**
* 远程升级参数(表 B.12)。
*
* @param apn 拨号点名称
* @param dialUser 拨号用户名
* @param dialPassword 拨号密码
* @param serverAddress 升级服务器地址IP 或域名IPv4 时前 2 字节为 0
* @param serverPort 升级服务器端口
* @param manufacturerCode 车载终端制造商 ID4 字节英文大写字母或数字)
* @param hardwareVersion 硬件版本5 字节)
* @param firmwareVersion 固件版本5 字节)
* @param upgradeUrl 升级 URL 地址(建议 FTP
* @param connectTimeoutMinutes 连接到升级服务器时限0~60000 分钟)
*/
record RemoteUpgrade(
String apn,
String dialUser,
String dialPassword,
byte[] serverAddress,
int serverPort,
String manufacturerCode,
String hardwareVersion,
String firmwareVersion,
String upgradeUrl,
int connectTimeoutMinutes
) implements ControlPayload {}
/**
* 报警/预警命令(表 B.13)。
*
* @param warningLevel 警告等级0x00 无报警 / 0x01-0x04 1-4 级报警 / 0xFF 无效
* @param info 预留字节,可变长
*/
record AlarmCommand(int warningLevel, byte[] info) implements ControlPayload {}
/** 未解析的自定义 payload0x80-0xFE 用户自定义等)。 */
record RawPayload(byte[] bytes) implements ControlPayload {}
}
/**
* 未解析或厂商自定义的命令体0xC0~0xFE 等)。
* 保留原始字节以便下游冷存分析。
*/
record RawCommand(int commandCode, byte[] bytes) implements CommandBody {}
}

View File

@@ -0,0 +1,60 @@
package com.lingniu.ingest.protocol.gb32960.model;
/**
* 命令标识。GB/T 32960.3-2025 表 3 和附录 B 表 B.2 "命令标识定义"。
*
* <p>命令标识是发起方的唯一标识,区分数据传输的方向与种类。
*
* <p>方向约定:
* <ul>
* <li>上行:车载终端 → 远程服务与管理平台
* <li>下行:远程服务与管理平台 → 车载终端
* <li>上行/下行:双向使用
* </ul>
*/
public enum CommandType {
/** 0x01车辆登入上行—— 见表 6。 */
VEHICLE_LOGIN(0x01),
/** 0x02实时信息上报上行—— 见表 7。 */
REALTIME_REPORT(0x02),
/** 0x03补发信息上报上行—— 数据格式与实时上报一致,用于通信链路恢复后补发 7d 内存储的数据。 */
RESEND_REPORT(0x03),
/** 0x04车辆登出上行—— 见表 28。 */
VEHICLE_LOGOUT(0x04),
/** 0x05平台登入上行—— 见表 292025 版附录 B.2 中 0x05/0x06 改为"平台传输数据占用,自定义"。 */
PLATFORM_LOGIN(0x05),
/** 0x06平台登出上行—— 见表 30。 */
PLATFORM_LOGOUT(0x06),
/** 0x07心跳上行—— 数据单元为空,见 B.3.5.10。 */
HEARTBEAT(0x07),
/** 0x08终端校时上行—— 信息标志及信息体为空,见 B.3.5.11。 */
TIME_CALIBRATION(0x08),
/** 0x09激活上行—— 见表 B.3。 */
ACTIVATION(0x09),
/** 0x0A激活应答下行—— 见表 B.4。 */
ACTIVATION_RESPONSE(0x0A),
/** 0x0B数据单元加密密钥交换上行/下行)—— 见表 31。 */
KEY_EXCHANGE(0x0B),
/** 0x80查询命令下行—— 见表 B.5 ~ B.8。 */
QUERY(0x80),
/** 0x81设置命令下行—— 见表 B.9。 */
SET_PARAMS(0x81),
/** 0x82车载终端控制命令下行—— 见表 B.10 ~ B.13。 */
TERMINAL_CONTROL(0x82);
private final int code;
CommandType(int code) {
this.code = code;
}
public int code() {
return code;
}
/** 按协议编码查找命令类型;未识别时抛异常以便 DLQ 路由。 */
public static CommandType of(int code) {
for (CommandType c : values()) if (c.code == code) return c;
throw new IllegalArgumentException("unknown gb32960 command: 0x" + Integer.toHexString(code));
}
}

View File

@@ -0,0 +1,38 @@
package com.lingniu.ingest.protocol.gb32960.model;
/**
* 数据单元加密方式。GB/T 32960.3-2025 表 2 "数据单元加密方式"。
*
* <p>当数据单元存在加密时,应<b>先加密后校验</b>,解析端<b>先校验后解密</b>。
*/
public enum EncryptType {
/** 0x01数据不加密。 */
UNENCRYPTED(0x01),
/** 0x02RSA 算法加密。 */
RSA(0x02),
/** 0x03AES高级加密标准算法加密。 */
AES(0x03),
/** 0x04SM2 算法加密2025 版新增)。 */
SM2(0x04),
/** 0x05SM4 算法加密2025 版新增)。 */
SM4(0x05),
/** 0xFE异常。 */
ABNORMAL(0xFE),
/** 0xFF无效。 */
INVALID(0xFF);
private final int code;
EncryptType(int code) {
this.code = code;
}
public int code() {
return code;
}
public static EncryptType of(int code) {
for (EncryptType e : values()) if (e.code == code) return e;
return INVALID;
}
}

View File

@@ -0,0 +1,36 @@
package com.lingniu.ingest.protocol.gb32960.model;
import java.time.Instant;
/**
* GB/T 32960.3 数据包报文头(含起始符/命令单元/VIN/加密方式/数据长度),见表 2 / 表 B.1。
*
* <p>完整数据包结构:
* <pre>
* 起始符 (2B) ## (0x23 0x23) 或 $$ (0x24 0x24)
* 命令标识 (1B) CommandType
* 应答标志 (1B) ResponseFlag
* 唯一识别码 (17B STRING) VIN 或自定义编码
* 数据单元加密方式 (1B) EncryptType
* 数据单元长度 (2B WORD) 0 ~ 65531
* 数据单元 (NB) 见 §7
* 校验码 (1B BCC) 对命令单元到数据单元结尾异或
* </pre>
*
* @param protocolVersion 协议版本,由起始符推断
* @param command 命令标识(表 3 / B.2
* @param responseFlag 应答标志(表 4
* @param vin 唯一识别码17 字节 ASCII
* @param encryptType 数据单元加密方式(表 2
* @param dataLength 数据单元长度(字节)
* @param eventTime 数据采集时间。仅实时/补发上报携带,其它命令为 null
*/
public record Gb32960Header(
ProtocolVersion protocolVersion,
CommandType command,
ResponseFlag responseFlag,
String vin,
EncryptType encryptType,
int dataLength,
Instant eventTime
) {}

View File

@@ -0,0 +1,43 @@
package com.lingniu.ingest.protocol.gb32960.model;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
/**
* 完整解析后的 32960 消息。一条消息要么是实时/补发上报(携带 {@link InfoBlock} 列表),
* 要么是登入/登出/平台/心跳/校时/激活/密钥交换等命令(携带 {@link CommandBody}
* 二者互斥。
*
* @param header 报文头
* @param infoBlocks 实时/补发上报的信息体列表;其他命令为空列表
* @param commandBody 非实时上报的命令数据单元;实时/补发上报为 null
* @param signature 实时上报尾部的签名原始字节2025 版新增2016 版为 null
*/
public record Gb32960Message(
Gb32960Header header,
List<InfoBlock> infoBlocks,
CommandBody commandBody,
byte[] signature
) {
/** 便捷构造:只含信息体(实时/补发上报2016 版常用)。 */
public Gb32960Message(Gb32960Header header, List<InfoBlock> infoBlocks) {
this(header, infoBlocks == null ? Collections.emptyList() : infoBlocks, null, null);
}
/** 便捷构造:非实时命令。 */
public Gb32960Message(Gb32960Header header, CommandBody commandBody) {
this(header, Collections.emptyList(), commandBody, null);
}
public boolean isReport() {
return header.command() == CommandType.REALTIME_REPORT
|| header.command() == CommandType.RESEND_REPORT;
}
@SuppressWarnings("unchecked")
public <T extends InfoBlock> Optional<T> findBlock(Class<T> type) {
return (Optional<T>) infoBlocks.stream().filter(type::isInstance).findFirst();
}
}

View File

@@ -0,0 +1,104 @@
package com.lingniu.ingest.protocol.gb32960.model;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/**
* 通用报警标志位定义GB/T 32960.3-2025 表 24。4 字节 32 位位图中的每个位对应一种报警,
* 2025 版将 2016 版 bit0-15 扩展到 bit0-27新增了热事件、氢系统、超级电容等位定义。
*
* <p>"1" 表示报警,"0" 表示正常;标志维持到报警条件解除。
*/
public enum GeneralAlarmFlagBit {
/** bit0温度差异报警 */
TEMP_DIFFERENCE(0),
/** bit1电池高温报警 */
BATTERY_HIGH_TEMP(1),
/** bit2车载储能装置类型过压报警 */
STORAGE_OVER_VOLTAGE(2),
/** bit3车载储能装置类型欠压报警 */
STORAGE_UNDER_VOLTAGE(3),
/** bit4SOC 低报警 */
SOC_LOW(4),
/** bit5最小并联单元过压报警 */
MIN_PARALLEL_UNIT_OVER_VOLTAGE(5),
/** bit6最小并联单元欠压报警 */
MIN_PARALLEL_UNIT_UNDER_VOLTAGE(6),
/** bit7SOC 过高报警 */
SOC_HIGH(7),
/** bit8SOC 跳变报警 */
SOC_JUMP(8),
/** bit9可充电储能系统不匹配报警 */
STORAGE_MISMATCH(9),
/** bit10最小并联单元一致性差报警 */
MIN_PARALLEL_UNIT_INCONSISTENT(10),
/** bit11绝缘电阻失效报警 */
INSULATION_FAILURE(11),
/** bit12DC-DC 温度报警 */
DCDC_TEMP(12),
/** bit13制动系统报警 */
BRAKE_SYSTEM(13),
/** bit14DC-DC 状态报警 */
DCDC_STATUS(14),
/** bit15驱动电机控制器温度报警 */
MOTOR_CONTROLLER_TEMP(15),
/** bit16高压互锁状态报警 */
HV_INTERLOCK(16),
/** bit17驱动电机温度报警 */
MOTOR_TEMP(17),
/** bit18车载储能装置类型过充报警 */
STORAGE_OVER_CHARGE(18),
/** bit19驱动电机超速报警2025 版新增) */
MOTOR_OVER_SPEED(19),
/** bit20驱动电机过流报警2025 版新增) */
MOTOR_OVER_CURRENT(20),
/** bit21超级电容过温报警2025 版新增) */
SUPER_CAP_OVER_TEMP(21),
/** bit22超级电容过压报警2025 版新增) */
SUPER_CAP_OVER_VOLTAGE(22),
/** bit23可充电储能装置热事件报警2025 版新增,出现即为 4 级故障) */
STORAGE_THERMAL_EVENT(23),
/** bit24氢气泄漏异常报警2025 版新增) */
HYDROGEN_LEAK(24),
/** bit25车载氢系统压力异常报警2025 版新增) */
HYDROGEN_PRESSURE_ABNORMAL(25),
/** bit26车载氢系统温度异常报警2025 版新增) */
HYDROGEN_TEMP_ABNORMAL(26),
/** bit27燃料电池电堆超温报警2025 版新增) */
FUEL_CELL_STACK_OVER_TEMP(27);
private final int bitIndex;
GeneralAlarmFlagBit(int bitIndex) {
this.bitIndex = bitIndex;
}
public int bitIndex() {
return bitIndex;
}
/** 当前位是否在 flag 中置位。 */
public boolean isSet(long flag) {
return (flag & (1L << bitIndex)) != 0;
}
/** 从 32 位标志解出所有置位的报警。 */
public static Set<GeneralAlarmFlagBit> parse(long flag) {
EnumSet<GeneralAlarmFlagBit> out = EnumSet.noneOf(GeneralAlarmFlagBit.class);
for (GeneralAlarmFlagBit bit : values()) {
if (bit.isSet(flag)) out.add(bit);
}
return out;
}
/** 作为可读 fault code 列表(形如 {@code SOC_LOW, MOTOR_TEMP})。 */
public static List<String> asCodeList(long flag) {
List<String> out = new ArrayList<>();
for (GeneralAlarmFlagBit bit : values()) {
if (bit.isSet(flag)) out.add(bit.name());
}
return out;
}
}

View File

@@ -0,0 +1,460 @@
package com.lingniu.ingest.protocol.gb32960.model;
import java.util.List;
/**
* 已解析的信息体基接口。按 {@code <协议+版本>.<数据类型>} 严格分组:
*
* <ul>
* <li>{@link Gb32960V2016} —— GB/T 32960.3-2016 附录 B 表 B.10~B.20
* <li>{@link Gb32960V2025} —— GB/T 32960.3-2025 §7.2.4 表 10~27
* <li>{@link GuangdongFc} —— 广东燃料电池汽车示范应用城市群综合监管平台规范 v1.0
* <li>{@link Raw} —— 未实现 parser 的原始字节兜底
* </ul>
*
* <p>哪怕同一个数据类型在两个版本字段一致(如 Engine 5 字节固定布局),也按规则
* 复制成两份 record绝不跨版本共用 record 类型,以便消费方在 instanceof / switch
* 时能严格区分数据来源。
*
* <p>异常值约定:
* <ul>
* <li>单字节:{@code 0xFE} 异常 / {@code 0xFF} 无效
* <li>双字节:{@code 0xFFFE} 异常 / {@code 0xFFFF} 无效
* <li>四字节:{@code 0xFFFFFFFE} 异常 / {@code 0xFFFFFFFF} 无效
* </ul>
* 对外 record 使用 {@link Double} / {@link Integer} 等装箱类型,异常或无效时设为
* {@code null}。
*/
public sealed interface InfoBlock
permits InfoBlock.Gb32960V2016,
InfoBlock.Gb32960V2025,
InfoBlock.GuangdongFc,
InfoBlock.Raw {
InfoBlockType type();
// ========================================================================
// GB/T 32960.3-2016
// ========================================================================
/** GB/T 32960.3-2016 附录 B 定义的全部信息体类型。 */
sealed interface Gb32960V2016 extends InfoBlock
permits Gb32960V2016.Vehicle,
Gb32960V2016.DriveMotor,
Gb32960V2016.FuelCell,
Gb32960V2016.Engine,
Gb32960V2016.Position,
Gb32960V2016.Extreme,
Gb32960V2016.Alarm,
Gb32960V2016.Voltage,
Gb32960V2016.Temperature {
/** 0x01 整车数据。固定 20 字节(含加速/制动踏板)。 */
record Vehicle(
Integer vehicleState,
Integer chargingState,
Integer runningMode,
Double speedKmh,
Double totalMileageKm,
Double totalVoltageV,
Double totalCurrentA,
Integer socPercent,
Integer dcDcStatus,
Integer gearRaw,
Integer insulationResistanceKohm,
Integer acceleratorPedal,
Integer brakePedal
) implements Gb32960V2016 {
@Override public InfoBlockType type() { return InfoBlockType.VEHICLE; }
}
/** 0x02 驱动电机数据。变长。每电机 12 字节rpm 偏移 20000torque 2 字节偏移 20000。 */
record DriveMotor(List<Motor> motors) implements Gb32960V2016 {
@Override public InfoBlockType type() { return InfoBlockType.DRIVE_MOTOR; }
public record Motor(
Integer serialNo,
Integer state,
Integer controllerTempC,
Integer rpm,
Double torqueNm,
Integer motorTempC,
Double controllerInputVoltageV,
Double controllerDcCurrentA
) {}
}
/** 0x03 燃料电池数据2016 版字段顺序,含 N×探针温度 1B。变长。 */
record FuelCell(
Double fcVoltageV,
Double fcCurrentA,
Double hydrogenConsumptionKgPer100km,
List<Integer> probeTempC,
Double maxHydrogenSystemTempC,
Integer maxHydrogenSystemTempProbeId,
Double maxHydrogenConcentrationPercent,
Integer maxHydrogenConcentrationProbeId,
Double maxHydrogenPressureMpa,
Integer maxHydrogenPressureProbeId,
Integer hvDcDcStatus
) implements Gb32960V2016 {
@Override public InfoBlockType type() { return InfoBlockType.FUEL_CELL_V2016; }
}
/** 0x04 发动机数据。固定 5 字节。 */
record Engine(
Integer engineState,
Integer crankshaftRpm,
Double fuelConsumptionRate
) implements Gb32960V2016 {
@Override public InfoBlockType type() { return InfoBlockType.ENGINE; }
}
/** 0x05 车辆位置数据。固定 9 字节。 */
record Position(
int statusFlag,
double longitude,
double latitude
) implements Gb32960V2016 {
@Override public InfoBlockType type() { return InfoBlockType.POSITION_V2016; }
}
/** 0x06 动力蓄电池极值数据。固定 14 字节。 */
record Extreme(
Integer maxCellVoltageSubSysNo,
Integer maxCellVoltageCellId,
Double maxCellVoltageV,
Integer minCellVoltageSubSysNo,
Integer minCellVoltageCellId,
Double minCellVoltageV,
Integer maxTempSubSysNo,
Integer maxTempProbeId,
Integer maxTempC,
Integer minTempSubSysNo,
Integer minTempProbeId,
Integer minTempC
) implements Gb32960V2016 {
@Override public InfoBlockType type() { return InfoBlockType.EXTREME_V2016; }
}
/** 0x07 报警数据。变长4 个故障代码列表)。 */
record Alarm(
Integer maxLevel,
long generalAlarmFlag,
List<Long> batteryFaults,
List<Long> motorFaults,
List<Long> engineFaults,
List<Long> otherFaults
) implements Gb32960V2016 {
@Override public InfoBlockType type() { return InfoBlockType.ALARM_V2016; }
}
/** 0x08 可充电储能装置电压数据。变长。PoC 仅保留汇总。 */
record Voltage(
int subSystemCount,
double maxCellVoltageV,
double minCellVoltageV,
double totalVoltageV
) implements Gb32960V2016 {
@Override public InfoBlockType type() { return InfoBlockType.VOLTAGE_V2016; }
}
/** 0x09 可充电储能装置温度数据。变长。 */
record Temperature(
int subSystemCount,
int maxTempC,
int minTempC
) implements Gb32960V2016 {
@Override public InfoBlockType type() { return InfoBlockType.TEMPERATURE_V2016; }
}
}
// ========================================================================
// GB/T 32960.3-2025
// ========================================================================
/** GB/T 32960.3-2025 §7.2.4 定义的全部信息体类型。 */
sealed interface Gb32960V2025 extends InfoBlock
permits Gb32960V2025.Vehicle,
Gb32960V2025.DriveMotor,
Gb32960V2025.FuelCell,
Gb32960V2025.Engine,
Gb32960V2025.Position,
Gb32960V2025.Alarm,
Gb32960V2025.MinParallelVoltage,
Gb32960V2025.BatteryTemperature,
Gb32960V2025.FuelCellStack,
Gb32960V2025.SuperCapacitor,
Gb32960V2025.SuperCapacitorExtreme {
/** 0x01 整车数据。固定 18 字节(删除加速/制动踏板)。 */
record Vehicle(
Integer vehicleState,
Integer chargingState,
Integer runningMode,
Double speedKmh,
Double totalMileageKm,
Double totalVoltageV,
Double totalCurrentA,
Integer socPercent,
Integer dcDcStatus,
Integer gearRaw,
Integer insulationResistanceKohm
) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.VEHICLE; }
}
/** 0x02 驱动电机数据。变长。每电机 14 字节rpm 偏移 32000torque 4 字节偏移 20000。 */
record DriveMotor(List<Motor> motors) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.DRIVE_MOTOR; }
public record Motor(
Integer serialNo,
Integer state,
Integer controllerTempC,
Integer rpm,
Double torqueNm,
Integer motorTempC,
Double controllerInputVoltageV,
Double controllerDcCurrentA
) {}
}
/** 0x03 燃料电池发动机及车载氢系统数据。固定 13 字节。 */
record FuelCell(
Double maxHydrogenSystemTempC,
Integer maxHydrogenSystemTempProbeId,
Double maxHydrogenConcentrationPercent,
Integer maxHydrogenConcentrationProbeId,
Double maxHydrogenPressureMpa,
Integer maxHydrogenPressureProbeId,
Integer hvDcDcStatus,
Integer remainingHydrogenPercent,
Integer hvDcDcControllerTempC
) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.FUEL_CELL_V2025; }
}
/** 0x04 发动机数据。固定 5 字节。字段同 2016 版。 */
record Engine(
Integer engineState,
Integer crankshaftRpm,
Double fuelConsumptionRate
) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.ENGINE; }
}
/** 0x05 车辆位置数据。固定 10 字节(多坐标系字节)。 */
record Position(
int statusFlag,
int coordSystem,
double longitude,
double latitude
) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.POSITION_V2025; }
}
/** 0x06 报警数据。变长4 个故障代码列表 + 通用报警故障等级列表)。 */
record Alarm(
Integer maxLevel,
long generalAlarmFlag,
List<Long> batteryFaults,
List<Long> motorFaults,
List<Long> engineFaults,
List<Long> otherFaults,
List<GeneralAlarmEntry> generalAlarmLevels
) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.ALARM_V2025; }
/** 通用报警故障等级列表项2 字节bit 对应表 24 位序号level 为故障等级。 */
public record GeneralAlarmEntry(int bit, int level) {}
}
/** 0x07 动力蓄电池最小并联单元电压数据。变长。 */
record MinParallelVoltage(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.MIN_PARALLEL_VOLTAGE_V2025; }
public record Pack(
int packNo,
Double packVoltageV,
Double packCurrentA,
int totalUnits,
List<Double> frameVoltages
) {}
}
/** 0x08 动力蓄电池温度数据。变长。 */
record BatteryTemperature(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.BATTERY_TEMPERATURE_V2025; }
public record Pack(int packNo, int probeCount, List<Integer> probeTempsC) {}
}
/** 0x30 燃料电池电堆数据。变长2025 标准)。 */
record FuelCellStack(int stackCount, List<Stack> stacks) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.FUEL_CELL_STACK; }
public record Stack(
int stackNo,
Double voltageV,
Double currentA,
Double hydrogenInletPressureKpa,
Double airInletPressureKpa,
Integer airInletTempC,
List<Integer> coolantOutletProbeTempsC
) {}
}
/** 0x31 超级电容器数据。变长。 */
record SuperCapacitor(
int systemNo,
Double totalVoltageV,
Double totalCurrentA,
List<Double> cellVoltages,
List<Integer> probeTempsC
) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.SUPER_CAPACITOR; }
}
/** 0x32 超级电容器极值数据。固定 18 字节。 */
record SuperCapacitorExtreme(
Integer maxVoltageSystemNo,
Integer maxVoltageCellId,
Double maxCellVoltageV,
Integer minVoltageSystemNo,
Integer minVoltageCellId,
Double minCellVoltageV,
Integer maxTempSystemNo,
Integer maxTempProbeId,
Integer maxTempC,
Integer minTempSystemNo,
Integer minTempProbeId,
Integer minTempC
) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.SUPER_CAPACITOR_EXTREME; }
}
}
// ========================================================================
// 广东燃料电池汽车示范应用城市群综合监管平台规范 v1.0(基于 GB/T 32960.3-2016 帧头)
// ========================================================================
/** 广东燃料电池汽车示范应用规范定义的扩展信息体typeCode 0x30~0x34、0x80+ 同 peer 的厂商私有 TLV。 */
sealed interface GuangdongFc extends InfoBlock
permits GuangdongFc.Stack,
GuangdongFc.Auxiliary,
GuangdongFc.DcDc,
GuangdongFc.AirConditioner,
GuangdongFc.VehicleInfo,
GuangdongFc.DemoExtension,
GuangdongFc.VendorTlv {
/** 0x30 燃料电池电堆数据§7.2.3.4 表 13/14。变长。 */
record Stack(int stackCount, List<Item> stacks) implements GuangdongFc {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_STACK; }
public record Item(
Integer engineWorkState,
Integer stackWaterOutletTempC,
Double hydrogenInletPressureKpa,
Double airInletPressureKpa,
Integer airInletTempC,
Integer maxCellVoltageId,
Integer minCellVoltageId,
Double maxCellVoltageV,
Double minCellVoltageV,
Double avgCellVoltageV,
Integer cellCount,
Integer frameCellStart,
Integer frameCellCount,
List<Double> frameCellVoltagesV
) {}
}
/** 0x31 辅助系统数据§7.2.3.5 表 15/16。变长。 */
record Auxiliary(int subSystemCount, List<Subsystem> subsystems) implements GuangdongFc {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_AUXILIARY; }
public record Subsystem(
Double airCompressorMotorVoltageV,
Integer airCompressorPowerKw,
Double hydrogenPumpMotorVoltageV,
Integer hydrogenPumpPowerKw,
Double waterPumpVoltageV,
Double ptcVoltageV,
Integer ptcPowerKw,
Double lowVoltageBatteryVoltageV
) {}
}
/** 0x32 DC/DC燃料电池系统数据§7.2.3.6 表 17。固定 9 字节。 */
record DcDc(
Double inputVoltageV,
Double inputCurrentA,
Double outputVoltageV,
Double outputCurrentA,
Integer controllerTempC
) implements GuangdongFc {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_DCDC; }
}
/** 0x33 空调数据§7.2.3.7 表 18。固定 5 字节。 */
record AirConditioner(
Integer status,
Integer powerKw,
Double compressorInputVoltageV
) implements GuangdongFc {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_AIR_CONDITIONER; }
}
/** 0x34 车辆信息数据§7.2.3.8 表 19。固定 7 字节。 */
record VehicleInfo(
Integer collisionAlarm,
Integer ambientTempC,
Double ambientPressureKpa,
Double hydrogenMassKg
) implements GuangdongFc {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_VEHICLE_INFO; }
}
/** 0x80 燃料电池汽车示范应用数据扩展§7.2.3.14 表 27。 */
record DemoExtension(
Integer stackTempC,
Double airCompressorVoltageV,
Double airCompressorCurrentA,
Double hydrogenPumpVoltageV,
Double hydrogenPumpCurrentA
) implements GuangdongFc {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_DEMO_EXTENSION; }
}
/**
* 厂商私有 TLV 块兜底容器。当对端在广东规范之外又下发了一个未公开的 typeCode
* (目前线上观察到 {@code 0x83}),但该块明显是 TLV 格式type(1) + length(2) +
* value(N))时,本 record 保留原始字节直到对端提供正式协议文档为止。
*
* <p>关键在于对应的 {@code GdFcVendorTlvBlockParser} 严格按 length 字段消费字节,
* 因此 BodyParser 主循环在该块之后**仍能继续解析其它信息体**,不会被吞光。
*
* @param typeCode 触发的 typeCode保留以便诊断/分类)
* @param declaredLength TLV 的 length 字段值payload 字节数)
* @param payload 原始 payload 字节,长度 = declaredLength
*/
record VendorTlv(int typeCode, int declaredLength, byte[] payload) implements GuangdongFc {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_VENDOR_TLV; }
}
}
// ========================================================================
// Raw / Unknown
// ========================================================================
/**
* 尚未实现 Parser 的原始信息体(含自定义 0x80~0xFE保留字节透传到冷存。
*
* @param typeCode 触发兜底时的真实信息体类型字节,便于诊断未知段或确认 parser 漂移落点
* @param type 枚举形态,目前固定 {@link InfoBlockType#RAW}(保留字段为后续真正分类预留)
* @param bytes 自该未知 type 字节之后到 body 结尾的剩余原始字节
*/
record Raw(int typeCode, InfoBlockType type, byte[] bytes) implements InfoBlock {}
}

View File

@@ -0,0 +1,63 @@
package com.lingniu.ingest.protocol.gb32960.model;
/**
* 信息体语义类型(逻辑分类)。不与协议 typeCode 一一对应。
*
* <p>因为 2016 和 2025 的 typeCode 0x06/0x07/0x08/0x09 语义不同,本枚举按<b>逻辑含义</b>
* 分类,而不是按 typeCode。真正的协议 typeCode 路由由 {@link com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser#typeCode()}
* 和 {@link com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry} 负责。
*/
public enum InfoBlockType {
/** 整车数据(表 10。 */
VEHICLE,
/** 驱动电机数据(表 15/16。 */
DRIVE_MOTOR,
/** 燃料电池数据2016 版,带电压/电流/温度探针列表)。 */
FUEL_CELL_V2016,
/** 燃料电池发动机及车载氢系统数据2025 版,表 17。 */
FUEL_CELL_V2025,
/** 发动机数据(表 20。 */
ENGINE,
/** 车辆位置数据2016 版,表 B.15)。 */
POSITION_V2016,
/** 车辆位置数据2025 版,表 21带坐标系字段。 */
POSITION_V2025,
/** 动力蓄电池极值数据2016 版,仅 2016 存在)。 */
EXTREME_V2016,
/** 报警数据2016 版)。 */
ALARM_V2016,
/** 报警数据2025 版,表 23新增通用报警故障等级列表。 */
ALARM_V2025,
/** 可充电储能装置电压数据2016 版)。 */
VOLTAGE_V2016,
/** 可充电储能装置温度数据2016 版)。 */
TEMPERATURE_V2016,
/** 动力蓄电池最小并联单元电压数据2025 版,表 11/12。 */
MIN_PARALLEL_VOLTAGE_V2025,
/** 动力蓄电池温度数据2025 版,表 13/14。 */
BATTERY_TEMPERATURE_V2025,
/** 燃料电池电堆数据2025 版,表 18/19。 */
FUEL_CELL_STACK,
/** 超级电容器数据2025 版,表 25。 */
SUPER_CAPACITOR,
/** 超级电容器极值数据2025 版,表 26。 */
SUPER_CAPACITOR_EXTREME,
/** 自定义数据 0x80~0xFE表 27。 */
USER_DEFINED,
/** 广东燃料电池规范 0x30 燃料电池电堆数据(表 14。 */
GD_FC_STACK,
/** 广东燃料电池规范 0x31 辅助系统数据(表 16。 */
GD_FC_AUXILIARY,
/** 广东燃料电池规范 0x32 DC/DC 数据(表 17。 */
GD_FC_DCDC,
/** 广东燃料电池规范 0x33 空调数据(表 18。 */
GD_FC_AIR_CONDITIONER,
/** 广东燃料电池规范 0x34 车辆信息数据(表 19。 */
GD_FC_VEHICLE_INFO,
/** 广东燃料电池规范 0x80 燃料电池汽车示范应用数据扩展(表 27。 */
GD_FC_DEMO_EXTENSION,
/** 同 peer 的厂商私有 TLV 兜底(如 0x83等待对端文档后升级为正式 parser。 */
GD_FC_VENDOR_TLV,
/** 原始/未解析。 */
RAW
}

View File

@@ -0,0 +1,37 @@
package com.lingniu.ingest.protocol.gb32960.model;
/**
* GB/T 32960.3 协议版本。
*
* <p>通过数据包起始符区分:
* <ul>
* <li>{@code ## (0x23 0x23)} → {@link #V2016} 对应 GB/T 32960.3-2016
* <li>{@code $$ (0x24 0x24)} → {@link #V2025} 对应 GB/T 32960.3-2025
* </ul>
*
* <p>两版核心差异GB/T 32960.3-2025 前言):
* <ul>
* <li>信息类型 0x06/0x07/0x08 语义重构:
* <table>
* <tr><th>类型</th><th>2016</th><th>2025</th></tr>
* <tr><td>0x06</td><td>动力蓄电池极值</td><td>报警数据</td></tr>
* <tr><td>0x07</td><td>报警数据</td><td>动力蓄电池最小并联单元电压</td></tr>
* <tr><td>0x08</td><td>可充电储能装置电压</td><td>动力蓄电池温度</td></tr>
* <tr><td>0x09</td><td>可充电储能装置温度</td><td>(已删除,合入 0x08)</td></tr>
* </table>
* <li>新增 0x30 燃料电池电堆 / 0x31 超级电容 / 0x32 超级电容极值
* <li>0x05 位置数据新增 1 字节坐标系(表 21
* <li>0x03 燃料电池数据字段重构:拆分为"燃料电池发动机及车载氢系统"
* + 独立 0x30"燃料电池电堆"(表 17/18/19
* <li>驱动电机 0x02 转速偏移由 {@code 20000} 改为 {@code 32000},转矩由 2 字节扩展为 4 字节
* <li>实时上报数据单元尾部增加签名信息(以类型 {@code 0xFF} 标识,表 8
* <li>加密方式新增 {@code 0x04 SM2} / {@code 0x05 SM4}
* <li>命令标识新增 {@code 0x07 心跳 / 0x08 校时 / 0x09 激活 / 0x0A 激活应答 / 0x0B 密钥交换}
* </ul>
*/
public enum ProtocolVersion {
/** GB/T 32960.3-2016。起始符 {@code 0x23 0x23 (##)}。 */
V2016,
/** GB/T 32960.3-2025。起始符 {@code 0x24 0x24 ($$)}。 */
V2025
}

View File

@@ -0,0 +1,46 @@
package com.lingniu.ingest.protocol.gb32960.model;
/**
* 应答标志。GB/T 32960.3-2025 表 4 "应答标志定义"。
*
* <p>命令的主动发起方应答标志为 {@code 0xFE}{@link #COMMAND}),表示此包为命令包;
* 当应答标志不是 {@code 0xFE} 时,此包表示为应答包。服务端发送应答时,应变更应答标志,
* 保留报文时间,删除其余报文内容,并重新计算校验位。
*/
public enum ResponseFlag {
/** 0x01成功 —— 接收到的信息正确。 */
SUCCESS(0x01),
/** 0x02其他错误 —— 其他收到的信息存在格式及内容错误。 */
OTHER_ERROR(0x02),
/** 0x03VIN 重复。 */
VIN_DUPLICATED(0x03),
/** 0x04VIN 不存在2025 版新增)。 */
VIN_NOT_EXIST(0x04),
/** 0x05验签错误2025 版新增)。 */
SIGNATURE_ERROR(0x05),
/** 0x06数据结构错误2025 版新增)。 */
STRUCTURE_ERROR(0x06),
/** 0x07解密错误2025 版新增)。 */
DECRYPT_ERROR(0x07),
/** 0xFE命令 —— 表示数据包为命令包,而非应答包。 */
COMMAND(0xFE);
private final int code;
ResponseFlag(int code) {
this.code = code;
}
public int code() {
return code;
}
public boolean isCommand() {
return this == COMMAND;
}
public static ResponseFlag of(int code) {
for (ResponseFlag r : values()) if (r.code == code) return r;
return OTHER_ERROR;
}
}

View File

@@ -0,0 +1,66 @@
package com.lingniu.ingest.protocol.gb32960.model;
import java.nio.ByteBuffer;
/**
* 实时信息的车端数字签名GB/T 32960.3-2025 表 8
*
* <p>签名信息出现在实时上报数据单元的末尾,紧跟在信息体循环后,由 {@code 0xFF}
* 作为"签名数据开始标识"引出(见表 9。签名范围从数据采集时间的第一字节到签名
* 信息前一个字节的数字签名。
*
* <p>表 8 字段:
* <pre>
* signType(1) 签名类型0x01 SM2 / 0x02 RSA / 0x03 ECC其他预留
* rLen(2 WORD) 签名 R 值长度
* r(rLen) 签名 R 值
* sLen(2 WORD) 签名 S 值长度
* s(sLen) 签名 S 值
* </pre>
*/
public record SignatureInfo(SignType signType, byte[] r, byte[] s) {
public enum SignType {
/** 0x01SM2 国密签名。 */
SM2(0x01),
/** 0x02RSA 签名。 */
RSA(0x02),
/** 0x03ECC 签名。 */
ECC(0x03),
/** 其他预留。 */
UNKNOWN(0xFF);
private final int code;
SignType(int code) { this.code = code; }
public int code() { return code; }
public static SignType of(int code) {
for (SignType t : values()) if (t.code == code) return t;
return UNKNOWN;
}
}
/**
* 尝试解析签名字节。非法数据时返回 null 并保留原始字节由冷存分析。
*
* @param bytes 签名原始字节(不含前缀的 0xFF 签名开始标识)
*/
public static SignatureInfo tryParse(byte[] bytes) {
if (bytes == null || bytes.length < 5) return null;
ByteBuffer buf = ByteBuffer.wrap(bytes);
try {
SignType type = SignType.of(buf.get() & 0xFF);
int rLen = buf.getShort() & 0xFFFF;
if (rLen > buf.remaining() - 2) return null;
byte[] r = new byte[rLen];
buf.get(r);
int sLen = buf.getShort() & 0xFFFF;
if (sLen > buf.remaining()) return null;
byte[] s = new byte[sLen];
buf.get(s);
return new SignatureInfo(type, r, s);
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.protocol.gb32960.config.Gb32960AutoConfiguration

View File

@@ -0,0 +1,172 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.api.spi.DecodeException;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
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;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 验证 {@link Gb32960BodyParser} 单块异常隔离行为。
*
* <p>三个隔离场景:
* <ol>
* <li>固定长度块 parser 抛异常 → 失败块兜 Raw按 fixedLen 截取)+ 后续块继续解析
* <li>固定长度块剩余字节不足(截断帧) → 兜 Raw(剩余) + break 循环
* <li>变长块 parser 抛异常 → 兜 Raw(从失败块起剩余全部) + break 循环
* </ol>
*
* <p>外加一个严格模式回退测试:{@code lenientBlockFailure=false} 时异常应抛 DecodeException。
*/
class Gb32960BodyParserIsolationTest {
/** 模拟一个固定长度的 Position parser声明 fixedLength=9parse 时主动抛异常。 */
private static final InfoBlockParser EXPLODING_FIXED_LEN_POSITION = new InfoBlockParser() {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x05; }
@Override public int fixedLength() { return 9; }
@Override public InfoBlock parse(ByteBuffer buffer) {
buffer.get();
buffer.get();
throw new DecodeException("simulated parser failure in Position");
}
};
/** 模拟一个变长 Alarm parserfixedLength=-1消费若干字节后抛。 */
private static final InfoBlockParser EXPLODING_VAR_LEN_ALARM = new InfoBlockParser() {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x07; }
@Override public int fixedLength() { return -1; }
@Override public InfoBlock parse(ByteBuffer buffer) {
buffer.get();
throw new DecodeException("simulated parser failure in Alarm list-length read");
}
};
@Test
void fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed() {
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
EXPLODING_FIXED_LEN_POSITION));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeValidVehicle(os); // 1 + 20 = 21B
writePositionTypeAnd9ByteBody(os); // 1 + 9 = 10Bparser 会爆)
writeValidVehicle(os); // 1 + 20 = 21B
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
var result = parser.parse(ProtocolVersion.V2016, body);
assertThat(result.blocks()).hasSize(3);
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
assertThat(raw.typeCode()).isEqualTo(0x05);
assertThat(raw.type()).isEqualTo(InfoBlockType.RAW);
assertThat(raw.bytes()).hasSize(9);
});
assertThat(result.blocks().get(2)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
assertThat(body.hasRemaining()).isFalse();
}
@Test
void truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates() {
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
new PositionV2016BlockParser()));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeValidVehicle(os);
os.write(0x05);
os.write(0);
os.write(0);
os.write(0);
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
var result = parser.parse(ProtocolVersion.V2016, body);
assertThat(result.blocks()).hasSize(2);
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
assertThat(raw.typeCode()).isEqualTo(0x05);
assertThat(raw.bytes()).hasSize(3);
});
}
@Test
void variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks() {
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
EXPLODING_VAR_LEN_ALARM));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeValidVehicle(os);
os.write(0x07);
for (int i = 0; i < 10; i++) os.write(0xAA);
writeValidVehicle(os);
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
var result = parser.parse(ProtocolVersion.V2016, body);
assertThat(result.blocks()).hasSize(2);
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
assertThat(raw.typeCode()).isEqualTo(0x07);
assertThat(raw.bytes()).hasSize(31);
});
}
@Test
void strictMode_throwsOnAnyBlockFailure() {
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
EXPLODING_FIXED_LEN_POSITION));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
parser.setLenientBlockFailure(false);
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeValidVehicle(os);
writePositionTypeAnd9ByteBody(os);
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
assertThatThrownBy(() -> parser.parse(ProtocolVersion.V2016, body))
.isInstanceOf(DecodeException.class);
}
private static void writeValidVehicle(ByteArrayOutputStream os) {
os.write(0x01);
os.write(0x01);
os.write(0x01);
os.write(0x01);
os.write(0); os.write(0);
os.write(0); os.write(0); os.write(0); os.write(0);
os.write(0); os.write(0);
os.write(0); os.write(0);
os.write(50);
os.write(0x01);
os.write(0);
os.write(0); os.write(0);
os.write(0);
os.write(0);
}
private static void writePositionTypeAnd9ByteBody(ByteArrayOutputStream os) {
os.write(0x05);
for (int i = 0; i < 9; i++) os.write(0);
}
}

View File

@@ -0,0 +1,106 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.EngineV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.ExtremeValueV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.FuelCellV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
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;
/**
* 黄金样本集回放测试:遍历 {@code src/test/resources/samples/gb32960/*.hex}
* 逐帧解码并断言命令类型来自 {@link com.lingniu.ingest.protocol.gb32960.model.CommandType} 有效值。
*
* <p>样本文件目前为空,该测试会优雅地跳过。加入样本后会自动变为 N 条动态用例。
*/
class Gb32960DecoderGoldenTest {
private final Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(
new Gb32960BodyParser(new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
new PositionV2016BlockParser(),
new DriveMotorV2016BlockParser(),
new FuelCellV2016BlockParser(),
new EngineV2016BlockParser(),
new ExtremeValueV2016BlockParser(),
new AlarmV2016BlockParser(),
new VoltageV2016BlockParser(),
new TemperatureV2016BlockParser()))));
@TestFactory
Collection<DynamicTest> replaySamples() throws URISyntaxException, IOException {
var url = getClass().getClassLoader().getResource("samples/gb32960");
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[] frame = readHex(sample);
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
assertThat(msg.header().vin()).hasSize(17);
assertThat(msg.header().command()).isNotNull();
// 749 字节真实生产帧realtime_002/003/010/200peer 在 V2016 帧里下发了
// 0x30/0x31/0x32 等 typeCode但这些在 GB/T 32960.3-2016 附录 B 表 B.3 是"预留"区,
// 没有任何字段定义。因此**期望产生 Raw 兜底块**——这是符合规范的正确行为。
// 若未来对端切换到 V2025 (2424 起始) 或者迁移到 0x80~0xFE 用户自定义区,可再调整断言。
String name = sample.getFileName().toString();
boolean isRealProductionFrame = frame.length == 774;
if (isRealProductionFrame) {
assertThat(msg.findBlock(InfoBlock.Raw.class))
.as("[%s] 749字节真实生产帧应有 Raw 兜底块peer 越界使用 0x30+ 预留 typeCode", name)
.isPresent();
}
// 总电流必须落在协议规定的 -1000~+1000 A 区间,防回归到 -3000 偏移
msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class).ifPresent(v -> {
if (v.totalCurrentA() != null) {
assertThat(v.totalCurrentA())
.as("[%s] vehicle.totalCurrentA 越界,疑似偏移常量回归", name)
.isBetween(-1000.0, 1000.0);
}
});
});
}
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,158 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.model.CommandBody;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 构造一条合成 32960 实时上报帧,跑通 Frame 解码 → Body 解析 → InfoBlock 的全链路。
*
* <p>样本无需外部文件:本测试既验证解码正确性,也作为 {@code samples/} 黄金样本的期望值参考实现。
*/
public class Gb32960DecoderTest {
@Test
void decodesSyntheticRealtimeReport() {
byte[] frame = buildRealtimeFrame("LTEST000000000001");
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(
List.of(new VehicleV2016BlockParser(), new PositionV2016BlockParser()));
Gb32960BodyParser bodyParser = new Gb32960BodyParser(registry);
Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(bodyParser);
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
assertThat(msg.header().command()).isEqualTo(CommandType.REALTIME_REPORT);
assertThat(msg.header().vin()).isEqualTo("LTEST000000000001");
assertThat(msg.header().eventTime()).isNotNull();
InfoBlock.Gb32960V2016.Vehicle v = msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class).orElseThrow();
assertThat(v.socPercent()).isEqualTo(70);
assertThat(v.speedKmh()).isEqualTo(52.3, org.assertj.core.data.Offset.offset(0.01));
assertThat(v.gearRaw()).isEqualTo(0x0F);
InfoBlock.Gb32960V2016.Position p = msg.findBlock(InfoBlock.Gb32960V2016.Position.class).orElseThrow();
assertThat(p.longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001));
assertThat(p.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001));
}
@Test
void decodesPlatformLoginWithMd5HexPasswordLongerThanDeclaredGbField() {
byte[] frame = buildPlatformLoginFrameWithExtendedPassword(
"Hyundai",
"f2e3445d7cda409fb4f278f6fb890734");
Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(new Gb32960BodyParser(
new InfoBlockParserRegistry(List.of())));
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
assertThat(msg.header().command()).isEqualTo(CommandType.PLATFORM_LOGIN);
assertThat(msg.commandBody()).isInstanceOf(CommandBody.PlatformLogin.class);
CommandBody.PlatformLogin login = (CommandBody.PlatformLogin) msg.commandBody();
assertThat(login.username()).isEqualTo("Hyundai");
assertThat(login.password()).isEqualTo("f2e3445d7cda409fb4f278f6fb890734");
}
/**
* 构造一条合法的 32960 实时上报帧:
* header + 6B 时间戳(2024-01-02 03:04:05) + 0x01 整车 + 0x05 位置 + BCC
*/
public static byte[] buildRealtimeFrame(String vin) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
// 时间戳
body.write(24); body.write(1); body.write(2); body.write(3); body.write(4); body.write(5);
// 0x01 整车 20 字节
body.write(0x01);
body.write(0x01); // vehicle state = 启动
body.write(0x03); // charging = 未充电
body.write(0x01); // 纯电
writeU16(body, 523); // 车速 52.3 km/h
writeU32(body, 1234567); // 里程 123456.7 km
writeU16(body, 6000); // 600.0 V
writeU16(body, 1100); // 110.0 A (110 - (1000-1000) = 10) 实际计算1100*0.1 - 1000 = -890此处只为结构测试
body.write(70); // SOC
body.write(0x01);
body.write(0x0F);
writeU16(body, 500);
body.write(30);
body.write(0);
// 0x05 位置 9 字节
body.write(0x05);
body.write(0x00); // 有效 + 北纬 + 东经
writeU32(body, 116_397_128L);
writeU32(body, 39_916_527L);
byte[] bodyBytes = body.toByteArray();
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(0x23); out.write(0x23);
out.write(0x02); // 实时上报
out.write(0xFE); // 应答 / 命令
byte[] vinBytes = vin.getBytes(StandardCharsets.US_ASCII);
out.write(vinBytes, 0, 17);
out.write(0x01); // 不加密
writeU16(out, bodyBytes.length);
out.write(bodyBytes, 0, bodyBytes.length);
byte[] almost = out.toByteArray();
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
out.write(bcc & 0xFF);
return out.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));
}
private static byte[] buildPlatformLoginFrameWithExtendedPassword(String username, String password) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
body.write(26); body.write(6); body.write(22); body.write(20); body.write(40); body.write(45);
writeU16(body, 1);
writePaddedAscii(body, username, 12);
writePaddedAscii(body, password, password.length());
body.write(0x01);
byte[] bodyBytes = body.toByteArray();
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(0x23); out.write(0x23);
out.write(0x05);
out.write(0xFE);
for (int i = 0; i < 17; i++) out.write(0);
out.write(0x01);
writeU16(out, 41);
out.write(bodyBytes, 0, bodyBytes.length);
byte[] almost = out.toByteArray();
out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
return out.toByteArray();
}
private static void writePaddedAscii(ByteArrayOutputStream os, String value, int len) {
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
int copy = Math.min(bytes.length, len);
os.write(bytes, 0, copy);
for (int i = copy; i < len; i++) os.write(0);
}
}

View File

@@ -0,0 +1,59 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.codec.BccChecksum;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class Gb32960FrameDecoderTest {
@Test
void keepsPlatformLoginWithMd5HexPasswordAsOneFrame() {
byte[] frame = buildPlatformLoginFrame("Hyundai", "f2e3445d7cda409fb4f278f6fb890734");
EmbeddedChannel channel = new EmbeddedChannel(new Gb32960FrameDecoder());
assertThat(channel.writeInbound(frame)).isTrue();
byte[] decoded = channel.readInbound();
assertThat(decoded).isEqualTo(frame);
assertThat((Object) channel.readInbound()).isNull();
}
private static byte[] buildPlatformLoginFrame(String username, String password) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
body.write(26); body.write(6); body.write(22); body.write(20); body.write(40); body.write(45);
writeU16(body, 1);
writePaddedAscii(body, username, 12);
writePaddedAscii(body, password, password.length());
body.write(0x01);
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(0x23); out.write(0x23);
out.write(0x05);
out.write(0xFE);
for (int i = 0; i < 17; i++) out.write(0);
out.write(0x01);
writeU16(out, 41);
byte[] bodyBytes = body.toByteArray();
out.write(bodyBytes, 0, bodyBytes.length);
byte[] almost = out.toByteArray();
out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
return out.toByteArray();
}
private static void writeU16(ByteArrayOutputStream os, int v) {
os.write((v >> 8) & 0xFF);
os.write(v & 0xFF);
}
private static void writePaddedAscii(ByteArrayOutputStream os, String value, int len) {
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
int copy = Math.min(bytes.length, len);
os.write(bytes, 0, copy);
for (int i = copy; i < len; i++) os.write(0);
}
}

View File

@@ -0,0 +1,152 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 验证新增的变长信息体 Parser0x02 驱动电机 / 0x07 报警 / 0x08 电压 / 0x09 温度)
* 与主解码器协同工作。
*/
class Gb32960FullBlocksTest {
private final Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(
new Gb32960BodyParser(new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
new PositionV2016BlockParser(),
new DriveMotorV2016BlockParser(),
new AlarmV2016BlockParser(),
new VoltageV2016BlockParser(),
new TemperatureV2016BlockParser()))));
@Test
void parsesDriveMotorBlock() {
byte[] frame = buildFrame(os -> {
os.write(0x02); // drive motor
os.write(1); // 1 motor
os.write(1); // serial
os.write(0x01); // state
os.write(100); // controllerTemp = 60
writeU16(os, 23_000); // rpm = 3000
writeU16(os, 21_000); // torque raw, actual = 100 Nm
os.write(90); // motorTemp = 50
writeU16(os, 5400); // voltage 540.0V
writeU16(os, 11000); // current raw → 100 A
});
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
InfoBlock.Gb32960V2016.DriveMotor dm = msg.findBlock(InfoBlock.Gb32960V2016.DriveMotor.class).orElseThrow();
assertThat(dm.motors()).hasSize(1);
var m = dm.motors().get(0);
assertThat(m.rpm()).isEqualTo(3000);
assertThat(m.torqueNm()).isEqualTo(100.0);
assertThat(m.controllerInputVoltageV()).isEqualTo(540.0);
}
@Test
void parsesAlarmBlock() {
byte[] frame = buildFrame(os -> {
os.write(0x07); // alarm
os.write(2); // max level
writeU32(os, 0x0000_0003L);// general flag
os.write(1); writeU32(os, 0xDEAD_BEEFL); // battery faults 1
os.write(0); // motor faults 0
os.write(0); // engine faults 0
os.write(1); writeU32(os, 0xCAFE_BABEL); // other faults 1
});
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
InfoBlock.Gb32960V2016.Alarm a = msg.findBlock(InfoBlock.Gb32960V2016.Alarm.class).orElseThrow();
assertThat(a.maxLevel()).isEqualTo(2);
assertThat(a.batteryFaults()).hasSize(1);
assertThat(a.otherFaults()).hasSize(1);
}
@Test
void parsesVoltageBlock() {
byte[] frame = buildFrame(os -> {
os.write(0x08); // voltage
os.write(1); // 1 subsystem
os.write(1); // battery index
writeU16(os, 3800); // 380.0 V
writeU16(os, 11_000); // current raw
writeU16(os, 96); // total cells
writeU16(os, 1); // start
os.write(3); // frame cells
writeU16(os, 3500); // 3.5V
writeU16(os, 3600); // 3.6V
writeU16(os, 3300); // 3.3V
});
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
InfoBlock.Gb32960V2016.Voltage v = msg.findBlock(InfoBlock.Gb32960V2016.Voltage.class).orElseThrow();
assertThat(v.subSystemCount()).isEqualTo(1);
assertThat(v.maxCellVoltageV()).isEqualTo(3.6, org.assertj.core.data.Offset.offset(0.001));
assertThat(v.minCellVoltageV()).isEqualTo(3.3, org.assertj.core.data.Offset.offset(0.001));
}
@Test
void parsesTemperatureBlock() {
byte[] frame = buildFrame(os -> {
os.write(0x09); // temperature
os.write(1); // subsystems
os.write(1); // battery index
writeU16(os, 4); // probes
os.write(60); // actual 20
os.write(65); // actual 25
os.write(70); // actual 30
os.write(55); // actual 15
});
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
InfoBlock.Gb32960V2016.Temperature t = msg.findBlock(InfoBlock.Gb32960V2016.Temperature.class).orElseThrow();
assertThat(t.maxTempC()).isEqualTo(30);
assertThat(t.minTempC()).isEqualTo(15);
}
// ===== frame builder helpers =====
private static byte[] buildFrame(java.util.function.Consumer<ByteArrayOutputStream> bodyWriter) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
// 时间戳
body.write(24); body.write(1); body.write(2); body.write(3); body.write(4); body.write(5);
bodyWriter.accept(body);
byte[] bodyBytes = body.toByteArray();
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(0x23); out.write(0x23);
out.write(0x02); // cmd: realtime
out.write(0xFE); // response flag
byte[] vin = "LTEST000000000010".getBytes(StandardCharsets.US_ASCII);
out.write(vin, 0, 17);
out.write(0x01); // not encrypted
writeU16(out, bodyBytes.length);
out.write(bodyBytes, 0, bodyBytes.length);
byte[] almost = out.toByteArray();
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
out.write(bcc & 0xFF);
return out.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,296 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAirConditionerBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAuxiliaryBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDcDcBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDemoExtensionBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcStackBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcVehicleInfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.profile.RuleBasedVendorExtensionSelector;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960ChannelHandler;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
/**
* 端到端集成测试:构造一帧带广东燃料电池规范扩展块的 V2016 帧,验证
* vendor profile selector 路由 + GuangdongFc 系列 parser 全链路解码。
*
* <p>覆盖:
* <ul>
* <li>9 个 GB/T 32960 标准块0x01~0x09原生解析
* <li>0x30 GuangdongFc.Stack含 1 个电堆、4 个单体电压样本)
* <li>0x32 GuangdongFc.DcDc
* <li>0x33 GuangdongFc.AirConditioner
* <li>0x34 GuangdongFc.VehicleInfo
* <li>0x80 GuangdongFc.DemoExtension
* <li>VendorExtensionSelector 按 platformAccount=lingniu 命中
* </ul>
*
* <p>0x31 Auxiliary 没有放进帧里(变长且字段相对琐碎),单独由 unit test 覆盖。
*/
class GuangdongFcEndToEndTest {
private final Gb32960MessageDecoder decoder = buildDecoder();
@Test
void parsesRealtimeFrameWithGuangdongFcExtensions() {
byte[] frame = buildFrame();
// 用 platformAccount=lingniu 触发 vendor profile
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame), "lingniu");
// ---- 标准块 ----
var v = msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class).orElseThrow();
assertThat(v.socPercent()).isEqualTo(85);
var pos = msg.findBlock(InfoBlock.Gb32960V2016.Position.class).orElseThrow();
assertThat(pos.longitude()).isCloseTo(120.848158, within(1e-6));
// ---- vendor 块0x30 Stack ----
var stack = msg.findBlock(InfoBlock.GuangdongFc.Stack.class).orElseThrow();
assertThat(stack.stackCount()).isEqualTo(1);
var s0 = stack.stacks().get(0);
assertThat(s0.engineWorkState()).isEqualTo(1);
assertThat(s0.stackWaterOutletTempC()).isEqualTo(50); // 90-40
assertThat(s0.maxCellVoltageV()).isCloseTo(0.745, within(1e-6)); // 745 mV
assertThat(s0.minCellVoltageV()).isCloseTo(0.730, within(1e-6));
assertThat(s0.avgCellVoltageV()).isCloseTo(0.738, within(1e-6));
assertThat(s0.cellCount()).isEqualTo(400);
assertThat(s0.frameCellCount()).isEqualTo(4);
assertThat(s0.frameCellVoltagesV()).hasSize(4)
.allSatisfy(c -> assertThat(c).isBetween(0.7, 0.8));
// ---- vendor 块0x32 DcDc ----
var dcdc = msg.findBlock(InfoBlock.GuangdongFc.DcDc.class).orElseThrow();
assertThat(dcdc.inputVoltageV()).isCloseTo(300.0, within(1e-6));
assertThat(dcdc.outputCurrentA()).isCloseTo(110.0, within(1e-6));
// ---- vendor 块0x33 AirConditioner ----
var ac = msg.findBlock(InfoBlock.GuangdongFc.AirConditioner.class).orElseThrow();
assertThat(ac.status()).isEqualTo(1);
assertThat(ac.powerKw()).isEqualTo(15);
// ---- vendor 块0x34 VehicleInfo ----
var info = msg.findBlock(InfoBlock.GuangdongFc.VehicleInfo.class).orElseThrow();
assertThat(info.collisionAlarm()).isEqualTo(0);
assertThat(info.ambientTempC()).isEqualTo(30);
assertThat(info.hydrogenMassKg()).isCloseTo(8.0, within(1e-6));
// ---- vendor 块0x80 DemoExtension ----
var demo = msg.findBlock(InfoBlock.GuangdongFc.DemoExtension.class).orElseThrow();
assertThat(demo.stackTempC()).isEqualTo(50);
assertThat(demo.airCompressorVoltageV()).isCloseTo(372.0, within(1e-6));
// 不应有 Raw 兜底:所有块都被结构化解析
assertThat(msg.findBlock(InfoBlock.Raw.class)).isEmpty();
}
@Test
void unmatchedAccountFallsBackToDefaultAndProducesRawForVendorBlocks() {
byte[] frame = buildFrame();
// 不传 account → selector 返回 null → default profile无 vendor parser
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame), null);
// 标准块仍能解析
assertThat(msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class)).isPresent();
// 但 0x30 应触发 unknown → Raw 兜底
assertThat(msg.findBlock(InfoBlock.Raw.class)).isPresent();
assertThat(msg.findBlock(InfoBlock.GuangdongFc.Stack.class)).isEmpty();
}
// ====================================================================
// 帧构造:标准块 0x01/0x05/0x07/0x08/0x09 + vendor 块 0x30/0x32/0x33/0x34/0x80
// 不带 0x02/0x04/0x06/0x31纯粹为了控制帧长和测试焦点。
// ====================================================================
private static byte[] buildFrame() {
ByteArrayOutputStream body = new ByteArrayOutputStream();
// 时间戳 6B
body.write(24); body.write(4); body.write(15); body.write(11); body.write(0); body.write(0);
// 0x01 整车数据 20B
body.write(0x01);
body.write(0x01); // vehicleState 启动
body.write(0x03); // chargingState 未充电
body.write(0x02); // runningMode 混动
writeU16(body, 0); // 速度
writeU32(body, 100_000L); // 累计里程 = 10000 km
writeU16(body, 5605); // totalVoltage 560.5 V
writeU16(body, 10000); // totalCurrent 0 A (after -1000 offset)
body.write(85); // SOC
body.write(0x01); // DC-DC 工作
body.write(0x00); // 挡位
writeU16(body, 10000); // 绝缘电阻
body.write(0x00); // accelPedal
body.write(0x00); // brakePedal
// 0x05 位置数据 9B
body.write(0x05);
body.write(0x00); // 状态:有效定位
writeU32(body, 120_848_158L); // 经度 120.848158
writeU32(body, 31_497_236L); // 纬度 31.497236
// 0x07 报警数据 10Blevel=0 + flag=0 + 4 个空 fault list
body.write(0x07);
body.write(0x00); // maxLevel
writeU32(body, 0); // generalFlag
body.write(0x00); // batteryFaultCount
body.write(0x00); // motorFaultCount
body.write(0x00); // engineFaultCount
body.write(0x00); // otherFaultCount
// 0x08 储能电压1 sub × 0 cell保持简洁
body.write(0x08);
body.write(0x01); // subCount
body.write(0x01); // batteryIndex
writeU16(body, 5605); // voltage
writeU16(body, 10000); // current
writeU16(body, 0); // cellCount
writeU16(body, 1); // frameCellStart
body.write(0x00); // frameCellCount = 0
// 0x09 储能温度1 sub × 0 probe
body.write(0x09);
body.write(0x01); // subCount
body.write(0x01); // batteryIndex
writeU16(body, 0); // probeCount = 0
// 0x30 GuangdongFc.Stack1 stack + 4 单体电压
body.write(0x30);
body.write(0x01); // stackCount
body.write(0x01); // engineWorkState 打开
body.write(90); // 水温 90-40 = 50°C
writeU16(body, 1850); // h2 入口压力 raw=1850 → 85 kPa
writeU16(body, 1500); // air 入口压力 raw=1500 → 50 kPa
body.write(70); // air 入口温度 70-40 = 30°C
writeU16(body, 12); // maxCellId
writeU16(body, 88); // minCellId
writeU16(body, 745); // maxCellV 745 mV
writeU16(body, 730); // minCellV 730 mV
writeU16(body, 738); // avgCellV 738 mV
writeU16(body, 400); // cellCount
writeU16(body, 1); // frameCellStart
body.write(0x04); // frameCellCount = 4
writeU16(body, 745);
writeU16(body, 740);
writeU16(body, 735);
writeU16(body, 730);
// 0x32 GuangdongFc.DcDc 9B
body.write(0x32);
writeU16(body, 3000); // inputV 300.0
writeU16(body, 1200); // inputC 120.0
writeU16(body, 5600); // outputV 560.0
writeU16(body, 1100); // outputC 110.0
body.write(105); // ctrlTemp 105-40 = 65°C
// 0x33 GuangdongFc.AirConditioner 5B
body.write(0x33);
body.write(0x01); // status 启动
writeU16(body, 20); // power 20-5 = 15 kw
writeU16(body, 5400); // compressorInputV 540.0
// 0x34 GuangdongFc.VehicleInfo 7B
body.write(0x34);
body.write(0x00); // collision 无
writeU16(body, 70); // ambientTemp 70-40 = 30°C
writeU16(body, 1100); // ambientPressure raw=1100 → 10.0 kPa
writeU16(body, 80); // h2Mass 80*0.1 = 8.0 kg
// 0x80 GuangdongFc.DemoExtension 12B含 2B 内层 length=9
body.write(0x80);
writeU16(body, 9); // declared length
body.write(90); // stackTemp 50°C
writeU16(body, 3720); // ac voltage 372.0
writeU16(body, 10000); // ac current 0 A
writeU16(body, 13); // h2 pump voltage 1.3
writeU16(body, 10000); // h2 pump current 0 A
byte[] bodyBytes = body.toByteArray();
// 帧封装
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(0x23); out.write(0x23);
out.write(0x02); // cmd realtime
out.write(0xFE); // 命令包
byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII);
out.write(vin, 0, 17);
out.write(0x01); // 不加密
writeU16(out, bodyBytes.length);
out.write(bodyBytes, 0, bodyBytes.length);
byte[] almost = out.toByteArray();
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
out.write(bcc & 0xFF);
return out.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));
}
// ====================================================================
// Decoder 装配:手动构造一个挂着 guangdong-fc 路由的 BodyParser
// ====================================================================
private static Gb32960MessageDecoder buildDecoder() {
var standardParsers = List.of(
(com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser) new VehicleV2016BlockParser(),
new DriveMotorV2016BlockParser(),
new PositionV2016BlockParser(),
new AlarmV2016BlockParser(),
new VoltageV2016BlockParser(),
new TemperatureV2016BlockParser());
var catalog = new VendorExtensionCatalog(java.util.Map.of(
"guangdong-fc", List.of(
new GdFcStackBlockParser(),
new GdFcAuxiliaryBlockParser(),
new GdFcDcDcBlockParser(),
new GdFcAirConditionerBlockParser(),
new GdFcVehicleInfoBlockParser(),
new GdFcDemoExtensionBlockParser())));
var profileRegistry = new Gb32960ProfileRegistry(
standardParsers, catalog, Set.of("guangdong-fc"));
// selectorlingniu 账号 → guangdong-fc
var entry = new Gb32960Properties.VendorExtension();
entry.setName("guangdong-fc");
var match = new Gb32960Properties.VendorExtension.Match();
match.setPlatformAccounts(List.of("lingniu"));
entry.setMatch(match);
var selector = new RuleBasedVendorExtensionSelector(
List.of(entry), Set.of("guangdong-fc"));
var bodyParser = new Gb32960BodyParser(profileRegistry, selector);
// PLATFORM_ACCOUNT_ATTR 仅供 handler 使用,这里不需要
return new Gb32960MessageDecoder(bodyParser);
}
// suppress unused-import warning for the channel handler attr key
@SuppressWarnings("unused")
private static final Object KEEP_ATTR_REF = Gb32960ChannelHandler.PLATFORM_ACCOUNT_ATTR;
}

View File

@@ -0,0 +1,87 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import org.junit.jupiter.api.Test;
import java.nio.ByteBuffer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
/**
* 单元测试:广东燃料电池规范 fixed-length parser0x32 / 0x33 / 0x34 / 0x80逐字段还原。
* 0x30/0x31 是变长,集成层会在 BodyParser 全链路测试里覆盖。
*/
class GdFcParserTest {
@Test
void dcDcBlockParsesAllFields() {
// inV=300.0 inC=120.0 outV=560.0 outC=110.5 ctrlTemp=65°C
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
0x0B, (byte) 0xB8, // 0x0BB8 = 3000 → 300.0 V
0x04, (byte) 0xB0, // 0x04B0 = 1200 → 120.0 A
0x15, (byte) 0xE0, // 0x15E0 = 5600 → 560.0 V
0x04, 0x52, // 0x0452 = 1106 → 110.6 A
0x69 // 0x69 = 105 - 40 = 65 °C
});
InfoBlock.GuangdongFc.DcDc b =
(InfoBlock.GuangdongFc.DcDc) new GdFcDcDcBlockParser().parse(buf);
assertThat(b.inputVoltageV()).isCloseTo(300.0, within(1e-6));
assertThat(b.inputCurrentA()).isCloseTo(120.0, within(1e-6));
assertThat(b.outputVoltageV()).isCloseTo(560.0, within(1e-6));
assertThat(b.outputCurrentA()).isCloseTo(110.6, within(1e-6));
assertThat(b.controllerTempC()).isEqualTo(65);
}
@Test
void airConditionerParsesPowerOffsetAndStatus() {
// status=01 power raw=20 (= 15 kw after -5 offset) inputV=540.0
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
0x01,
0x00, 0x14, // 20 - 5 = 15 kw
0x15, (byte) 0x18 // 0x1518 = 5400 → 540.0 V
});
InfoBlock.GuangdongFc.AirConditioner b =
(InfoBlock.GuangdongFc.AirConditioner) new GdFcAirConditionerBlockParser().parse(buf);
assertThat(b.status()).isEqualTo(1);
assertThat(b.powerKw()).isEqualTo(15);
assertThat(b.compressorInputVoltageV()).isCloseTo(540.0, within(1e-6));
}
@Test
void vehicleInfoParsesAmbientFields() {
// collision=00 ambientTemp raw=70 (=30°C) ambientPressure raw=1100 (10kPa) h2Mass raw=80 (8.0 kg)
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
0x00,
0x00, 0x46, // 70 - 40 = 30°C
0x04, 0x4C, // 0x044C = 1100 → 110*0.1 - 100 = 10.0 kPa
0x00, 0x50 // 0x0050 = 80 → 8.0 kg
});
InfoBlock.GuangdongFc.VehicleInfo b =
(InfoBlock.GuangdongFc.VehicleInfo) new GdFcVehicleInfoBlockParser().parse(buf);
assertThat(b.collisionAlarm()).isEqualTo(0);
assertThat(b.ambientTempC()).isEqualTo(30);
assertThat(b.ambientPressureKpa()).isCloseTo(10.0, within(1e-6));
assertThat(b.hydrogenMassKg()).isCloseTo(8.0, within(1e-6));
}
@Test
void demoExtensionParsesInnerLengthAndFields() {
// length=9, stackTemp=50, acV=372.0, acC=0, h2V=1.3V, h2C=0
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
0x00, 0x09, // declared length
0x5A, // 90 - 40 = 50°C
0x0E, (byte) 0x88, // 0x0E88 = 3720 → 372.0V
0x27, 0x10, // 0x2710 = 10000 → 0 A (after -1000)
0x00, 0x0D, // 0x000D = 13 → 1.3V
0x27, 0x10 // 0 A
});
InfoBlock.GuangdongFc.DemoExtension b =
(InfoBlock.GuangdongFc.DemoExtension) new GdFcDemoExtensionBlockParser().parse(buf);
assertThat(b.stackTempC()).isEqualTo(50);
assertThat(b.airCompressorVoltageV()).isCloseTo(372.0, within(1e-6));
assertThat(b.airCompressorCurrentA()).isCloseTo(0.0, within(1e-6));
assertThat(b.hydrogenPumpVoltageV()).isCloseTo(1.3, within(1e-6));
assertThat(b.hydrogenPumpCurrentA()).isCloseTo(0.0, within(1e-6));
}
}

View File

@@ -0,0 +1,118 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
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;
/**
* 验证 {@link GdFcVendorTlvBlockParser} 严格按 length 消费字节,不会贪心吞光后续信息体。
*
* <p>这是关键回归 case用户问"加 0x83 兜底会不会把后面其它块也吃掉?"——通过构造
* 一个 {@code 0x83 TLV + 0x01 Vehicle} 串联的 body断言 BodyParser 解出**两个块**而非一个。
*/
class GdFcVendorTlvBlockParserTest {
@Test
void parsesPayloadOfDeclaredLengthExactly() {
// 0x83 typeCode 已被外层消费buffer 从 length 字段开始
byte[] data = bytes(
0x00, 0x05, // length = 5
0xAA, 0xBB, 0xCC, 0xDD, 0xEE); // 5 bytes payload
ByteBuffer buf = ByteBuffer.wrap(data);
InfoBlock.GuangdongFc.VendorTlv tlv =
(InfoBlock.GuangdongFc.VendorTlv) new GdFcVendorTlvBlockParser(0x83).parse(buf);
assertThat(tlv.typeCode()).isEqualTo(0x83);
assertThat(tlv.declaredLength()).isEqualTo(5);
assertThat(tlv.payload()).containsExactly(0xAA, 0xBB, 0xCC, 0xDD, 0xEE);
assertThat(buf.remaining()).as("应正好消费 2+5=7 字节").isZero();
}
@Test
void doesNotSwallowSubsequentBlocksWhenChainedInBodyParser() {
// 构造 body0x83 TLV(length=4) + 0x01 Vehicle(20B 标准布局)
ByteArrayOutputStream body = new ByteArrayOutputStream();
// 0x83 TLV3+4 = 7 字节
body.write(0x83);
body.write(0x00); body.write(0x04); // length = 4
body.write(0x11); body.write(0x22); body.write(0x33); body.write(0x44);
// 0x01 Vehicle: typeCode + 20 字节 body
body.write(0x01);
body.write(0x01); // vehicleState
body.write(0x03); // chargingState
body.write(0x02); // runningMode
write16(body, 0); // speed
write32(body, 100_000L); // mileage
write16(body, 5605); // totalVoltage
write16(body, 10000); // totalCurrent
body.write(85); // SOC
body.write(0x01); // dcDcStatus
body.write(0x00); // gear
write16(body, 10000); // insulation
body.write(0x00); // accelPedal
body.write(0x00); // brakePedal
// 装配 body parser注册 0x83 vendor TLV + 0x01 标准 Vehicle
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new GdFcVendorTlvBlockParser(0x83),
new VehicleV2016BlockParser()));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
ByteBuffer buf = ByteBuffer.wrap(body.toByteArray());
Gb32960MessageDecoder.BodyParseResult result = parser.parse(ProtocolVersion.V2016, buf);
// 关键断言两个块都解析出来了0x01 没被 0x83 吞掉
assertThat(result.blocks()).hasSize(2);
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.GuangdongFc.VendorTlv.class);
assertThat(result.blocks().get(1)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
InfoBlock.GuangdongFc.VendorTlv tlv = (InfoBlock.GuangdongFc.VendorTlv) result.blocks().get(0);
assertThat(tlv.declaredLength()).isEqualTo(4);
assertThat(tlv.payload()).containsExactly(0x11, 0x22, 0x33, 0x44);
InfoBlock.Gb32960V2016.Vehicle v = (InfoBlock.Gb32960V2016.Vehicle) result.blocks().get(1);
assertThat(v.socPercent()).isEqualTo(85);
assertThat(v.totalVoltageV()).isEqualTo(560.5);
}
@Test
void truncatesPayloadIfDeclaredLengthExceedsRemaining() {
// 防御场景length 字段声称 100 字节但 buffer 只剩 3 字节
byte[] data = bytes(0x00, 0x64, 0x01, 0x02, 0x03);
ByteBuffer buf = ByteBuffer.wrap(data);
InfoBlock.GuangdongFc.VendorTlv tlv =
(InfoBlock.GuangdongFc.VendorTlv) new GdFcVendorTlvBlockParser(0x83).parse(buf);
assertThat(tlv.declaredLength()).isEqualTo(100);
assertThat(tlv.payload()).hasSize(3).containsExactly(0x01, 0x02, 0x03);
}
private static byte[] bytes(int... values) {
byte[] out = new byte[values.length];
for (int i = 0; i < values.length; i++) out[i] = (byte) values[i];
return out;
}
private static void write16(ByteArrayOutputStream os, int v) {
os.write((v >> 8) & 0xFF);
os.write(v & 0xFF);
}
private static void write32(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,106 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RuleBasedVendorExtensionSelectorTest {
@Test
void platformAccountMatchHits() {
var ext = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of());
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNV1234567890ABCD", "lingniu");
assertThat(selector.select(ctx)).isEqualTo("guangdong-fc");
}
@Test
void vinExactMatchHits() {
var ext = entry("guangdong-fc", List.of(), List.of("LNV1234567890ABCD"), List.of());
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "lnv1234567890abcd", null);
assertThat(selector.select(ctx))
.as("VIN match should be case-insensitive")
.isEqualTo("guangdong-fc");
}
@Test
void vinPrefixMatchHits() {
var ext = entry("guangdong-fc", List.of(), List.of(), List.of("LNVFC", "LZG"));
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNVFC0001", null);
assertThat(selector.select(ctx)).isEqualTo("guangdong-fc");
}
@Test
void noMatchReturnsNull() {
var ext = entry("guangdong-fc", List.of("other"), List.of(), List.of());
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "ZZZ", "lingniu");
assertThat(selector.select(ctx)).isNull();
}
@Test
void firstMatchWinsTopDown() {
var first = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of());
var second = entry("guangdong-fc", List.of(), List.of(), List.of("LNV"));
var selector = new RuleBasedVendorExtensionSelector(
List.of(first, second), Set.of("guangdong-fc"));
// 即便两条都能命中,应返回第一条匹配 entry 的 name这里凑巧一样
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNV1", "lingniu");
assertThat(selector.select(ctx)).isEqualTo("guangdong-fc");
}
@Test
void emptyConfigReturnsNullWithoutScanning() {
var selector = new RuleBasedVendorExtensionSelector(List.of(), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "ANY", "ANY");
assertThat(selector.select(ctx)).isNull();
}
@Test
void unknownExtensionNameRejectedAtConstruction() {
var ext = entry("not-in-catalog", List.of("x"), List.of(), List.of());
assertThatThrownBy(() ->
new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc")))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not-in-catalog");
}
@Test
void resultIsCachedPerAccountVinTuple() {
var ext = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of());
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx1 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN1", "lingniu");
var ctx2 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN1", "lingniu");
selector.select(ctx1);
selector.select(ctx2);
// 同一个 (account, vin) 应只产生一个 cache 条目
assertThat(selector.cacheSize()).isEqualTo(1);
var ctx3 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN2", "lingniu");
selector.select(ctx3);
assertThat(selector.cacheSize()).isEqualTo(2);
}
private static Gb32960Properties.VendorExtension entry(String name,
List<String> accounts,
List<String> vins,
List<String> vinPrefixes) {
var e = new Gb32960Properties.VendorExtension();
e.setName(name);
var m = new Gb32960Properties.VendorExtension.Match();
m.setPlatformAccounts(accounts);
m.setVins(vins);
m.setVinPrefixes(vinPrefixes);
e.setMatch(m);
return e;
}
}

View File

@@ -0,0 +1,79 @@
package com.lingniu.ingest.protocol.gb32960.handler;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.protocol.gb32960.mapper.Gb32960EventMapper;
import com.lingniu.ingest.protocol.gb32960.model.CommandBody;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Header;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 保障 {@link Gb32960RealtimeHandler} 正确委托给 {@link Gb32960EventMapper}
* 不吞掉任何需要上送下游的事件。
*/
class Gb32960RealtimeHandlerTest {
private final Gb32960RealtimeHandler handler = new Gb32960RealtimeHandler(new Gb32960EventMapper());
@Test
void onPlatform_login_emitsLoginEventWithKindPlatform() {
Gb32960Message msg = buildPlatformLoginMessage("lingniu");
List<VehicleEvent> events = handler.onPlatform(msg);
assertThat(events).hasSize(1);
VehicleEvent first = events.get(0);
assertThat(first).isInstanceOf(VehicleEvent.Login.class);
assertThat(first.vin()).isEqualTo("platform:lingniu");
assertThat(first.metadata())
.containsEntry("kind", "platform")
.containsEntry("username", "lingniu");
}
@Test
void onPlatform_logout_emitsLogoutEventWithKindPlatform() {
Gb32960Message msg = new Gb32960Message(
new Gb32960Header(
ProtocolVersion.V2016,
CommandType.PLATFORM_LOGOUT,
ResponseFlag.COMMAND,
"00000000000000000",
EncryptType.UNENCRYPTED,
0,
null),
new CommandBody.PlatformLogout(Instant.parse("2026-04-20T10:00:00Z"), 1));
List<VehicleEvent> events = handler.onPlatform(msg);
assertThat(events).hasSize(1);
assertThat(events.get(0)).isInstanceOf(VehicleEvent.Logout.class);
assertThat(events.get(0).metadata()).containsEntry("kind", "platform");
}
private static Gb32960Message buildPlatformLoginMessage(String username) {
return new Gb32960Message(
new Gb32960Header(
ProtocolVersion.V2016,
CommandType.PLATFORM_LOGIN,
ResponseFlag.COMMAND,
"00000000000000000",
EncryptType.UNENCRYPTED,
0,
null),
new CommandBody.PlatformLogin(
Instant.parse("2026-04-20T10:00:00Z"),
1,
username,
"secret",
EncryptType.UNENCRYPTED));
}
}

View File

@@ -0,0 +1,52 @@
package com.lingniu.ingest.protocol.gb32960.inbound;
import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer;
import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Gb32960AccessServiceTest {
@Test
void bindPlatformAccount_makesAccountAvailableForLaterFrames() {
Gb32960AccessService service = newService(false);
EmbeddedChannel channel = new EmbeddedChannel();
service.bindPlatformAccount(channel, "lingniu");
assertThat(service.platformAccount(channel)).isEqualTo("lingniu");
}
@Test
void authenticatePlatformLogin_rejectsUnknownPeerIpWhenPolicyRestrictsIt() {
Gb32960Properties.Auth auth = new Gb32960Properties.Auth();
Gb32960Properties.Auth.Platform platform = new Gb32960Properties.Auth.Platform();
platform.setUsername("lingniu");
platform.setPassword("secret");
platform.setAllowedIps(List.of("10.0.0.1"));
auth.setPlatforms(List.of(platform));
Gb32960AccessService service = new Gb32960AccessService(
new Gb32960VinAuthorizer(auth),
new Gb32960PlatformAuthorizer(auth.getPlatforms()));
EmbeddedChannel channel = new EmbeddedChannel();
channel.connect(new InetSocketAddress("10.0.0.2", 9001));
assertThat(service.authenticatePlatformLogin("lingniu", "secret", channel)
.accepted()).isFalse();
}
private static Gb32960AccessService newService(boolean vinAuthEnabled) {
Gb32960Properties.Auth auth = new Gb32960Properties.Auth();
auth.setEnabled(vinAuthEnabled);
return new Gb32960AccessService(
new Gb32960VinAuthorizer(auth),
new Gb32960PlatformAuthorizer(auth.getPlatforms()));
}
}

View File

@@ -0,0 +1,42 @@
package com.lingniu.ingest.protocol.gb32960.inbound;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
import io.netty.buffer.ByteBuf;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class Gb32960AckServiceTest {
@Test
void writeResponse_writesGb32960AckFrameToChannel() {
Gb32960AckService service = new Gb32960AckService();
EmbeddedChannel channel = new EmbeddedChannel();
byte[] rawVin = "LTEST000000000001".getBytes(StandardCharsets.US_ASCII);
service.writeResponse(
channel,
ProtocolVersion.V2016,
CommandType.VEHICLE_LOGIN,
ResponseFlag.SUCCESS,
rawVin,
Instant.parse("2026-04-20T10:00:00Z"),
null,
"vehicle-login-ack");
ByteBuf out = channel.readOutbound();
assertThat(out).isNotNull();
byte[] bytes = new byte[out.readableBytes()];
out.readBytes(bytes);
assertThat(bytes[0]).isEqualTo((byte) 0x23);
assertThat(bytes[1]).isEqualTo((byte) 0x23);
assertThat(bytes[2]).isEqualTo((byte) CommandType.VEHICLE_LOGIN.code());
assertThat(bytes[3]).isEqualTo((byte) ResponseFlag.SUCCESS.code());
}
}

View File

@@ -0,0 +1,45 @@
package com.lingniu.ingest.protocol.gb32960.inbound;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Gb32960FrameDiagnosticsTest {
@Test
void shouldLogFirstSeenRawSignatureOnlyOncePerChannel() {
Gb32960FrameDiagnostics diagnostics = new Gb32960FrameDiagnostics(8);
EmbeddedChannel channel = new EmbeddedChannel();
List<String> rawTypes = List.of("0x30", "0x83");
assertThat(diagnostics.markFirstSeen(channel, "VIN001", rawTypes)).isTrue();
assertThat(diagnostics.markFirstSeen(channel, "VIN001", rawTypes)).isFalse();
}
@Test
void markFirstSeen_keepsBoundedKeysPerChannel() {
Gb32960FrameDiagnostics diagnostics = new Gb32960FrameDiagnostics(2);
EmbeddedChannel channel = new EmbeddedChannel();
assertThat(diagnostics.markFirstSeen(channel, "VIN001", List.of("0x30"))).isTrue();
assertThat(diagnostics.markFirstSeen(channel, "VIN001", List.of("0x31"))).isTrue();
assertThat(diagnostics.markFirstSeen(channel, "VIN001", List.of("0x32"))).isTrue();
assertThat(diagnostics.seenKeyCount(channel)).isLessThanOrEqualTo(2);
}
@Test
void collectRawTypeHex_returnsOnlyRawBlocks() {
List<String> rawTypes = Gb32960FrameDiagnostics.collectRawTypeHex(List.of(
new InfoBlock.Raw(0x30, InfoBlockType.RAW, new byte[] {1}),
new InfoBlock.Raw(0x83, InfoBlockType.RAW, new byte[] {2})));
assertThat(rawTypes).containsExactly("0x30", "0x83");
}
}

View File

@@ -0,0 +1,82 @@
package com.lingniu.ingest.protocol.gb32960.mapper;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.AlarmPayload;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960DecoderTest;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Header;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.GeneralAlarmFlagBit;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
import org.junit.jupiter.api.Test;
import java.nio.ByteBuffer;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Gb32960EventMapperTest {
@Test
void realtimeReportProducesRealtimeAndLocationEvents() {
byte[] frame = Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000002");
Gb32960BodyParser body = new Gb32960BodyParser(new InfoBlockParserRegistry(
List.of(new VehicleV2016BlockParser(), new PositionV2016BlockParser())));
Gb32960Message msg = new Gb32960MessageDecoder(body).decode(ByteBuffer.wrap(frame));
List<VehicleEvent> events = new Gb32960EventMapper().toEvents(msg);
assertThat(events).hasSize(2);
assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Realtime);
assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Location);
assertThat(events).allMatch(e -> e.source() == ProtocolId.GB32960);
assertThat(events).allMatch(e -> e.vin().equals("LTEST000000000002"));
}
@Test
void hydrogenLeakAlarmBitProducesCriticalSafetyAlarmEvent() {
long hydrogenLeakFlag = 1L << GeneralAlarmFlagBit.HYDROGEN_LEAK.bitIndex();
Gb32960Message msg = new Gb32960Message(
new Gb32960Header(
ProtocolVersion.V2025,
CommandType.REALTIME_REPORT,
ResponseFlag.COMMAND,
"LTEST000000000003",
EncryptType.UNENCRYPTED,
0,
Instant.parse("2026-06-22T01:00:00Z")),
List.of(new InfoBlock.Gb32960V2025.Alarm(
0,
hydrogenLeakFlag,
List.of(),
List.of(),
List.of(),
List.of(),
List.of())));
List<VehicleEvent> events = new Gb32960EventMapper().toEvents(msg);
VehicleEvent.Alarm alarm = events.stream()
.filter(VehicleEvent.Alarm.class::isInstance)
.map(VehicleEvent.Alarm.class::cast)
.findFirst()
.orElseThrow();
assertThat(alarm.payload().level()).isEqualTo(AlarmPayload.AlarmLevel.CRITICAL);
assertThat(alarm.payload().safetyCategory()).isEqualTo(AlarmPayload.SafetyCategory.HYDROGEN_LEAK);
assertThat(alarm.payload().hydrogenLeakDetected()).isTrue();
assertThat(alarm.payload().hydrogenLeakLevel()).isEqualTo(AlarmPayload.HydrogenLeakLevel.CRITICAL);
assertThat(alarm.payload().hydrogenLeakActionRequired()).isTrue();
assertThat(alarm.payload().activeBits()).contains("HYDROGEN_LEAK");
}
}

View File

@@ -0,0 +1,41 @@
# GB/T 32960 黄金样本集
> 本目录用于回放测试与双跑对账。所有样本从旧服务 `lingniu-vehicle-data-reception` 线上抓包后脱敏VIN 替换为 `LTEST<序号>XXXXXXXX`)。
## 文件命名
```
<命令类型>_<场景>_<序号>.hex
```
命令类型取自 `CommandType` 枚举:
- `vehicle_login` (0x01)
- `realtime_report` (0x02)
- `resend_report` (0x03)
- `vehicle_logout` (0x04)
- `heartbeat` (0x07)
## 文件格式
每个 `.hex` 文件是一行紧凑十六进制,字节之间**无分隔符**。
行首允许以 `#` 开头写注释,测试代码忽略注释行与空行。
示例 `realtime_report_basic_001.hex`:
```
# 2017-05-21 10:00:00 宇通 YT01SOC 70%,车速 52.3 km/h
2323020000010203040506070809101112131415160100352016051510000001010012350000037102710000A064010505050A0005010A00102003E8...
```
## 新增样本步骤
1. 从生产抓包工具tcpdump / rg-samples导出一帧完整字节`0x23 0x23` 起始和 BCC 尾)
2. VIN 替换为测试段:`LTEST0000<9位序号>`
3. 放入本目录,文件名按规范
4.`Gb32960DecoderGoldenTest` 中添加断言(期望 VIN / 命令 / 关键字段)
5. 运行 `mvn -pl protocol-gb32960 test`
## 验证目标
- 100% 解析一致性(新服务解析结果 === 旧服务解析结果)
- BCC 校验通过
- 事件映射字段单位一致(车速 km/h、里程 km、经纬度十进制度

View File

@@ -0,0 +1 @@
232307fe4c54455354303030303030303030303131010000a2

View File

@@ -0,0 +1 @@
232307fe4c54455354303030303030303030303231010000a1

View File

@@ -0,0 +1 @@
232302fe4c544553543030303030303030303030310102101a040d0c213901010302037a000531c2157825fb44012e27103400020101025575304eca6b157a288b030c82050a00aa0002686c02940200000100630101050006c4f414015eedea06018c0f0701310ebf01014a01054907000000000000000000080101157825fb00900001900eea0eea0eea0ee60ee60ee60ef00eef0ef10eeb0eec0eeb0eed0eee0eee0eeb0eed0eea0ed30ed20ed20ed30ed50ed40ecc0ecd0ecc0ece0ece0ece0ed40ed40ed40ed30ed10ed00ed20ed10ed00ece0ece0ecf0ed40ed40ed40eda0eda0edb0ebf0ed40ed30ed60ed60ed50ed40ed50ede0edf0ede0ede0eee0eed0eec0ee80ee80ee50ef00eef0eef0ee80ee90eea0ee40ee70ee80eeb0eeb0eea0eee0eef0eef0ef60ef60ef50ef80ef70ef50ef60ef50ef60eef0ef00ef10ef60ef80ef90efa0ef70ef90efa0ef80ef80efc0efc0efc0ef50ef50ef50ef20ef30ef40ef50ef50ef40ef50ef50ef70ef40ef40ef50ef80ef80ef70ef70ef50ef40ef70ef70ef70efa0efd0efc0ef70ef80ef70ef80ef60ef50f050f070f070ef00eed0ef009010100084a4a4a4a494a4a493001026c08fcffffff0003003a02ee02e402e5000000000031010c800006ffffffff0c80ffffffff0088320c8004c2159002c35333ffffffffff34ffffffffff001c8000096c0c802742ffffffff8300250019000e0b002300020000000000014f00ffffffffffff00000282ffffffff1ffe1fedff1fa0

View File

@@ -0,0 +1 @@
232302fe4c544553543030303030303030303030320102ed1a040d0c213a01010302013600036758162826284c012e2710010002010101485bf84de44b165526ec030da9017400d200026a6b023a0200000100b101010402ffff0015050107373d6c01d2e6880601020f8701520efe010141010540070000000000000000000801011628262800900001900f860f870f860f6d0f6c0f6c0f870f860f870f6d0f700f6e0f7e0f7d0f710f640f630f650f6e0f800f7e0f260f250f250f810f810f820f620f660f670f810f820f830f670f680f670f780f770f770f670f660f670f640f630f620f1a0f1c0f1c0f500f500f4d0f690f6b0f690f540f530f5a0f420f440f440f6e0f6e0f6d0f460f450f450f720f6c0f6b0f650f640f670f500f4f0f4c0f150f140f150f780f760f780efe0eff0eff0f7b0f7c0f7d0f640f640f640f6e0f6d0f6e0f360f390f390f5c0f5e0f5c0f650f660f660f7e0f800f800f640f620f640f810f800f800f300f330f2f0f800f820f830f670f660f680f820f840f820f650f670f660f820f840f830f680f660f660f820f810f810f680f660f660f720f720f710f680f670f67090101000841414141404040403001026b092eff00ff000100730ca80c800ca4006c00016c0ca40ca80ca30ca30ca30ca30ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca431010dc00005ffffffff0dc0ffffffff008a320dc000ff15e7009e4833ffffffffff34ffffffffff002f8000096b0dc02715000d000e83002402040100002700080000000000c900000000ffffffffffffffffffffffff1ffe15c8631f18

View File

@@ -0,0 +1 @@
232302fe4c544553543030303030303030303030330102ed1a040d0c2200010203010000000418d914ed27103a020007d0000002010104454e204e204814be2710030006000000b400024a4902bc02000001006701000400ffff0012050006be89500161217106013d0e91012c0e860101470105460700000000000000000008010114ed271000900001900e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e8a0e870e870e880e880e880e880e880e880e880e880e880e870e860e860e880e880e8a0e880e880e880e880e880e880e880e880e880e880e880e880e910e900e900e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e88090101000847474747464746463001004907c6ff00ff00000039000000000004006c00016c00000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040005000500050005000500050005000531010eb00005ffffffff0eb0ffffffff0081320eb01b3b14e613234733ffffffffff34ffffffffff001d800009490eb02710000d000e83002402040100002700080000000000d500000000ffffffffffffffffffffffff1ffe167c021f7b

View File

@@ -0,0 +1 @@
232302fe4c544553543030303030303030303031300102ed1a040d0c220501020301006400049992163027df4f012e27101e00020101015952da51365215f4279f030005000000000002625f02a80200000100a801000400ffff0000050106c3d5cf016240a406010b0f90015b0f4001014a01054907000000000000000000080101163027df00900001900f8c0f8d0f8d0f8d0f8e0f8e0f8f0f8f0f8e0f8f0f900f900f8f0f900f7a0f7c0f800f800f7f0f7c0f7c0f7c0f7e0f7e0f810f7f0f7f0f7e0f820f830f500f500f500f840f850f840f840f820f850f850f820f820f730f750f730f690f6a0f680f620f5f0f600f500f500f500f630f5a0f6d0f6c0f6c0f6d0f500f500f500f6b0f6b0f6c0f6a0f5d0f5f0f5c0f5f0f610f5c0f5e0f5a0f5c0f5c0f610f630f630f640f510f520f540f500f500f500f610f550f500f400f430f410f500f510f500f4e0f4f0f530f500f500f4f0f550f550f560f550f570f560f540f570f560f510f540f530f540f560f5a0f5a0f590f570f7e0f7e0f810f580f4e0f530f570f550f510f500f480f4d0f870f7e0f7e0f7c0f7e0f7f0f7c0f7b0f810f7d0f7e0f7f09010100084a4a4a4a494949493001005f07daff00ff00000038000000000004006c00016c0000000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000500050005000500050005000500053101003c0005ffffffff003cffffffff008a32003c00000000ffff4f33ffffffffff34ffffffffff002c8000095f003c2710000d000e830024020401000027000800000000000000000000ffffffffffffffffffffffff1ffe17ae631ff9

View File

@@ -0,0 +1 @@
232302fe4c544553543030303030303030303032330101d61a040d0c22070101030203010004c1ee17fc261337012e271032000201010157705a4fe26e17f2260c0309d50bba021a00027f6e022b030000010106010105000733a7d401d832920601840eda011c0ebf01044b0101480700000000000000000008010117fc261300a20001a20ecc0eca0ec30ecb0ec90ec70eca0ec90ec20ec20ec40ec20ec70eca0eca0ec90ece0ecd0ec80ecd0eca0ecc0ec90ec80ece0ec70ec50ebf0ed40ed20ed40ed30ed40ed30ed50ed40ed30ed50ed20ed50ed50ed30ecc0ecb0ec80ecf0ec90ed00ec70ecd0ec70ec50ec70ece0eca0eca0ec90eca0ed10ec90ed00ecf0eca0ed10ed30ed10ecf0ed20ed00ed20ed40ed50ed20ed20ed60ed20ed30ed20ed50ed50ed70ed40ed10ed40ed30ed20ed30ed20ecc0ed00ed10ecc0ed30ed10ed00ecb0ec90ecd0ec80ed10ec50ec80ed20ec10ec90ec50ec90ec30ec50ed40ed40ed40ed40ed40ed40ed60ed50ed30ed50ed50ed50ed50ed40ed50ed40ed30ed20ed20ed30ed60ed50eda0ed00ed70ed80ed60ed70ed50ed60ece0ed50ed40ed60ed40ed10ed30ed20ed40ed40ed40ecf0ed80ed60ed50ed60ed70ed60ed60ed40ed50ed70eda0901010020484a4a4b4b4b4b4a484b4b4b4b4b4b4a484a4b4b4b4b4b4a484a4b4b4b4b4b4acc

View File

@@ -0,0 +1 @@
232302fe4c544553543030303030303030303130370101ae1a040d0c39263001016908fc09c4410003007403200320032001b000c9c803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e8cb

View File

@@ -0,0 +1 @@
232302fe4c544553543030303030303030303231310102ed1a040d0c3a120101030200000002533c150727203e01002710000002010104464e204e204814fc2710030004000200be00025353023a01000001010201010402ffff00130500072eb49401de012e06013f0ea7012a0e9301014001033f070000000000000000000801011507272000900001900ea60ea60ea60ea60ea60ea60ea50ea60ea60ea60ea60ea60ea60ea50e960e990e960e980e980e990e970e990e970e960e980e980e990e980e950e970e950e940e950e960e970e940e990e960e960e950e950e930e960e9b0e9c0e9c0e990e9b0e940e950e930e980e9a0e9a0e940e950ea50ea50ea50ea50ea50ea60ea70ea60ea50ea50ea50e9d0e9e0e9e0e9e0e990e970e970e990e980e9a0e9d0e9a0e9c0e9a0e990e9a0e9a0e9b0e9c0e9c0e980e950e980e990e990e990e980e970e970e980e970e970e990e990e9a0e990e990e990e9a0e980e960e960e990e970e970e970e970e980e950e930e9b0e980e9a0e990e9a0e990e940e940e950e940e950e950e990e990e9b0e9a0e9b0e9b0e960e950e960e950e950e950e950e940e99090101000840403f403f3f3f3f300102530708ff00ff00000037000000000000006c00016c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000031010e880005ffffffff0e88ffffffff008a320e880002150e00014733ffffffffff34ffffffffff0044800009530e882710000d000e830024020401000027000800000000005401000000ffffffffffffffffffffffff1ffe1d7b011fdd

View File

@@ -0,0 +1 @@
232303fe4c544553543030303030303030303032320102101a040d0d0b0a010203010000000532ad156d271c45011007d0006502010103514e204e205c003f27100300070000000000025e5c03b601000001014d0100050106c3aa530160540f0601010ee301120ede01014801054707000000000000000000080101156d271c00900001900ee30ee30ee30ee00ee20ee20ee20ee10ee20ee30ee20ee20ee20ee10ee30ee10ee10ede0ee10ee10ee10ee00ee10ee10ee10ee00ee00ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee1090101000848484848474848473001005c0816ffffff0000003a000a0000000100000000003101003c0005ffffffff003cffffffff008b32003c00000000ffff5233ffffffffff34ffffffffff004c8000095c003c2710ffffffff8300250019000e0b002300020000000000000000ffffffffffff0000000affffffff1ffe1fedff0089

View File

@@ -0,0 +1 @@
232303fe4c544553543030303030303030303230390101ae1a040d0c391b3001005307ee09c4410037000100000000000001b000c9c803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e8be

View File

@@ -0,0 +1 @@
232301fe4c5445535430303030303030303030313901001e1a040d0d0b1a000c38393836303332313435323039303735363530390100bd

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>protocol-jsatl12</artifactId>
<name>protocol-jsatl12</name>
<description>
苏标JSATL12主动安全报警附件上传协议。独立 TCP 端口(默认 7612
与 JT808 使用相同的转义规则,但数据帧带 01cd 魔数前缀,用于大文件分块上传。
</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-core</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jt808</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>vehicle-identity</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>sink-archive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,102 @@
package com.lingniu.ingest.protocol.jsatl12.codec;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.util.List;
/**
* 苏标报警附件协议分帧。
*
* <p>两种帧并存:
* <ol>
* <li>标准 JT/T 808 帧:以 {@code 0x7e} 为分界,承载 0x1210 / 0x1211 / 0x9212 等信令
* <li>数据帧:以 {@code 0x30 0x31 0x63 0x64}(即 ASCII "01cd")为魔数,后续 4 字节大端长度 + 数据
* </ol>
*
* <p>本 decoder 合并处理:根据第一个字节决定分帧策略,输出带类型标记的 {@link Jsatl12RawFrame}。
*/
public class Jsatl12FrameDecoder extends ByteToMessageDecoder {
private static final byte[] DATA_MAGIC = {0x30, 0x31, 0x63, 0x64};
private static final int DEFAULT_MAX_FRAME = 64 * 1024;
private final int maxFrameLength;
public Jsatl12FrameDecoder() {
this(DEFAULT_MAX_FRAME);
}
public Jsatl12FrameDecoder(int maxFrameLength) {
this.maxFrameLength = maxFrameLength <= 0 ? DEFAULT_MAX_FRAME : maxFrameLength;
}
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
while (in.readableBytes() >= 1) {
byte first = in.getByte(in.readerIndex());
if (first == 0x7e) {
if (!decodeJtFrame(in, out)) return;
} else if (first == DATA_MAGIC[0]) {
if (!decodeDataFrame(in, out)) return;
} else {
in.skipBytes(1);
}
}
}
private boolean decodeJtFrame(ByteBuf in, List<Object> out) {
if (in.readableBytes() < 2) return false;
int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e);
if (endIdx < 0) {
if (in.readableBytes() > maxFrameLength) {
byte[] malformed = new byte[in.readableBytes()];
in.readBytes(malformed);
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.MALFORMED, malformed));
return true;
}
return false;
}
int len = endIdx - (in.readerIndex() + 1);
if (len <= 0) {
in.skipBytes(1);
return true;
}
byte[] raw = new byte[len];
in.skipBytes(1);
in.readBytes(raw);
in.skipBytes(1);
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.JT_MESSAGE, raw));
return true;
}
private boolean decodeDataFrame(ByteBuf in, List<Object> out) {
if (in.readableBytes() < DATA_MAGIC.length + 4) return false;
for (int i = 0; i < DATA_MAGIC.length; i++) {
if (in.getByte(in.readerIndex() + i) != DATA_MAGIC[i]) {
in.skipBytes(1);
return true;
}
}
if (in.readableBytes() < 62) return false;
// DataPacket 布局4B magic + 50B 文件名 + 4B offset + 4B length + data
long dataLen = in.getUnsignedInt(in.readerIndex() + 58);
if (dataLen > maxFrameLength) {
int malformedLen = in.readableBytes();
long declaredTotal = 62L + dataLen;
if (declaredTotal <= Integer.MAX_VALUE && in.readableBytes() >= declaredTotal) {
malformedLen = (int) declaredTotal;
}
byte[] malformed = new byte[malformedLen];
in.readBytes(malformed);
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.MALFORMED, malformed));
return true;
}
int totalFrameLen = 62 + (int) dataLen;
if (in.readableBytes() < totalFrameLen) return false;
byte[] frame = new byte[totalFrameLen];
in.readBytes(frame);
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.DATA_CHUNK, frame));
return true;
}
}

View File

@@ -0,0 +1,8 @@
package com.lingniu.ingest.protocol.jsatl12.codec;
/**
* JSATL12 原始帧:区分信令帧、数据帧与无法继续解析但需要审计的畸形帧。
*/
public record Jsatl12RawFrame(Type type, byte[] bytes) {
public enum Type { JT_MESSAGE, DATA_CHUNK, MALFORMED }
}

View File

@@ -0,0 +1,58 @@
package com.lingniu.ingest.protocol.jsatl12.config;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.identity.VehicleIdentityResolver;
import com.lingniu.ingest.protocol.jsatl12.handler.Jsatl12ArchiveEventHandler;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12AttachmentTracker;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12JtMessageBridge;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12NettyServer;
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration;
import com.lingniu.ingest.sink.archive.ArchiveStore;
import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@AutoConfiguration(after = {SinkArchiveAutoConfiguration.class, Jt808AutoConfiguration.class})
@EnableConfigurationProperties(Jsatl12Properties.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.jsatl12", name = "enabled", havingValue = "true")
@ConditionalOnBean({ArchiveStore.class, Jt808MessageDecoder.class})
public class Jsatl12AutoConfiguration {
@Bean
@ConditionalOnMissingBean
public Jsatl12AttachmentTracker jsatl12AttachmentTracker() {
return new Jsatl12AttachmentTracker();
}
@Bean
@ConditionalOnMissingBean
public Jsatl12JtMessageBridge jsatl12JtMessageBridge(Jt808MessageDecoder decoder,
VehicleIdentityResolver identityResolver,
Jsatl12AttachmentTracker attachmentTracker,
Dispatcher dispatcher) {
return new Jsatl12JtMessageBridge(decoder, identityResolver, attachmentTracker, dispatcher::dispatch);
}
@Bean
@ConditionalOnMissingBean
public Jsatl12ArchiveEventHandler jsatl12ArchiveEventHandler() {
return new Jsatl12ArchiveEventHandler();
}
@Bean
@ConditionalOnMissingBean
public Jsatl12NettyServer jsatl12NettyServer(Jsatl12Properties props,
ArchiveStore archive,
Jsatl12JtMessageBridge jtMessageBridge,
Jsatl12AttachmentTracker attachmentTracker,
VehicleIdentityResolver identityResolver,
Dispatcher dispatcher) {
return new Jsatl12NettyServer(props.getPort(), props.getWorkerThreads(),
props.getMaxFrameLength(), archive, jtMessageBridge, attachmentTracker, identityResolver, dispatcher::dispatch);
}
}

View File

@@ -0,0 +1,25 @@
package com.lingniu.ingest.protocol.jsatl12.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** JSATL12 主动安全附件接入配置。 */
@ConfigurationProperties(prefix = "lingniu.ingest.jsatl12")
public class Jsatl12Properties {
private boolean enabled = false;
private int port = 7612;
private int workerThreads = 0;
private int maxFrameLength = 64 * 1024;
/** 附件落盘根目录,支持 file:// / s3:// / oss:// 形式,由 sink-archive 解析。 */
private String fileStore = "file:///var/lingniu/alarm-files";
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
public int getWorkerThreads() { return workerThreads; }
public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; }
public int getMaxFrameLength() { return maxFrameLength; }
public void setMaxFrameLength(int maxFrameLength) { this.maxFrameLength = maxFrameLength; }
public String getFileStore() { return fileStore; }
public void setFileStore(String fileStore) { this.fileStore = fileStore; }
}

View File

@@ -0,0 +1,137 @@
package com.lingniu.ingest.protocol.jsatl12.handler;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.annotation.EventEmit;
import com.lingniu.ingest.api.annotation.MessageMapping;
import com.lingniu.ingest.api.annotation.ProtocolHandler;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchiveFailure;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchivedChunk;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedFrame;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedJtMessage;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MessageId;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@ProtocolHandler(protocol = ProtocolId.JSATL12)
public final class Jsatl12ArchiveEventHandler {
@MessageMapping(command = Jsatl12MessageId.DATA_CHUNK_ARCHIVED, desc = "主动安全附件数据块已归档")
@EventEmit(VehicleEvent.MediaMeta.class)
public List<VehicleEvent> onDataChunkArchived(Jsatl12ArchivedChunk chunk) {
Instant eventTime = chunk.archivedAt() == null ? Instant.now() : chunk.archivedAt();
String peer = chunk.peer() == null || chunk.peer().isBlank() ? "unknown" : chunk.peer();
return List.of(new VehicleEvent.MediaMeta(
UUID.randomUUID().toString(),
nullToUnknown(chunk.vin()),
ProtocolId.JSATL12,
eventTime,
Instant.now(),
null,
baseMeta(
"vin", nullToUnknown(chunk.vin()),
"phone", nullToEmpty(chunk.phone()),
"identityResolved", Boolean.toString(chunk.identityResolved()),
"identitySource", nullToEmpty(chunk.identitySource()),
"peer", peer,
"archiveKey", nullToEmpty(chunk.archiveKey()),
"archiveRef", nullToEmpty(chunk.archiveRef()),
"fileName", nullToEmpty(chunk.fileName()),
"fileOffset", Long.toString(Math.max(0, chunk.fileOffset())),
"declaredChunkSizeBytes", Long.toString(Math.max(0, chunk.declaredSizeBytes())),
"chunkSizeBytes", Long.toString(Math.max(0, chunk.sizeBytes()))),
"jsatl12-" + peer + "-" + eventTime.toEpochMilli(),
"jsatl12-data-chunk",
Math.max(0, chunk.sizeBytes()),
nullToEmpty(chunk.archiveRef())));
}
@MessageMapping(command = Jsatl12MessageId.MALFORMED_JT_MESSAGE, desc = "主动安全 JT 消息解析失败兜底")
@EventEmit(VehicleEvent.Passthrough.class)
public List<VehicleEvent> onMalformedJtMessage(Jsatl12MalformedJtMessage malformed) {
Instant eventTime = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt();
String peer = malformed.peer() == null || malformed.peer().isBlank() ? "unknown" : malformed.peer();
return List.of(new VehicleEvent.Passthrough(
UUID.randomUUID().toString(),
"unknown",
ProtocolId.JSATL12,
eventTime,
Instant.now(),
null,
baseMeta(
"peer", peer,
"source", "jsatl12-jt-message",
"parseError", "true",
"parseErrorMessage", nullToEmpty(malformed.errorMessage())),
Jsatl12MessageId.MALFORMED_JT_MESSAGE,
malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes()));
}
@MessageMapping(command = Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED, desc = "主动安全附件数据块归档失败兜底")
@EventEmit(VehicleEvent.Passthrough.class)
public List<VehicleEvent> onArchiveFailure(Jsatl12ArchiveFailure failure) {
Instant eventTime = failure.failedAt() == null ? Instant.now() : failure.failedAt();
String peer = failure.peer() == null || failure.peer().isBlank() ? "unknown" : failure.peer();
return List.of(new VehicleEvent.Passthrough(
UUID.randomUUID().toString(),
"unknown",
ProtocolId.JSATL12,
eventTime,
Instant.now(),
null,
baseMeta(
"peer", peer,
"source", "jsatl12-data-chunk",
"archiveError", "true",
"archiveErrorMessage", nullToEmpty(failure.errorMessage()),
"archiveKey", nullToEmpty(failure.archiveKey()),
"chunkSizeBytes", Long.toString(Math.max(0, failure.sizeBytes()))),
Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED,
failure.rawBytes() == null ? new byte[0] : failure.rawBytes()));
}
@MessageMapping(command = Jsatl12MessageId.MALFORMED_FRAME, desc = "主动安全附件帧边界异常兜底")
@EventEmit(VehicleEvent.Passthrough.class)
public List<VehicleEvent> onMalformedFrame(Jsatl12MalformedFrame malformed) {
Instant eventTime = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt();
String peer = malformed.peer() == null || malformed.peer().isBlank() ? "unknown" : malformed.peer();
return List.of(new VehicleEvent.Passthrough(
UUID.randomUUID().toString(),
"unknown",
ProtocolId.JSATL12,
eventTime,
Instant.now(),
null,
baseMeta(
"peer", peer,
"source", "jsatl12-frame",
"frameError", "true",
"frameErrorMessage", nullToEmpty(malformed.errorMessage()),
"parseError", "true",
"parseErrorMessage", nullToEmpty(malformed.errorMessage())),
Jsatl12MessageId.MALFORMED_FRAME,
malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes()));
}
private static Map<String, String> baseMeta(String... kv) {
java.util.HashMap<String, String> out = new java.util.HashMap<>();
out.put("vin", "unknown");
out.put("identityResolved", "false");
out.put("identitySource", "UNKNOWN");
for (int i = 0; i + 1 < kv.length; i += 2) {
out.put(kv[i], kv[i + 1] == null ? "" : kv[i + 1]);
}
return Map.copyOf(out);
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
private static String nullToUnknown(String value) {
return value == null || value.isBlank() ? "unknown" : value;
}
}

View File

@@ -0,0 +1,12 @@
package com.lingniu.ingest.protocol.jsatl12.inbound;
import java.time.Instant;
public record Jsatl12ArchiveFailure(
byte[] rawBytes,
String archiveKey,
long sizeBytes,
String peer,
String errorMessage,
Instant failedAt
) {}

View File

@@ -0,0 +1,45 @@
package com.lingniu.ingest.protocol.jsatl12.inbound;
import java.time.Instant;
public record Jsatl12ArchivedChunk(
String archiveRef,
String archiveKey,
String fileName,
long fileOffset,
long declaredSizeBytes,
long sizeBytes,
String peer,
Instant archivedAt,
String phone,
String vin,
boolean identityResolved,
String identitySource
) {
public Jsatl12ArchivedChunk {
phone = phone == null ? "" : phone.trim();
vin = vin == null || vin.isBlank() ? "unknown" : vin.trim();
identitySource = identitySource == null || identitySource.isBlank() ? "UNKNOWN" : identitySource.trim();
}
public Jsatl12ArchivedChunk(String archiveRef,
String archiveKey,
String fileName,
long fileOffset,
long declaredSizeBytes,
long sizeBytes,
String peer,
Instant archivedAt) {
this(archiveRef, archiveKey, fileName, fileOffset, declaredSizeBytes, sizeBytes, peer, archivedAt,
"", "unknown", false, "UNKNOWN");
}
public Jsatl12ArchivedChunk(String archiveRef,
String archiveKey,
long sizeBytes,
String peer,
Instant archivedAt) {
this(archiveRef, archiveKey, "", 0, sizeBytes, sizeBytes, peer, archivedAt,
"", "unknown", false, "UNKNOWN");
}
}

View File

@@ -0,0 +1,165 @@
package com.lingniu.ingest.protocol.jsatl12.inbound;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class Jsatl12AttachmentTracker {
private final Map<String, Map<String, FileState>> filesByPhone = new HashMap<>();
public synchronized void registerAttachmentList(String phone, byte[] body) {
String key = normalizePhone(phone);
byte[] raw = body == null ? new byte[0] : body;
if (raw.length < 57) {
throw new IllegalArgumentException("jsatl12 T1210 body too short: " + raw.length);
}
int total = raw[56] & 0xFF;
int pos = 57;
Map<String, FileState> files = filesByPhone.computeIfAbsent(key, ignored -> new HashMap<>());
for (int i = 0; i < total && pos < raw.length; i++) {
int nameLen = raw[pos++] & 0xFF;
if (pos + nameLen + 4 > raw.length) {
throw new IllegalArgumentException("jsatl12 T1210 item truncated at index " + i);
}
String name = new String(raw, pos, nameLen, StandardCharsets.UTF_8).trim();
pos += nameLen;
long size = Jsatl12DataPacket.readDword(raw, pos);
pos += 4;
files.put(name, new FileState(name, 0, size));
}
}
public synchronized Jsatl12DataPacket recordDataPacket(String phone, byte[] rawPacket) {
Jsatl12DataPacket packet = Jsatl12DataPacket.parse(rawPacket);
String key = normalizePhone(phone);
Map<String, FileState> files = filesByPhone.computeIfAbsent(key, ignored -> new HashMap<>());
FileState state = files.computeIfAbsent(packet.name(),
ignored -> new FileState(packet.name(), 0, packet.offset() + packet.length()));
state.ranges.add(new Range(packet.offset(), packet.offset() + packet.length()));
return packet;
}
public synchronized Jsatl12UploadCompletionAck completeUpload(String phone, byte[] body) {
T1212 completion = parseT1212(body);
String key = normalizePhone(phone);
Map<String, FileState> files = filesByPhone.computeIfAbsent(key, ignored -> new HashMap<>());
FileState state = files.computeIfAbsent(completion.name(),
ignored -> new FileState(completion.name(), completion.type(), completion.size()));
mergeUnknownFileInto(key, state);
state.type = completion.type();
state.size = completion.size();
List<Jsatl12UploadCompletionAck.Range> missing = missingRanges(state);
if (missing.isEmpty()) {
files.remove(completion.name());
if (files.isEmpty()) {
filesByPhone.remove(key);
}
return new Jsatl12UploadCompletionAck(completion.name(), completion.type(), 0, List.of());
}
return new Jsatl12UploadCompletionAck(completion.name(), completion.type(), 1, missing);
}
private void mergeUnknownFileInto(String ownerKey, FileState target) {
if ("unknown".equals(ownerKey)) {
return;
}
Map<String, FileState> unknownFiles = filesByPhone.get("unknown");
if (unknownFiles == null) {
return;
}
FileState unknown = unknownFiles.remove(target.name);
if (unknown == null) {
return;
}
target.ranges.addAll(unknown.ranges);
if (unknownFiles.isEmpty()) {
filesByPhone.remove("unknown");
}
}
public synchronized Set<String> knownFiles(String phone) {
Map<String, FileState> files = filesByPhone.get(normalizePhone(phone));
if (files == null) {
return Set.of();
}
return Set.copyOf(new HashSet<>(files.keySet()));
}
public synchronized String ownerPhone(String fileName) {
String name = fileName == null ? "" : fileName.trim();
if (name.isBlank()) {
return "";
}
for (Map.Entry<String, Map<String, FileState>> entry : filesByPhone.entrySet()) {
if (!"unknown".equals(entry.getKey()) && entry.getValue().containsKey(name)) {
return entry.getKey();
}
}
return "";
}
private static List<Jsatl12UploadCompletionAck.Range> missingRanges(FileState state) {
List<Range> ranges = new ArrayList<>(state.ranges);
ranges.sort((a, b) -> Long.compare(a.start, b.start));
List<Jsatl12UploadCompletionAck.Range> missing = new ArrayList<>();
long cursor = 0;
for (Range range : ranges) {
if (range.end <= cursor) {
continue;
}
if (range.start > cursor) {
missing.add(new Jsatl12UploadCompletionAck.Range(cursor, range.start - cursor));
}
cursor = Math.max(cursor, range.end);
if (cursor >= state.size) {
break;
}
}
if (cursor < state.size) {
missing.add(new Jsatl12UploadCompletionAck.Range(cursor, state.size - cursor));
}
return List.copyOf(missing);
}
private static T1212 parseT1212(byte[] body) {
byte[] raw = body == null ? new byte[0] : body;
if (raw.length < 6) {
throw new IllegalArgumentException("jsatl12 T1212 body too short: " + raw.length);
}
int nameLen = raw[0] & 0xFF;
if (raw.length < 1 + nameLen + 1 + 4) {
throw new IllegalArgumentException("jsatl12 T1212 body truncated");
}
String name = new String(raw, 1, nameLen, StandardCharsets.UTF_8).trim();
int type = raw[1 + nameLen] & 0xFF;
long size = Jsatl12DataPacket.readDword(raw, 2 + nameLen);
return new T1212(name, type, size);
}
private static String normalizePhone(String phone) {
return phone == null || phone.isBlank() ? "unknown" : phone;
}
private static final class FileState {
private final String name;
private final List<Range> ranges = new ArrayList<>();
private int type;
private long size;
private FileState(String name, int type, long size) {
this.name = name;
this.type = type;
this.size = Math.max(0, size);
}
}
private record Range(long start, long end) {}
private record T1212(String name, int type, long size) {}
}

Some files were not shown because too many files have changed in this diff Show More