docs: add detailed 32960 pipeline comments

This commit is contained in:
kkfluous
2026-06-23 13:17:37 +08:00
parent ba68ffe061
commit a096e4ce0e
125 changed files with 493 additions and 14 deletions

View File

@@ -20,6 +20,9 @@ import java.util.Set;
* <li>平台登入有 12B 用户名 + 20B 密码 + 可选 IP 白名单
* </ul>
*
* <p>平台账号不仅用于 0x05 鉴权,也会绑定到 channel attribute后续 0x02/0x03 上报会用它选择
* vendor profile。账号配错时可能表现为 0x30+ 厂商字段全部落 Raw。
*
* <p>匹配规则:
* <ol>
* <li>配置列表为空 → 返回 {@link Result#ALLOW_NO_POLICY}(兼容老行为,放行但标记未鉴权)

View File

@@ -13,6 +13,9 @@ import java.util.concurrent.atomic.AtomicReference;
*
* <p>线程安全:内部持有 {@code AtomicReference<Set>},支持运行时热替换白名单
* (未来可扩展为从 Nacos / 数据库定时刷新)。
*
* <p>该校验只决定是否允许设备继续进入接收链路,不负责车辆主数据注册。生产环境若打开白名单,
* 需要确保新增车辆先同步到这里,否则终端会收到 VIN_NOT_EXIST 应答并断开。
*/
public final class Gb32960VinAuthorizer {
@@ -38,6 +41,7 @@ public final class Gb32960VinAuthorizer {
public boolean isAllowed(String vin) {
if (!enabled) return true;
if (vin == null || vin.isBlank()) return false;
// 默认大小写不敏感,以容忍部分平台把 VIN 小写转发;规范 VIN 本身仍应为大写。
String key = caseSensitive ? vin.trim() : vin.trim().toUpperCase();
return whitelist.get().contains(key);
}

View File

@@ -97,6 +97,7 @@ public final class Gb32960BodyParser {
/** 主入口:根据上下文按 selector 选 vendor profile然后照旧解析循环。 */
public Gb32960MessageDecoder.BodyParseResult parse(Gb32960ParserContext ctx, ByteBuffer body) {
ProtocolVersion version = ctx.version();
// selector 只决定“标准 parser 集合上是否叠加某个 vendor 扩展集合”;标准 typeCode 始终可解析。
InfoBlockParserRegistry registry = profileRegistry.forProfile(selector.select(ctx));
List<InfoBlock> blocks = new ArrayList<>(8);
byte[] signature = null;
@@ -121,6 +122,7 @@ public final class Gb32960BodyParser {
if (parser == null) {
// lenient未知类型无法安全跳过吞剩余字节作为 Raw 后终止。
// 真实 typeCode 透传到 Raw 字段,便于在日志/JSON 里直接看到漂移落点。
// 如果该未知块实际是 TLV应优先新增专用 TLV parser让主循环可以继续解析后续块。
int remaining = body.remaining();
byte[] rest = new byte[remaining];
body.get(rest);
@@ -149,6 +151,7 @@ public final class Gb32960BodyParser {
"info block 0x" + Integer.toHexString(typeCode)
+ " needs " + fixedLen + " bytes but got " + body.remaining());
}
// 固定长度块尾部缺字节时不猜测字段;保留剩余 bytes方便后续用 RAW 回放复盘。
int remaining = body.remaining();
byte[] tail = new byte[remaining];
body.get(tail);

View File

@@ -83,6 +83,8 @@ public final class Gb32960CommandParser {
Instant time = readTime(buf);
int serial = buf.getShort() & 0xFFFF;
String user = readAscii(buf, 12);
// 标准密码字段为 20B但现场存在扩展到 32B 的平台登入包;这里按剩余长度-1 读取,
// 最后一字节仍保留给加密规则,兼容两种形态。
int passwordLength = Math.max(0, buf.remaining() - 1);
String pwd = readAscii(buf, passwordLength);
EncryptType enc = EncryptType.of(buf.get() & 0xFF);

View File

@@ -76,6 +76,8 @@ public class Gb32960FrameDecoder extends ByteToMessageDecoder {
if (command != 0x05 || dataLen != 41) {
return standardLen;
}
// 现场平台登入包存在"声明 41B、实际 53B"的兼容形态;只有扩展长度 BCC 成立时才按 53B 分帧,
// 否则回退标准长度,避免误吞下一条帧。
int extendedLen = HEADER_LEN + 53 + 1;
if (in.readableBytes() < extendedLen) {
return standardLen;

View File

@@ -17,6 +17,9 @@ import java.time.ZoneId;
*
* <p>应答规则§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、
* 重新计算校验位。
*
* <p>该类只做协议编码,不写 Channel。是否立即关闭连接由 {@code Gb32960AckService}
* 根据鉴权结果决定。
*/
public final class Gb32960FrameEncoder {

View File

@@ -139,6 +139,7 @@ public final class Gb32960MessageDecoder implements FrameDecoder<Gb32960Message>
}
private static boolean isExtendedPlatformLogin(int commandCode, int declaredDataLength, int actualDataLength) {
// 与 FrameDecoder 的兼容规则保持一致:部分平台登录帧声明长度 41B但真实账号/密码体为 53B。
return commandCode == 0x05 && declaredDataLength == 41 && actualDataLength == 53;
}

View File

@@ -11,6 +11,8 @@ import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
* @param version 协议版本(来自帧起始符)
* @param vin 17 字节 VINtrim 后),平台登入 0x05 等帧 VIN 全 0 时为空串
* @param platformAccount 平台登入用户名;非平台对接场景为 null
*
* <p>广东燃料电池扩展等厂商字段通常不是靠 VIN 就能可靠判断,上游平台账号是更稳定的路由线索。
*/
public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) {

View File

@@ -18,6 +18,7 @@ public final class InfoBlockParserRegistry {
public InfoBlockParserRegistry(List<InfoBlockParser> list) {
for (InfoBlockParser p : list) {
// 后注册覆盖前注册vendor profile 可以用同一 typeCode 替换默认解析器。
parsers.put(new Key(p.version(), p.typeCode()), p);
}
}

View File

@@ -33,6 +33,7 @@ public final class ValueDecoder {
}
public static int u16raw(ByteBuffer buf) {
// 仅用于长度、数量、bitmask 等协议控制字段;业务测量值应优先走 u16/scaledU16。
return buf.getShort() & 0xFFFF;
}
@@ -45,6 +46,7 @@ public final class ValueDecoder {
}
public static long u32raw(ByteBuffer buf) {
// 仅用于故障码、标志位等需要保留全部 bit 的字段。
return buf.getInt() & 0xFFFFFFFFL;
}

View File

@@ -38,6 +38,7 @@ public final class AlarmV2016BlockParser implements InfoBlockParser {
int count = buf.get() & 0xFF;
List<Long> out = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
// 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。
out.add(buf.getInt() & 0xFFFFFFFFL);
}
return out;

View File

@@ -27,6 +27,7 @@ public final class DriveMotorV2016BlockParser implements InfoBlockParser {
int count = buf.get() & 0xFF;
List<InfoBlock.Gb32960V2016.DriveMotor.Motor> motors = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
// rpm/torque 在标准里是带偏移的无符号数,先按原始无符号读,再统一减偏移。
Integer serialNo = ValueDecoder.u8(buf);
Integer state = ValueDecoder.u8(buf);
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);

View File

@@ -19,6 +19,7 @@ public final class ExtremeValueV2016BlockParser implements InfoBlockParser {
@Override
public InfoBlock parse(ByteBuffer buf) {
// 极值块只给出子系统号/单体号/探针号及对应值,不包含完整单体数组。
Integer maxVSub = ValueDecoder.u8(buf);
Integer maxVCell = ValueDecoder.u8(buf);
Double maxV = ValueDecoder.scaledU16(buf, 0.001);

View File

@@ -30,6 +30,7 @@ public final class FuelCellV2016BlockParser implements InfoBlockParser {
// 防御probeCount 异常时回退为空列表避免吞 buffer
List<Integer> probes = new ArrayList<>();
int tailFixed = 2 + 1 + 2 + 1 + 2 + 1 + 1; // 尾部固定 10 字节
// probeCount 来自车端报文,错误值会导致尾部固定段错位;这里先确保尾部还能完整解析。
if (probeCount >= 0 && probeCount <= buf.remaining() - tailFixed) {
for (int i = 0; i < probeCount; i++) {
Integer t = ValueDecoder.tempOffset40(buf);

View File

@@ -25,6 +25,7 @@ public final class PositionV2016BlockParser implements InfoBlockParser {
long latRaw = buf.getInt() & 0xFFFFFFFFL;
double longitude = lonRaw / 1_000_000.0;
double latitude = latRaw / 1_000_000.0;
// 标准用状态位表达南纬/西经;原始经纬度本身始终按正数传输。
if ((statusFlag & 0b100) != 0) longitude = -longitude;
if ((statusFlag & 0b010) != 0) latitude = -latitude;
return new InfoBlock.Gb32960V2016.Position(statusFlag, longitude, latitude);

View File

@@ -26,6 +26,7 @@ public final class TemperatureV2016BlockParser implements InfoBlockParser {
for (int i = 0; i < subCount; i++) {
buf.get(); // batteryIndex
int probes = buf.getShort() & 0xFFFF;
// 与电压块类似,这里暂不保存每个探针,只输出可查询的最高/最低温摘要。
for (int p = 0; p < probes; p++) {
int t = (buf.get() & 0xFF) - 40;
if (t > maxT) maxT = t;

View File

@@ -13,6 +13,8 @@ import java.nio.ByteBuffer;
* + totalMileageKm(4) + totalVoltageV(2) + totalCurrentA(2)
* + socPercent(1) + dcDcStatus(1) + gearRaw(1) + insulationKohm(2)
* + accelPedal(1) + brakePedal(1)。
*
* <p>该块是实时事件和 snapshot 的核心公共字段来源,字段名变更会影响前端常用查询。
*/
public final class VehicleV2016BlockParser implements InfoBlockParser {

View File

@@ -34,12 +34,14 @@ public final class VoltageV2016BlockParser implements InfoBlockParser {
int cellCount = buf.getShort() & 0xFFFF;
buf.getShort(); // frameCellStart
int frameCellCount = buf.get() & 0xFF;
// 报文可能只上传本帧范围内的单体电压;聚合层只保存最高/最低/总压摘要。
int actual = Math.min(frameCellCount, cellCount);
for (int c = 0; c < actual; c++) {
double cellV = (buf.getShort() & 0xFFFF) * 0.001;
if (cellV > maxCell) maxCell = cellV;
if (cellV < minCell) minCell = cellV;
}
// 如果 frameCellCount 大于 cellCount剩余字节仍要消费掉避免影响后续子系统解析。
for (int c = actual; c < frameCellCount; c++) buf.getShort();
}
if (maxCell == Double.MIN_VALUE) maxCell = 0;

View File

@@ -30,6 +30,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser {
int generalAlarmCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
List<InfoBlock.Gb32960V2025.Alarm.GeneralAlarmEntry> levels = new ArrayList<>(generalAlarmCount);
// 2025 新增通用报警等级列表;尾部不完整时保留已读条目,避免整帧丢弃。
for (int i = 0; i < generalAlarmCount && buf.remaining() >= 2; i++) {
int bit = buf.get() & 0xFF;
int level = buf.get() & 0xFF;
@@ -44,6 +45,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser {
int count = buf.get() & 0xFF;
List<Long> out = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
// 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。
out.add(buf.getInt() & 0xFFFFFFFFL);
}
return out;

View File

@@ -26,6 +26,7 @@ public final class BatteryTemperatureV2025BlockParser implements InfoBlockParser
int packNo = buf.get() & 0xFF;
int probeCount = buf.getShort() & 0xFFFF;
List<Integer> temps = new ArrayList<>(probeCount);
// probeCount 是车端声明值;实际数组按剩余字节裁剪,防止坏包读穿。
int safeCount = Math.min(probeCount, buf.remaining());
for (int k = 0; k < safeCount; k++) {
temps.add((buf.get() & 0xFF) - 40);

View File

@@ -29,6 +29,7 @@ public final class DriveMotorV2025BlockParser implements InfoBlockParser {
int count = buf.get() & 0xFF;
List<InfoBlock.Gb32960V2025.DriveMotor.Motor> motors = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
// 2025 版 torque 扩为 4 字节,不能沿用 2016 的 u16 解析。
Integer serialNo = ValueDecoder.u8(buf);
Integer state = ValueDecoder.u8(buf);
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);

View File

@@ -33,6 +33,7 @@ public final class FuelCellStackV2025BlockParser implements InfoBlockParser {
Integer airInletTemp = ValueDecoder.tempOffset40(buf);
int probeCount = buf.getShort() & 0xFFFF;
List<Integer> temps = new ArrayList<>(probeCount);
// 坏包 probeCount 过大时只消费剩余可用字节;声明数量仍保存在 stack 对象外层上下文中。
int safeCount = Math.min(probeCount, buf.remaining());
for (int k = 0; k < safeCount; k++) {
temps.add((buf.get() & 0xFF) - 40);

View File

@@ -29,6 +29,7 @@ public final class MinParallelVoltageV2025BlockParser implements InfoBlockParser
Double current = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
int totalUnits = buf.getShort() & 0xFFFF;
List<Double> frameVoltages = new ArrayList<>(totalUnits);
// 每个最小并联单元电压占 2 字节;不足时只读完整单元,避免半个值污染后续解析。
int safeCount = Math.min(totalUnits, buf.remaining() / 2);
for (int k = 0; k < safeCount; k++) {
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);

View File

@@ -18,6 +18,7 @@ public final class SuperCapacitorExtremeV2025BlockParser implements InfoBlockPar
@Override
public InfoBlock parse(ByteBuffer buf) {
// 2025 超级电容极值的单体/探针编号为 2 字节,区别于 2016 蓄电池极值的 1 字节编号。
Integer maxVSys = ValueDecoder.u8(buf);
Integer maxVCell = ValueDecoder.u16(buf);
Double maxV = ValueDecoder.scaledU16(buf, 0.001);

View File

@@ -25,12 +25,14 @@ public final class SuperCapacitorV2025BlockParser implements InfoBlockParser {
Double totalCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
int cellCount = buf.getShort() & 0xFFFF;
List<Double> cells = new ArrayList<>(cellCount);
// 电压数组按 2 字节一个值裁剪,避免声明数量异常时读穿温度段。
int safeCellCount = Math.min(cellCount, buf.remaining() / 2);
for (int i = 0; i < safeCellCount; i++) {
cells.add((buf.getShort() & 0xFFFF) * 0.001);
}
int probeCount = buf.hasRemaining() ? (buf.getShort() & 0xFFFF) : 0;
List<Integer> temps = new ArrayList<>(probeCount);
// 温度探针每个 1 字节,按剩余字节裁剪。
int safeProbeCount = Math.min(probeCount, buf.remaining());
for (int i = 0; i < safeProbeCount; i++) {
temps.add((buf.get() & 0xFF) - 40);

View File

@@ -10,6 +10,8 @@ import java.nio.ByteBuffer;
/**
* 整车数据 (0x01) —— GB/T 32960.3-2025。固定 18 字节。
* 相比 2016 版删除加速踏板和制动踏板。
*
* <p>为保持查询接口稳定,后续转换层会把 2016/2025 两版整车字段映射到同一组逻辑字段。
*/
public final class VehicleV2025BlockParser implements InfoBlockParser {

View File

@@ -18,6 +18,8 @@ import java.nio.ByteBuffer;
* outputCurrentA(2, 0.1A, 0~600A)
* controllerTempC(1, -40 offset)
* </pre>
*
* <p>只有 vendor profile 命中 {@code guangdong-fc} 时才会解析到该结构;否则同一 typeCode 会落 Raw。
*/
public final class GdFcDcDcBlockParser implements InfoBlockParser {

View File

@@ -61,6 +61,8 @@ public final class GdFcStackBlockParser implements InfoBlockParser {
Integer frameStart = ValueDecoder.u16(buf);
int frameCells = buf.get() & 0xFF;
List<Double> frameVoltages = new ArrayList<>(frameCells);
// 对端会把单体电压按 frameCellStart/frameCellCount 分帧上送;这里只解析当前帧片段,
// 完整电压数组由查询层按同一 vin+eventTime 合并,避免入站侧持有跨帧状态。
int safe = Math.min(frameCells, buf.remaining() / 2);
for (int k = 0; k < safe; k++) {
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);

View File

@@ -28,6 +28,7 @@ public final class GdFcVehicleInfoBlockParser implements InfoBlockParser {
public InfoBlock parse(ByteBuffer buf) {
Integer collision = ValueDecoder.u8raw(buf);
int rawTemp = buf.getShort() & 0xFFFF;
// 规范保留值按缺失处理,避免 snapshot 中出现 65494/65495 这种不可读温度。
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);

View File

@@ -48,6 +48,7 @@ public final class GdFcVendorTlvBlockParser implements InfoBlockParser {
public InfoBlock parse(ByteBuffer buf) {
int len = buf.getShort() & 0xFFFF;
// 防御:若 length 大于剩余可读字节,截短到剩余长度(避免 BufferUnderflowException
// declaredLength 仍保留原值,后续可以在 API/日志里看出对端长度声明是否异常。
int safe = Math.min(len, buf.remaining());
byte[] payload = new byte[safe];
buf.get(payload);

View File

@@ -45,6 +45,7 @@ public class Gb32960ProfileRegistry {
}
List<InfoBlockParser> combined = new ArrayList<>(defaultParsers.size() + vendorParsers.size());
combined.addAll(defaultParsers);
// vendor parser 只新增扩展 typeCode不应覆盖标准 parser若未来需覆盖必须显式设计优先级。
combined.addAll(vendorParsers);
map.put(name, new InfoBlockParserRegistry(combined));
}

View File

@@ -49,6 +49,7 @@ public final class RuleBasedVendorExtensionSelector implements VendorExtensionSe
CacheKey key = new CacheKey(normalize(ctx.platformAccount()), normalize(ctx.vin()));
Optional<String> cached = cache.get(key);
if (cached != null) return cached.orElse(null);
// 32960 高频上报通常同一连接/同一 VIN 连续上报,缓存可避免每帧扫描所有规则。
String hit = scan(key);
cache.put(key, Optional.ofNullable(hit));
return hit;

View File

@@ -24,6 +24,7 @@ public final class VendorExtensionCatalog {
private final Map<String, List<InfoBlockParser>> extensions;
public VendorExtensionCatalog(Map<String, List<InfoBlockParser>> extensions) {
// 只冻结顶层 mapparser 实例本身按无状态单例使用,不应在运行期修改。
this.extensions = Map.copyOf(extensions);
}

View File

@@ -6,6 +6,7 @@ import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
* 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile
*
* <p>实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。
* 命中结果会直接影响 0x30+ 等厂商字段是否解析成结构化字段,还是退化为 Raw。
*/
public interface VendorExtensionSelector {

View File

@@ -58,6 +58,9 @@ import java.util.List;
*
* <p>每个 Parser Bean 都会被 {@link InfoBlockParserRegistry} 按
* {@code (ProtocolVersion, typeCode)} 注册。2016/2025 可同时加载。
*
* <p>只有 {@code lingniu.ingest.gb32960.enabled=true} 时才会启动 32960 TCP 服务。
* 解析、鉴权、ACK、RAW 入库都从 {@link Gb32960NettyServer} 建立的 Netty pipeline 进入。
*/
@AutoConfiguration
@EnableConfigurationProperties(Gb32960Properties.class)
@@ -114,6 +117,7 @@ public class Gb32960AutoConfiguration {
new GdFcVehicleInfoBlockParser(),
new GdFcDemoExtensionBlockParser(),
// 0x83 厂商私有 TLV 兜底(同 peer 在广东规范之外又下发的未公开扩展)
// 这里只注册已经在线上见过的 typeCode新增 typeCode 应先确认长度字段格式。
new GdFcVendorTlvBlockParser(0x83))));
}
@@ -133,6 +137,7 @@ public class Gb32960AutoConfiguration {
for (Gb32960Properties.VendorExtension e : props.getVendorExtensions()) {
if (e.getName() != null && !e.getName().isBlank()) enabled.add(e.getName());
}
// enabled 只来自配置里实际引用过的扩展,避免无关 vendor parser 进入默认解析路径。
return new Gb32960ProfileRegistry(parsers, catalog, enabled);
}
@@ -142,6 +147,7 @@ public class Gb32960AutoConfiguration {
public VendorExtensionSelector gb32960VendorExtensionSelector(Gb32960Properties props,
VendorExtensionCatalog catalog) {
if (props.getVendorExtensions() == null || props.getVendorExtensions().isEmpty()) {
// 没有 vendor 配置时强制 NONE0x30+ 在 2016 帧里会 Raw 兜底,防止误套扩展解析。
return VendorExtensionSelector.NONE;
}
return new RuleBasedVendorExtensionSelector(props.getVendorExtensions(), catalog.names());

View File

@@ -10,8 +10,11 @@ import java.util.Set;
@ConfigurationProperties(prefix = "lingniu.ingest.gb32960")
public class Gb32960Properties {
/** 是否启动 32960 TCP 监听。生产 32960 线路打开后,此开关决定端口是否真正 bind。 */
private boolean enabled = false;
/** 32960 TCP 监听端口;当前生产验证线使用 32960。 */
private int port = 9000;
/** Netty worker 线程数0 表示按 Netty/CPU 默认策略分配。 */
private int workerThreads = 0;
/**

View File

@@ -17,7 +17,11 @@ import java.util.List;
*
* <p>这是 GB32960 协议在通用 Dispatcher 管线里的**唯一入口** Bean——移除将导致所有
* GB32960 帧产出的事件丢失。构造器注入 {@link Gb32960EventMapper},全部业务逻辑限制
* 在纯函数里,不触碰数据库 / 不做 IO。产出的事件由 Dispatcher 统一投递到 Disruptor → Kafka。
* 在纯函数里,不触碰数据库 / 不做 IO。产出的事件由 Dispatcher 统一投递到 Disruptor
* 下游 sink 再决定写 raw archive、DuckDB/Parquet 历史库或转发 Kafka。
*
* <p>当前 32960 专用 snapshot 查询不依赖 Realtime/Location 事件表,而是通过 raw archive URI
* 回读原始 .bin 并即时解码合并,避免不同车辆或不同子包之间做大范围 join。
*/
@ProtocolHandler(protocol = ProtocolId.GB32960)
public class Gb32960RealtimeHandler {
@@ -33,6 +37,7 @@ public class Gb32960RealtimeHandler {
@RateLimited(perVin = 50)
@EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class})
public List<VehicleEvent> onReport(Gb32960Message msg) {
// 这里保留 Realtime/Location 事件供实时消费者使用;历史全字段查询走 raw archive 解码。
return mapper.toEvents(msg);
}

View File

@@ -12,6 +12,9 @@ import java.net.InetSocketAddress;
*
* <p>Keeps authentication and channel-scoped platform identity out of the Netty
* handler so the hot path can stay small and testable.
*
* <p>平台登入成功后username 会绑定到 Netty channel attribute。后续实时上报帧没有账号字段
* 只能通过同一连接上的 attribute 找回平台账号,用于 Hyundai/广东扩展等 vendor profile 选择。
*/
public final class Gb32960AccessService {
@@ -50,10 +53,12 @@ public final class Gb32960AccessService {
public Gb32960PlatformAuthorizer.Result authenticatePlatformLogin(String username,
String password,
Channel channel) {
// 鉴权策略可以按来源 IP 做限制,因此这里把远端地址从 Netty channel 中抽出。
return platformAuthorizer.authenticate(username, password, peerIp(channel));
}
public void bindPlatformAccount(Channel channel, String username) {
// 只绑定到当前连接,断线重连后必须重新平台登入,避免跨连接复用旧账号。
channel.attr(PLATFORM_ACCOUNT_ATTR).set(username);
}

View File

@@ -15,6 +15,9 @@ import java.time.Instant;
/**
* Builds and writes GB/T 32960 response frames.
*
* <p>ACK 需要尽量复用请求帧里的原始 17 字节 VIN。平台登入这类命令可能没有真实 VIN
* 如果把 trim 后的字符串重新 padding部分上级平台会认为应答 VIN 与请求不一致。
*/
public final class Gb32960AckService {
@@ -39,6 +42,7 @@ public final class Gb32960AckService {
Instant eventTime,
byte[] data,
String tag) {
// rawVin 版本用于严格回显请求帧 VIN 字节,优先给入站 handler 使用。
byte[] ack = Gb32960FrameEncoder.buildResponse(
version, command, responseFlag, rawVin, eventTime, data);
write(channel, ack, tag);
@@ -86,6 +90,7 @@ public final class Gb32960AckService {
channel.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> {
if (f.isSuccess()) {
if (highFrequency) {
// 实时上报 ACK 频率高,默认降到 DEBUG避免生产日志被正常心跳/上报刷满。
if (log.isDebugEnabled()) {
log.debug("[gb32960] {} flushed peer={} len={} hex={}",
tag, addr(channel), ack.length, hex);

View File

@@ -27,6 +27,10 @@ import java.util.Map;
/**
* Netty 入站处理器:字节 → Gb32960Message → 认证 → RawFrame → Dispatcher。
*
* <p>这是 32960 生产链路的入口边界。它只做协议层职责:解码、鉴权、应答、组装
* {@link RawFrame} 并投递 Dispatcher原始包归档、DuckDB/Parquet 索引、Kafka 转发都在
* 下游 sink 中完成,避免 Netty EventLoop 被存储 IO 阻塞。
*
* <p>认证逻辑:
* <ul>
* <li>{@link Gb32960VinAuthorizer#isEnforcing()} = false放行所有 VIN
@@ -99,6 +103,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
String platformAccount = accessService.platformAccount(ctx.channel());
msg = decoder.decode(ByteBuffer.wrap(frame), platformAccount);
} catch (Exception e) {
// 解码失败只丢当前帧不主动断开连接。真实设备偶发脏字节时FrameDecoder 会继续寻找下一帧头。
log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e);
return;
}
@@ -138,6 +143,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
if (platformAccount != null && !platformAccount.isBlank()) {
sourceMeta.put("platformAccount", platformAccount);
}
// RawFrame.rawBytes 是后续 raw archive 和按 rawArchiveUri 回查的源头;不要在这里裁剪或重编码。
RawFrame rf = new RawFrame(
ProtocolId.GB32960,
cmd.code(),
@@ -244,6 +250,9 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
* 0x02/0x03/0x07 实时 / 补发 / 心跳:按 §6.3.2 回应答帧,保留原帧采集时间。
* 部分车端实现严格按规范没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。
*
* <p>注意:应答成功不代表下游已完成持久化,只表示平台已接收并通过 Dispatcher 投递。
* 生产排查存储问题时,应继续检查 raw archive sink、EventFileStoreSink 和 Kafka sink 的日志。
*
* <p>日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏):
* <ul>
* <li>0x02 / 0x03 首次见到某 (vin, normal) → INFO 一条,含 peer/vin/platformAccount/version/parsedBlocks。
@@ -326,6 +335,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
String vin = msg.header().vin();
CommandType cmd = msg.header().command();
if (cmd == CommandType.VEHICLE_LOGIN) {
// 规范要求登入失败要回 ACK让终端能明确知道 VIN 不存在,而不是当作网络超时重试。
ackService.writeAndClose(
ctx,
msg.header().protocolVersion(),

View File

@@ -15,6 +15,9 @@ import java.util.Set;
/**
* Channel-scoped diagnostics for high-frequency GB/T 32960 reports.
*
* <p>每个 TCP channel 只记录有限个首次出现的 (vin, rawTypeSignature),用于发现厂商 profile
* 没命中、字段解析退化为 Raw 的问题。它不参与业务判断,也不会影响 raw archive 和历史查询。
*/
public final class Gb32960FrameDiagnostics {
@@ -76,6 +79,7 @@ public final class Gb32960FrameDiagnostics {
private void trimToLimit(Set<String> seen) {
while (seen.size() > maxKeysPerChannel) {
// LinkedHashSet 保持插入顺序,超限时淘汰最早的签名,避免异常设备导致 attribute 无界增长。
String first = seen.iterator().next();
seen.remove(first);
}

View File

@@ -32,6 +32,8 @@ import java.util.concurrent.ThreadFactory;
* GB/T 32960 Netty Server 启动器。
*
* <p>EventLoop 只做解码与投递 {@link Dispatcher},业务处理由 Disruptor 侧的虚拟线程承担。
* 端口是否启动由 {@code lingniu.ingest.gb32960.enabled} 控制,生产 32960 线应确认该开关、
* 端口、防火墙和上游平台账号同时匹配。
*
* <p>支持可选 TLS{@link Gb32960Properties.Tls#isEnabled()} = true 时pipeline 前置
* {@code SslHandler},加载服务端证书 + 私钥 + 受信 CA默认要求客户端证书双向 TLS
@@ -97,6 +99,7 @@ public class Gb32960NettyServer implements InitializingBean, DisposableBean {
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
// Pipeline 顺序不能调换TLS 先解密FrameDecoder 再做拆包ChannelHandler 最后做业务应答和派发。
if (sslContext != null) {
ch.pipeline().addLast("ssl", sslContext.newHandler(ch.alloc()));
}

View File

@@ -26,6 +26,9 @@ import java.util.stream.Collectors;
*
* <p>实时/补发上报产出 {@code Realtime + Location}(可选)+ {@code Alarm}(可选);
* 车辆登入/登出/心跳产出对应的会话事件。
*
* <p>该 Mapper 只生成统一领域事件字段是面向实时总线的精简视图。32960 的完整包字段、
* 厂商扩展字段和 snapshot 合并字段由历史查询服务从 raw archive 重新解码,不在这里展开。
*/
public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
@@ -42,6 +45,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
CommandType cmd = header.command();
if (cmd == CommandType.REALTIME_REPORT || cmd == CommandType.RESEND_REPORT) {
// 补发包与实时包进入同一套实时事件模型,差异保留在 metadata.command 里供下游判断。
buildRealtime(message, header, baseMeta, eventTime, ingestTime, out);
return out;
}
@@ -155,6 +159,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
null, meta, payload));
}
if (p != null) {
// 位置事件只从 POSITION 子包生成。没有 POSITION 时不从整车数据推断经纬度,避免制造假定位。
LocationPayload loc = new LocationPayload(
p.longitude, p.latitude, 0.0,
v != null && v.speedKmh != null ? v.speedKmh : 0.0,
@@ -165,6 +170,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
null, meta, loc));
}
if (alarm != null && shouldEmitAlarm(alarm)) {
// 只在存在告警等级或氢安全关键位时发 Alarm减少正常高频帧对告警消费者的噪声。
List<String> faultCodes = new ArrayList<>();
addFaultCodes(faultCodes, "BAT", alarm.batteryFaults);
addFaultCodes(faultCodes, "MOT", alarm.motorFaults);

View File

@@ -274,6 +274,7 @@ public sealed interface InfoBlock
record MinParallelVoltage(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.MIN_PARALLEL_VOLTAGE_V2025; }
/** totalUnits 是车端声明数量frameVoltages 是本帧实际解析出的完整电压值。 */
public record Pack(
int packNo,
Double packVoltageV,
@@ -287,6 +288,7 @@ public sealed interface InfoBlock
record BatteryTemperature(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.BATTERY_TEMPERATURE_V2025; }
/** probeCount 是车端声明数量probeTempsC 是本帧实际解析出的温度值。 */
public record Pack(int packNo, int probeCount, List<Integer> probeTempsC) {}
}
@@ -298,6 +300,7 @@ public sealed interface InfoBlock
int stackNo,
Double voltageV,
Double currentA,
// 标准字段名写 pressure协议比例按 kPa 命名,避免与 2016 Mpa 字段混淆。
Double hydrogenInletPressureKpa,
Double airInletPressureKpa,
Integer airInletTempC,

View File

@@ -5,6 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.jt1078")
public class Jt1078Properties {
/** 是否启用 JT/T 1078 音视频能力;它不参与 32960 快照/历史字段查询。 */
private boolean enabled = false;
private MediaStream mediaStream = new MediaStream();
@@ -14,13 +15,18 @@ public class Jt1078Properties {
public void setMediaStream(MediaStream mediaStream) { this.mediaStream = mediaStream; }
public static class MediaStream {
/** 媒体流子模块开关,便于只启用信令、不落媒体文件。 */
private boolean enabled = true;
/** TCP 媒体流监听,适合生产默认路径。 */
private boolean tcpEnabled = true;
/** UDP 媒体流监听默认关闭,开启前需要确认网络和包乱序处理能力。 */
private boolean udpEnabled = false;
private int port = 11078;
private int udpPort = 11078;
private int workerThreads = 0;
/** 单包最大长度保护,防止异常媒体流耗尽内存。 */
private int maxPacketLength = 1024 * 1024;
/** 媒体文件分段秒数,影响归档文件粒度。 */
private int segmentSeconds = 300;
public boolean isEnabled() { return enabled; }

View File

@@ -54,6 +54,7 @@ public final class Jt1078MediaArchiveService {
long segment = segment(packet.timestamp());
String key = key(vin, packet, segment);
try {
// JT1078 是 RTP 分片流,同一分段追加写入 .media只发布一次 MediaMeta 作为索引事件。
String uri = archive.append(key, packet.payload());
long segmentSizeBytes = archiveSize(key, packet.payload().length);
publishOnce(packet, peer, identity, vin, segment, key, uri, segmentSizeBytes);
@@ -103,6 +104,7 @@ public final class Jt1078MediaArchiveService {
: archiveKey;
String old = publishedSegmentRefs.getIfPresent(segmentKey);
if (old != null) {
// 同一个分段只发一次索引事件,避免每个 RTP 包都在历史库产生一条 media meta。
return;
}
publishedSegmentRefs.put(segmentKey, uri);
@@ -166,6 +168,7 @@ public final class Jt1078MediaArchiveService {
private long segment(long timestamp) {
long seconds = timestamp > 10_000_000_000L ? timestamp / 1000 : timestamp;
// 按配置秒数对齐分段,保证同一路通道同一时间窗落到同一个 archive key。
return seconds - Math.floorMod(seconds, segmentSeconds);
}

View File

@@ -19,6 +19,7 @@ final class Jt1078RtpPacketParser {
String sim = readBcd(in, 6);
int channelId = in.readUnsignedByte();
int dataAndPacketType = in.readUnsignedByte();
// 高 4 位是数据类型,低 4 位是分包类型;归档 key 需要二者共同区分。
int dataType = (dataAndPacketType >>> 4) & 0x0F;
int packetType = dataAndPacketType & 0x0F;
long timestamp = in.readLong();
@@ -37,6 +38,7 @@ final class Jt1078RtpPacketParser {
if (in == null || in.readableBytes() < HEADER_LEN || in.getInt(in.readerIndex()) != MAGIC) {
return "";
}
// UDP/TCP 解码器在完整 parse 前先取 SIM用于日志和初步身份定位不移动 readerIndex。
StringBuilder sb = new StringBuilder(12);
int simOffset = in.readerIndex() + 6;
for (int i = 0; i < 6; i++) {

View File

@@ -47,6 +47,7 @@ public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
int bodyLength = attrs & 0x03FF;
int encryptType = (attrs >> 10) & 0x07;
boolean subpacket = (attrs & 0x2000) != 0;
// JT/T 808-2019 通过属性 bit14 标识,终端手机号从 6B BCD 扩展到 10B BCD。
boolean v2019 = (attrs & 0x4000) != 0;
Jt808Header.ProtocolVersion version = v2019
@@ -64,6 +65,7 @@ public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
int totalPkgs = 0, pkgSeq = 0;
int bodyStart = headerLen;
if (subpacket) {
// 分包头紧跟消息流水号之后;当前只暴露包总数/序号,重组由上游或后续模块处理。
totalPkgs = ((bytes[bodyStart] & 0xFF) << 8) | (bytes[bodyStart + 1] & 0xFF);
pkgSeq = ((bytes[bodyStart + 2] & 0xFF) << 8) | (bytes[bodyStart + 3] & 0xFF);
bodyStart += 4;
@@ -82,6 +84,7 @@ public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
BodyParser parser = registry.find(messageId);
Jt808Body body;
if (parser == null) {
// 未实现的消息体保持 Raw仍可进入 archive/event-history避免协议扩展导致数据丢失。
byte[] raw = new byte[bodyLength];
bodyBuf.get(raw);
body = new Jt808Body.Raw(messageId, raw);

View File

@@ -4,8 +4,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.jt808")
public class Jt808Properties {
/** 是否启动 JT/T 808 TCP 监听。与 GB32960 独立,通常用于部标终端。 */
private boolean enabled = false;
/** JT808 默认监听端口;不要与 32960 端口共用。 */
private int port = 10808;
/** Netty worker 线程数0 表示使用框架默认值。 */
private int workerThreads = 0;
public boolean isEnabled() { return enabled; }

View File

@@ -67,6 +67,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
CompletableFuture<Jt808Message> pending = pendingRequests.await(ctx.phone, ctx.serial);
writeFrames(ctx, true).whenComplete((unused, ex) -> {
if (ex != null) {
// 写 channel 失败时必须移除 pending否则后续同 phone/serial 重用会匹配到旧 future。
pendingRequests.cancel(ctx.phone, ctx.serial);
pending.completeExceptionally(ex);
}
@@ -74,6 +75,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
return pending.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
.whenComplete((r, ex) -> {
// 超时/取消同样清理 pending避免长期运行后内存泄漏。
if (ex != null) pendingRequests.cancel(ctx.phone, ctx.serial);
})
.thenApply(msg -> {
@@ -122,6 +124,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
Optional<DeviceSession> s = sessions.findBySessionId(sessionId)
.or(() -> sessions.findByPhone(sessionId))
.or(() -> sessions.findByVin(sessionId));
// HTTP 调用方可以传 sessionId/VIN/phone最终写 Netty Channel 必须落到 JT808 phone。
return s.map(DeviceSession::phone)
.filter(p -> p != null && !p.isBlank())
.orElse(sessionId); // 回落:直接把 sessionId 当 phone

View File

@@ -105,6 +105,8 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
try {
processDecodedFrame(ctx, frame, msg);
} catch (RuntimeException e) {
// 解码成功但业务处理失败时仍派发一条 malformed RawFrame
// 保证原始帧能被 archive/event-file-store 留痕,方便事后排查。
log.warn("[jt808] processing failed peer={} phone={} msgId=0x{} error={}",
addr(ctx), msg.header().phone(), Integer.toHexString(msg.header().messageId()), e.getMessage());
log.debug("[jt808] processing failed stack peer={}", addr(ctx), e);
@@ -175,11 +177,13 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
private IdentityResolution resolveIdentity(Jt808Message msg) {
String phone = msg.header().phone();
if (identityResolver == null) {
// 没有绑定表时用手机号兜底为 VIN保证会话/下行仍可按 phone 找到连接。
return new IdentityResolution(
new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE),
null);
}
try {
// 优先把 JT808 phone/device/plate 映射到内部 VIN后续所有事件都用内部 VIN 做主键。
return new IdentityResolution(
identityResolver.resolve(new VehicleIdentityLookup(
ProtocolId.JT808,
@@ -206,6 +210,7 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
private void dispatchMalformed(String peer, byte[] frame, String errorMessage, boolean frameError) {
String message = errorMessage == null ? "" : errorMessage;
// 无法解出 header 的坏帧也进入 Dispatcher后续 archive sink 可以保留 raw bytes。
Jt808Message malformed = new Jt808Message(
new Jt808Header(0, frame == null ? 0 : frame.length, 0, false,
Jt808Header.ProtocolVersion.V2013, "unknown", 0, 0, 0),

View File

@@ -168,6 +168,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
private IdentityResolution resolveIdentity(Jt808Message message) {
if (message.body() instanceof Jt808Body.Malformed) {
// 坏帧没有可信 header/body统一 unknown原始字节仍由 passthrough 事件保留。
return IdentityResolution.ok(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN));
}
var header = message.header();
@@ -212,6 +213,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
Instant ingestTime) {
List<VehicleEvent> out = new ArrayList<>(batch.locations().size());
Map<String, String> batchMeta = withMeta(meta, "batchType", Integer.toString(batch.batchType()));
// 批量位置拆成多条 Location 事件,方便历史库按单点时间查询和导出。
for (Jt808Body.Location loc : batch.locations()) {
out.add(locationEvent(vin, batchMeta, loc, ingestTime));
}
@@ -224,6 +226,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
Instant ingestTime) {
List<VehicleEvent> out = new ArrayList<>(2);
if (media.location() != null) {
// 多媒体上传消息内嵌位置时同时产出 Location避免只看到媒体元数据看不到拍摄位置。
out.add(locationEvent(vin, withMeta(meta, "mediaId", Long.toString(media.mediaId())),
media.location(), ingestTime));
}

View File

@@ -31,6 +31,7 @@ public final class Jt808ChannelRegistry {
reverse.put(channel, phone);
if (old != null && old != channel) {
reverse.remove(old);
// 同一 phone 新连接上线时关闭旧 channel避免下行命令写到已被终端替换的连接。
old.close();
log.info("channel rebound phone={} oldChannel={} newChannel={}", phone, old.id(), channel.id());
}
@@ -55,6 +56,7 @@ public final class Jt808ChannelRegistry {
}
public int nextSerial(String phone) {
// JT808 流水号 16 bit无符号回绕到 0pending 匹配也使用这个序号。
return serials.computeIfAbsent(phone, k -> new AtomicInteger())
.updateAndGet(i -> (i + 1) & 0xFFFF);
}

View File

@@ -23,6 +23,7 @@ public final class Jt808PendingRequests {
public CompletableFuture<Jt808Message> await(String phone, int serial) {
CompletableFuture<Jt808Message> cf = new CompletableFuture<>();
// JT808 平台下行流水号按终端 phone 独立递增,因此 phone+serial 才能唯一定位一次请求。
pending.put(key(phone, serial), cf);
return cf;
}