Every 0x02/0x03/0x07 ack produced an INFO line that, at one report per
vehicle every few seconds across many vehicles, drowns out meaningful
events in the log stream.
writeAck now checks the tag string: tags starting with "report-ack-" or
"heartbeat-ack" log their success at DEBUG (guarded by isDebugEnabled to
avoid building the hex dump string when DEBUG is off). Sparse ack tags
-- vehicle-login-ack / vehicle-logout-ack / platform-login-ack /
platform-logout-ack / time-calibration-ack -- keep INFO for audit.
Flush *failure* is still WARN for every tag so problems are never
silenced.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Adds a vendor-extensions entry under lingniu.ingest.gb32960 mapping the
existing lingniu platform account (already present in auth.platforms)
to the guangdong-fc parser bundle, so that incoming 0x02 realtime frames
from this peer get routed to the GuangdongFc.* parsers instead of
falling through to the default profile and producing the
"unknown info block typeCode=0x30" WARN.
vin-prefixes / vins are left empty pending real values from the peer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds GuangdongFcEndToEndTest covering the full vendor extension pipeline:
build a synthetic V2016 realtime frame containing standard blocks
(0x01/0x05/0x07/0x08/0x09) plus the Guangdong-specific 0x30 stack (with
4 sample cell voltages), 0x32 DC/DC, 0x33 air conditioner, 0x34 vehicle
info, and 0x80 demo extension. Decodes it through Gb32960MessageDecoder
with a hand-wired BodyParser whose Gb32960ProfileRegistry knows about
the guangdong-fc catalog entry and a RuleBasedVendorExtensionSelector
matching platformAccount=lingniu.
Two cases:
- parsesRealtimeFrameWithGuangdongFcExtensions: passes "lingniu" as the
platform account, asserts each GuangdongFc.* record is present and
field values match the synthesized bytes, and that no Raw fallback
is produced.
- unmatchedAccountFallsBackToDefaultAndProducesRawForVendorBlocks: passes
null as the account, asserts standard blocks still parse but the
vendor 0x30 segment now lands in InfoBlock.Raw via the default-profile
unknown-typeCode path. This pins down the selector's gate behavior.
Test count: 30 -> 32. All green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restructure InfoBlock from a flat sealed interface into three nested
sealed interfaces grouped by protocol and version, so every record's
fully-qualified name follows the rule InfoBlock.<protocolVersion>.<dataType>
and the type system enforces strict separation between version variants.
New shape:
InfoBlock (sealed)
├── Gb32960V2016 (sealed)
│ ├── Vehicle (20 B, has accel/brake pedal)
│ ├── DriveMotor (rpm offset 20000, torque 2 B)
│ ├── FuelCell (with N x 1B probe temps)
│ ├── Engine (5 B fixed)
│ ├── Position (9 B, no coord system)
│ ├── Extreme (14 B, 2016-only)
│ ├── Alarm (typeCode 0x07, no general alarm levels)
│ ├── Voltage (0x08 storage voltage)
│ └── Temperature (0x09 storage temperature)
├── Gb32960V2025 (sealed)
│ ├── Vehicle (18 B, no pedal fields)
│ ├── DriveMotor (rpm offset 32000, torque 4 B)
│ ├── FuelCell (no current/voltage, no probe list)
│ ├── Engine (5 B fixed, identical layout to V2016 but
│ duplicated to keep version separation strict)
│ ├── Position (10 B, with coordSystem byte)
│ ├── Alarm (typeCode 0x06, with generalAlarmLevels)
│ ├── MinParallelVoltage (0x07 in V2025)
│ ├── BatteryTemperature (0x08 in V2025)
│ ├── FuelCellStack (0x30)
│ ├── SuperCapacitor (0x31)
│ └── SuperCapacitorExtreme (0x32)
├── GuangdongFc (sealed)
│ ├── Stack (0x30, vendor variant — different field order
│ and adds max/min/avg single-cell voltage stats)
│ ├── Auxiliary (0x31, air compressor / hydrogen pump / PTC)
│ ├── DcDc (0x32 fixed 9 B)
│ ├── AirConditioner (0x33 fixed 5 B)
│ ├── VehicleInfo (0x34 fixed 7 B)
│ └── DemoExtension (0x80 demo extension)
└── Raw
Even types that share an identical field list (Engine, the two
DriveMotor.Motor inner records, Vehicle when ignoring pedals) are
deliberately duplicated rather than shared, per the rule "数据类型严格按
版本拆分". This lets consumers branch on instanceof / pattern-match
without losing track of which protocol version produced the data.
Knock-on changes (mechanical for the most part):
- All 16 standard parsers (v2016/, v2025/) updated to instantiate the
matching nested record; e.g.
new InfoBlock.Vehicle(...)
becomes
new InfoBlock.Gb32960V2016.Vehicle(...).
- The 6 Guangdong vendor parsers now return InfoBlock.GuangdongFc.* records
(previously InfoBlock.GdFcXxx flat records).
- Position/Alarm record pairs split: Position no longer carries a nullable
coordSystem field (V2025 has its own record with coordSystem; V2016
has neither), and Alarm no longer carries the protocolVersion tag —
the nested type already encodes the version.
- Gb32960EventMapper grew three small private "view" records
(VehicleView, PositionView, AlarmView) plus extract* helpers that try
V2016 first then fall back to V2025, so downstream payload assembly
still sees a single uniform view.
- Test files updated to reference the new fully-qualified names.
InfoBlockType enum is left untouched: it is the logical type tag, and
both DriveMotor variants still report InfoBlockType.DRIVE_MOTOR even
though they live under different sealed parents — the *structural*
separation is what matters.
All 30 tests pass (18 existing + 8 selector + 4 vendor parser).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Final piece that lets the vendor extension framework introduced in
0e6f120 actually take effect on real frames.
Decoder (Gb32960MessageDecoder):
- Adds an overload decode(ByteBuffer, String platformAccount) alongside
the existing decode(ByteBuffer). Realtime/resend frames build a
Gb32960ParserContext(version, vin, platformAccount) and pass it to
bodyParser.parse(ctx, body) so the selector can pick a vendor profile.
- The legacy decode(ByteBuffer) delegates with a null account and is
preserved for tests and any caller that doesn't care about routing.
Channel handler (Gb32960ChannelHandler):
- Defines AttributeKey<String> PLATFORM_ACCOUNT_ATTR.
- On a successful 0x05 PLATFORM_LOGIN, stores the authenticated username
on ctx.channel().attr(PLATFORM_ACCOUNT_ATTR).
- On every channelRead0, reads the attribute (null for vehicle-direct
connections) and passes it to decoder.decode(buffer, account).
Autoconfig (Gb32960AutoConfiguration):
- Replaces the old single-registry wiring with three new beans:
* VendorExtensionCatalog with the built-in `guangdong-fc` entry
(the 6 GdFc* parsers from 957a86d)
* Gb32960ProfileRegistry, built from the union of all standard
InfoBlockParser beans plus, for each entry referenced in
lingniu.ingest.gb32960.vendor-extensions, a per-extension registry
that adds the catalog parsers on top of the defaults
* VendorExtensionSelector — RuleBasedVendorExtensionSelector when
vendor-extensions is non-empty, otherwise VendorExtensionSelector.NONE
- gb32960BodyParser now takes (Gb32960ProfileRegistry, VendorExtensionSelector).
Net effect: with the following yml fragment, lingniu account or any VIN
starting with LNVFC routes to the guangdong-fc profile and the previously
"unknown 0x30+" warns become real GdFc* InfoBlock entries:
lingniu.ingest.gb32960.vendor-extensions:
- name: guangdong-fc
match:
platform-accounts: [lingniu]
vin-prefixes: [LNVFC, LZG]
All existing 18 tests + 12 new tests (selector + vendor parsers) pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>