feat(gb32960/vendor): add TLV catch-all parser for Guangdong peer 0x83

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) <noreply@anthropic.com>
This commit is contained in:
lingniu-dev
2026-04-15 17:13:46 +08:00
parent abd1c7c6d6
commit e8150114df
5 changed files with 199 additions and 3 deletions

View File

@@ -0,0 +1,56 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池 peer 的厂商私有 TLV 块兜底 parser。
*
* <p>同一 peer 在广东规范 v1.0 表 8 列出的 0x30~0x34 / 0x80 之外,**还会下发**一些
* 在该规范里完全未定义的扩展 typeCode线上首先观察到 {@code 0x83},未来不排除有
* 0x84 等)。这些块经验上是 **TLV 格式**
*
* <pre>
* typeCode (1B, 由 BodyParser 外层消费)
* length (2B WORD, 大端)
* payload (length 字节)
* </pre>
*
* <p>本 parser 严格按 length 消费字节,<b>不</b>贪心读到 buffer 末尾,保证 BodyParser
* 主循环在该块之后仍能继续解析后面的标准/扩展块。
*
* <p>typeCode 通过构造器注入,未来若发现新的 vendor typeCode只需在 catalog 里多注册
* 一个 {@code new GdFcVendorTlvBlockParser(0x84)} 即可,无需新写 parser 类。
*
* <p>length 字段宽度为 2 字节是从 {@code 0x83} 实际字节序列推断而来0x0024=36 与
* payload 实际长度匹配);如未来对端的某个 typeCode 用 1B length 而非 2B将出现
* 字节漂移并被 BodyParser 的 hex dump 立刻暴露,可针对性新增独立 parser。
*/
public final class GdFcVendorTlvBlockParser implements InfoBlockParser {
private final int typeCode;
public GdFcVendorTlvBlockParser(int typeCode) {
if (typeCode < 0 || typeCode > 0xFF) {
throw new IllegalArgumentException("typeCode out of range: " + typeCode);
}
this.typeCode = typeCode;
}
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return typeCode; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int len = buf.getShort() & 0xFFFF;
// 防御:若 length 大于剩余可读字节,截短到剩余长度(避免 BufferUnderflowException
int safe = Math.min(len, buf.remaining());
byte[] payload = new byte[safe];
buf.get(payload);
return new InfoBlock.GuangdongFc.VendorTlv(typeCode, len, payload);
}
}

View File

@@ -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 =================

View File

@@ -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<Item> 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 保留原始字节直到对端提供正式协议文档为止。
*
* <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; }
}
}
// ========================================================================

View File

@@ -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
}

View File

@@ -0,0 +1,118 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 验证 {@link GdFcVendorTlvBlockParser} 严格按 length 消费字节,不会贪心吞光后续信息体。
*
* <p>这是关键回归 case用户问"加 0x83 兜底会不会把后面其它块也吃掉?"——通过构造
* 一个 {@code 0x83 TLV + 0x01 Vehicle} 串联的 body断言 BodyParser 解出**两个块**而非一个。
*/
class GdFcVendorTlvBlockParserTest {
@Test
void parsesPayloadOfDeclaredLengthExactly() {
// 0x83 typeCode 已被外层消费buffer 从 length 字段开始
byte[] data = bytes(
0x00, 0x05, // length = 5
0xAA, 0xBB, 0xCC, 0xDD, 0xEE); // 5 bytes payload
ByteBuffer buf = ByteBuffer.wrap(data);
InfoBlock.GuangdongFc.VendorTlv tlv =
(InfoBlock.GuangdongFc.VendorTlv) new GdFcVendorTlvBlockParser(0x83).parse(buf);
assertThat(tlv.typeCode()).isEqualTo(0x83);
assertThat(tlv.declaredLength()).isEqualTo(5);
assertThat(tlv.payload()).containsExactly(0xAA, 0xBB, 0xCC, 0xDD, 0xEE);
assertThat(buf.remaining()).as("应正好消费 2+5=7 字节").isZero();
}
@Test
void doesNotSwallowSubsequentBlocksWhenChainedInBodyParser() {
// 构造 body0x83 TLV(length=4) + 0x01 Vehicle(20B 标准布局)
ByteArrayOutputStream body = new ByteArrayOutputStream();
// 0x83 TLV3+4 = 7 字节
body.write(0x83);
body.write(0x00); body.write(0x04); // length = 4
body.write(0x11); body.write(0x22); body.write(0x33); body.write(0x44);
// 0x01 Vehicle: typeCode + 20 字节 body
body.write(0x01);
body.write(0x01); // vehicleState
body.write(0x03); // chargingState
body.write(0x02); // runningMode
write16(body, 0); // speed
write32(body, 100_000L); // mileage
write16(body, 5605); // totalVoltage
write16(body, 10000); // totalCurrent
body.write(85); // SOC
body.write(0x01); // dcDcStatus
body.write(0x00); // gear
write16(body, 10000); // insulation
body.write(0x00); // accelPedal
body.write(0x00); // brakePedal
// 装配 body parser注册 0x83 vendor TLV + 0x01 标准 Vehicle
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new GdFcVendorTlvBlockParser(0x83),
new VehicleV2016BlockParser()));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
ByteBuffer buf = ByteBuffer.wrap(body.toByteArray());
Gb32960MessageDecoder.BodyParseResult result = parser.parse(ProtocolVersion.V2016, buf);
// 关键断言两个块都解析出来了0x01 没被 0x83 吞掉
assertThat(result.blocks()).hasSize(2);
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.GuangdongFc.VendorTlv.class);
assertThat(result.blocks().get(1)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
InfoBlock.GuangdongFc.VendorTlv tlv = (InfoBlock.GuangdongFc.VendorTlv) result.blocks().get(0);
assertThat(tlv.declaredLength()).isEqualTo(4);
assertThat(tlv.payload()).containsExactly(0x11, 0x22, 0x33, 0x44);
InfoBlock.Gb32960V2016.Vehicle v = (InfoBlock.Gb32960V2016.Vehicle) result.blocks().get(1);
assertThat(v.socPercent()).isEqualTo(85);
assertThat(v.totalVoltageV()).isEqualTo(560.5);
}
@Test
void truncatesPayloadIfDeclaredLengthExceedsRemaining() {
// 防御场景length 字段声称 100 字节但 buffer 只剩 3 字节
byte[] data = bytes(0x00, 0x64, 0x01, 0x02, 0x03);
ByteBuffer buf = ByteBuffer.wrap(data);
InfoBlock.GuangdongFc.VendorTlv tlv =
(InfoBlock.GuangdongFc.VendorTlv) new GdFcVendorTlvBlockParser(0x83).parse(buf);
assertThat(tlv.declaredLength()).isEqualTo(100);
assertThat(tlv.payload()).hasSize(3).containsExactly(0x01, 0x02, 0x03);
}
private static byte[] bytes(int... values) {
byte[] out = new byte[values.length];
for (int i = 0; i < values.length; i++) out[i] = (byte) values[i];
return out;
}
private static void write16(ByteArrayOutputStream os, int v) {
os.write((v >> 8) & 0xFF);
os.write(v & 0xFF);
}
private static void write32(ByteArrayOutputStream os, long v) {
os.write((int) ((v >> 24) & 0xFF));
os.write((int) ((v >> 16) & 0xFF));
os.write((int) ((v >> 8) & 0xFF));
os.write((int) (v & 0xFF));
}
}