feat(gb32960): wire vendor parser profile selector into decoder pipeline

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>
This commit is contained in:
lingniu-dev
2026-04-15 16:38:31 +08:00
parent 957a86d581
commit 982272460a
3 changed files with 85 additions and 7 deletions

View File

@@ -66,6 +66,15 @@ public final class Gb32960MessageDecoder implements FrameDecoder<Gb32960Message>
@Override
public Gb32960Message decode(ByteBuffer buffer) {
return decode(buffer, null);
}
/**
* 带 platformAccount 提示的解码。Handler 在已经完成 0x05 PLATFORM_LOGIN 鉴权后通过
* channel attribute 取到账号名再传进来;以便 BodyParser 内部的 vendor profile selector
* 路由。其他命令传 {@code null} 即可。
*/
public Gb32960Message decode(ByteBuffer buffer, String platformAccount) {
int frameLen = buffer.remaining();
if (frameLen < MIN_FRAME) {
throw new DecodeException("frame too short: " + frameLen);
@@ -103,7 +112,8 @@ public final class Gb32960MessageDecoder implements FrameDecoder<Gb32960Message>
if (dataLength < 6) throw new DecodeException("report body missing timestamp");
Instant eventTime = parseTimestamp(frame, bodyStart);
ByteBuffer body = ByteBuffer.wrap(frame, bodyStart + 6, dataLength - 6);
BodyParseResult result = bodyParser.parse(version, body);
Gb32960ParserContext ctx = new Gb32960ParserContext(version, vin, platformAccount);
BodyParseResult result = bodyParser.parse(ctx, body);
Gb32960Header header = new Gb32960Header(
version, cmdType, rspFlag, vin, encType, dataLength, eventTime);
return new Gb32960Message(header, result.blocks(), null, result.signature());

View File

@@ -28,6 +28,16 @@ import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.PositionV2025Block
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.SuperCapacitorExtremeV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.SuperCapacitorV2025BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.VehicleV2025BlockParser;
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.codec.profile.VendorExtensionSelector;
import com.lingniu.ingest.protocol.gb32960.handler.Gb32960RealtimeHandler;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer;
import com.lingniu.ingest.protocol.gb32960.mapper.Gb32960EventMapper;
@@ -79,18 +89,63 @@ public class Gb32960AutoConfiguration {
@Bean public InfoBlockParser gb32960V2025SuperCap() { return new SuperCapacitorV2025BlockParser(); }
@Bean public InfoBlockParser gb32960V2025SuperCapExtreme() { return new SuperCapacitorExtremeV2025BlockParser(); }
// ================= Core components =================
// ================= Vendor 扩展套件目录 =================
/**
* 内置 vendor 扩展套件目录。当前注册:
* <ul>
* <li>{@code guangdong-fc} —— 广东燃料电池汽车示范规范 0x30~0x34、0x80
* </ul>
* 未来新增其他厂商扩展时直接在这里加 entry。
*/
@Bean
@ConditionalOnMissingBean
public InfoBlockParserRegistry gb32960InfoBlockParserRegistry(List<InfoBlockParser> parsers) {
return new InfoBlockParserRegistry(parsers);
public VendorExtensionCatalog gb32960VendorExtensionCatalog() {
return new VendorExtensionCatalog(java.util.Map.of(
"guangdong-fc", List.of(
new GdFcStackBlockParser(),
new GdFcAuxiliaryBlockParser(),
new GdFcDcDcBlockParser(),
new GdFcAirConditionerBlockParser(),
new GdFcVehicleInfoBlockParser(),
new GdFcDemoExtensionBlockParser())));
}
// ================= Core components =================
/**
* Profile 注册表:把全部已注入的标准 parser 作为 default profile
* 同时按配置 {@code lingniu.ingest.gb32960.vendor-extensions} 里被引用的 vendor 套件名
* 分别构建一份 "default + vendor" 的 registry。
*/
@Bean
@ConditionalOnMissingBean
public Gb32960ProfileRegistry gb32960ProfileRegistry(List<InfoBlockParser> parsers,
VendorExtensionCatalog catalog,
Gb32960Properties props) {
java.util.Set<String> enabled = new java.util.LinkedHashSet<>();
for (Gb32960Properties.VendorExtension e : props.getVendorExtensions()) {
if (e.getName() != null && !e.getName().isBlank()) enabled.add(e.getName());
}
return new Gb32960ProfileRegistry(parsers, catalog, enabled);
}
/** Vendor 扩展选择器:按配置 list 顺序首匹。 */
@Bean
@ConditionalOnMissingBean
public VendorExtensionSelector gb32960VendorExtensionSelector(Gb32960Properties props,
VendorExtensionCatalog catalog) {
if (props.getVendorExtensions() == null || props.getVendorExtensions().isEmpty()) {
return VendorExtensionSelector.NONE;
}
return new RuleBasedVendorExtensionSelector(props.getVendorExtensions(), catalog.names());
}
@Bean
@ConditionalOnMissingBean
public Gb32960BodyParser gb32960BodyParser(InfoBlockParserRegistry registry) {
return new Gb32960BodyParser(registry);
public Gb32960BodyParser gb32960BodyParser(Gb32960ProfileRegistry profileRegistry,
VendorExtensionSelector selector) {
return new Gb32960BodyParser(profileRegistry, selector);
}
@Bean

View File

@@ -19,6 +19,7 @@ import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.util.AttributeKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -47,6 +48,14 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
private static final Logger log = LoggerFactory.getLogger(Gb32960ChannelHandler.class);
/**
* 0x05 PLATFORM_LOGIN 鉴权通过后写入;后续 0x02/0x03 实时上报帧从 channel attribute 取出
* 作为 {@code Gb32960ParserContext.platformAccount},供 vendor profile selector 路由。
* 普通车端直连场景0x01 VEHICLE_LOGIN此 attribute 始终为 null。
*/
public static final AttributeKey<String> PLATFORM_ACCOUNT_ATTR =
AttributeKey.valueOf("gb32960.platformAccount");
/** 仅用于调试:把解析后的 Gb32960Message 序列化成 JSON 打到日志。 */
private static final ObjectMapper DEBUG_JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
@@ -94,7 +103,9 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
protected void channelRead0(ChannelHandlerContext ctx, byte[] frame) {
Gb32960Message msg;
try {
msg = decoder.decode(ByteBuffer.wrap(frame));
String platformAccount = ctx.channel().hasAttr(PLATFORM_ACCOUNT_ATTR)
? ctx.channel().attr(PLATFORM_ACCOUNT_ATTR).get() : null;
msg = decoder.decode(ByteBuffer.wrap(frame), platformAccount);
} catch (Exception e) {
log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e);
return;
@@ -164,6 +175,8 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
}
log.info("[gb32960] platform login peer={} username={} encryptRule={} serial={} time={} policy={}",
addr(ctx), pl.username(), pl.encryptRule(), pl.serialNo(), pl.loginTime(), result);
// 把 username 钉到 channel attribute供后续 0x02/0x03 帧的 vendor profile 路由
ctx.channel().attr(PLATFORM_ACCOUNT_ATTR).set(pl.username());
byte[] ack = Gb32960FrameEncoder.buildResponse(
msg.header().protocolVersion(),
CommandType.PLATFORM_LOGIN,