diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/GuangdongFcEndToEndTest.java b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/GuangdongFcEndToEndTest.java new file mode 100644 index 00000000..2a3a959e --- /dev/null +++ b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/GuangdongFcEndToEndTest.java @@ -0,0 +1,296 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAirConditionerBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAuxiliaryBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDcDcBlockParser; +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.profile.Gb32960ProfileRegistry; +import com.lingniu.ingest.protocol.gb32960.codec.profile.RuleBasedVendorExtensionSelector; +import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog; +import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties; +import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960ChannelHandler; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * 端到端集成测试:构造一帧带广东燃料电池规范扩展块的 V2016 帧,验证 + * vendor profile selector 路由 + GuangdongFc 系列 parser 全链路解码。 + * + *
覆盖: + *
0x31 Auxiliary 没有放进帧里(变长且字段相对琐碎),单独由 unit test 覆盖。 + */ +class GuangdongFcEndToEndTest { + + private final Gb32960MessageDecoder decoder = buildDecoder(); + + @Test + void parsesRealtimeFrameWithGuangdongFcExtensions() { + byte[] frame = buildFrame(); + // 用 platformAccount=lingniu 触发 vendor profile + Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame), "lingniu"); + + // ---- 标准块 ---- + var v = msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class).orElseThrow(); + assertThat(v.socPercent()).isEqualTo(85); + + var pos = msg.findBlock(InfoBlock.Gb32960V2016.Position.class).orElseThrow(); + assertThat(pos.longitude()).isCloseTo(120.848158, within(1e-6)); + + // ---- vendor 块:0x30 Stack ---- + var stack = msg.findBlock(InfoBlock.GuangdongFc.Stack.class).orElseThrow(); + assertThat(stack.stackCount()).isEqualTo(1); + var s0 = stack.stacks().get(0); + assertThat(s0.engineWorkState()).isEqualTo(1); + assertThat(s0.stackWaterOutletTempC()).isEqualTo(50); // 90-40 + assertThat(s0.maxCellVoltageV()).isCloseTo(0.745, within(1e-6)); // 745 mV + assertThat(s0.minCellVoltageV()).isCloseTo(0.730, within(1e-6)); + assertThat(s0.avgCellVoltageV()).isCloseTo(0.738, within(1e-6)); + assertThat(s0.cellCount()).isEqualTo(400); + assertThat(s0.frameCellCount()).isEqualTo(4); + assertThat(s0.frameCellVoltagesV()).hasSize(4) + .allSatisfy(c -> assertThat(c).isBetween(0.7, 0.8)); + + // ---- vendor 块:0x32 DcDc ---- + var dcdc = msg.findBlock(InfoBlock.GuangdongFc.DcDc.class).orElseThrow(); + assertThat(dcdc.inputVoltageV()).isCloseTo(300.0, within(1e-6)); + assertThat(dcdc.outputCurrentA()).isCloseTo(110.0, within(1e-6)); + + // ---- vendor 块:0x33 AirConditioner ---- + var ac = msg.findBlock(InfoBlock.GuangdongFc.AirConditioner.class).orElseThrow(); + assertThat(ac.status()).isEqualTo(1); + assertThat(ac.powerKw()).isEqualTo(15); + + // ---- vendor 块:0x34 VehicleInfo ---- + var info = msg.findBlock(InfoBlock.GuangdongFc.VehicleInfo.class).orElseThrow(); + assertThat(info.collisionAlarm()).isEqualTo(0); + assertThat(info.ambientTempC()).isEqualTo(30); + assertThat(info.hydrogenMassKg()).isCloseTo(8.0, within(1e-6)); + + // ---- vendor 块:0x80 DemoExtension ---- + var demo = msg.findBlock(InfoBlock.GuangdongFc.DemoExtension.class).orElseThrow(); + assertThat(demo.stackTempC()).isEqualTo(50); + assertThat(demo.airCompressorVoltageV()).isCloseTo(372.0, within(1e-6)); + + // 不应有 Raw 兜底:所有块都被结构化解析 + assertThat(msg.findBlock(InfoBlock.Raw.class)).isEmpty(); + } + + @Test + void unmatchedAccountFallsBackToDefaultAndProducesRawForVendorBlocks() { + byte[] frame = buildFrame(); + // 不传 account → selector 返回 null → default profile(无 vendor parser) + Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame), null); + + // 标准块仍能解析 + assertThat(msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class)).isPresent(); + // 但 0x30 应触发 unknown → Raw 兜底 + assertThat(msg.findBlock(InfoBlock.Raw.class)).isPresent(); + assertThat(msg.findBlock(InfoBlock.GuangdongFc.Stack.class)).isEmpty(); + } + + // ==================================================================== + // 帧构造:标准块 0x01/0x05/0x07/0x08/0x09 + vendor 块 0x30/0x32/0x33/0x34/0x80 + // 不带 0x02/0x04/0x06/0x31,纯粹为了控制帧长和测试焦点。 + // ==================================================================== + private static byte[] buildFrame() { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + // 时间戳 6B + body.write(24); body.write(4); body.write(15); body.write(11); body.write(0); body.write(0); + + // 0x01 整车数据 20B + body.write(0x01); + body.write(0x01); // vehicleState 启动 + body.write(0x03); // chargingState 未充电 + body.write(0x02); // runningMode 混动 + writeU16(body, 0); // 速度 + writeU32(body, 100_000L); // 累计里程 = 10000 km + writeU16(body, 5605); // totalVoltage 560.5 V + writeU16(body, 10000); // totalCurrent 0 A (after -1000 offset) + body.write(85); // SOC + body.write(0x01); // DC-DC 工作 + body.write(0x00); // 挡位 + writeU16(body, 10000); // 绝缘电阻 + body.write(0x00); // accelPedal + body.write(0x00); // brakePedal + + // 0x05 位置数据 9B + body.write(0x05); + body.write(0x00); // 状态:有效定位 + writeU32(body, 120_848_158L); // 经度 120.848158 + writeU32(body, 31_497_236L); // 纬度 31.497236 + + // 0x07 报警数据 10B:level=0 + flag=0 + 4 个空 fault list + body.write(0x07); + body.write(0x00); // maxLevel + writeU32(body, 0); // generalFlag + body.write(0x00); // batteryFaultCount + body.write(0x00); // motorFaultCount + body.write(0x00); // engineFaultCount + body.write(0x00); // otherFaultCount + + // 0x08 储能电压:1 sub × 0 cell(保持简洁) + body.write(0x08); + body.write(0x01); // subCount + body.write(0x01); // batteryIndex + writeU16(body, 5605); // voltage + writeU16(body, 10000); // current + writeU16(body, 0); // cellCount + writeU16(body, 1); // frameCellStart + body.write(0x00); // frameCellCount = 0 + + // 0x09 储能温度:1 sub × 0 probe + body.write(0x09); + body.write(0x01); // subCount + body.write(0x01); // batteryIndex + writeU16(body, 0); // probeCount = 0 + + // 0x30 GuangdongFc.Stack:1 stack + 4 单体电压 + body.write(0x30); + body.write(0x01); // stackCount + body.write(0x01); // engineWorkState 打开 + body.write(90); // 水温 90-40 = 50°C + writeU16(body, 1850); // h2 入口压力 raw=1850 → 85 kPa + writeU16(body, 1500); // air 入口压力 raw=1500 → 50 kPa + body.write(70); // air 入口温度 70-40 = 30°C + writeU16(body, 12); // maxCellId + writeU16(body, 88); // minCellId + writeU16(body, 745); // maxCellV 745 mV + writeU16(body, 730); // minCellV 730 mV + writeU16(body, 738); // avgCellV 738 mV + writeU16(body, 400); // cellCount + writeU16(body, 1); // frameCellStart + body.write(0x04); // frameCellCount = 4 + writeU16(body, 745); + writeU16(body, 740); + writeU16(body, 735); + writeU16(body, 730); + + // 0x32 GuangdongFc.DcDc 9B + body.write(0x32); + writeU16(body, 3000); // inputV 300.0 + writeU16(body, 1200); // inputC 120.0 + writeU16(body, 5600); // outputV 560.0 + writeU16(body, 1100); // outputC 110.0 + body.write(105); // ctrlTemp 105-40 = 65°C + + // 0x33 GuangdongFc.AirConditioner 5B + body.write(0x33); + body.write(0x01); // status 启动 + writeU16(body, 20); // power 20-5 = 15 kw + writeU16(body, 5400); // compressorInputV 540.0 + + // 0x34 GuangdongFc.VehicleInfo 7B + body.write(0x34); + body.write(0x00); // collision 无 + writeU16(body, 70); // ambientTemp 70-40 = 30°C + writeU16(body, 1100); // ambientPressure raw=1100 → 10.0 kPa + writeU16(body, 80); // h2Mass 80*0.1 = 8.0 kg + + // 0x80 GuangdongFc.DemoExtension 12B(含 2B 内层 length=9) + body.write(0x80); + writeU16(body, 9); // declared length + body.write(90); // stackTemp 50°C + writeU16(body, 3720); // ac voltage 372.0 + writeU16(body, 10000); // ac current 0 A + writeU16(body, 13); // h2 pump voltage 1.3 + writeU16(body, 10000); // h2 pump current 0 A + + byte[] bodyBytes = body.toByteArray(); + + // 帧封装 + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x23); out.write(0x23); + out.write(0x02); // cmd realtime + out.write(0xFE); // 命令包 + byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII); + out.write(vin, 0, 17); + out.write(0x01); // 不加密 + writeU16(out, bodyBytes.length); + out.write(bodyBytes, 0, bodyBytes.length); + byte[] almost = out.toByteArray(); + byte bcc = BccChecksum.compute(almost, 2, almost.length - 2); + out.write(bcc & 0xFF); + return out.toByteArray(); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(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)); + } + + // ==================================================================== + // Decoder 装配:手动构造一个挂着 guangdong-fc 路由的 BodyParser + // ==================================================================== + private static Gb32960MessageDecoder buildDecoder() { + var standardParsers = List.of( + (com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser) new VehicleV2016BlockParser(), + new DriveMotorV2016BlockParser(), + new PositionV2016BlockParser(), + new AlarmV2016BlockParser(), + new VoltageV2016BlockParser(), + new TemperatureV2016BlockParser()); + var catalog = new VendorExtensionCatalog(java.util.Map.of( + "guangdong-fc", List.of( + new GdFcStackBlockParser(), + new GdFcAuxiliaryBlockParser(), + new GdFcDcDcBlockParser(), + new GdFcAirConditionerBlockParser(), + new GdFcVehicleInfoBlockParser(), + new GdFcDemoExtensionBlockParser()))); + var profileRegistry = new Gb32960ProfileRegistry( + standardParsers, catalog, Set.of("guangdong-fc")); + + // selector:lingniu 账号 → guangdong-fc + var entry = new Gb32960Properties.VendorExtension(); + entry.setName("guangdong-fc"); + var match = new Gb32960Properties.VendorExtension.Match(); + match.setPlatformAccounts(List.of("lingniu")); + entry.setMatch(match); + var selector = new RuleBasedVendorExtensionSelector( + List.of(entry), Set.of("guangdong-fc")); + + var bodyParser = new Gb32960BodyParser(profileRegistry, selector); + // PLATFORM_ACCOUNT_ATTR 仅供 handler 使用,这里不需要 + return new Gb32960MessageDecoder(bodyParser); + } + + // suppress unused-import warning for the channel handler attr key + @SuppressWarnings("unused") + private static final Object KEEP_ATTR_REF = Gb32960ChannelHandler.PLATFORM_ACCOUNT_ATTR; +}