docs: add detailed 32960 pipeline comments
This commit is contained in:
@@ -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}(兼容老行为,放行但标记未鉴权)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,9 @@ import java.time.ZoneId;
|
||||
*
|
||||
* <p>应答规则(§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、
|
||||
* 重新计算校验位。
|
||||
*
|
||||
* <p>该类只做协议编码,不写 Channel。是否立即关闭连接由 {@code Gb32960AckService}
|
||||
* 根据鉴权结果决定。
|
||||
*/
|
||||
public final class Gb32960FrameEncoder {
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
* @param version 协议版本(来自帧起始符)
|
||||
* @param vin 17 字节 VIN(trim 后),平台登入 0x05 等帧 VIN 全 0 时为空串
|
||||
* @param platformAccount 平台登入用户名;非平台对接场景为 null
|
||||
*
|
||||
* <p>广东燃料电池扩展等厂商字段通常不是靠 VIN 就能可靠判断,上游平台账号是更稳定的路由线索。
|
||||
*/
|
||||
public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) {
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ public final class VendorExtensionCatalog {
|
||||
private final Map<String, List<InfoBlockParser>> extensions;
|
||||
|
||||
public VendorExtensionCatalog(Map<String, List<InfoBlockParser>> extensions) {
|
||||
// 只冻结顶层 map;parser 实例本身按无状态单例使用,不应在运行期修改。
|
||||
this.extensions = Map.copyOf(extensions);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
|
||||
* 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile)。
|
||||
*
|
||||
* <p>实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。
|
||||
* 命中结果会直接影响 0x30+ 等厂商字段是否解析成结构化字段,还是退化为 Raw。
|
||||
*/
|
||||
public interface VendorExtensionSelector {
|
||||
|
||||
|
||||
@@ -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 配置时强制 NONE,0x30+ 在 2016 帧里会 Raw 兜底,防止误套扩展解析。
|
||||
return VendorExtensionSelector.NONE;
|
||||
}
|
||||
return new RuleBasedVendorExtensionSelector(props.getVendorExtensions(), catalog.names());
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,无符号回绕到 0;pending 匹配也使用这个序号。
|
||||
return serials.computeIfAbsent(phone, k -> new AtomicInteger())
|
||||
.updateAndGet(i -> (i + 1) & 0xFFFF);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user