feat(gb32960/vendor): implement Guangdong fuel-cell extension parsers

Adds 6 vendor parsers under codec/parser/vendor/guangdong/ implementing the
Guangdong "燃料电池汽车数据接入技术规范 v1.0" extension typeCodes that the
city's data ingestion platform sends inside V2016 frames:

  0x30  GdFcStackBlockParser           §7.2.3.4 表 13/14  fuel-cell stack
  0x31  GdFcAuxiliaryBlockParser       §7.2.3.5 表 15/16  auxiliary system
  0x32  GdFcDcDcBlockParser            §7.2.3.6 表 17     DC/DC
  0x33  GdFcAirConditionerBlockParser  §7.2.3.7 表 18     air conditioner
  0x34  GdFcVehicleInfoBlockParser     §7.2.3.8 表 19     vehicle info
  0x80  GdFcDemoExtensionBlockParser   §7.2.3.14 表 27    demo data extension

Each parser uses ProtocolVersion.V2016 + its respective typeCode and is
strictly Guangdong-spec layout, not the GB/T 32960.3-2025 fuel-cell stack
layout (the two share typeCode 0x30 but have different field orders and
extra cell-voltage statistics in the Guangdong variant).

Field model in InfoBlock.java gains:
- GdFcStack(stackCount, stacks: Stack(workState, waterTempC,
  h2InletPressureKpa, airInletPressureKpa, airInletTempC, max/min/avg cell
  voltage ids and values, cellCount, frameCellStart, frameCellCount,
  frameCellVoltagesV))
- GdFcAuxiliary(subSystemCount, subsystems: Subsystem with air compressor,
  hydrogen pump, water pump, PTC, low-voltage battery)
- GdFcDcDc, GdFcAirConditioner, GdFcVehicleInfo, GdFcDemoExtension
InfoBlockType enum gains the matching GD_FC_* constants.

Tests:
- GdFcParserTest covers the 4 fixed-length parsers (0x32/0x33/0x34/0x80)
  with hand-crafted bytes asserting field-by-field decoded values.
- 0x30/0x31 are variable-length and will be exercised end-to-end via the
  body parser integration test in a follow-up.

Spec doc: adds reference/广东燃料电池汽车示范应用城市群综合监管平台-...pdf
(v1.0.220822) used as the source of truth for these parsers.

These parsers exist as classes only; no Spring beans are wired yet.
Wiring + decoder context plumbing land in the next commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lingniu-dev
2026-04-15 16:37:53 +08:00
parent 0e6f120ca9
commit 957a86d581
10 changed files with 483 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import org.junit.jupiter.api.Test;
import java.nio.ByteBuffer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
/**
* 单元测试:广东燃料电池规范 fixed-length parser0x32 / 0x33 / 0x34 / 0x80逐字段还原。
* 0x30/0x31 是变长,集成层会在 BodyParser 全链路测试里覆盖。
*/
class GdFcParserTest {
@Test
void dcDcBlockParsesAllFields() {
// inV=300.0 inC=120.0 outV=560.0 outC=110.5 ctrlTemp=65°C
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
0x0B, (byte) 0xB8, // 0x0BB8 = 3000 → 300.0 V
0x04, (byte) 0xB0, // 0x04B0 = 1200 → 120.0 A
0x15, (byte) 0xE0, // 0x15E0 = 5600 → 560.0 V
0x04, 0x52, // 0x0452 = 1106 → 110.6 A
0x69 // 0x69 = 105 - 40 = 65 °C
});
InfoBlock.GdFcDcDc b = (InfoBlock.GdFcDcDc) new GdFcDcDcBlockParser().parse(buf);
assertThat(b.inputVoltageV()).isCloseTo(300.0, within(1e-6));
assertThat(b.inputCurrentA()).isCloseTo(120.0, within(1e-6));
assertThat(b.outputVoltageV()).isCloseTo(560.0, within(1e-6));
assertThat(b.outputCurrentA()).isCloseTo(110.6, within(1e-6));
assertThat(b.controllerTempC()).isEqualTo(65);
}
@Test
void airConditionerParsesPowerOffsetAndStatus() {
// status=01 power raw=20 (= 15 kw after -5 offset) inputV=540.0
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
0x01,
0x00, 0x14, // 20 - 5 = 15 kw
0x15, (byte) 0x18 // 0x1518 = 5400 → 540.0 V
});
InfoBlock.GdFcAirConditioner b =
(InfoBlock.GdFcAirConditioner) new GdFcAirConditionerBlockParser().parse(buf);
assertThat(b.status()).isEqualTo(1);
assertThat(b.powerKw()).isEqualTo(15);
assertThat(b.compressorInputVoltageV()).isCloseTo(540.0, within(1e-6));
}
@Test
void vehicleInfoParsesAmbientFields() {
// collision=00 ambientTemp raw=70 (=30°C) ambientPressure raw=1100 (10kPa) h2Mass raw=80 (8.0 kg)
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
0x00,
0x00, 0x46, // 70 - 40 = 30°C
0x04, 0x4C, // 0x044C = 1100 → 110*0.1 - 100 = 10.0 kPa
0x00, 0x50 // 0x0050 = 80 → 8.0 kg
});
InfoBlock.GdFcVehicleInfo b =
(InfoBlock.GdFcVehicleInfo) new GdFcVehicleInfoBlockParser().parse(buf);
assertThat(b.collisionAlarm()).isEqualTo(0);
assertThat(b.ambientTempC()).isEqualTo(30);
assertThat(b.ambientPressureKpa()).isCloseTo(10.0, within(1e-6));
assertThat(b.hydrogenMassKg()).isCloseTo(8.0, within(1e-6));
}
@Test
void demoExtensionParsesInnerLengthAndFields() {
// length=9, stackTemp=50, acV=372.0, acC=0, h2V=1.3V, h2C=0
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
0x00, 0x09, // declared length
0x5A, // 90 - 40 = 50°C
0x0E, (byte) 0x88, // 0x0E88 = 3720 → 372.0V
0x27, 0x10, // 0x2710 = 10000 → 0 A (after -1000)
0x00, 0x0D, // 0x000D = 13 → 1.3V
0x27, 0x10 // 0 A
});
InfoBlock.GdFcDemoExtension b =
(InfoBlock.GdFcDemoExtension) new GdFcDemoExtensionBlockParser().parse(buf);
assertThat(b.stackTempC()).isEqualTo(50);
assertThat(b.airCompressorVoltageV()).isCloseTo(372.0, within(1e-6));
assertThat(b.airCompressorCurrentA()).isCloseTo(0.0, within(1e-6));
assertThat(b.hydrogenPumpVoltageV()).isCloseTo(1.3, within(1e-6));
assertThat(b.hydrogenPumpCurrentA()).isCloseTo(0.0, within(1e-6));
}
}