feat: make gb32960 archive history query production ready
This commit is contained in:
59
modules/protocols/protocol-gb32960/pom.xml
Normal file
59
modules/protocols/protocol-gb32960/pom.xml
Normal 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>
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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> </td></tr>
|
||||
* <tr><td>0x07 报警</td><td>0x07 动力蓄电池最小并联单元电压</td><td> </td></tr>
|
||||
* <tr><td>0x08 储能电压</td><td>0x08 动力蓄电池温度</td><td> </td></tr>
|
||||
* <tr><td>0x09 储能温度</td><td>—</td><td> </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()=0,position() = bodyStart+6,limit() = 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();
|
||||
}
|
||||
}
|
||||
@@ -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.7),paramValue 长度根据 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 SS,GMT+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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
// VIN(17 字节,由调用方给出;平台登入等场景直接回显请求的原始字节)
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 时间定义(表 5):YY 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) {}
|
||||
}
|
||||
@@ -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 字节 VIN(trim 后),平台登入 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ------------ 业务标度辅助 ------------
|
||||
|
||||
/** 读 u16,scale 后返回(例如 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 偏移变为 -32000;torque 由 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) - 5,0xFFFE/0xFFFF 异常/无效。 */
|
||||
private static Integer readPowerOffsetMinus5(ByteBuffer buf) {
|
||||
int raw = buf.getShort() & 0xFFFF;
|
||||
if (raw == 0xFFFE || raw == 0xFFFF) return null;
|
||||
return raw - 5;
|
||||
}
|
||||
}
|
||||
@@ -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 表 17:DC/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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 名取 registry;null 或未注册的名字返回默认 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 版信息体 Parser(com...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 版信息体 Parser(com...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());
|
||||
}
|
||||
}
|
||||
@@ -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>车辆登入 0x01:VIN 不在白名单时返回 {@code 0x04 VIN_NOT_EXIST} 应答,关闭连接
|
||||
* <li>实时上报 0x02 / 补发 0x03 / 登出 0x04 / 心跳 0x07:VIN 不在白名单时丢弃,关闭连接
|
||||
* </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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 协议 Handler:Dispatcher 路由到此的所有 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 字节 VIN(frame[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 表示鉴权通过应继续 dispatch;false 表示调用方应 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 profile(例:peer 是广东燃料电池但 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";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=N,writerIdle=0,allIdle=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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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~20,2025 版新增)
|
||||
* @param batteryPackCounts 每个电池管理系统对应的动力蓄电池包个数
|
||||
* @param batteryCodes 动力蓄电池包编码列表(GB/T 34014,24 字节/个)
|
||||
*/
|
||||
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.8:0x01-0x10,2025 版新增 0x0D-0x10)
|
||||
*/
|
||||
record QueryParams(Instant queryTime, List<Integer> paramIds) implements CommandBody {}
|
||||
|
||||
/**
|
||||
* 参数查询返回 0x80 应答(表 B.6/B.7/B.8)。车载终端响应平台。
|
||||
*
|
||||
* @param respondTime 返回查询参数时间
|
||||
* @param params 参数项键值对(key = paramId,value = 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 车载终端制造商 ID(4 字节英文大写字母或数字)
|
||||
* @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 {}
|
||||
|
||||
/** 未解析的自定义 payload(0x80-0xFE 用户自定义等)。 */
|
||||
record RawPayload(byte[] bytes) implements ControlPayload {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 未解析或厂商自定义的命令体(0xC0~0xFE 等)。
|
||||
* 保留原始字节以便下游冷存分析。
|
||||
*/
|
||||
record RawCommand(int commandCode, byte[] bytes) implements CommandBody {}
|
||||
}
|
||||
@@ -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:平台登入(上行)—— 见表 29;2025 版附录 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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
/** 0x02:RSA 算法加密。 */
|
||||
RSA(0x02),
|
||||
/** 0x03:AES(高级加密标准)算法加密。 */
|
||||
AES(0x03),
|
||||
/** 0x04:SM2 算法加密(2025 版新增)。 */
|
||||
SM2(0x04),
|
||||
/** 0x05:SM4 算法加密(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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
/** bit4:SOC 低报警 */
|
||||
SOC_LOW(4),
|
||||
/** bit5:最小并联单元过压报警 */
|
||||
MIN_PARALLEL_UNIT_OVER_VOLTAGE(5),
|
||||
/** bit6:最小并联单元欠压报警 */
|
||||
MIN_PARALLEL_UNIT_UNDER_VOLTAGE(6),
|
||||
/** bit7:SOC 过高报警 */
|
||||
SOC_HIGH(7),
|
||||
/** bit8:SOC 跳变报警 */
|
||||
SOC_JUMP(8),
|
||||
/** bit9:可充电储能系统不匹配报警 */
|
||||
STORAGE_MISMATCH(9),
|
||||
/** bit10:最小并联单元一致性差报警 */
|
||||
MIN_PARALLEL_UNIT_INCONSISTENT(10),
|
||||
/** bit11:绝缘电阻失效报警 */
|
||||
INSULATION_FAILURE(11),
|
||||
/** bit12:DC-DC 温度报警 */
|
||||
DCDC_TEMP(12),
|
||||
/** bit13:制动系统报警 */
|
||||
BRAKE_SYSTEM(13),
|
||||
/** bit14:DC-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;
|
||||
}
|
||||
}
|
||||
@@ -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 偏移 20000,torque 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 偏移 32000,torque 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 {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
/** 0x03:VIN 重复。 */
|
||||
VIN_DUPLICATED(0x03),
|
||||
/** 0x04:VIN 不存在(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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
/** 0x01:SM2 国密签名。 */
|
||||
SM2(0x01),
|
||||
/** 0x02:RSA 签名。 */
|
||||
RSA(0x02),
|
||||
/** 0x03:ECC 签名。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.protocol.gb32960.config.Gb32960AutoConfiguration
|
||||
@@ -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=9,parse 时主动抛异常。 */
|
||||
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 parser(fixedLength=-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 = 10B(parser 会爆)
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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/200):peer 在 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 验证新增的变长信息体 Parser(0x02 驱动电机 / 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));
|
||||
}
|
||||
}
|
||||
@@ -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 报警数据 10B:level=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.Stack:1 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"));
|
||||
|
||||
// selector:lingniu 账号 → 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;
|
||||
}
|
||||
@@ -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 parser(0x32 / 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));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
// 构造 body:0x83 TLV(length=4) + 0x01 Vehicle(20B 标准布局)
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
// 0x83 TLV:3+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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 宇通 YT01,SOC 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、经纬度十进制度)
|
||||
@@ -0,0 +1 @@
|
||||
232307fe4c54455354303030303030303030303131010000a2
|
||||
@@ -0,0 +1 @@
|
||||
232307fe4c54455354303030303030303030303231010000a1
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303030310102101a040d0c213901010302037a000531c2157825fb44012e27103400020101025575304eca6b157a288b030c82050a00aa0002686c02940200000100630101050006c4f414015eedea06018c0f0701310ebf01014a01054907000000000000000000080101157825fb00900001900eea0eea0eea0ee60ee60ee60ef00eef0ef10eeb0eec0eeb0eed0eee0eee0eeb0eed0eea0ed30ed20ed20ed30ed50ed40ecc0ecd0ecc0ece0ece0ece0ed40ed40ed40ed30ed10ed00ed20ed10ed00ece0ece0ecf0ed40ed40ed40eda0eda0edb0ebf0ed40ed30ed60ed60ed50ed40ed50ede0edf0ede0ede0eee0eed0eec0ee80ee80ee50ef00eef0eef0ee80ee90eea0ee40ee70ee80eeb0eeb0eea0eee0eef0eef0ef60ef60ef50ef80ef70ef50ef60ef50ef60eef0ef00ef10ef60ef80ef90efa0ef70ef90efa0ef80ef80efc0efc0efc0ef50ef50ef50ef20ef30ef40ef50ef50ef40ef50ef50ef70ef40ef40ef50ef80ef80ef70ef70ef50ef40ef70ef70ef70efa0efd0efc0ef70ef80ef70ef80ef60ef50f050f070f070ef00eed0ef009010100084a4a4a4a494a4a493001026c08fcffffff0003003a02ee02e402e5000000000031010c800006ffffffff0c80ffffffff0088320c8004c2159002c35333ffffffffff34ffffffffff001c8000096c0c802742ffffffff8300250019000e0b002300020000000000014f00ffffffffffff00000282ffffffff1ffe1fedff1fa0
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303030320102ed1a040d0c213a01010302013600036758162826284c012e2710010002010101485bf84de44b165526ec030da9017400d200026a6b023a0200000100b101010402ffff0015050107373d6c01d2e6880601020f8701520efe010141010540070000000000000000000801011628262800900001900f860f870f860f6d0f6c0f6c0f870f860f870f6d0f700f6e0f7e0f7d0f710f640f630f650f6e0f800f7e0f260f250f250f810f810f820f620f660f670f810f820f830f670f680f670f780f770f770f670f660f670f640f630f620f1a0f1c0f1c0f500f500f4d0f690f6b0f690f540f530f5a0f420f440f440f6e0f6e0f6d0f460f450f450f720f6c0f6b0f650f640f670f500f4f0f4c0f150f140f150f780f760f780efe0eff0eff0f7b0f7c0f7d0f640f640f640f6e0f6d0f6e0f360f390f390f5c0f5e0f5c0f650f660f660f7e0f800f800f640f620f640f810f800f800f300f330f2f0f800f820f830f670f660f680f820f840f820f650f670f660f820f840f830f680f660f660f820f810f810f680f660f660f720f720f710f680f670f67090101000841414141404040403001026b092eff00ff000100730ca80c800ca4006c00016c0ca40ca80ca30ca30ca30ca30ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca431010dc00005ffffffff0dc0ffffffff008a320dc000ff15e7009e4833ffffffffff34ffffffffff002f8000096b0dc02715000d000e83002402040100002700080000000000c900000000ffffffffffffffffffffffff1ffe15c8631f18
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303030330102ed1a040d0c2200010203010000000418d914ed27103a020007d0000002010104454e204e204814be2710030006000000b400024a4902bc02000001006701000400ffff0012050006be89500161217106013d0e91012c0e860101470105460700000000000000000008010114ed271000900001900e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e8a0e870e870e880e880e880e880e880e880e880e880e880e870e860e860e880e880e8a0e880e880e880e880e880e880e880e880e880e880e880e880e910e900e900e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e88090101000847474747464746463001004907c6ff00ff00000039000000000004006c00016c00000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040005000500050005000500050005000531010eb00005ffffffff0eb0ffffffff0081320eb01b3b14e613234733ffffffffff34ffffffffff001d800009490eb02710000d000e83002402040100002700080000000000d500000000ffffffffffffffffffffffff1ffe167c021f7b
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303031300102ed1a040d0c220501020301006400049992163027df4f012e27101e00020101015952da51365215f4279f030005000000000002625f02a80200000100a801000400ffff0000050106c3d5cf016240a406010b0f90015b0f4001014a01054907000000000000000000080101163027df00900001900f8c0f8d0f8d0f8d0f8e0f8e0f8f0f8f0f8e0f8f0f900f900f8f0f900f7a0f7c0f800f800f7f0f7c0f7c0f7c0f7e0f7e0f810f7f0f7f0f7e0f820f830f500f500f500f840f850f840f840f820f850f850f820f820f730f750f730f690f6a0f680f620f5f0f600f500f500f500f630f5a0f6d0f6c0f6c0f6d0f500f500f500f6b0f6b0f6c0f6a0f5d0f5f0f5c0f5f0f610f5c0f5e0f5a0f5c0f5c0f610f630f630f640f510f520f540f500f500f500f610f550f500f400f430f410f500f510f500f4e0f4f0f530f500f500f4f0f550f550f560f550f570f560f540f570f560f510f540f530f540f560f5a0f5a0f590f570f7e0f7e0f810f580f4e0f530f570f550f510f500f480f4d0f870f7e0f7e0f7c0f7e0f7f0f7c0f7b0f810f7d0f7e0f7f09010100084a4a4a4a494949493001005f07daff00ff00000038000000000004006c00016c0000000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000500050005000500050005000500053101003c0005ffffffff003cffffffff008a32003c00000000ffff4f33ffffffffff34ffffffffff002c8000095f003c2710000d000e830024020401000027000800000000000000000000ffffffffffffffffffffffff1ffe17ae631ff9
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303032330101d61a040d0c22070101030203010004c1ee17fc261337012e271032000201010157705a4fe26e17f2260c0309d50bba021a00027f6e022b030000010106010105000733a7d401d832920601840eda011c0ebf01044b0101480700000000000000000008010117fc261300a20001a20ecc0eca0ec30ecb0ec90ec70eca0ec90ec20ec20ec40ec20ec70eca0eca0ec90ece0ecd0ec80ecd0eca0ecc0ec90ec80ece0ec70ec50ebf0ed40ed20ed40ed30ed40ed30ed50ed40ed30ed50ed20ed50ed50ed30ecc0ecb0ec80ecf0ec90ed00ec70ecd0ec70ec50ec70ece0eca0eca0ec90eca0ed10ec90ed00ecf0eca0ed10ed30ed10ecf0ed20ed00ed20ed40ed50ed20ed20ed60ed20ed30ed20ed50ed50ed70ed40ed10ed40ed30ed20ed30ed20ecc0ed00ed10ecc0ed30ed10ed00ecb0ec90ecd0ec80ed10ec50ec80ed20ec10ec90ec50ec90ec30ec50ed40ed40ed40ed40ed40ed40ed60ed50ed30ed50ed50ed50ed50ed40ed50ed40ed30ed20ed20ed30ed60ed50eda0ed00ed70ed80ed60ed70ed50ed60ece0ed50ed40ed60ed40ed10ed30ed20ed40ed40ed40ecf0ed80ed60ed50ed60ed70ed60ed60ed40ed50ed70eda0901010020484a4a4b4b4b4b4a484b4b4b4b4b4b4a484a4b4b4b4b4b4a484a4b4b4b4b4b4acc
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303130370101ae1a040d0c39263001016908fc09c4410003007403200320032001b000c9c803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e8cb
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303231310102ed1a040d0c3a120101030200000002533c150727203e01002710000002010104464e204e204814fc2710030004000200be00025353023a01000001010201010402ffff00130500072eb49401de012e06013f0ea7012a0e9301014001033f070000000000000000000801011507272000900001900ea60ea60ea60ea60ea60ea60ea50ea60ea60ea60ea60ea60ea60ea50e960e990e960e980e980e990e970e990e970e960e980e980e990e980e950e970e950e940e950e960e970e940e990e960e960e950e950e930e960e9b0e9c0e9c0e990e9b0e940e950e930e980e9a0e9a0e940e950ea50ea50ea50ea50ea50ea60ea70ea60ea50ea50ea50e9d0e9e0e9e0e9e0e990e970e970e990e980e9a0e9d0e9a0e9c0e9a0e990e9a0e9a0e9b0e9c0e9c0e980e950e980e990e990e990e980e970e970e980e970e970e990e990e9a0e990e990e990e9a0e980e960e960e990e970e970e970e970e980e950e930e9b0e980e9a0e990e9a0e990e940e940e950e940e950e950e990e990e9b0e9a0e9b0e9b0e960e950e960e950e950e950e950e940e99090101000840403f403f3f3f3f300102530708ff00ff00000037000000000000006c00016c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000031010e880005ffffffff0e88ffffffff008a320e880002150e00014733ffffffffff34ffffffffff0044800009530e882710000d000e830024020401000027000800000000005401000000ffffffffffffffffffffffff1ffe1d7b011fdd
|
||||
@@ -0,0 +1 @@
|
||||
232303fe4c544553543030303030303030303032320102101a040d0d0b0a010203010000000532ad156d271c45011007d0006502010103514e204e205c003f27100300070000000000025e5c03b601000001014d0100050106c3aa530160540f0601010ee301120ede01014801054707000000000000000000080101156d271c00900001900ee30ee30ee30ee00ee20ee20ee20ee10ee20ee30ee20ee20ee20ee10ee30ee10ee10ede0ee10ee10ee10ee00ee10ee10ee10ee00ee00ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee1090101000848484848474848473001005c0816ffffff0000003a000a0000000100000000003101003c0005ffffffff003cffffffff008b32003c00000000ffff5233ffffffffff34ffffffffff004c8000095c003c2710ffffffff8300250019000e0b002300020000000000000000ffffffffffff0000000affffffff1ffe1fedff0089
|
||||
@@ -0,0 +1 @@
|
||||
232303fe4c544553543030303030303030303230390101ae1a040d0c391b3001005307ee09c4410037000100000000000001b000c9c803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e8be
|
||||
@@ -0,0 +1 @@
|
||||
232301fe4c5445535430303030303030303030313901001e1a040d0d0b1a000c38393836303332313435323039303735363530390100bd
|
||||
Reference in New Issue
Block a user