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