docs: add detailed 32960 pipeline comments
This commit is contained in:
@@ -20,6 +20,9 @@ import java.util.Set;
|
||||
* <li>平台登入有 12B 用户名 + 20B 密码 + 可选 IP 白名单
|
||||
* </ul>
|
||||
*
|
||||
* <p>平台账号不仅用于 0x05 鉴权,也会绑定到 channel attribute,后续 0x02/0x03 上报会用它选择
|
||||
* vendor profile。账号配错时,可能表现为 0x30+ 厂商字段全部落 Raw。
|
||||
*
|
||||
* <p>匹配规则:
|
||||
* <ol>
|
||||
* <li>配置列表为空 → 返回 {@link Result#ALLOW_NO_POLICY}(兼容老行为,放行但标记未鉴权)
|
||||
|
||||
@@ -13,6 +13,9 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
*
|
||||
* <p>线程安全:内部持有 {@code AtomicReference<Set>},支持运行时热替换白名单
|
||||
* (未来可扩展为从 Nacos / 数据库定时刷新)。
|
||||
*
|
||||
* <p>该校验只决定是否允许设备继续进入接收链路,不负责车辆主数据注册。生产环境若打开白名单,
|
||||
* 需要确保新增车辆先同步到这里,否则终端会收到 VIN_NOT_EXIST 应答并断开。
|
||||
*/
|
||||
public final class Gb32960VinAuthorizer {
|
||||
|
||||
@@ -38,6 +41,7 @@ public final class Gb32960VinAuthorizer {
|
||||
public boolean isAllowed(String vin) {
|
||||
if (!enabled) return true;
|
||||
if (vin == null || vin.isBlank()) return false;
|
||||
// 默认大小写不敏感,以容忍部分平台把 VIN 小写转发;规范 VIN 本身仍应为大写。
|
||||
String key = caseSensitive ? vin.trim() : vin.trim().toUpperCase();
|
||||
return whitelist.get().contains(key);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ public final class Gb32960BodyParser {
|
||||
/** 主入口:根据上下文按 selector 选 vendor profile,然后照旧解析循环。 */
|
||||
public Gb32960MessageDecoder.BodyParseResult parse(Gb32960ParserContext ctx, ByteBuffer body) {
|
||||
ProtocolVersion version = ctx.version();
|
||||
// selector 只决定“标准 parser 集合上是否叠加某个 vendor 扩展集合”;标准 typeCode 始终可解析。
|
||||
InfoBlockParserRegistry registry = profileRegistry.forProfile(selector.select(ctx));
|
||||
List<InfoBlock> blocks = new ArrayList<>(8);
|
||||
byte[] signature = null;
|
||||
@@ -121,6 +122,7 @@ public final class Gb32960BodyParser {
|
||||
if (parser == null) {
|
||||
// lenient:未知类型无法安全跳过,吞剩余字节作为 Raw 后终止。
|
||||
// 真实 typeCode 透传到 Raw 字段,便于在日志/JSON 里直接看到漂移落点。
|
||||
// 如果该未知块实际是 TLV,应优先新增专用 TLV parser,让主循环可以继续解析后续块。
|
||||
int remaining = body.remaining();
|
||||
byte[] rest = new byte[remaining];
|
||||
body.get(rest);
|
||||
@@ -149,6 +151,7 @@ public final class Gb32960BodyParser {
|
||||
"info block 0x" + Integer.toHexString(typeCode)
|
||||
+ " needs " + fixedLen + " bytes but got " + body.remaining());
|
||||
}
|
||||
// 固定长度块尾部缺字节时不猜测字段;保留剩余 bytes,方便后续用 RAW 回放复盘。
|
||||
int remaining = body.remaining();
|
||||
byte[] tail = new byte[remaining];
|
||||
body.get(tail);
|
||||
|
||||
@@ -83,6 +83,8 @@ public final class Gb32960CommandParser {
|
||||
Instant time = readTime(buf);
|
||||
int serial = buf.getShort() & 0xFFFF;
|
||||
String user = readAscii(buf, 12);
|
||||
// 标准密码字段为 20B,但现场存在扩展到 32B 的平台登入包;这里按剩余长度-1 读取,
|
||||
// 最后一字节仍保留给加密规则,兼容两种形态。
|
||||
int passwordLength = Math.max(0, buf.remaining() - 1);
|
||||
String pwd = readAscii(buf, passwordLength);
|
||||
EncryptType enc = EncryptType.of(buf.get() & 0xFF);
|
||||
|
||||
@@ -76,6 +76,8 @@ public class Gb32960FrameDecoder extends ByteToMessageDecoder {
|
||||
if (command != 0x05 || dataLen != 41) {
|
||||
return standardLen;
|
||||
}
|
||||
// 现场平台登入包存在"声明 41B、实际 53B"的兼容形态;只有扩展长度 BCC 成立时才按 53B 分帧,
|
||||
// 否则回退标准长度,避免误吞下一条帧。
|
||||
int extendedLen = HEADER_LEN + 53 + 1;
|
||||
if (in.readableBytes() < extendedLen) {
|
||||
return standardLen;
|
||||
|
||||
@@ -17,6 +17,9 @@ import java.time.ZoneId;
|
||||
*
|
||||
* <p>应答规则(§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、
|
||||
* 重新计算校验位。
|
||||
*
|
||||
* <p>该类只做协议编码,不写 Channel。是否立即关闭连接由 {@code Gb32960AckService}
|
||||
* 根据鉴权结果决定。
|
||||
*/
|
||||
public final class Gb32960FrameEncoder {
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ public final class Gb32960MessageDecoder implements FrameDecoder<Gb32960Message>
|
||||
}
|
||||
|
||||
private static boolean isExtendedPlatformLogin(int commandCode, int declaredDataLength, int actualDataLength) {
|
||||
// 与 FrameDecoder 的兼容规则保持一致:部分平台登录帧声明长度 41B,但真实账号/密码体为 53B。
|
||||
return commandCode == 0x05 && declaredDataLength == 41 && actualDataLength == 53;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
* @param version 协议版本(来自帧起始符)
|
||||
* @param vin 17 字节 VIN(trim 后),平台登入 0x05 等帧 VIN 全 0 时为空串
|
||||
* @param platformAccount 平台登入用户名;非平台对接场景为 null
|
||||
*
|
||||
* <p>广东燃料电池扩展等厂商字段通常不是靠 VIN 就能可靠判断,上游平台账号是更稳定的路由线索。
|
||||
*/
|
||||
public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) {
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ public final class InfoBlockParserRegistry {
|
||||
|
||||
public InfoBlockParserRegistry(List<InfoBlockParser> list) {
|
||||
for (InfoBlockParser p : list) {
|
||||
// 后注册覆盖前注册:vendor profile 可以用同一 typeCode 替换默认解析器。
|
||||
parsers.put(new Key(p.version(), p.typeCode()), p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class ValueDecoder {
|
||||
}
|
||||
|
||||
public static int u16raw(ByteBuffer buf) {
|
||||
// 仅用于长度、数量、bitmask 等协议控制字段;业务测量值应优先走 u16/scaledU16。
|
||||
return buf.getShort() & 0xFFFF;
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ public final class ValueDecoder {
|
||||
}
|
||||
|
||||
public static long u32raw(ByteBuffer buf) {
|
||||
// 仅用于故障码、标志位等需要保留全部 bit 的字段。
|
||||
return buf.getInt() & 0xFFFFFFFFL;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ public final class AlarmV2016BlockParser implements InfoBlockParser {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<Long> out = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
// 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。
|
||||
out.add(buf.getInt() & 0xFFFFFFFFL);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -27,6 +27,7 @@ public final class DriveMotorV2016BlockParser implements InfoBlockParser {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<InfoBlock.Gb32960V2016.DriveMotor.Motor> motors = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
// rpm/torque 在标准里是带偏移的无符号数,先按原始无符号读,再统一减偏移。
|
||||
Integer serialNo = ValueDecoder.u8(buf);
|
||||
Integer state = ValueDecoder.u8(buf);
|
||||
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);
|
||||
|
||||
@@ -19,6 +19,7 @@ public final class ExtremeValueV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
// 极值块只给出子系统号/单体号/探针号及对应值,不包含完整单体数组。
|
||||
Integer maxVSub = ValueDecoder.u8(buf);
|
||||
Integer maxVCell = ValueDecoder.u8(buf);
|
||||
Double maxV = ValueDecoder.scaledU16(buf, 0.001);
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class FuelCellV2016BlockParser implements InfoBlockParser {
|
||||
// 防御:probeCount 异常时回退为空列表避免吞 buffer
|
||||
List<Integer> probes = new ArrayList<>();
|
||||
int tailFixed = 2 + 1 + 2 + 1 + 2 + 1 + 1; // 尾部固定 10 字节
|
||||
// probeCount 来自车端报文,错误值会导致尾部固定段错位;这里先确保尾部还能完整解析。
|
||||
if (probeCount >= 0 && probeCount <= buf.remaining() - tailFixed) {
|
||||
for (int i = 0; i < probeCount; i++) {
|
||||
Integer t = ValueDecoder.tempOffset40(buf);
|
||||
|
||||
@@ -25,6 +25,7 @@ public final class PositionV2016BlockParser implements InfoBlockParser {
|
||||
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);
|
||||
|
||||
@@ -26,6 +26,7 @@ public final class TemperatureV2016BlockParser implements InfoBlockParser {
|
||||
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;
|
||||
|
||||
@@ -13,6 +13,8 @@ import java.nio.ByteBuffer;
|
||||
* + totalMileageKm(4) + totalVoltageV(2) + totalCurrentA(2)
|
||||
* + socPercent(1) + dcDcStatus(1) + gearRaw(1) + insulationKohm(2)
|
||||
* + accelPedal(1) + brakePedal(1)。
|
||||
*
|
||||
* <p>该块是实时事件和 snapshot 的核心公共字段来源,字段名变更会影响前端常用查询。
|
||||
*/
|
||||
public final class VehicleV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
|
||||
@@ -34,12 +34,14 @@ public final class VoltageV2016BlockParser implements InfoBlockParser {
|
||||
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;
|
||||
}
|
||||
// 如果 frameCellCount 大于 cellCount,剩余字节仍要消费掉,避免影响后续子系统解析。
|
||||
for (int c = actual; c < frameCellCount; c++) buf.getShort();
|
||||
}
|
||||
if (maxCell == Double.MIN_VALUE) maxCell = 0;
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser {
|
||||
|
||||
int generalAlarmCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
|
||||
List<InfoBlock.Gb32960V2025.Alarm.GeneralAlarmEntry> levels = new ArrayList<>(generalAlarmCount);
|
||||
// 2025 新增通用报警等级列表;尾部不完整时保留已读条目,避免整帧丢弃。
|
||||
for (int i = 0; i < generalAlarmCount && buf.remaining() >= 2; i++) {
|
||||
int bit = buf.get() & 0xFF;
|
||||
int level = buf.get() & 0xFF;
|
||||
@@ -44,6 +45,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<Long> out = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
// 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。
|
||||
out.add(buf.getInt() & 0xFFFFFFFFL);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -26,6 +26,7 @@ public final class BatteryTemperatureV2025BlockParser implements InfoBlockParser
|
||||
int packNo = buf.get() & 0xFF;
|
||||
int probeCount = buf.getShort() & 0xFFFF;
|
||||
List<Integer> temps = new ArrayList<>(probeCount);
|
||||
// probeCount 是车端声明值;实际数组按剩余字节裁剪,防止坏包读穿。
|
||||
int safeCount = Math.min(probeCount, buf.remaining());
|
||||
for (int k = 0; k < safeCount; k++) {
|
||||
temps.add((buf.get() & 0xFF) - 40);
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class DriveMotorV2025BlockParser implements InfoBlockParser {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<InfoBlock.Gb32960V2025.DriveMotor.Motor> motors = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
// 2025 版 torque 扩为 4 字节,不能沿用 2016 的 u16 解析。
|
||||
Integer serialNo = ValueDecoder.u8(buf);
|
||||
Integer state = ValueDecoder.u8(buf);
|
||||
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class FuelCellStackV2025BlockParser implements InfoBlockParser {
|
||||
Integer airInletTemp = ValueDecoder.tempOffset40(buf);
|
||||
int probeCount = buf.getShort() & 0xFFFF;
|
||||
List<Integer> temps = new ArrayList<>(probeCount);
|
||||
// 坏包 probeCount 过大时只消费剩余可用字节;声明数量仍保存在 stack 对象外层上下文中。
|
||||
int safeCount = Math.min(probeCount, buf.remaining());
|
||||
for (int k = 0; k < safeCount; k++) {
|
||||
temps.add((buf.get() & 0xFF) - 40);
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class MinParallelVoltageV2025BlockParser implements InfoBlockParser
|
||||
Double current = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
|
||||
int totalUnits = buf.getShort() & 0xFFFF;
|
||||
List<Double> frameVoltages = new ArrayList<>(totalUnits);
|
||||
// 每个最小并联单元电压占 2 字节;不足时只读完整单元,避免半个值污染后续解析。
|
||||
int safeCount = Math.min(totalUnits, buf.remaining() / 2);
|
||||
for (int k = 0; k < safeCount; k++) {
|
||||
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);
|
||||
|
||||
@@ -18,6 +18,7 @@ public final class SuperCapacitorExtremeV2025BlockParser implements InfoBlockPar
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
// 2025 超级电容极值的单体/探针编号为 2 字节,区别于 2016 蓄电池极值的 1 字节编号。
|
||||
Integer maxVSys = ValueDecoder.u8(buf);
|
||||
Integer maxVCell = ValueDecoder.u16(buf);
|
||||
Double maxV = ValueDecoder.scaledU16(buf, 0.001);
|
||||
|
||||
@@ -25,12 +25,14 @@ public final class SuperCapacitorV2025BlockParser implements InfoBlockParser {
|
||||
Double totalCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
|
||||
int cellCount = buf.getShort() & 0xFFFF;
|
||||
List<Double> cells = new ArrayList<>(cellCount);
|
||||
// 电压数组按 2 字节一个值裁剪,避免声明数量异常时读穿温度段。
|
||||
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);
|
||||
// 温度探针每个 1 字节,按剩余字节裁剪。
|
||||
int safeProbeCount = Math.min(probeCount, buf.remaining());
|
||||
for (int i = 0; i < safeProbeCount; i++) {
|
||||
temps.add((buf.get() & 0xFF) - 40);
|
||||
|
||||
@@ -10,6 +10,8 @@ import java.nio.ByteBuffer;
|
||||
/**
|
||||
* 整车数据 (0x01) —— GB/T 32960.3-2025。固定 18 字节。
|
||||
* 相比 2016 版删除加速踏板和制动踏板。
|
||||
*
|
||||
* <p>为保持查询接口稳定,后续转换层会把 2016/2025 两版整车字段映射到同一组逻辑字段。
|
||||
*/
|
||||
public final class VehicleV2025BlockParser implements InfoBlockParser {
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import java.nio.ByteBuffer;
|
||||
* outputCurrentA(2, 0.1A, 0~600A)
|
||||
* controllerTempC(1, -40 offset)
|
||||
* </pre>
|
||||
*
|
||||
* <p>只有 vendor profile 命中 {@code guangdong-fc} 时才会解析到该结构;否则同一 typeCode 会落 Raw。
|
||||
*/
|
||||
public final class GdFcDcDcBlockParser implements InfoBlockParser {
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ public final class GdFcStackBlockParser implements InfoBlockParser {
|
||||
Integer frameStart = ValueDecoder.u16(buf);
|
||||
int frameCells = buf.get() & 0xFF;
|
||||
List<Double> frameVoltages = new ArrayList<>(frameCells);
|
||||
// 对端会把单体电压按 frameCellStart/frameCellCount 分帧上送;这里只解析当前帧片段,
|
||||
// 完整电压数组由查询层按同一 vin+eventTime 合并,避免入站侧持有跨帧状态。
|
||||
int safe = Math.min(frameCells, buf.remaining() / 2);
|
||||
for (int k = 0; k < safe; k++) {
|
||||
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);
|
||||
|
||||
@@ -28,6 +28,7 @@ public final class GdFcVehicleInfoBlockParser implements InfoBlockParser {
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
Integer collision = ValueDecoder.u8raw(buf);
|
||||
int rawTemp = buf.getShort() & 0xFFFF;
|
||||
// 规范保留值按缺失处理,避免 snapshot 中出现 65494/65495 这种不可读温度。
|
||||
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);
|
||||
|
||||
@@ -48,6 +48,7 @@ public final class GdFcVendorTlvBlockParser implements InfoBlockParser {
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
int len = buf.getShort() & 0xFFFF;
|
||||
// 防御:若 length 大于剩余可读字节,截短到剩余长度(避免 BufferUnderflowException)
|
||||
// declaredLength 仍保留原值,后续可以在 API/日志里看出对端长度声明是否异常。
|
||||
int safe = Math.min(len, buf.remaining());
|
||||
byte[] payload = new byte[safe];
|
||||
buf.get(payload);
|
||||
|
||||
@@ -45,6 +45,7 @@ public class Gb32960ProfileRegistry {
|
||||
}
|
||||
List<InfoBlockParser> combined = new ArrayList<>(defaultParsers.size() + vendorParsers.size());
|
||||
combined.addAll(defaultParsers);
|
||||
// vendor parser 只新增扩展 typeCode,不应覆盖标准 parser;若未来需覆盖,必须显式设计优先级。
|
||||
combined.addAll(vendorParsers);
|
||||
map.put(name, new InfoBlockParserRegistry(combined));
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ public final class RuleBasedVendorExtensionSelector implements VendorExtensionSe
|
||||
CacheKey key = new CacheKey(normalize(ctx.platformAccount()), normalize(ctx.vin()));
|
||||
Optional<String> cached = cache.get(key);
|
||||
if (cached != null) return cached.orElse(null);
|
||||
// 32960 高频上报通常同一连接/同一 VIN 连续上报,缓存可避免每帧扫描所有规则。
|
||||
String hit = scan(key);
|
||||
cache.put(key, Optional.ofNullable(hit));
|
||||
return hit;
|
||||
|
||||
@@ -24,6 +24,7 @@ public final class VendorExtensionCatalog {
|
||||
private final Map<String, List<InfoBlockParser>> extensions;
|
||||
|
||||
public VendorExtensionCatalog(Map<String, List<InfoBlockParser>> extensions) {
|
||||
// 只冻结顶层 map;parser 实例本身按无状态单例使用,不应在运行期修改。
|
||||
this.extensions = Map.copyOf(extensions);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
|
||||
* 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile)。
|
||||
*
|
||||
* <p>实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。
|
||||
* 命中结果会直接影响 0x30+ 等厂商字段是否解析成结构化字段,还是退化为 Raw。
|
||||
*/
|
||||
public interface VendorExtensionSelector {
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ import java.util.List;
|
||||
*
|
||||
* <p>每个 Parser Bean 都会被 {@link InfoBlockParserRegistry} 按
|
||||
* {@code (ProtocolVersion, typeCode)} 注册。2016/2025 可同时加载。
|
||||
*
|
||||
* <p>只有 {@code lingniu.ingest.gb32960.enabled=true} 时才会启动 32960 TCP 服务。
|
||||
* 解析、鉴权、ACK、RAW 入库都从 {@link Gb32960NettyServer} 建立的 Netty pipeline 进入。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(Gb32960Properties.class)
|
||||
@@ -114,6 +117,7 @@ public class Gb32960AutoConfiguration {
|
||||
new GdFcVehicleInfoBlockParser(),
|
||||
new GdFcDemoExtensionBlockParser(),
|
||||
// 0x83 厂商私有 TLV 兜底(同 peer 在广东规范之外又下发的未公开扩展)
|
||||
// 这里只注册已经在线上见过的 typeCode;新增 typeCode 应先确认长度字段格式。
|
||||
new GdFcVendorTlvBlockParser(0x83))));
|
||||
}
|
||||
|
||||
@@ -133,6 +137,7 @@ public class Gb32960AutoConfiguration {
|
||||
for (Gb32960Properties.VendorExtension e : props.getVendorExtensions()) {
|
||||
if (e.getName() != null && !e.getName().isBlank()) enabled.add(e.getName());
|
||||
}
|
||||
// enabled 只来自配置里实际引用过的扩展,避免无关 vendor parser 进入默认解析路径。
|
||||
return new Gb32960ProfileRegistry(parsers, catalog, enabled);
|
||||
}
|
||||
|
||||
@@ -142,6 +147,7 @@ public class Gb32960AutoConfiguration {
|
||||
public VendorExtensionSelector gb32960VendorExtensionSelector(Gb32960Properties props,
|
||||
VendorExtensionCatalog catalog) {
|
||||
if (props.getVendorExtensions() == null || props.getVendorExtensions().isEmpty()) {
|
||||
// 没有 vendor 配置时强制 NONE,0x30+ 在 2016 帧里会 Raw 兜底,防止误套扩展解析。
|
||||
return VendorExtensionSelector.NONE;
|
||||
}
|
||||
return new RuleBasedVendorExtensionSelector(props.getVendorExtensions(), catalog.names());
|
||||
|
||||
@@ -10,8 +10,11 @@ import java.util.Set;
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.gb32960")
|
||||
public class Gb32960Properties {
|
||||
|
||||
/** 是否启动 32960 TCP 监听。生产 32960 线路打开后,此开关决定端口是否真正 bind。 */
|
||||
private boolean enabled = false;
|
||||
/** 32960 TCP 监听端口;当前生产验证线使用 32960。 */
|
||||
private int port = 9000;
|
||||
/** Netty worker 线程数;0 表示按 Netty/CPU 默认策略分配。 */
|
||||
private int workerThreads = 0;
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,11 @@ import java.util.List;
|
||||
*
|
||||
* <p>这是 GB32960 协议在通用 Dispatcher 管线里的**唯一入口** Bean——移除将导致所有
|
||||
* GB32960 帧产出的事件丢失。构造器注入 {@link Gb32960EventMapper},全部业务逻辑限制
|
||||
* 在纯函数里,不触碰数据库 / 不做 IO。产出的事件由 Dispatcher 统一投递到 Disruptor → Kafka。
|
||||
* 在纯函数里,不触碰数据库 / 不做 IO。产出的事件由 Dispatcher 统一投递到 Disruptor;
|
||||
* 下游 sink 再决定写 raw archive、DuckDB/Parquet 历史库或转发 Kafka。
|
||||
*
|
||||
* <p>当前 32960 专用 snapshot 查询不依赖 Realtime/Location 事件表,而是通过 raw archive URI
|
||||
* 回读原始 .bin 并即时解码合并,避免不同车辆或不同子包之间做大范围 join。
|
||||
*/
|
||||
@ProtocolHandler(protocol = ProtocolId.GB32960)
|
||||
public class Gb32960RealtimeHandler {
|
||||
@@ -33,6 +37,7 @@ public class Gb32960RealtimeHandler {
|
||||
@RateLimited(perVin = 50)
|
||||
@EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class})
|
||||
public List<VehicleEvent> onReport(Gb32960Message msg) {
|
||||
// 这里保留 Realtime/Location 事件供实时消费者使用;历史全字段查询走 raw archive 解码。
|
||||
return mapper.toEvents(msg);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import java.net.InetSocketAddress;
|
||||
*
|
||||
* <p>Keeps authentication and channel-scoped platform identity out of the Netty
|
||||
* handler so the hot path can stay small and testable.
|
||||
*
|
||||
* <p>平台登入成功后,username 会绑定到 Netty channel attribute。后续实时上报帧没有账号字段,
|
||||
* 只能通过同一连接上的 attribute 找回平台账号,用于 Hyundai/广东扩展等 vendor profile 选择。
|
||||
*/
|
||||
public final class Gb32960AccessService {
|
||||
|
||||
@@ -50,10 +53,12 @@ public final class Gb32960AccessService {
|
||||
public Gb32960PlatformAuthorizer.Result authenticatePlatformLogin(String username,
|
||||
String password,
|
||||
Channel channel) {
|
||||
// 鉴权策略可以按来源 IP 做限制,因此这里把远端地址从 Netty channel 中抽出。
|
||||
return platformAuthorizer.authenticate(username, password, peerIp(channel));
|
||||
}
|
||||
|
||||
public void bindPlatformAccount(Channel channel, String username) {
|
||||
// 只绑定到当前连接,断线重连后必须重新平台登入,避免跨连接复用旧账号。
|
||||
channel.attr(PLATFORM_ACCOUNT_ATTR).set(username);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Builds and writes GB/T 32960 response frames.
|
||||
*
|
||||
* <p>ACK 需要尽量复用请求帧里的原始 17 字节 VIN。平台登入这类命令可能没有真实 VIN,
|
||||
* 如果把 trim 后的字符串重新 padding,部分上级平台会认为应答 VIN 与请求不一致。
|
||||
*/
|
||||
public final class Gb32960AckService {
|
||||
|
||||
@@ -39,6 +42,7 @@ public final class Gb32960AckService {
|
||||
Instant eventTime,
|
||||
byte[] data,
|
||||
String tag) {
|
||||
// rawVin 版本用于严格回显请求帧 VIN 字节,优先给入站 handler 使用。
|
||||
byte[] ack = Gb32960FrameEncoder.buildResponse(
|
||||
version, command, responseFlag, rawVin, eventTime, data);
|
||||
write(channel, ack, tag);
|
||||
@@ -86,6 +90,7 @@ public final class Gb32960AckService {
|
||||
channel.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> {
|
||||
if (f.isSuccess()) {
|
||||
if (highFrequency) {
|
||||
// 实时上报 ACK 频率高,默认降到 DEBUG,避免生产日志被正常心跳/上报刷满。
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("[gb32960] {} flushed peer={} len={} hex={}",
|
||||
tag, addr(channel), ack.length, hex);
|
||||
|
||||
@@ -27,6 +27,10 @@ import java.util.Map;
|
||||
/**
|
||||
* Netty 入站处理器:字节 → Gb32960Message → 认证 → RawFrame → Dispatcher。
|
||||
*
|
||||
* <p>这是 32960 生产链路的入口边界。它只做协议层职责:解码、鉴权、应答、组装
|
||||
* {@link RawFrame} 并投递 Dispatcher;原始包归档、DuckDB/Parquet 索引、Kafka 转发都在
|
||||
* 下游 sink 中完成,避免 Netty EventLoop 被存储 IO 阻塞。
|
||||
*
|
||||
* <p>认证逻辑:
|
||||
* <ul>
|
||||
* <li>{@link Gb32960VinAuthorizer#isEnforcing()} = false:放行所有 VIN
|
||||
@@ -99,6 +103,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
||||
String platformAccount = accessService.platformAccount(ctx.channel());
|
||||
msg = decoder.decode(ByteBuffer.wrap(frame), platformAccount);
|
||||
} catch (Exception e) {
|
||||
// 解码失败只丢当前帧,不主动断开连接。真实设备偶发脏字节时,FrameDecoder 会继续寻找下一帧头。
|
||||
log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e);
|
||||
return;
|
||||
}
|
||||
@@ -138,6 +143,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
||||
if (platformAccount != null && !platformAccount.isBlank()) {
|
||||
sourceMeta.put("platformAccount", platformAccount);
|
||||
}
|
||||
// RawFrame.rawBytes 是后续 raw archive 和按 rawArchiveUri 回查的源头;不要在这里裁剪或重编码。
|
||||
RawFrame rf = new RawFrame(
|
||||
ProtocolId.GB32960,
|
||||
cmd.code(),
|
||||
@@ -244,6 +250,9 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
||||
* 0x02/0x03/0x07 实时 / 补发 / 心跳:按 §6.3.2 回应答帧,保留原帧采集时间。
|
||||
* 部分车端实现严格按规范,没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。
|
||||
*
|
||||
* <p>注意:应答成功不代表下游已完成持久化,只表示平台已接收并通过 Dispatcher 投递。
|
||||
* 生产排查存储问题时,应继续检查 raw archive sink、EventFileStoreSink 和 Kafka sink 的日志。
|
||||
*
|
||||
* <p>日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏):
|
||||
* <ul>
|
||||
* <li>0x02 / 0x03 首次见到某 (vin, normal) → INFO 一条,含 peer/vin/platformAccount/version/parsedBlocks。
|
||||
@@ -326,6 +335,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
||||
String vin = msg.header().vin();
|
||||
CommandType cmd = msg.header().command();
|
||||
if (cmd == CommandType.VEHICLE_LOGIN) {
|
||||
// 规范要求登入失败要回 ACK,让终端能明确知道 VIN 不存在,而不是当作网络超时重试。
|
||||
ackService.writeAndClose(
|
||||
ctx,
|
||||
msg.header().protocolVersion(),
|
||||
|
||||
@@ -15,6 +15,9 @@ import java.util.Set;
|
||||
|
||||
/**
|
||||
* Channel-scoped diagnostics for high-frequency GB/T 32960 reports.
|
||||
*
|
||||
* <p>每个 TCP channel 只记录有限个首次出现的 (vin, rawTypeSignature),用于发现厂商 profile
|
||||
* 没命中、字段解析退化为 Raw 的问题。它不参与业务判断,也不会影响 raw archive 和历史查询。
|
||||
*/
|
||||
public final class Gb32960FrameDiagnostics {
|
||||
|
||||
@@ -76,6 +79,7 @@ public final class Gb32960FrameDiagnostics {
|
||||
|
||||
private void trimToLimit(Set<String> seen) {
|
||||
while (seen.size() > maxKeysPerChannel) {
|
||||
// LinkedHashSet 保持插入顺序,超限时淘汰最早的签名,避免异常设备导致 attribute 无界增长。
|
||||
String first = seen.iterator().next();
|
||||
seen.remove(first);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ import java.util.concurrent.ThreadFactory;
|
||||
* GB/T 32960 Netty Server 启动器。
|
||||
*
|
||||
* <p>EventLoop 只做解码与投递 {@link Dispatcher},业务处理由 Disruptor 侧的虚拟线程承担。
|
||||
* 端口是否启动由 {@code lingniu.ingest.gb32960.enabled} 控制,生产 32960 线应确认该开关、
|
||||
* 端口、防火墙和上游平台账号同时匹配。
|
||||
*
|
||||
* <p>支持可选 TLS:{@link Gb32960Properties.Tls#isEnabled()} = true 时,pipeline 前置
|
||||
* {@code SslHandler},加载服务端证书 + 私钥 + 受信 CA,默认要求客户端证书(双向 TLS)。
|
||||
@@ -97,6 +99,7 @@ public class Gb32960NettyServer implements InitializingBean, DisposableBean {
|
||||
.childHandler(new ChannelInitializer<SocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(SocketChannel ch) {
|
||||
// Pipeline 顺序不能调换:TLS 先解密,FrameDecoder 再做拆包,ChannelHandler 最后做业务应答和派发。
|
||||
if (sslContext != null) {
|
||||
ch.pipeline().addLast("ssl", sslContext.newHandler(ch.alloc()));
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ import java.util.stream.Collectors;
|
||||
*
|
||||
* <p>实时/补发上报产出 {@code Realtime + Location}(可选)+ {@code Alarm}(可选);
|
||||
* 车辆登入/登出/心跳产出对应的会话事件。
|
||||
*
|
||||
* <p>该 Mapper 只生成统一领域事件,字段是面向实时总线的精简视图。32960 的完整包字段、
|
||||
* 厂商扩展字段和 snapshot 合并字段由历史查询服务从 raw archive 重新解码,不在这里展开。
|
||||
*/
|
||||
public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
|
||||
|
||||
@@ -42,6 +45,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
|
||||
|
||||
CommandType cmd = header.command();
|
||||
if (cmd == CommandType.REALTIME_REPORT || cmd == CommandType.RESEND_REPORT) {
|
||||
// 补发包与实时包进入同一套实时事件模型,差异保留在 metadata.command 里供下游判断。
|
||||
buildRealtime(message, header, baseMeta, eventTime, ingestTime, out);
|
||||
return out;
|
||||
}
|
||||
@@ -155,6 +159,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
|
||||
null, meta, payload));
|
||||
}
|
||||
if (p != null) {
|
||||
// 位置事件只从 POSITION 子包生成。没有 POSITION 时不从整车数据推断经纬度,避免制造假定位。
|
||||
LocationPayload loc = new LocationPayload(
|
||||
p.longitude, p.latitude, 0.0,
|
||||
v != null && v.speedKmh != null ? v.speedKmh : 0.0,
|
||||
@@ -165,6 +170,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
|
||||
null, meta, loc));
|
||||
}
|
||||
if (alarm != null && shouldEmitAlarm(alarm)) {
|
||||
// 只在存在告警等级或氢安全关键位时发 Alarm,减少正常高频帧对告警消费者的噪声。
|
||||
List<String> faultCodes = new ArrayList<>();
|
||||
addFaultCodes(faultCodes, "BAT", alarm.batteryFaults);
|
||||
addFaultCodes(faultCodes, "MOT", alarm.motorFaults);
|
||||
|
||||
@@ -274,6 +274,7 @@ public sealed interface InfoBlock
|
||||
record MinParallelVoltage(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
|
||||
@Override public InfoBlockType type() { return InfoBlockType.MIN_PARALLEL_VOLTAGE_V2025; }
|
||||
|
||||
/** totalUnits 是车端声明数量;frameVoltages 是本帧实际解析出的完整电压值。 */
|
||||
public record Pack(
|
||||
int packNo,
|
||||
Double packVoltageV,
|
||||
@@ -287,6 +288,7 @@ public sealed interface InfoBlock
|
||||
record BatteryTemperature(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
|
||||
@Override public InfoBlockType type() { return InfoBlockType.BATTERY_TEMPERATURE_V2025; }
|
||||
|
||||
/** probeCount 是车端声明数量;probeTempsC 是本帧实际解析出的温度值。 */
|
||||
public record Pack(int packNo, int probeCount, List<Integer> probeTempsC) {}
|
||||
}
|
||||
|
||||
@@ -298,6 +300,7 @@ public sealed interface InfoBlock
|
||||
int stackNo,
|
||||
Double voltageV,
|
||||
Double currentA,
|
||||
// 标准字段名写 pressure,协议比例按 kPa 命名,避免与 2016 Mpa 字段混淆。
|
||||
Double hydrogenInletPressureKpa,
|
||||
Double airInletPressureKpa,
|
||||
Integer airInletTempC,
|
||||
|
||||
Reference in New Issue
Block a user