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,34 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池规范 §7.2.3.7 表 18空调数据。typeCode {@code 0x33}。
*
* <p>固定 5 字节:
* <pre>
* status(1) 0 关闭 / 1 启动
* powerKw(2, offset -5, 1 kw, range -5~+30)
* compressorInputVoltageV(2, 0.1V, 0~1000V)
* </pre>
*/
public final class GdFcAirConditionerBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x33; }
@Override public int fixedLength() { return 5; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer status = ValueDecoder.u8raw(buf);
int rawPower = buf.getShort() & 0xFFFF;
Integer power = (rawPower == 0xFFFE || rawPower == 0xFFFF) ? null : rawPower - 5;
Double inputV = ValueDecoder.scaledU16(buf, 0.1);
return new InfoBlock.GdFcAirConditioner(status, power, inputV);
}
}

View File

@@ -0,0 +1,61 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 广东燃料电池规范 §7.2.3.5 表 15/16辅助系统数据。typeCode {@code 0x31}。变长。
*
* <pre>
* subSystemCount(1)
* For each:
* airCompressorMotorVoltageV(2, 0.1V, 0~1000V)
* airCompressorPowerKw(2, offset -5, 1 kw, range -5~+30)
* hydrogenPumpMotorVoltageV(2, 0.2V, 0~50V)
* hydrogenPumpPowerKw(2, offset -5, 1 kw)
* waterPumpVoltageV(2, 0.1V)
* ptcVoltageV(2, 0.1V)
* ptcPowerKw(2, offset -5, 1 kw)
* lowVoltageBatteryVoltageV(2, 0.1V, 0~32V)
* </pre>
*
* 每子系统 16 字节。
*/
public final class GdFcAuxiliaryBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x31; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int count = buf.get() & 0xFF;
List<InfoBlock.GdFcAuxiliary.Subsystem> subs = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
Double airCompV = ValueDecoder.scaledU16(buf, 0.1);
Integer airCompP = readPowerOffsetMinus5(buf);
Double h2PumpV = ValueDecoder.scaledU16(buf, 0.2);
Integer h2PumpP = readPowerOffsetMinus5(buf);
Double waterPumpV = ValueDecoder.scaledU16(buf, 0.1);
Double ptcV = ValueDecoder.scaledU16(buf, 0.1);
Integer ptcP = readPowerOffsetMinus5(buf);
Double lvBattV = ValueDecoder.scaledU16(buf, 0.1);
subs.add(new InfoBlock.GdFcAuxiliary.Subsystem(
airCompV, airCompP, h2PumpV, h2PumpP, waterPumpV, ptcV, ptcP, lvBattV));
}
return new InfoBlock.GdFcAuxiliary(count, subs);
}
/** 功率字段raw(2B) - 50xFFFE/0xFFFF 异常/无效。 */
private static Integer readPowerOffsetMinus5(ByteBuffer buf) {
int raw = buf.getShort() & 0xFFFF;
if (raw == 0xFFFE || raw == 0xFFFF) return null;
return raw - 5;
}
}

View File

@@ -0,0 +1,37 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池规范 §7.2.3.6 表 17DC/DC燃料电池系统数据。typeCode {@code 0x32}。
*
* <p>固定 9 字节:
* <pre>
* inputVoltageV(2, 0.1V, 0~400V)
* inputCurrentA(2, 0.1A, 0~600A)
* outputVoltageV(2, 0.1V, 0~700V)
* outputCurrentA(2, 0.1A, 0~600A)
* controllerTempC(1, -40 offset)
* </pre>
*/
public final class GdFcDcDcBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x32; }
@Override public int fixedLength() { return 9; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Double inV = ValueDecoder.scaledU16(buf, 0.1);
Double inC = ValueDecoder.scaledU16(buf, 0.1);
Double outV = ValueDecoder.scaledU16(buf, 0.1);
Double outC = ValueDecoder.scaledU16(buf, 0.1);
Integer ctrlTemp = ValueDecoder.tempOffset40(buf);
return new InfoBlock.GdFcDcDc(inV, inC, outV, outC, ctrlTemp);
}
}

View File

@@ -0,0 +1,47 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池规范 §7.2.3.14 表 27燃料电池汽车示范应用数据扩展。typeCode {@code 0x80}。
*
* <p>结构typeCode 由外层消费):
* <pre>
* dataLength (2B WORD) = 9 表示后续扩展数据长度
* stackTempC (1B, -40 offset, -40~+210°C)
* airCompressorVoltageV (2B, 0.1V, 0~2000V)
* airCompressorCurrentA (2B, 0.1A, -1000 offset, -1000~+1000A)
* hydrogenPumpVoltageV (2B, 0.1V, 0~2000V)
* hydrogenPumpCurrentA (2B, 0.1A, -1000 offset, -1000~+1000A)
* </pre>
*
* 含 length 字段共 11 字节 body不含外层 1B typeCode
*/
public final class GdFcDemoExtensionBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x80; }
@Override public int fixedLength() { return 11; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int declaredLen = buf.getShort() & 0xFFFF;
// declaredLen 通常 = 9但即便不是也按 9 字节固定布局解,避免无谓的失败。
Integer stackTemp = ValueDecoder.tempOffset40(buf);
Double acV = ValueDecoder.scaledU16(buf, 0.1);
Double acC = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
Double h2V = ValueDecoder.scaledU16(buf, 0.1);
Double h2C = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
// 防御:若 declaredLen 与实际不一致,仅在 DEBUG 留痕。
if (declaredLen != 9 && org.slf4j.LoggerFactory.getLogger(GdFcDemoExtensionBlockParser.class).isDebugEnabled()) {
org.slf4j.LoggerFactory.getLogger(GdFcDemoExtensionBlockParser.class)
.debug("[gb32960/gd-fc/0x80] declaredLen={} (expected 9), parsed by fixed layout", declaredLen);
}
return new InfoBlock.GdFcDemoExtension(stackTemp, acV, acC, h2V, h2C);
}
}

View File

@@ -0,0 +1,75 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 广东燃料电池汽车示范应用城市群综合监管平台 §7.2.3.4 表 13/14燃料电池电堆数据。
*
* <p>typeCode {@code 0x30},工程上观察到对端在 GB/T 32960.3-<b>2016</b> 帧({@code 2323}
* 起始符)里下发该块。本规范在 GB/T 32960.3-2016 标准里属于 {@code 0x30~0x7F} 预留区段,
* 因此只有当对端被 vendor profile 选中后才启用此 parser。
*
* <p>结构:
* <pre>
* stackCount (1B)
* For each stack:
* engineWorkState (1B) 00 关闭 / 01 打开 / FE 异常 / FF 无效
* stackWaterOutletTempC (1B, -40 offset)
* hydrogenInletPressureKpa (2B WORD, -100 offset, 0.1 kPa)
* airInletPressureKpa (2B WORD, -100 offset, 0.1 kPa)
* airInletTempC (1B, -40 offset)
* maxCellVoltageId (2B WORD)
* minCellVoltageId (2B WORD)
* maxCellVoltageV (2B WORD, 0.001 V)
* minCellVoltageV (2B WORD, 0.001 V)
* avgCellVoltageV (2B WORD, 0.001 V)
* cellCount (2B WORD)
* frameCellStart (2B WORD)
* frameCellCount (1B)
* frameCellVoltagesV (2 * frameCellCount, 0.001 V)
* </pre>
*/
public final class GdFcStackBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x30; }
@Override public int fixedLength() { return -1; }
@Override
public InfoBlock parse(ByteBuffer buf) {
int stackCount = buf.get() & 0xFF;
List<InfoBlock.GdFcStack.Stack> stacks = new ArrayList<>(stackCount);
for (int i = 0; i < stackCount; i++) {
Integer workState = ValueDecoder.u8raw(buf);
Integer waterTemp = ValueDecoder.tempOffset40(buf);
Double h2InletP = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
Double airInletP = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
Integer airInletTemp = ValueDecoder.tempOffset40(buf);
Integer maxId = ValueDecoder.u16(buf);
Integer minId = ValueDecoder.u16(buf);
Double maxV = ValueDecoder.scaledU16(buf, 0.001);
Double minV = ValueDecoder.scaledU16(buf, 0.001);
Double avgV = ValueDecoder.scaledU16(buf, 0.001);
Integer cellCount = ValueDecoder.u16(buf);
Integer frameStart = ValueDecoder.u16(buf);
int frameCells = buf.get() & 0xFF;
List<Double> frameVoltages = new ArrayList<>(frameCells);
int safe = Math.min(frameCells, buf.remaining() / 2);
for (int k = 0; k < safe; k++) {
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);
}
stacks.add(new InfoBlock.GdFcStack.Stack(
workState, waterTemp, h2InletP, airInletP, airInletTemp,
maxId, minId, maxV, minV, avgV,
cellCount, frameStart, frameCells, frameVoltages));
}
return new InfoBlock.GdFcStack(stackCount, stacks);
}
}

View File

@@ -0,0 +1,36 @@
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import java.nio.ByteBuffer;
/**
* 广东燃料电池规范 §7.2.3.8 表 19车辆信息补充。typeCode {@code 0x34}。
*
* <p>固定 7 字节:
* <pre>
* collisionAlarm(1) 0 无 / 1 有
* ambientTempC(2, -40 offset, 1°C)
* ambientPressureKpa(2, -100 offset, 0.1 kPa)
* hydrogenMassKg(2, 0.1 kg)
* </pre>
*/
public final class GdFcVehicleInfoBlockParser implements InfoBlockParser {
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
@Override public int typeCode() { return 0x34; }
@Override public int fixedLength() { return 7; }
@Override
public InfoBlock parse(ByteBuffer buf) {
Integer collision = ValueDecoder.u8raw(buf);
int rawTemp = buf.getShort() & 0xFFFF;
Integer tempC = (rawTemp == 0xFFFE || rawTemp == 0xFFFF) ? null : rawTemp - 40;
Double pressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
Double h2Mass = ValueDecoder.scaledU16(buf, 0.1);
return new InfoBlock.GdFcVehicleInfo(collision, tempC, pressure, h2Mass);
}
}

View File

@@ -32,6 +32,12 @@ public sealed interface InfoBlock
InfoBlock.FuelCellStack,
InfoBlock.SuperCapacitor,
InfoBlock.SuperCapacitorExtreme,
InfoBlock.GdFcStack,
InfoBlock.GdFcAuxiliary,
InfoBlock.GdFcDcDc,
InfoBlock.GdFcAirConditioner,
InfoBlock.GdFcVehicleInfo,
InfoBlock.GdFcDemoExtension,
InfoBlock.Raw {
InfoBlockType type();
@@ -402,6 +408,95 @@ public sealed interface InfoBlock
@Override public InfoBlockType type() { return InfoBlockType.SUPER_CAPACITOR_EXTREME; }
}
// ========================================================================
// 广东燃料电池汽车示范规范扩展块typeCode 0x30~0x34、0x80
// ========================================================================
/**
* 广东规范 0x30 燃料电池电堆数据(表 13/14。变长。
* 一个 stackCount + 多个 stack每个 stack 包含定长字段后跟 frameCellCount 个单体电压2B/个)。
*/
record GdFcStack(int stackCount, List<Stack> stacks) implements InfoBlock {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_STACK; }
public record Stack(
Integer engineWorkState, // 1B
Integer stackWaterOutletTempC, // 1B (-40 offset)
Double hydrogenInletPressureKpa, // 2B (-100 offset, 0.1 kPa)
Double airInletPressureKpa, // 2B (-100 offset, 0.1 kPa)
Integer airInletTempC, // 1B (-40 offset)
Integer maxCellVoltageId, // 2B
Integer minCellVoltageId, // 2B
Double maxCellVoltageV, // 2B (0.001 V)
Double minCellVoltageV, // 2B (0.001 V)
Double avgCellVoltageV, // 2B (0.001 V)
Integer cellCount, // 2B
Integer frameCellStart, // 2B
Integer frameCellCount, // 1B
List<Double> frameCellVoltagesV // 2*m B (0.001 V)
) {}
}
/** 广东规范 0x31 辅助系统数据(表 15/16。变长。 */
record GdFcAuxiliary(int subSystemCount, List<Subsystem> subsystems) implements InfoBlock {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_AUXILIARY; }
public record Subsystem(
Double airCompressorMotorVoltageV, // 2B 0.1V
Integer airCompressorPowerKw, // 2B (-5 offset, 1 kw)
Double hydrogenPumpMotorVoltageV, // 2B 0.2V
Integer hydrogenPumpPowerKw, // 2B (-5 offset, 1 kw)
Double waterPumpVoltageV, // 2B 0.1V
Double ptcVoltageV, // 2B 0.1V
Integer ptcPowerKw, // 2B (-5 offset, 1 kw)
Double lowVoltageBatteryVoltageV // 2B 0.1V
) {}
}
/** 广东规范 0x32 DC/DC燃料电池系统数据表 17。固定 9 字节。 */
record GdFcDcDc(
Double inputVoltageV, // 2B 0.1V (0~400V)
Double inputCurrentA, // 2B 0.1A (0~600A)
Double outputVoltageV, // 2B 0.1V (0~700V)
Double outputCurrentA, // 2B 0.1A (0~600A)
Integer controllerTempC // 1B -40 offset
) implements InfoBlock {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_DCDC; }
}
/** 广东规范 0x33 空调数据(表 18。固定 5 字节。 */
record GdFcAirConditioner(
Integer status, // 1B 0 关闭 / 1 启动
Integer powerKw, // 2B (-5 offset, 1 kw, range -5~+30)
Double compressorInputVoltageV // 2B 0.1V (0~1000V)
) implements InfoBlock {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_AIR_CONDITIONER; }
}
/** 广东规范 0x34 车辆信息数据(表 19。固定 7 字节。 */
record GdFcVehicleInfo(
Integer collisionAlarm, // 1B 0 无 / 1 有
Integer ambientTempC, // 2B (-40 offset, 1°C)
Double ambientPressureKpa, // 2B (-100 offset, 0.1 kPa)
Double hydrogenMassKg // 2B 0.1 kg
) implements InfoBlock {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_VEHICLE_INFO; }
}
/**
* 广东规范 0x80 燃料电池汽车示范应用数据扩展(表 27。固定 12 字节
* (含 2 字节内层 length=9 + 9 字节数据)。
*/
record GdFcDemoExtension(
Integer stackTempC, // 1B -40 offset
Double airCompressorVoltageV, // 2B 0.1V (0~2000V)
Double airCompressorCurrentA, // 2B 0.1A (-1000 offset)
Double hydrogenPumpVoltageV, // 2B 0.1V (0~2000V)
Double hydrogenPumpCurrentA // 2B 0.1A (-1000 offset)
) implements InfoBlock {
@Override public InfoBlockType type() { return InfoBlockType.GD_FC_DEMO_EXTENSION; }
}
// ========================================================================
// Raw / Unknown
// ========================================================================

View File

@@ -44,6 +44,18 @@ public enum InfoBlockType {
SUPER_CAPACITOR_EXTREME,
/** 自定义数据 0x80~0xFE表 27。 */
USER_DEFINED,
/** 广东燃料电池规范 0x30 燃料电池电堆数据(表 14。 */
GD_FC_STACK,
/** 广东燃料电池规范 0x31 辅助系统数据(表 16。 */
GD_FC_AUXILIARY,
/** 广东燃料电池规范 0x32 DC/DC 数据(表 17。 */
GD_FC_DCDC,
/** 广东燃料电池规范 0x33 空调数据(表 18。 */
GD_FC_AIR_CONDITIONER,
/** 广东燃料电池规范 0x34 车辆信息数据(表 19。 */
GD_FC_VEHICLE_INFO,
/** 广东燃料电池规范 0x80 燃料电池汽车示范应用数据扩展(表 27。 */
GD_FC_DEMO_EXTENSION,
/** 原始/未解析。 */
RAW
}

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