From e8150114df78958520b85ae3a17c2f16a28fdf51 Mon Sep 17 00:00:00 2001 From: lingniu-dev Date: Wed, 15 Apr 2026 17:13:46 +0800 Subject: [PATCH] feat(gb32960/vendor): add TLV catch-all parser for Guangdong peer 0x83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same peer that uses the Guangdong fuel-cell extension also emits a proprietary 0x83 block that is NOT in the published v1.0 spec table 8. Empirically it follows a TLV layout: type(1) + length(2) + value(N). Adds: - InfoBlock.GuangdongFc.VendorTlv(typeCode, declaredLength, payload) record + permits clause + InfoBlockType.GD_FC_VENDOR_TLV enum entry. - GdFcVendorTlvBlockParser: typeCode injected via constructor, reads 2B big-endian length then exactly that many payload bytes. Defends against declaredLength > remaining by truncating to remaining. - AutoConfig: registers `new GdFcVendorTlvBlockParser(0x83)` under the guangdong-fc catalog entry. Future unknown vendor typeCodes can be added by inserting more instances; no new class required. Critical correctness property — the parser is strictly bounded by the length field, so BodyParser's main loop continues processing blocks that come AFTER the TLV instead of having them swallowed by the old generic Raw fallback. Three unit tests cover this: - parsesPayloadOfDeclaredLengthExactly: round-trip a 5-byte payload - doesNotSwallowSubsequentBlocksWhenChainedInBodyParser: feeds a body containing 0x83 TLV(4B) followed by a 0x01 standard Vehicle block; asserts both blocks are parsed - truncatesPayloadIfDeclaredLengthExceedsRemaining: defensive case Test count: 32 -> 35. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../guangdong/GdFcVendorTlvBlockParser.java | 56 +++++++++ .../config/Gb32960AutoConfiguration.java | 5 +- .../protocol/gb32960/model/InfoBlock.java | 21 +++- .../protocol/gb32960/model/InfoBlockType.java | 2 + .../GdFcVendorTlvBlockParserTest.java | 118 ++++++++++++++++++ 5 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java create mode 100644 protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParserTest.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java new file mode 100644 index 00000000..2ca9628e --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java @@ -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。 + * + *

同一 peer 在广东规范 v1.0 表 8 列出的 0x30~0x34 / 0x80 之外,**还会下发**一些 + * 在该规范里完全未定义的扩展 typeCode(线上首先观察到 {@code 0x83},未来不排除有 + * 0x84 等)。这些块经验上是 **TLV 格式**: + * + *

+ *   typeCode (1B, 由 BodyParser 外层消费)
+ *   length   (2B WORD, 大端)
+ *   payload  (length 字节)
+ * 
+ * + *

本 parser 严格按 length 消费字节,贪心读到 buffer 末尾,保证 BodyParser + * 主循环在该块之后仍能继续解析后面的标准/扩展块。 + * + *

typeCode 通过构造器注入,未来若发现新的 vendor typeCode,只需在 catalog 里多注册 + * 一个 {@code new GdFcVendorTlvBlockParser(0x84)} 即可,无需新写 parser 类。 + * + *

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); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java index c3068854..055c7d59 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java @@ -34,6 +34,7 @@ import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDcD 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; @@ -108,7 +109,9 @@ public class Gb32960AutoConfiguration { new GdFcDcDcBlockParser(), new GdFcAirConditionerBlockParser(), new GdFcVehicleInfoBlockParser(), - new GdFcDemoExtensionBlockParser()))); + new GdFcDemoExtensionBlockParser(), + // 0x83 厂商私有 TLV 兜底(同 peer 在广东规范之外又下发的未公开扩展) + new GdFcVendorTlvBlockParser(0x83)))); } // ================= Core components ================= diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java index 138e02bc..cc241d1a 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java @@ -339,14 +339,15 @@ public sealed interface InfoBlock // 广东燃料电池汽车示范应用城市群综合监管平台规范 v1.0(基于 GB/T 32960.3-2016 帧头) // ======================================================================== - /** 广东燃料电池汽车示范应用规范定义的扩展信息体(typeCode 0x30~0x34、0x80)。 */ + /** 广东燃料电池汽车示范应用规范定义的扩展信息体(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.DemoExtension, + GuangdongFc.VendorTlv { /** 0x30 燃料电池电堆数据(§7.2.3.4 表 13/14)。变长。 */ record Stack(int stackCount, List stacks) implements GuangdongFc { @@ -426,6 +427,22 @@ public sealed interface InfoBlock ) implements GuangdongFc { @Override public InfoBlockType type() { return InfoBlockType.GD_FC_DEMO_EXTENSION; } } + + /** + * 厂商私有 TLV 块兜底容器。当对端在广东规范之外又下发了一个未公开的 typeCode + * (目前线上观察到 {@code 0x83}),但该块明显是 TLV 格式(type(1) + length(2) + + * value(N))时,本 record 保留原始字节直到对端提供正式协议文档为止。 + * + *

关键在于对应的 {@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; } + } } // ======================================================================== diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java index 0f7bc1dc..1fef0255 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java @@ -56,6 +56,8 @@ public enum InfoBlockType { GD_FC_VEHICLE_INFO, /** 广东燃料电池规范 0x80 燃料电池汽车示范应用数据扩展(表 27)。 */ GD_FC_DEMO_EXTENSION, + /** 同 peer 的厂商私有 TLV 兜底(如 0x83),等待对端文档后升级为正式 parser。 */ + GD_FC_VENDOR_TLV, /** 原始/未解析。 */ RAW } diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParserTest.java b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParserTest.java new file mode 100644 index 00000000..e8d93891 --- /dev/null +++ b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParserTest.java @@ -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 消费字节,不会贪心吞光后续信息体。 + * + *

这是关键回归 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)); + } +}