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)); + } +}