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