commit 064ecc479ce8e830da21113860438bc60ef06edc Author: lingniu-dev Date: Wed Apr 15 16:08:57 2026 +0800 chore: initial import of lingniu-vehicle-ingest Multi-module Spring Boot ingest service for vehicle telemetry. Modules: - ingest-api / ingest-core / ingest-codec-common: shared SPI, dispatcher, Disruptor event bus, BCC/BCD codec helpers - protocol-gb32960: GB/T 32960.3 inbound (Netty + per-version parser packages v2016/v2025), platform login auth, VIN whitelist, idle handler - protocol-jt808 / protocol-jt1078 / protocol-jsatl12: JT/T inbound - inbound-mqtt / inbound-xinda-push: alternative ingest channels - session-core: per-channel session state - sink-archive / sink-mq: persistence sinks (local file / Kafka) - command-gateway: terminal control command gateway - bootstrap-all: aggregator Spring Boot app - observability: Micrometer / Actuator wiring Includes hex-dump golden samples under protocol-gb32960/src/test/resources and the GB/T 32960.3-2016 / 2025 reference PDFs under reference/. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9ca1778d --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +target/ +*.class +*.jar +*.war +*.log +*.log.* +app.log +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.project +.classpath +.settings/ +.factorypath +.DS_Store +Thumbs.db +.mvn/wrapper/maven-wrapper.jar +dependency-reduced-pom.xml +logs/ +data/ +build/ +out/ + +# Local config / secrets +application-local.yml +application-local.yaml +application-local.properties +*.local.yml +*.local.yaml +*.local.properties + +# Temp & JVM crash dumps +*.tmp +*.bak +*.swp +hs_err_pid* +replay_pid* diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 00000000..528f8341 --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,65 @@ +# 架构决策记录(ADR 汇总) + +> 本文件记录 lingniu-vehicle-ingest v2 的关键架构决策,后续每次重大调整追加条目(不删除旧条目)。 + +## ADR-001 MQ 选型:Kafka +- **Status**: Accepted +- **Context**: 需要高吞吐、严格单车有序、成熟生态 +- **Decision**: 使用 Kafka,分区 key = VIN +- **Consequences**: 下游消费者需使用消费组 + 分区内顺序消费;不使用 RocketMQ 的事务/顺序特性 + +## ADR-002 线上消息格式:Protobuf +- **Status**: Accepted +- **Context**: 需要向前兼容、体积小、性能好 +- **Decision**: 线上用 Protobuf,调试/诊断保留 JSON 序列化能力 +- **Consequences**: 需维护 `.proto` schema,`ingest-api` 模块负责生成 Java stub + +## ADR-003 command-gateway 独立模块 +- **Status**: Accepted +- **Context**: 下行命令链路不应与接入进程耦合 +- **Decision**: 拆出独立 `command-gateway` 模块,复用 `session-core`;本次重构同步交付 +- **Consequences**: HTTP 对外接口(原 JT808Controller / JT1078Controller)迁移到此模块 + +## ADR-004 信达 Push:保留模块但彻底重写 +- **Status**: Accepted +- **Context**: 旧实现硬编码凭证、无重连、0300/0401 空实现 +- **Decision**: + 1. 新模块 `inbound-xinda-push` 隔离第三方库 `gps-push-client` + 2. 配置外置(yml + env) + 3. 自动重连 + Resilience4j 熔断 + 4. 0200/0300/0401 **仅做协议解析**,转换为 `VehicleEvent` 后直接投 Kafka + 5. **业务处理全部下沉到下游消费者**,本服务不再实现报警/透传业务逻辑 +- **Consequences**: 旧代码 messageReceived 里的 TODO 不再回到本仓库;需要协调下游消费者团队认领 + +## ADR-005 JT1078 / JSATL12 进第一批 +- **Status**: Accepted +- **Decision**: 第一批迁移即覆盖 JT1078 信令 + JSATL12 报警附件上传 +- **Consequences**: Phase 2 工期相应拉长;JSATL12 需要对象存储后端(本地 / S3 / OSS) + +## ADR-006 部署形态:all-in-one +- **Status**: Accepted +- **Context**: 当前规模无需按协议拆 Pod +- **Decision**: 生产默认 `bootstrap-all`,通过配置开关按需启停各协议模块 +- **Consequences**: 单进程资源需求较高;未来若规模扩大可切换到 `bootstrap-slim`(保留能力,不交付) + +## ADR-007 Java 25 + 虚拟线程 + Disruptor +- **Status**: Accepted +- **Decision**: + - Netty EventLoop 只做解码与 RingBuffer 投递,严禁阻塞 + - 业务 Handler 走虚拟线程(`Thread.ofVirtual()`) + - 协议间背压通过 Disruptor RingBuffer 表达 + - 禁用 `synchronized`,使用 `ReentrantLock` 避免虚拟线程 pinning +- **Consequences**: 需开启 `jdk.VirtualThreadPinned` JFR 监控 + +## ADR-008 本服务不写业务库 +- **Status**: Accepted +- **Decision**: 所有业务表(vehicle_data_actual / history / travel / gps_data 等)不再由本服务写入 +- **Consequences**: 需要新建消费服务 `vehicle-analytics-service` 承接;迁移期采用双跑对账 + +## ADR-009 构建工具:Maven +- **Status**: Accepted +- **Decision**: 沿用 Maven,统一 BOM 管理版本,Spotless 格式化,ArchUnit 守护分层 + +## ADR-010 框架:Spring Boot 3.4 +- **Status**: Accepted +- **Decision**: Spring Boot 3.4.x,支持 JDK 25;AutoConfiguration 用于按需启停 diff --git a/README.md b/README.md new file mode 100644 index 00000000..34a83e78 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# lingniu-vehicle-ingest + +> 羚牛车辆数据接入平台 v2 + +## 设计目标 + +- **协议接入统一抽象**:GB/T 32960、JT/T 808、JT/T 1078、JSATL12、MQTT、信达 Push 全部变成可插拔 Inbound Adapter +- **原子能力化**:每个协议一个独立 Maven 模块 + 独立 AutoConfiguration + 独立配置开关 +- **业务 / 实时彻底解耦**:本服务只负责 **收 → 解析 → 校验 → 规整 → 投递 Kafka**,不碰业务库 +- **高并发低延迟**:Netty + Disruptor + Java 25 虚拟线程;目标单节点 ≥ 5 万 msg/s,P99 < 50 ms +- **可观测、可回放、可灰度**:统一 traceId、Envelope、幂等键、DLQ、原始报文冷存 + +## 技术栈 + +| 类别 | 选型 | +|---|---| +| 语言 | Java **25** | +| 框架 | Spring Boot 3.4.x | +| 网络 | Netty 4.1 | +| 并发 | Disruptor 4 + 虚拟线程 + `StructuredTaskScope` | +| 消息队列 | **Kafka**(vin 分区,保证单车有序) | +| 线上消息格式 | **Protobuf**,调试走 JSON | +| MQTT 客户端 | HiveMQ MQTT Client | +| 会话缓存 | Caffeine + Redis 兜底 | +| 熔断/限流 | Resilience4j 2 | +| 可观测 | Micrometer + Prometheus + OpenTelemetry | +| 冷存 | S3 / OSS / 本地 | +| 构建 | Maven + Spotless + ArchUnit | + +## 模块划分 + +``` +lingniu-vehicle-ingest/ +├── bom/ 依赖版本统一 +├── ingest-api/ SPI + sealed 领域事件 + 注解 +├── ingest-codec-common/ 公共编解码工具(BCD/CRC/BCC/bit utils) +├── ingest-core/ Pipeline / Dispatcher / Disruptor / Session 桥 +├── session-core/ 设备会话 + 鉴权 + Token +├── observability/ metrics / tracing / health +├── sink-mq/ Kafka producer + Protobuf Envelope +├── sink-archive/ 原始报文冷存 +├── protocol-gb32960/ GB/T 32960 +├── protocol-jt808/ JT/T 808 +├── protocol-jt1078/ JT/T 1078 +├── protocol-jsatl12/ 苏标主动安全报警附件 +├── inbound-mqtt/ MQTT 接入(宇通等) +├── inbound-xinda-push/ 信达 Push 接入(重写) +├── command-gateway/ HTTP → 设备下行命令 +└── bootstrap-all/ 一体化启动 +``` + +## 快速开始 + +```bash +# 要求:JDK 25, Maven 3.9+ +mvn -v +mvn clean install -DskipTests +cd bootstrap-all && mvn spring-boot:run +``` + +## 迁移说明 + +本项目是 `lingniu-vehicle-data-reception` 的 v2 重构,采用 strangler fig 渐进式迁移,旧项目保留只读参考。迁移路径与决策参见 `../REFRACTOR_PLAN.md`。 + +## 核心原则 + +1. **本服务不写业务库**:历史、行程、在线检测、里程统计全部由 Kafka 下游消费者实现 +2. **协议即插拔**:每个 `protocol-*` / `inbound-*` 都可独立开关(`lingniu.ingest..enabled`) +3. **顺序保证**:同一 VIN 严格有序(Disruptor hash + Kafka 分区 key) +4. **幂等消费**:Envelope 带 `eventId`,下游去重 +5. **原始可回放**:`vehicle.raw.archive` topic + 对象存储 diff --git a/bootstrap-all/pom.xml b/bootstrap-all/pom.xml new file mode 100644 index 00000000..8af5baa0 --- /dev/null +++ b/bootstrap-all/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + bootstrap-all + bootstrap-all + 一体化启动:默认集成全部协议与 Sink。生产环境通过配置开关按需启停。 + + + + + com.lingniu.ingest + ingest-core + + + com.lingniu.ingest + session-core + + + com.lingniu.ingest + observability + + + + + com.lingniu.ingest + sink-mq + + + com.lingniu.ingest + sink-archive + + + + + com.lingniu.ingest + protocol-gb32960 + + + com.lingniu.ingest + protocol-jt808 + + + com.lingniu.ingest + protocol-jt1078 + + + com.lingniu.ingest + protocol-jsatl12 + + + + + com.lingniu.ingest + inbound-mqtt + + + com.lingniu.ingest + inbound-xinda-push + + + + + com.lingniu.ingest + command-gateway + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + lingniu-vehicle-ingest + + + org.springframework.boot + spring-boot-maven-plugin + + + --sun-misc-unsafe-memory-access=allow + + + + + repackage + + + + + + + diff --git a/bootstrap-all/src/main/java/com/lingniu/ingest/bootstrap/IngestApplication.java b/bootstrap-all/src/main/java/com/lingniu/ingest/bootstrap/IngestApplication.java new file mode 100644 index 00000000..5ffb76c0 --- /dev/null +++ b/bootstrap-all/src/main/java/com/lingniu/ingest/bootstrap/IngestApplication.java @@ -0,0 +1,30 @@ +package com.lingniu.ingest.bootstrap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 一体化启动入口。 + * 各协议 / Sink 模块通过 Spring Boot AutoConfiguration + @ConditionalOnProperty 按需加载。 + * + *

启动失败兜底:某个 Netty server 绑定端口失败时,Spring 会尝试销毁已初始化的 Bean。 + * 但如果 {@code destroy()} 过程中再次失败或卡住,残留的 Netty boss/worker 线程(非守护线程) + * 会让 JVM 永远不退出,表现为进程仍在监听端口,下次启动时出现 {@code Address already in use}。 + * 这里 main 捕获 Throwable 后强制 {@link System#exit(int)},确保进程干净退出。 + */ +@SpringBootApplication(scanBasePackages = "com.lingniu.ingest") +public class IngestApplication { + + private static final Logger log = LoggerFactory.getLogger(IngestApplication.class); + + public static void main(String[] args) { + try { + SpringApplication.run(IngestApplication.class, args); + } catch (Throwable t) { + log.error("Ingest application failed to start, forcing JVM exit", t); + System.exit(1); + } + } +} diff --git a/bootstrap-all/src/main/resources/application.yml b/bootstrap-all/src/main/resources/application.yml new file mode 100644 index 00000000..467e9711 --- /dev/null +++ b/bootstrap-all/src/main/resources/application.yml @@ -0,0 +1,130 @@ +spring: + application: + name: lingniu-vehicle-ingest + threads: + virtual: + enabled: true + +server: + port: 20000 + +lingniu: + ingest: + gb32960: + enabled: true + port: 9000 + boss-threads: 1 + worker-threads: 0 # 0 = Runtime.availableProcessors() * 2 + # VIN 白名单认证(内存配置)。GB/T 32960 设备没有账号密码,身份由 VIN 决定。 + auth: + enabled: ${GB32960_AUTH_ENABLED:false} # true 启用白名单;false 放行所有 VIN + case-sensitive: false # 默认大小写不敏感 + whitelist: # VIN 列表,未列入的 0x01 登入返回 0x04 VIN_NOT_EXIST + # - LTEST000000000001 + # - LGWEF4A58RE123456 + # 平台登入 0x05 账号列表(GB/T 32960.3 表 29)。 + # 列表为空时放行所有平台登入(兼容旧行为),否则 username/password 必须匹配, + # 失败返回 0x02 OTHER_ERROR 应答并关闭连接。 + platforms: + - username: ${GB32960_PLATFORM_USER_1:lingniu} + password: ${GB32960_PLATFORM_PWD_1:Lingniu.2024#} + allowed-ips: # 可选:限制来源 IP,为空不限制 + description: 外部下级平台 (原旧服务客户端) + # TLS 双向认证(可选)。启用后 Netty pipeline 前置 SslHandler 并强制客户端证书。 + tls: + enabled: ${GB32960_TLS_ENABLED:false} + cert-path: ${GB32960_TLS_CERT:} # 服务端证书 PEM 路径 + key-path: ${GB32960_TLS_KEY:} # 服务端私钥 PEM 路径(PKCS#8 未加密) + trust-cert-path: ${GB32960_TLS_CA:} # 受信 CA 证书 PEM,用于校验客户端证书 + require-client-auth: true # 强制双向;false=单向 TLS + jt808: + enabled: false + port: 10808 + max-frame-length: 1048 + jt1078: + enabled: true + jsatl12: + enabled: true + port: 7612 + file-store: file:///var/lingniu/alarm-files + mqtt: + enabled: false + endpoints: + - name: yutong + uri: ${MQTT_URI:ssl://cpxlm.axxc.cn:38883} + topic: ${MQTT_TOPIC:/ytforward/shln/+} + qos: 1 + client-id: ${MQTT_CLIENT_ID:shlnClientId} + username: ${MQTT_USER:shln} + password: ${MQTT_PWD:} + tls: + ca-pem: ${MQTT_CA_PEM:} + client-pem: ${MQTT_CLIENT_PEM:} + client-key: ${MQTT_CLIENT_KEY:} + xinda-push: + enabled: false + host: ${XINDA_HOST:115.231.168.135} + port: ${XINDA_PORT:10100} + username: ${XINDA_USER:浙江羚牛} + password: ${XINDA_PWD:xd123456+} + subscribe-msg-ids: 0200,0300,0401 + client-desc: 羚牛客户端 + + pipeline: + disruptor: + ring-buffer-size: 131072 # 2^17 + wait-strategy: yielding # blocking | sleeping | yielding | busy-spin + producer-type: multi + dedup: + enabled: true + cache-size: 200000 + ttl-seconds: 600 + rate-limit: + per-vin-qps: 50 + + session: + store: memory-and-redis + token-ttl-seconds: 86400 + + sink: + mq: + # MQ Sink 总开关。true=装配 Kafka Producer 并投递事件;false=完全不装配(适合本地开发或 Kafka 不可达)。 + enabled: ${KAFKA_ENABLED:false} + type: kafka # 后端选择:kafka | (rocketmq | pulsar 预留) + bootstrap-servers: ${KAFKA_BROKERS:localhost:9092} + compression-type: zstd + linger-ms: 20 + batch-size: 65536 + acks: all + enable-idempotence: true + topics: + realtime: vehicle.realtime + location: vehicle.location + alarm: vehicle.alarm + session: vehicle.session + media-meta: vehicle.media.meta + raw-archive: vehicle.raw.archive + dlq: vehicle.dlq + archive: + enabled: true + type: local # local | s3 | oss + path: ./archive/ + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env + metrics: + tags: + application: lingniu-vehicle-ingest + +logging: + level: + root: INFO + com.lingniu.ingest: INFO + com.lingniu.ingest.protocol.gb32960.inbound.Gb32960ChannelHandler: INFO + com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameDecoder: INFO + com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser: INFO + # 信达 push 诊断:打开下面一行可以看到每一条收发的原始字节 hex dump + com.lingniu.ingest.inbound.xinda: INFO diff --git a/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/IngestEndToEndTest.java b/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/IngestEndToEndTest.java new file mode 100644 index 00000000..7bbfaf95 --- /dev/null +++ b/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/IngestEndToEndTest.java @@ -0,0 +1,166 @@ +package com.lingniu.ingest.bootstrap; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.sink.EventSink; +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +import java.io.ByteArrayOutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 端到端集成测试: + *

+ *   test client → TCP → Gb32960 Netty server → frame decoder → dispatcher
+ *   → InterceptorChain → Handler → DisruptorEventBus → TestEventSink
+ * 
+ * + *

关闭了所有非 gb32960 的协议与 Kafka sink,只测核心链路连通性。 + */ +@SpringBootTest( + classes = IngestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "lingniu.ingest.gb32960.enabled=true", + "lingniu.ingest.gb32960.port=0", + "lingniu.ingest.jt808.enabled=false", + "lingniu.ingest.jt1078.enabled=false", + "lingniu.ingest.jsatl12.enabled=false", + "lingniu.ingest.mqtt.enabled=false", + "lingniu.ingest.xinda-push.enabled=false", + "lingniu.ingest.command-gateway.enabled=false", + // 总开关关闭 Kafka sink:TestEventSink 成为唯一 Sink + "lingniu.ingest.sink.mq.enabled=false", + "lingniu.ingest.sink.archive.enabled=false", + "spring.main.allow-bean-definition-overriding=true" + }) +@Import(IngestEndToEndTest.TestConfig.class) +class IngestEndToEndTest { + + @Autowired + private Gb32960NettyServer server; + + @Autowired + private TestEventSink testSink; + + @Test + void gb32960FrameReachesEventSink() throws Exception { + int port = server.getBoundPort(); + assertThat(port).isGreaterThan(0); + + byte[] frame = buildRealtimeFrame("LTEST0000000E2E01"); + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.getOutputStream().write(frame); + socket.getOutputStream().flush(); + } + + // DisruptorEventBus + Virtual threads have some jitter; poll up to 3s. + VehicleEvent captured = null; + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + captured = testSink.queue.poll(100, TimeUnit.MILLISECONDS); + if (captured instanceof VehicleEvent.Realtime) break; + if (captured != null) continue; + } + assertThat(captured).isInstanceOf(VehicleEvent.Realtime.class); + assertThat(captured.vin()).isEqualTo("LTEST0000000E2E01"); + } + + // ===== test config ===== + + @TestConfiguration + static class TestConfig { + @Bean + TestEventSink testEventSink() { + return new TestEventSink(); + } + } + + /** 收集所有 publish 的事件。 */ + static final class TestEventSink implements EventSink { + final BlockingQueue queue = new LinkedBlockingQueue<>(); + + @Override public String name() { return "test"; } + + @Override + public CompletableFuture publish(VehicleEvent event) { + queue.add(event); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture publishBatch(List batch) { + queue.addAll(batch); + return CompletableFuture.completedFuture(null); + } + } + + // ===== frame builder (inlined copy of Gb32960DecoderTest#buildRealtimeFrame) ===== + + private static byte[] buildRealtimeFrame(String vin) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(24); body.write(1); body.write(2); body.write(3); body.write(4); body.write(5); + + // 0x01 整车 20 字节 + body.write(0x01); + body.write(0x01); body.write(0x03); body.write(0x01); + writeU16(body, 523); + writeU32(body, 1234567); + writeU16(body, 6000); + writeU16(body, 1100); + body.write(70); + body.write(0x01); + body.write(0x0F); + writeU16(body, 500); + body.write(30); + body.write(0); + + // 0x05 位置 9 字节 + body.write(0x05); + body.write(0x00); + writeU32(body, 116_397_128L); + writeU32(body, 39_916_527L); + + byte[] bodyBytes = body.toByteArray(); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x23); out.write(0x23); + out.write(0x02); + out.write(0xFE); + byte[] vinBytes = vin.getBytes(StandardCharsets.US_ASCII); + out.write(vinBytes, 0, 17); + out.write(0x01); + writeU16(out, bodyBytes.length); + out.write(bodyBytes, 0, bodyBytes.length); + byte[] almost = out.toByteArray(); + byte bcc = BccChecksum.compute(almost, 2, almost.length - 2); + out.write(bcc & 0xFF); + return out.toByteArray(); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(ByteArrayOutputStream os, long v) { + os.write((int) ((v >> 24) & 0xFF)); + os.write((int) ((v >> 16) & 0xFF)); + os.write((int) ((v >> 8) & 0xFF)); + os.write((int) (v & 0xFF)); + } +} diff --git a/command-gateway/pom.xml b/command-gateway/pom.xml new file mode 100644 index 00000000..8da24131 --- /dev/null +++ b/command-gateway/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + command-gateway + command-gateway + + 下行命令网关:HTTP → 设备。复用 session-core 的 CommandDispatcher, + 覆盖原 JT808Controller / JT1078Controller 的 43 个端点(PoC 阶段先落核心子集)。 + + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + session-core + + + com.lingniu.ingest + protocol-jt808 + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + diff --git a/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java b/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java new file mode 100644 index 00000000..e2449f30 --- /dev/null +++ b/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java @@ -0,0 +1,14 @@ +package com.lingniu.ingest.gateway; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.web.servlet.DispatcherServlet; + +@AutoConfiguration +@ConditionalOnClass(DispatcherServlet.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.command-gateway", name = "enabled", havingValue = "true", matchIfMissing = true) +@ComponentScan(basePackageClasses = TerminalCommandController.class) +public class CommandGatewayAutoConfiguration { +} diff --git a/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandResult.java b/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandResult.java new file mode 100644 index 00000000..d9ed98e0 --- /dev/null +++ b/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandResult.java @@ -0,0 +1,24 @@ +package com.lingniu.ingest.gateway; + +/** + * 统一命令响应 envelope。 + * + * @param success 是否成功 + * @param code 业务状态码(0=成功,其它=失败原因) + * @param message 人类可读消息 + * @param data 可选负载(设备应答对象、JSON 节点等) + */ +public record CommandResult(boolean success, int code, String message, Object data) { + + public static CommandResult ok(Object data) { + return new CommandResult(true, 0, "ok", data); + } + + public static CommandResult notImplemented(String hint) { + return new CommandResult(false, 501, "not_implemented: " + hint, null); + } + + public static CommandResult failure(int code, String message) { + return new CommandResult(false, code, message, null); + } +} diff --git a/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java b/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java new file mode 100644 index 00000000..abeb1e3c --- /dev/null +++ b/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java @@ -0,0 +1,145 @@ +package com.lingniu.ingest.gateway; + +import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.session.CommandDispatcher; +import com.lingniu.ingest.session.DeviceSession; +import com.lingniu.ingest.session.SessionStore; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.stream.Collectors; + +/** + * 下行命令 REST 入口。命令通过 {@link CommandDispatcher} 发往协议模块, + * 由 protocol-jt808 的 {@code Jt808CommandDispatcher} 实际发送到终端。 + * + *

路径参数 {@code clientId} 可以是 sessionId / phone / vin, + * 由 {@link SessionStore} 三索引解析。 + */ +@RestController +@RequestMapping("/terminal") +public class TerminalCommandController { + + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(20); + + private final SessionStore sessions; + private final CommandDispatcher dispatcher; + + public TerminalCommandController(SessionStore sessions, CommandDispatcher dispatcher) { + this.sessions = sessions; + this.dispatcher = dispatcher; + } + + @GetMapping("/session") + public CommandResult session(@RequestParam String clientId) { + Optional s = resolveSession(clientId); + return s.map(CommandResult::ok) + .orElseGet(() -> CommandResult.failure(404, "session not found: " + clientId)); + } + + @GetMapping("/location") + public CommandResult queryLocation(@RequestParam String clientId) { + return awaitSync(clientId, Jt808Commands.queryLocation(), "query-location"); + } + + @GetMapping("/parameters") + public CommandResult queryParameters(@RequestParam String clientId) { + return awaitSync(clientId, Jt808Commands.queryParameters(), "query-parameters"); + } + + @PutMapping("/parameters") + public CommandResult setParameters(@RequestParam String clientId, + @RequestBody Map body) { + List items = body.entrySet().stream() + .map(e -> new Jt808Commands.ParamItem( + parseId(e.getKey()), + e.getValue() == null ? new byte[0] : e.getValue().getBytes(StandardCharsets.UTF_8))) + .collect(Collectors.toList()); + return awaitSync(clientId, Jt808Commands.setParameters(items), "set-parameters"); + } + + @PostMapping("/control") + public CommandResult terminalControl(@RequestParam String clientId, + @RequestBody Map body) { + int word = Integer.parseInt(body.getOrDefault("command", "0").toString()); + String params = body.getOrDefault("params", "").toString(); + return awaitSync(clientId, Jt808Commands.terminalControl(word, params), "terminal-control"); + } + + @PostMapping("/ack") + public CommandResult ack(@RequestParam String clientId, @RequestBody Map body) { + int ackSerial = body.getOrDefault("ackSerial", 0); + int ackMsgId = body.getOrDefault("ackMsgId", 0); + int result = body.getOrDefault("result", 0); + return fireAndForget(clientId, Jt808Commands.platformAck(ackSerial, ackMsgId, result), "platform-ack"); + } + + @GetMapping("/attributes") + public CommandResult attributes(@RequestParam String clientId) { + return CommandResult.notImplemented("0x8107 query-attributes"); + } + + @DeleteMapping("/area") + public CommandResult deleteArea(@RequestParam String clientId, @RequestParam String ids) { + return CommandResult.notImplemented("0x8601 delete-area"); + } + + @PostMapping("/alarm_ack") + public CommandResult alarmAck(@RequestParam String clientId, @RequestBody Map body) { + return CommandResult.notImplemented("0x8203 alarm-ack"); + } + + // ===== helpers ===== + + private Optional resolveSession(String clientId) { + return sessions.findBySessionId(clientId) + .or(() -> sessions.findByPhone(clientId)) + .or(() -> sessions.findByVin(clientId)); + } + + private CommandResult awaitSync(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { + CompletableFuture future = + dispatcher.request(clientId, cmd, Jt808Message.class, DEFAULT_TIMEOUT); + try { + Jt808Message resp = future.join(); + return CommandResult.ok(Map.of( + "messageId", "0x" + Integer.toHexString(resp.header().messageId()), + "serial", resp.header().serialNo(), + "phone", resp.header().phone())); + } catch (CompletionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + return CommandResult.failure(500, op + " failed: " + cause.getMessage()); + } catch (Exception e) { + return CommandResult.failure(500, op + " failed: " + e.getMessage()); + } + } + + private CommandResult fireAndForget(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { + try { + dispatcher.notify(clientId, cmd).join(); + return CommandResult.ok(Map.of("op", op)); + } catch (Exception e) { + return CommandResult.failure(500, op + " failed: " + e.getMessage()); + } + } + + private static long parseId(String key) { + if (key == null || key.isBlank()) return 0; + String k = key.startsWith("0x") ? key.substring(2) : key; + return Long.parseLong(k, 16); + } +} diff --git a/command-gateway/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/command-gateway/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..ac81ba27 --- /dev/null +++ b/command-gateway/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.gateway.CommandGatewayAutoConfiguration diff --git a/inbound-mqtt/pom.xml b/inbound-mqtt/pom.xml new file mode 100644 index 00000000..ec98ea2f --- /dev/null +++ b/inbound-mqtt/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + inbound-mqtt + inbound-mqtt + MQTT 接入模块(支持多 endpoint,宇通等),HiveMQ MQTT Client + 双向 TLS。 + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + ingest-core + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-configuration-processor + true + + + com.hivemq + hivemq-mqtt-client + + + com.fasterxml.jackson.core + jackson-databind + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java new file mode 100644 index 00000000..660378c6 --- /dev/null +++ b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java @@ -0,0 +1,152 @@ +package com.lingniu.ingest.inbound.mqtt.client; + +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; +import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * MQTT 多 endpoint 生命周期管理。 + * + *

为每个 endpoint 创建一个 HiveMQ 异步客户端 → 连接 → 订阅 → 注册回调 → + * 回调里解析 payload → {@link Dispatcher#dispatch(RawFrame)}。 + * + *

Netty EventLoop 回调不做业务处理,虚拟线程由 Dispatcher 侧的 Disruptor 承接。 + */ +public final class MqttEndpointManager implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(MqttEndpointManager.class); + + private final MqttInboundProperties props; + private final MqttPayloadParser payloadParser; + private final Dispatcher dispatcher; + private final List clients = new ArrayList<>(); + + public MqttEndpointManager(MqttInboundProperties props, + MqttPayloadParser payloadParser, + Dispatcher dispatcher) { + this.props = props; + this.payloadParser = payloadParser; + this.dispatcher = dispatcher; + } + + public void start() { + for (MqttInboundProperties.Endpoint ep : props.getEndpoints()) { + try { + Mqtt3AsyncClient client = buildClient(ep); + clients.add(client); + connectAndSubscribe(client, ep); + } catch (Exception e) { + log.error("mqtt endpoint [{}] init failed, skipped", ep.getName(), e); + } + } + } + + private Mqtt3AsyncClient buildClient(MqttInboundProperties.Endpoint ep) { + URI uri = URI.create(ep.getUri()); + int port = uri.getPort() > 0 ? uri.getPort() : defaultPort(uri.getScheme()); + var builder = Mqtt3Client.builder() + .identifier(ep.getClientId() == null || ep.getClientId().isBlank() + ? "lingniu-" + UUID.randomUUID() : ep.getClientId()) + .serverHost(uri.getHost()) + .serverPort(port) + .automaticReconnectWithDefaultConfig(); + + if ("ssl".equalsIgnoreCase(uri.getScheme()) || "tls".equalsIgnoreCase(uri.getScheme()) + || "mqtts".equalsIgnoreCase(uri.getScheme())) { + // TODO: 基于 ep.getTls() 的 caPem / clientPem / clientKey 构造自定义 SslConfig + builder = builder.sslWithDefaultConfig(); + } + return builder.buildAsync(); + } + + private void connectAndSubscribe(Mqtt3AsyncClient client, MqttInboundProperties.Endpoint ep) { + var connectBuilder = client.connectWith(); + if (ep.getUsername() != null && !ep.getUsername().isBlank()) { + connectBuilder = connectBuilder.simpleAuth() + .username(ep.getUsername()) + .password(ep.getPassword() == null ? new byte[0] : ep.getPassword().getBytes()) + .applySimpleAuth(); + } + connectBuilder.send().whenComplete((ack, err) -> { + if (err != null) { + log.error("mqtt endpoint [{}] connect failed", ep.getName(), err); + return; + } + log.info("mqtt endpoint [{}] connected code={}", ep.getName(), ack.getReturnCode()); + client.subscribeWith() + .topicFilter(ep.getTopic()) + .qos(mapQos(ep.getQos())) + .callback(publish -> onMessage(ep, publish.getTopic().toString(), publish.getPayloadAsBytes())) + .send() + .whenComplete((subAck, subErr) -> { + if (subErr != null) { + log.error("mqtt endpoint [{}] subscribe failed topic={}", ep.getName(), ep.getTopic(), subErr); + } else { + log.info("mqtt endpoint [{}] subscribed topic={} qos={}", + ep.getName(), ep.getTopic(), ep.getQos()); + } + }); + }); + } + + private void onMessage(MqttInboundProperties.Endpoint ep, String topic, byte[] payload) { + try { + MqttPayload parsed = payloadParser.parse(ep.getName(), ep.getProfile(), topic, payload); + Map meta = new HashMap<>(4); + meta.put("vin", parsed.vin() == null ? "unknown" : parsed.vin()); + meta.put("endpoint", ep.getName()); + meta.put("topic", topic); + RawFrame rf = new RawFrame( + ProtocolId.MQTT_YUTONG, 0, 0, + parsed, payload, meta, Instant.now()); + dispatcher.dispatch(rf); + } catch (Exception e) { + log.warn("mqtt endpoint [{}] message handling failed topic={} len={}", + ep.getName(), topic, payload.length, e); + } + } + + private static MqttQos mapQos(int level) { + return switch (level) { + case 0 -> MqttQos.AT_MOST_ONCE; + case 2 -> MqttQos.EXACTLY_ONCE; + default -> MqttQos.AT_LEAST_ONCE; + }; + } + + private static int defaultPort(String scheme) { + if (scheme == null) return 1883; + return switch (scheme.toLowerCase()) { + case "ssl", "tls", "mqtts" -> 8883; + default -> 1883; + }; + } + + @Override + public void close() { + for (Mqtt3AsyncClient c : clients) { + try { + c.disconnect(); + } catch (Exception ignored) { + // best-effort + } + } + log.info("mqtt endpoint manager stopped, clients={}", clients.size()); + } +} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java new file mode 100644 index 00000000..17fdc5e9 --- /dev/null +++ b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java @@ -0,0 +1,66 @@ +package com.lingniu.ingest.inbound.mqtt.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.inbound.mqtt.client.MqttEndpointManager; +import com.lingniu.ingest.inbound.mqtt.handler.MqttRealtimeHandler; +import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; +import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@EnableConfigurationProperties(MqttInboundProperties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.mqtt", name = "enabled", havingValue = "true") +public class MqttInboundAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public ObjectMapper mqttObjectMapper() { + return new ObjectMapper(); + } + + @Bean + @ConditionalOnMissingBean + public MqttPayloadParser mqttPayloadParser(ObjectMapper objectMapper) { + return new MqttPayloadParser(objectMapper); + } + + @Bean + @ConditionalOnMissingBean + public YutongEventMapper yutongEventMapper() { + return new YutongEventMapper(); + } + + @Bean + @ConditionalOnMissingBean + public MqttRealtimeHandler mqttRealtimeHandler(YutongEventMapper mapper) { + return new MqttRealtimeHandler(mapper); + } + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean + public MqttEndpointManager mqttEndpointManager(MqttInboundProperties props, + MqttPayloadParser parser, + Dispatcher dispatcher) { + return new MqttEndpointManager(props, parser, dispatcher); + } + + /** + * Spring Boot 启动完成后触发 MQTT 连接,避免与 Netty / Kafka 启动竞争。 + */ + @Bean + public ApplicationRunner mqttStartupRunner(MqttEndpointManager manager) { + return new ApplicationRunner() { + @Override + public void run(ApplicationArguments args) { + manager.start(); + } + }; + } +} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java new file mode 100644 index 00000000..47e5456a --- /dev/null +++ b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java @@ -0,0 +1,69 @@ +package com.lingniu.ingest.inbound.mqtt.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +/** + * MQTT 接入配置。支持多 endpoint(每个 endpoint 可对应一个车厂 / 一个 broker)。 + */ +@ConfigurationProperties(prefix = "lingniu.ingest.mqtt") +public class MqttInboundProperties { + + private boolean enabled = false; + private List endpoints = new ArrayList<>(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public List getEndpoints() { return endpoints; } + public void setEndpoints(List endpoints) { this.endpoints = endpoints; } + + public static class Endpoint { + /** 逻辑名称,用于指标与日志。 */ + private String name; + /** 对端 URI。形如 ssl://host:port 或 tcp://host:port。 */ + private String uri; + /** 订阅 topic,支持 + / # 通配符。 */ + private String topic; + /** QoS 0/1/2。 */ + private int qos = 1; + private String clientId; + private String username; + private String password; + /** JSON 字段适配器 profile。默认 yutong。未来可支持 haipote / others。 */ + private String profile = "yutong"; + private Tls tls = new Tls(); + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getUri() { return uri; } + public void setUri(String uri) { this.uri = uri; } + public String getTopic() { return topic; } + public void setTopic(String topic) { this.topic = topic; } + public int getQos() { return qos; } + public void setQos(int qos) { this.qos = qos; } + public String getClientId() { return clientId; } + public void setClientId(String clientId) { this.clientId = clientId; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getProfile() { return profile; } + public void setProfile(String profile) { this.profile = profile; } + public Tls getTls() { return tls; } + public void setTls(Tls tls) { this.tls = tls; } + } + + public static class Tls { + private String caPem; + private String clientPem; + private String clientKey; + public String getCaPem() { return caPem; } + public void setCaPem(String caPem) { this.caPem = caPem; } + public String getClientPem() { return clientPem; } + public void setClientPem(String clientPem) { this.clientPem = clientPem; } + public String getClientKey() { return clientKey; } + public void setClientKey(String clientKey) { this.clientKey = clientKey; } + } +} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java new file mode 100644 index 00000000..9293f18e --- /dev/null +++ b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java @@ -0,0 +1,35 @@ +package com.lingniu.ingest.inbound.mqtt.handler; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.annotation.EventEmit; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.annotation.ProtocolHandler; +import com.lingniu.ingest.api.annotation.RateLimited; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; + +import java.util.List; + +/** + * MQTT 接入示例 Handler。 + * + *

MQTT 没有"命令字"概念,使用默认的 wildcard 匹配({@code command} 为空)。 + * 同一 Dispatcher 仍能正确路由,因为协议 ID 做了隔离。 + */ +@ProtocolHandler(protocol = ProtocolId.MQTT_YUTONG) +public class MqttRealtimeHandler { + + private final YutongEventMapper mapper; + + public MqttRealtimeHandler(YutongEventMapper mapper) { + this.mapper = mapper; + } + + @MessageMapping(desc = "宇通 MQTT 实时数据") + @RateLimited(perVin = 20) + @EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class}) + public List onMqtt(MqttPayload payload) { + return mapper.toEvents(payload); + } +} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java new file mode 100644 index 00000000..457abc8d --- /dev/null +++ b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java @@ -0,0 +1,99 @@ +package com.lingniu.ingest.inbound.mqtt.mapper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.RealtimePayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.spi.EventMapper; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 宇通 MQTT payload → 领域事件。 + * + *

字段名取自旧项目的 {@code ReceptionDataVO}(大写 + 下划线),对字段映射一致性做出显式断言, + * 便于与旧服务双跑对账。 + */ +public final class YutongEventMapper implements EventMapper { + + @Override + public List toEvents(MqttPayload payload) { + JsonNode d = payload.data(); + if (d == null || !d.isObject()) return List.of(); + + Instant ingestTime = Instant.now(); + String vin = payload.vin(); + Map meta = Map.of( + "endpoint", payload.endpointName() == null ? "" : payload.endpointName(), + "topic", payload.topic() == null ? "" : payload.topic(), + "profile", payload.profile() == null ? "" : payload.profile()); + + Double speed = optDouble(d, "METER_SPEED"); + Double mileage = optDouble(d, "TOTAL_MILEAGE"); + Double soc = optDouble(d, "BATTERY_CAPACITY_SOC"); + Double fcVoltage = optDouble(d, "VOLTAGE_OF_FC"); + Double fcCurrent = optDouble(d, "CURRENT_OF_FC"); + Double fcTemp = optDouble(d, "TEMPT_OF_FC"); + Double leftH2 = optDouble(d, "LEFT_HYDROGEN"); + Double lowH2Pressure = optDouble(d, "HYDROGEN_LOW_PRESSURE"); + Double longitude = optCoord(d, "LONGITUDE"); + Double latitude = optCoord(d, "LATITUDE"); + Double direction = optDouble(d, "GPSDirection"); + Double acc = optDouble(d, "ACC_PEDAL_APT"); + Double brake = optDouble(d, "BRAKEPEDALAPT"); + Integer gear = optInt(d, "ON_GEAR"); + + RealtimePayload realtime = new RealtimePayload( + speed, mileage, soc, null, null, + fcVoltage, fcCurrent, fcTemp, leftH2, null, lowH2Pressure, + null, null, null, gear, acc, brake, + longitude, latitude, null, direction, + null, null); + + List out = new ArrayList<>(2); + out.add(new VehicleEvent.Realtime( + UUID.randomUUID().toString(), + vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime, + null, meta, realtime)); + + if (longitude != null && latitude != null) { + LocationPayload loc = new LocationPayload( + longitude, latitude, 0.0, + speed == null ? 0.0 : speed, + direction == null ? 0.0 : direction, + 0, 0); + out.add(new VehicleEvent.Location( + UUID.randomUUID().toString(), + vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime, + null, meta, loc)); + } + return out; + } + + private static Double optDouble(JsonNode n, String field) { + JsonNode v = n.get(field); + if (v == null || v.isNull() || !v.isNumber()) return null; + return v.asDouble(); + } + + private static Integer optInt(JsonNode n, String field) { + JsonNode v = n.get(field); + if (v == null || v.isNull() || !v.isNumber()) return null; + return v.asInt(); + } + + /** 宇通坐标约定:1e6 整数 或 已经是小数。同时过滤无效值 999999 / 0。 */ + private static Double optCoord(JsonNode n, String field) { + Double v = optDouble(n, field); + if (v == null) return null; + if (v.intValue() == 999_999 || v == 0.0) return null; + if (Math.abs(v) > 1000) return v / 1_000_000.0; + return v; + } +} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java new file mode 100644 index 00000000..38f0662b --- /dev/null +++ b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java @@ -0,0 +1,24 @@ +package com.lingniu.ingest.inbound.mqtt.model; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.time.Instant; + +/** + * MQTT 解析后的统一消息体:{@code Dispatcher} 收到的 RawFrame payload 就是这个。 + * + * @param endpointName 来源 endpoint 逻辑名 + * @param profile 字段映射 profile(如 yutong / haipote) + * @param topic 原始 topic + * @param vin VIN(解析自 payload 的 device / vin 字段) + * @param deviceTime 设备上报时刻 + * @param data 原始 JSON 树,供 Mapper 按 profile 提取字段 + */ +public record MqttPayload( + String endpointName, + String profile, + String topic, + String vin, + Instant deviceTime, + JsonNode data +) {} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java new file mode 100644 index 00000000..c1980966 --- /dev/null +++ b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java @@ -0,0 +1,68 @@ +package com.lingniu.ingest.inbound.mqtt.parser; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.spi.DecodeException; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * 宇通 MQTT 消息字段适配器(对标旧 {@code VehicleDataReceptionVO} + {@code ReceptionDataVO})。 + * + *

约定宇通 MQTT 的 payload JSON 形如: + *

{@code
+ * {
+ *   "id": "xxx",
+ *   "device": "LTEST000000000001",   // VIN
+ *   "code": "C001",
+ *   "time": "20260413100000",        // yyyyMMddHHmmss
+ *   "data": { "METER_SPEED": 52, "LATITUDE": 39916527, ... }
+ * }
+ * }
+ * + *

其它厂商 profile 只需新增一个 Parser 实现即可,由 profile 路由。 + */ +public final class MqttPayloadParser { + + private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + private final ObjectMapper mapper; + + public MqttPayloadParser(ObjectMapper mapper) { + this.mapper = mapper; + } + + public MqttPayload parse(String endpointName, String profile, String topic, byte[] bytes) { + try { + JsonNode root = mapper.readTree(bytes); + String vin = text(root, "device", "vin"); + Instant deviceTime = parseTime(text(root, "time")); + JsonNode data = root.has("data") ? root.get("data") : root; + return new MqttPayload(endpointName, profile, topic, vin, deviceTime, data); + } catch (Exception e) { + throw new DecodeException("mqtt payload parse failed: " + e.getMessage(), e); + } + } + + private static String text(JsonNode root, String... keys) { + for (String k : keys) { + JsonNode n = root.get(k); + if (n != null && !n.isNull()) return n.asText(); + } + return ""; + } + + private static Instant parseTime(String raw) { + if (raw == null || raw.isEmpty()) return Instant.now(); + try { + return LocalDateTime.parse(raw, TIME_FMT).atZone(ZONE).toInstant(); + } catch (Exception e) { + return Instant.now(); + } + } +} diff --git a/inbound-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/inbound-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..c8aa81c4 --- /dev/null +++ b/inbound-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.inbound.mqtt.config.MqttInboundAutoConfiguration diff --git a/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java b/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java new file mode 100644 index 00000000..94df6207 --- /dev/null +++ b/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java @@ -0,0 +1,67 @@ +package com.lingniu.ingest.inbound.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; +import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class YutongEventMapperTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final MqttPayloadParser parser = new MqttPayloadParser(objectMapper); + private final YutongEventMapper mapper = new YutongEventMapper(); + + @Test + void parsesYutongSamplePayload() { + String json = """ + { + "id": "abc", + "device": "LTEST000000000001", + "code": "C1", + "time": "20260413100000", + "data": { + "METER_SPEED": 52.3, + "TOTAL_MILEAGE": 123456.7, + "BATTERY_CAPACITY_SOC": 70, + "LONGITUDE": 116397128, + "LATITUDE": 39916527, + "GPSDirection": 90, + "LEFT_HYDROGEN": 5.5, + "VOLTAGE_OF_FC": 600.0, + "CURRENT_OF_FC": 120.0, + "TEMPT_OF_FC": 65.0, + "ON_GEAR": 3, + "ACC_PEDAL_APT": 25, + "BRAKEPEDALAPT": 0 + } + } + """; + + MqttPayload payload = parser.parse("yutong", "yutong", "/ytforward/shln/dev1", json.getBytes()); + assertThat(payload.vin()).isEqualTo("LTEST000000000001"); + + List events = mapper.toEvents(payload); + assertThat(events).hasSize(2); + + VehicleEvent.Realtime rt = (VehicleEvent.Realtime) events.stream() + .filter(e -> e instanceof VehicleEvent.Realtime).findFirst().orElseThrow(); + assertThat(rt.source()).isEqualTo(ProtocolId.MQTT_YUTONG); + assertThat(rt.vin()).isEqualTo("LTEST000000000001"); + assertThat(rt.payload().speedKmh()).isEqualTo(52.3); + assertThat(rt.payload().batterySoc()).isEqualTo(70.0); + assertThat(rt.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); + assertThat(rt.payload().latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001)); + assertThat(rt.payload().hydrogenRemainingKg()).isEqualTo(5.5); + + VehicleEvent.Location loc = (VehicleEvent.Location) events.stream() + .filter(e -> e instanceof VehicleEvent.Location).findFirst().orElseThrow(); + assertThat(loc.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); + } +} diff --git a/inbound-xinda-push/pom.xml b/inbound-xinda-push/pom.xml new file mode 100644 index 00000000..39633339 --- /dev/null +++ b/inbound-xinda-push/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + inbound-xinda-push + inbound-xinda-push + + 信达平台推送接入。基于私仓 org.lingniu:gps-push-client:1.0 封装的 + com.gps31.push.netty.PushClient 重写,保留信达真实帧格式, + 登录/心跳/订阅/位置/报警 全程对接成功后把事件直接投到 Kafka。 + + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + ingest-core + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-configuration-processor + true + + + io.netty + netty-all + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.lingniu + gps-push-client + 1.0 + + + + com.alibaba + fastjson + 1.2.83 + + + + org.apache.commons + commons-collections4 + 4.4 + + + + org.apache.commons + commons-lang3 + + + + commons-codec + commons-codec + + + + diff --git a/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java b/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java new file mode 100644 index 00000000..820d8d86 --- /dev/null +++ b/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java @@ -0,0 +1,267 @@ +package com.lingniu.ingest.inbound.xinda; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.gps31.push.netty.PushClient; +import com.gps31.push.netty.PushMsg; +import com.gps31.push.netty.client.TcpClient; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.inbound.xinda.config.XindaPushProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +/** + * 信达 Push 接入客户端。基于 {@code org.lingniu:gps-push-client:1.0} 的 {@link PushClient} + * 实现,完全使用信达真实协议帧格式(帧头 / 长度 / cmd / json / 帧尾 + 序列号 + 心跳)。 + * + *

行为: + *

    + *
  • {@link #start()} 调用基类 {@code TcpClient.start()} 建立连接并自动重连 + *
  • 基类在 {@code channelActive} 自动发送 {@code 0x0001 登录} 帧,携带 userName / pwd / csTag / desc + *
  • 登录成功收到应答后(由基类内部处理)会自动订阅 {@code subMsgIds}(默认空,需显式调用 setSubMsgIds) + *
  • {@link #messageReceived(TcpClient, PushMsg)} 为业务入口:cmd 字符串区分消息类型 + *
  • 位置 {@code 0200} → {@link VehicleEvent.Location} + *
  • 报警 {@code 0300} → {@link VehicleEvent.Alarm}(转为 Passthrough 保留原始 json 供下游分析) + *
  • 透传 {@code 0401} → {@link VehicleEvent.Passthrough} + *
+ * + *

类继承基类 {@code PushClient},不再需要手工处理帧编解码、心跳、重连、订阅等机制。 + */ +public class XindaPushClient extends PushClient { + + private static final Logger log = LoggerFactory.getLogger(XindaPushClient.class); + + private final XindaPushProperties props; + private final Dispatcher dispatcher; + private final DisruptorEventBus eventBus; + + public XindaPushClient(XindaPushProperties props, Dispatcher dispatcher, DisruptorEventBus eventBus) { + super(); + this.props = props; + this.dispatcher = dispatcher; + this.eventBus = eventBus; + this.isLog = false; + } + + /** + * 启动客户端:填入 host/port/user/pwd/desc,配置订阅消息 ID,交给基类建立连接。 + */ + public void start() { + if (props.getHost() == null || props.getHost().isBlank()) { + log.error("[xinda-push] host not configured, module stays idle"); + return; + } + if (props.getUsername() == null || props.getPassword() == null) { + log.error("[xinda-push] credentials missing, module stays idle " + + "(set lingniu.ingest.xinda-push.username/password)"); + return; + } + setHost(props.getHost()); + setPort(props.getPort()); + setUserName(props.getUsername()); + setPwd(props.getPassword()); + setDesc(props.getClientDesc()); + // 订阅消息 ID:信达基类 PushClient.setSubMsgIds 用管道符 "|" 作为分隔符 + // (内部调用 StringUtil.splitStr(s, "|")) + if (props.getSubscribeMsgIds() != null && !props.getSubscribeMsgIds().isEmpty()) { + setSubMsgIds(String.join("|", props.getSubscribeMsgIds())); + } + // 基类 PushClient 构造器默认 isLog=false / isLogBytes=false; + // 这里尊重默认值,不再覆盖为 true,避免 <<>>Log接收 每条帧都被 ERROR 级打印。 + // 真要打开 wire-level 诊断时,临时改为 setLog(true) 即可。 + try { + log.info("[xinda-push] starting host={} port={} user={} subMsgIds={}", + getHost(), getPort(), getUserName(), getSubMsgIds()); + super.start(); + } catch (Exception e) { + log.error("[xinda-push] start failed", e); + } + } + + /** + * Spring 生命周期停止钩子。 + */ + public void stop() { + try { + destroy(); + log.info("[xinda-push] stopped"); + } catch (Exception e) { + log.warn("[xinda-push] stop error", e); + } + } + + // ======================================================================== + // 业务回调:收到完整的 PushMsg 后分派事件 + // ======================================================================== + + @Override + public void messageReceived(TcpClient client, PushMsg msg) throws Exception { + super.messageReceived(client, msg); + String cmd = msg.getCmd(); + if (cmd == null) return; + try { + switch (cmd) { + case "8001" -> handleLoginAck(msg); + case "8002" -> log.debug("[xinda-push] heartbeat ack"); + case "8003" -> log.info("[xinda-push] subscribe ack json={}", msg.getJson()); + case "0200" -> handleLocation(msg); + case "0300" -> handleAlarm(msg); + case "0401" -> handlePassthrough(msg); + default -> log.debug("[xinda-push] rx cmd={} json={}", cmd, msg.getJson()); + } + } catch (Exception e) { + log.warn("[xinda-push] dispatch failed cmd={} json={}", cmd, msg.getJson(), e); + } + } + + /** + * 收到 8001 登录应答后处理:若 {@code rspResult=0} 则主动发送 0003 订阅命令。 + * + *

信达订阅命令的 body 格式(从旧 {@code PushClientHandler} 和 8003 错误提示反推得到): + *

{@code
+     * {
+     *   "seq":     "1",                             // 序列号
+     *   "action":  "add",                           // 操作: add 订阅 / del 取消
+     *   "msgIds":  "[\"0200\",\"0300\",\"0401\"]"  // JSON 数组字符串(注意是字符串,不是数组)
+     * }
+     * }
+ *

+ * 其中 {@code msgIds} 的值来自基类 {@link PushClient#getSubCmdSet()},内容由 + * {@link PushClient#setSubMsgIds(String)} 填充(分隔符是 {@code |})。 + */ + private void handleLoginAck(PushMsg msg) throws Exception { + String json = msg.getJson() == null ? "" : msg.getJson(); + log.info("[xinda-push] login ack json={}", json); + JSONObject parsed = JSON.parseObject(json); + String rsp = parsed == null ? null : parsed.getString("rspResult"); + if (!"0".equals(rsp)) { + log.error("[xinda-push] login REJECTED rspResult={}", rsp); + return; + } + Map body = new java.util.HashMap<>(); + body.put("seq", "1"); + body.put("action", "add"); + body.put("msgIds", JSON.toJSONString(getSubCmdSet())); + PushMsg subscribe = getInstance("0003", body); + sendMsg(subscribe); + log.info("[xinda-push] subscribe sent action=add msgIds={}", getSubCmdSet()); + } + + @Override + public void channelActive(TcpClient client) throws Exception { + super.channelActive(client); + log.info("[xinda-push] channel active, login frame sent"); + } + + @Override + public void channelInactive(TcpClient client) throws Exception { + super.channelInactive(client); + log.warn("[xinda-push] channel inactive, will auto-reconnect"); + } + + // ======================================================================== + // 业务处理(0200/0300/0401 → VehicleEvent) + // ======================================================================== + + /** + * 0200 定位消息 → {@link VehicleEvent.Location}。 + *

信达 0200 常见字段(不同版本可能略有差异): + *

+     *   plateNo  车牌号
+     *   lat/lng  WGS84 度 * 1e6 (整数)
+     *   speed    km/h
+     *   drct     方向
+     *   height   海拔 m
+     *   alarmStts 报警状态位
+     *   statusStts 状态位
+     *   time     yyyyMMddHHmmss
+     * 
+ */ + private void handleLocation(PushMsg msg) { + JSONObject json = JSON.parseObject(msg.getJson()); + String vin = firstNonBlank(json, "vin", "plateNo", "carNo"); + double lon = asDouble(json, "lng", "lon", "longitude") / 1_000_000.0; + double lat = asDouble(json, "lat", "latitude") / 1_000_000.0; + // 有的实现已经是十进制度,做一次保护:如果 |lon| > 1000 显然是 1e6 单位再除一次 + if (Math.abs(lon) > 1000) lon /= 1.0; // already divided above + double speed = asDouble(json, "speed"); + double direction = asDouble(json, "drct", "direction"); + long alarmFlag = (long) asDouble(json, "alarmStts", "alarmFlag"); + long statusFlag = (long) asDouble(json, "statusStts", "statusFlag"); + + VehicleEvent.Location loc = new VehicleEvent.Location( + UUID.randomUUID().toString(), + vin.isBlank() ? "unknown" : vin, + ProtocolId.XINDA_PUSH, + Instant.now(), Instant.now(), + null, + Map.of("source", "xinda-push", "cmd", "0200"), + new LocationPayload(lon, lat, 0.0, speed, direction, alarmFlag, statusFlag)); + eventBus.publish(loc); + log.debug("[xinda-push] 0200 location vin={} lon={} lat={} speed={}", vin, lon, lat, speed); + } + + private void handleAlarm(PushMsg msg) { + emitPassthrough(0x0300, msg); + log.info("[xinda-push] 0300 alarm json={}", msg.getJson()); + } + + private void handlePassthrough(PushMsg msg) { + emitPassthrough(0x0401, msg); + log.debug("[xinda-push] 0401 passthrough json={}", msg.getJson()); + } + + private void emitPassthrough(int type, PushMsg msg) { + JSONObject json = JSON.parseObject(msg.getJson()); + String vin = firstNonBlank(json, "vin", "plateNo", "carNo"); + byte[] raw = msg.getJson() == null + ? new byte[0] : msg.getJson().getBytes(StandardCharsets.UTF_8); + VehicleEvent.Passthrough pt = new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin.isBlank() ? "unknown" : vin, + ProtocolId.XINDA_PUSH, + Instant.now(), Instant.now(), + null, + Map.of("source", "xinda-push", "cmd", String.format("%04x", type), + "rawJson", msg.getJson() == null ? "" : msg.getJson()), + type, raw); + eventBus.publish(pt); + } + + // ======================================================================== + // JSON helpers + // ======================================================================== + + private static String firstNonBlank(JSONObject node, String... keys) { + if (node == null) return ""; + for (String k : keys) { + Object v = node.get(k); + if (v != null && !v.toString().isBlank()) return v.toString(); + } + return ""; + } + + private static double asDouble(JSONObject node, String... keys) { + if (node == null) return 0.0; + for (String k : keys) { + Object v = node.get(k); + if (v instanceof Number n) return n.doubleValue(); + if (v != null) { + try { + return Double.parseDouble(v.toString()); + } catch (NumberFormatException ignored) { + } + } + } + return 0.0; + } +} diff --git a/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java b/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java new file mode 100644 index 00000000..ffdc51fe --- /dev/null +++ b/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java @@ -0,0 +1,36 @@ +package com.lingniu.ingest.inbound.xinda.config; + +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.inbound.xinda.XindaPushClient; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@EnableConfigurationProperties(XindaPushProperties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.xinda-push", name = "enabled", havingValue = "true") +public class XindaPushAutoConfiguration { + + @Bean(destroyMethod = "stop") + @ConditionalOnMissingBean + public XindaPushClient xindaPushClient(XindaPushProperties props, + Dispatcher dispatcher, + DisruptorEventBus eventBus) { + return new XindaPushClient(props, dispatcher, eventBus); + } + + @Bean + public ApplicationRunner xindaPushStartupRunner(XindaPushClient client) { + return new ApplicationRunner() { + @Override + public void run(ApplicationArguments args) { + client.start(); + } + }; + } +} diff --git a/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java b/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java new file mode 100644 index 00000000..e168222a --- /dev/null +++ b/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java @@ -0,0 +1,40 @@ +package com.lingniu.ingest.inbound.xinda.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +/** + * 信达 Push 接入配置。所有敏感字段均可通过环境变量注入,彻底取代旧代码里的硬编码。 + */ +@ConfigurationProperties(prefix = "lingniu.ingest.xinda-push") +public class XindaPushProperties { + + private boolean enabled = false; + private String host; + private int port = 10100; + private String username; + private String password; + private String clientDesc = "lingniu-ingest"; + private List subscribeMsgIds = new ArrayList<>(List.of("0200", "0300", "0401")); + /** 断线重连间隔(秒)。 */ + private int reconnectIntervalSec = 5; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getHost() { return host; } + public void setHost(String host) { this.host = host; } + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getClientDesc() { return clientDesc; } + public void setClientDesc(String clientDesc) { this.clientDesc = clientDesc; } + public List getSubscribeMsgIds() { return subscribeMsgIds; } + public void setSubscribeMsgIds(List subscribeMsgIds) { this.subscribeMsgIds = subscribeMsgIds; } + public int getReconnectIntervalSec() { return reconnectIntervalSec; } + public void setReconnectIntervalSec(int reconnectIntervalSec) { this.reconnectIntervalSec = reconnectIntervalSec; } +} diff --git a/inbound-xinda-push/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/inbound-xinda-push/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..43286331 --- /dev/null +++ b/inbound-xinda-push/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.inbound.xinda.config.XindaPushAutoConfiguration diff --git a/ingest-api/pom.xml b/ingest-api/pom.xml new file mode 100644 index 00000000..6d484399 --- /dev/null +++ b/ingest-api/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + ingest-api + ingest-api + SPI、sealed 领域事件、注解定义。零 Spring 依赖。 + + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/ProtocolId.java b/ingest-api/src/main/java/com/lingniu/ingest/api/ProtocolId.java new file mode 100644 index 00000000..785c4411 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/ProtocolId.java @@ -0,0 +1,14 @@ +package com.lingniu.ingest.api; + +/** + * 协议标识,作为所有 SPI / 注解 / 路由 key 的统一枚举。 + * 新增协议需要在此处登记,避免散落的字符串常量。 + */ +public enum ProtocolId { + GB32960, + JT808, + JT1078, + JSATL12, + MQTT_YUTONG, + XINDA_PUSH +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java new file mode 100644 index 00000000..6f28febf --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java @@ -0,0 +1,22 @@ +package com.lingniu.ingest.api.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。 + * + *

处理方法签名需为 {@code void foo(List list)} 或返回 {@code List}。 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface AsyncBatch { + + int size() default 1000; + + long waitMs() default 500; + + int poolSize() default 2; +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/EventEmit.java b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/EventEmit.java new file mode 100644 index 00000000..48c81478 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/EventEmit.java @@ -0,0 +1,17 @@ +package com.lingniu.ingest.api.annotation; + +import com.lingniu.ingest.api.event.VehicleEvent; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 声明当前 Handler 方法产出的事件类型。用于文档化和静态校验。 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface EventEmit { + Class[] value(); +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java new file mode 100644 index 00000000..d8a1d36c --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java @@ -0,0 +1,17 @@ +package com.lingniu.ingest.api.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。 + * + *

示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")} + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface IdempotentKey { + String value(); +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java new file mode 100644 index 00000000..ed24a145 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java @@ -0,0 +1,27 @@ +package com.lingniu.ingest.api.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 方法级路由注解:声明此方法处理哪些协议消息。 + * + *

{@link #command} 和 {@link #infoType} 的含义由各协议自行解释,Dispatcher 只负责匹配: + *

    + *
  • GB/T 32960:{@code command} = 0x01 车辆登入 / 0x02 实时上报 / ...;{@code infoType} = 信息体 ID + *
  • JT/T 808:{@code command} = 消息 ID(0x0100 / 0x0200 / ...);{@code infoType} 留空 + *
  • MQTT:{@code command} 可为 topic hash 或忽略 + *
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface MessageMapping { + + int[] command() default {}; + + int[] infoType() default {}; + + String desc() default ""; +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/ProtocolHandler.java b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/ProtocolHandler.java new file mode 100644 index 00000000..6e308444 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/ProtocolHandler.java @@ -0,0 +1,30 @@ +package com.lingniu.ingest.api.annotation; + +import com.lingniu.ingest.api.ProtocolId; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标记一个类为协议 Handler Bean,由 {@code AnnotationHandlerProcessor} 自动扫描注册。 + * + *

典型用法: + *

{@code
+ * @ProtocolHandler(protocol = ProtocolId.GB32960)
+ * public class Gb32960RealtimeHandler {
+ *     @MessageMapping(command = 0x02)
+ *     public VehicleEvent.Realtime handle(Gb32960Message msg) { ... }
+ * }
+ * }
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface ProtocolHandler { + + ProtocolId protocol(); + + /** 可选:子协议版本(如 32960 的 2011 / 2017)。 */ + String version() default ""; +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java new file mode 100644 index 00000000..73c47a25 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java @@ -0,0 +1,20 @@ +package com.lingniu.ingest.api.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 单 VIN 速率限制。超限消息直接进 DLQ 并打点。 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface RateLimited { + + /** 单 VIN 每秒最大消息数。 */ + int perVin() default 50; + + /** 全局 QPS 上限;<=0 表示不设限。 */ + int global() default -1; +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/TraceSpan.java b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/TraceSpan.java new file mode 100644 index 00000000..daf225c0 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/TraceSpan.java @@ -0,0 +1,15 @@ +package com.lingniu.ingest.api.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 为 Handler 方法开启 OpenTelemetry span。 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface TraceSpan { + String value(); +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java b/ingest-api/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java new file mode 100644 index 00000000..b56cb03e --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java @@ -0,0 +1,37 @@ +package com.lingniu.ingest.api.event; + +import java.util.List; +import java.util.Set; + +/** + * 报警事件载荷,跨协议归一。 + * + * @param level 报警最高等级 + * @param alarmTypeCode 协议原始 alarm type 编码(按协议自定义),可用于联查协议规范 + * @param alarmTypeName 报警类型名称,如 {@code GB32960_ALARM} + * @param faultCodes 故障代码列表(协议私有编码字符串化,便于下游去重/聚合) + * @param activeBits 结构化的通用报警位集合(2016 版:0~15;2025 版:0~27),对照 GB/T 32960.3 表 24 + * @param longitude 经度(可选,若事件伴随位置) + * @param latitude 纬度(可选) + */ +public record AlarmPayload( + AlarmLevel level, + int alarmTypeCode, + String alarmTypeName, + List faultCodes, + Set activeBits, + Double longitude, + Double latitude +) { + /** + * 报警等级(归一化的跨协议分类)。 + * + *
    + *
  • {@link #INFO}:提示级(对应 GB/T 32960.3 0/无故障) + *
  • {@link #MINOR}:1 级 —— 不影响车辆正常行驶 + *
  • {@link #MAJOR}:2 级 —— 影响车辆性能,需驾驶员限制行驶 + *
  • {@link #CRITICAL}:3 级(驾驶员应立即停车)或 4 级(热事件最高级) + *
+ */ + public enum AlarmLevel { INFO, MINOR, MAJOR, CRITICAL } +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java b/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java new file mode 100644 index 00000000..132f8921 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java @@ -0,0 +1,12 @@ +package com.lingniu.ingest.api.event; + +/** 位置事件载荷。坐标已统一转换为 WGS84 十进制度。 */ +public record LocationPayload( + double longitude, + double latitude, + double altitudeM, + double speedKmh, + double directionDeg, + long alarmFlag, + long statusFlag +) {} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/event/RealtimePayload.java b/ingest-api/src/main/java/com/lingniu/ingest/api/event/RealtimePayload.java new file mode 100644 index 00000000..76ceefa1 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/event/RealtimePayload.java @@ -0,0 +1,49 @@ +package com.lingniu.ingest.api.event; + +/** + * 实时数据载荷(扁平 record,字段用 {@code Double} / {@code Integer} 允许 null,表达"未上报")。 + * + *

不再复刻旧服务 {@code VehicleDataActual} 的 273 字段巨型类 —— 协议特有细节通过 + * {@link VehicleEvent#metadata()} 传递。此处只保留跨协议通用字段。 + */ +public record RealtimePayload( + // ===== 动力 & 能源 ===== + Double speedKmh, + Double totalMileageKm, + Double batterySoc, + Double batteryVoltageV, + Double batteryCurrentA, + + // ===== 燃料电池 / 氢能 ===== + Double fcVoltageV, + Double fcCurrentA, + Double fcTempC, + Double hydrogenRemainingKg, + Double hydrogenHighPressureMpa, + Double hydrogenLowPressureMpa, + + // ===== 车辆状态 ===== + VehicleState vehicleState, + ChargingState chargingState, + RunningMode runningMode, + Integer gearLevel, + Double acceleratorPedal, + Double brakePedal, + + // ===== 位置(冗余一份便于单主题消费)===== + Double longitude, + Double latitude, + Double altitudeM, + Double directionDeg, + + // ===== 环境 ===== + Double ambientTempC, + Double coolantTempC +) { + + public enum VehicleState { STARTED, SHUTDOWN, OTHER, INVALID } + + public enum ChargingState { UNCHARGED, PARKED_CHARGING, DRIVING_CHARGING, CHARGED, OTHER, INVALID } + + public enum RunningMode { ELECTRIC, HYBRID, FUEL, OTHER, INVALID } +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java b/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java new file mode 100644 index 00000000..1044b8a4 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java @@ -0,0 +1,141 @@ +package com.lingniu.ingest.api.event; + +import com.lingniu.ingest.api.ProtocolId; + +import java.time.Instant; +import java.util.Map; + +/** + * 领域事件根类型。所有协议归一到这里。 + * + *

使用 sealed + record 表达有界闭合的事件集合,下游消费方可用 {@code switch} 模式匹配穷尽处理。 + * + *

字段约定: + *

    + *
  • {@link #vin} 作为 Kafka 分区 key,保证单车顺序 + *
  • {@link #eventTime} 设备上报时间(而非服务器接收时间) + *
  • {@link #ingestTime} 服务器接收时间,用于延迟监控 + *
  • {@link #traceId} W3C traceparent 上下文 + *
+ */ +public sealed interface VehicleEvent + permits VehicleEvent.Realtime, + VehicleEvent.Location, + VehicleEvent.Alarm, + VehicleEvent.Login, + VehicleEvent.Logout, + VehicleEvent.Heartbeat, + VehicleEvent.MediaMeta, + VehicleEvent.Passthrough { + + String eventId(); + String vin(); + ProtocolId source(); + Instant eventTime(); + Instant ingestTime(); + String traceId(); + + /** 通用元数据:协议版本、终端手机号、网关 IP 等。 */ + Map metadata(); + + // ===== 具体事件类型 ===== + + /** 整车实时数据(32960 实时上报 / MQTT 宇通 / JT808 位置扩展等都归一到这里)。 */ + record Realtime( + String eventId, + String vin, + ProtocolId source, + Instant eventTime, + Instant ingestTime, + String traceId, + Map metadata, + RealtimePayload payload + ) implements VehicleEvent {} + + /** 纯位置事件(808 的 0x0200 / Xinda 0200 映射到此)。 */ + record Location( + String eventId, + String vin, + ProtocolId source, + Instant eventTime, + Instant ingestTime, + String traceId, + Map metadata, + LocationPayload payload + ) implements VehicleEvent {} + + /** 报警事件。 */ + record Alarm( + String eventId, + String vin, + ProtocolId source, + Instant eventTime, + Instant ingestTime, + String traceId, + Map metadata, + AlarmPayload payload + ) implements VehicleEvent {} + + /** 设备登入 / 车辆登入。 */ + record Login( + String eventId, + String vin, + ProtocolId source, + Instant eventTime, + Instant ingestTime, + String traceId, + Map metadata, + String iccid, + String protocolVersion + ) implements VehicleEvent {} + + /** 设备登出。 */ + record Logout( + String eventId, + String vin, + ProtocolId source, + Instant eventTime, + Instant ingestTime, + String traceId, + Map metadata + ) implements VehicleEvent {} + + /** 心跳 / 链路保活。 */ + record Heartbeat( + String eventId, + String vin, + ProtocolId source, + Instant eventTime, + Instant ingestTime, + String traceId, + Map metadata + ) implements VehicleEvent {} + + /** 多媒体元数据(大文件本体走对象存储,通过 archiveRef 引用)。 */ + record MediaMeta( + String eventId, + String vin, + ProtocolId source, + Instant eventTime, + Instant ingestTime, + String traceId, + Map metadata, + String mediaId, + String mediaType, + long sizeBytes, + String archiveRef + ) implements VehicleEvent {} + + /** 透传 / 自定义扩展消息。 */ + record Passthrough( + String eventId, + String vin, + ProtocolId source, + Instant eventTime, + Instant ingestTime, + String traceId, + Map metadata, + int passthroughType, + byte[] data + ) implements VehicleEvent {} +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/package-info.java b/ingest-api/src/main/java/com/lingniu/ingest/api/package-info.java new file mode 100644 index 00000000..c93d2a45 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/package-info.java @@ -0,0 +1,13 @@ +/** + * lingniu-vehicle-ingest 公共 API 模块。零 Spring 依赖,只定义 SPI、sealed 领域事件、注解。 + * + *

分包: + *

    + *
  • {@code annotation} - Handler 注解体系 + *
  • {@code event} - sealed {@code VehicleEvent} + payload records + *
  • {@code pipeline} - RawFrame / IngestContext / IngestInterceptor + *
  • {@code sink} - EventSink SPI + *
  • {@code spi} - ProtocolPlugin / FrameDecoder / FrameEncoder / EventMapper + *
+ */ +package com.lingniu.ingest.api; diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java b/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java new file mode 100644 index 00000000..6cb22f0a --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java @@ -0,0 +1,46 @@ +package com.lingniu.ingest.api.pipeline; + +import java.util.HashMap; +import java.util.Map; + +/** + * 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享,不需要同步。 + */ +public final class IngestContext { + + private final String traceId; + private final Map attributes = new HashMap<>(8); + private volatile boolean aborted; + private volatile String abortReason; + + public IngestContext(String traceId) { + this.traceId = traceId; + } + + public String traceId() { + return traceId; + } + + public T attr(String key) { + @SuppressWarnings("unchecked") + T v = (T) attributes.get(key); + return v; + } + + public void attr(String key, Object value) { + attributes.put(key, value); + } + + public void abort(String reason) { + this.aborted = true; + this.abortReason = reason; + } + + public boolean aborted() { + return aborted; + } + + public String abortReason() { + return abortReason; + } +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestInterceptor.java b/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestInterceptor.java new file mode 100644 index 00000000..e0574fa8 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestInterceptor.java @@ -0,0 +1,21 @@ +package com.lingniu.ingest.api.pipeline; + +import com.lingniu.ingest.api.event.VehicleEvent; + +/** + * 拦截器 SPI。内置实现:Auth / Dedup / RateLimit / CoordTransform / Tracing。 + * 实现 {@link org.springframework.core.Ordered}(或使用 {@code @Order})控制顺序。 + */ +public interface IngestInterceptor { + + /** 解码后、分发到 Handler 之前触发。返回 false 表示终止后续处理。 */ + default boolean before(RawFrame frame, IngestContext ctx) { + return true; + } + + /** Handler 成功产出事件后触发,允许修饰事件或取消投递。 */ + default void after(VehicleEvent event, IngestContext ctx) {} + + /** 处理链任一环节抛出异常时触发。 */ + default void onError(Throwable error, IngestContext ctx) {} +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java b/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java new file mode 100644 index 00000000..081621ca --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java @@ -0,0 +1,27 @@ +package com.lingniu.ingest.api.pipeline; + +import com.lingniu.ingest.api.ProtocolId; + +import java.time.Instant; +import java.util.Map; + +/** + * 进入 Pipeline 的原始帧描述。 + * + * @param protocolId 来源协议 + * @param command 协议主命令 / 消息 ID(由各协议自行定义) + * @param infoType 可选子类型(如 32960 的信息体 ID) + * @param payload 已经被 {@link com.lingniu.ingest.api.spi.FrameDecoder} 解析出的协议对象 + * @param rawBytes 原始字节,用于冷存与排障(可能为 null,按配置开关) + * @param sourceMeta 来源元数据:peer ip、端口、session id、topic 等 + * @param receivedAt 服务器接收时刻 + */ +public record RawFrame( + ProtocolId protocolId, + int command, + int infoType, + Object payload, + byte[] rawBytes, + Map sourceMeta, + Instant receivedAt +) {} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/sink/EventSink.java b/ingest-api/src/main/java/com/lingniu/ingest/api/sink/EventSink.java new file mode 100644 index 00000000..dcc13451 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/sink/EventSink.java @@ -0,0 +1,28 @@ +package com.lingniu.ingest.api.sink; + +import com.lingniu.ingest.api.event.VehicleEvent; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * 事件出口 SPI。Kafka、本地归档、指标打点都是一种 Sink。 + * + *

实现应当线程安全、非阻塞(内部异步 IO)。由 Disruptor EventBus 扇出调用。 + */ +public interface EventSink { + + String name(); + + CompletableFuture publish(VehicleEvent event); + + default CompletableFuture publishBatch(List batch) { + CompletableFuture[] all = batch.stream().map(this::publish).toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(all); + } + + /** 是否接受此事件类型。默认全部接受。 */ + default boolean accepts(VehicleEvent event) { + return true; + } +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/DecodeException.java b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/DecodeException.java new file mode 100644 index 00000000..0c49d960 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/DecodeException.java @@ -0,0 +1,12 @@ +package com.lingniu.ingest.api.spi; + +/** 解码阶段异常,指示数据体本身违反协议。应被 Dispatcher 路由到 DLQ。 */ +public class DecodeException extends RuntimeException { + public DecodeException(String message) { + super(message); + } + + public DecodeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/EventMapper.java b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/EventMapper.java new file mode 100644 index 00000000..ecceef3c --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/EventMapper.java @@ -0,0 +1,16 @@ +package com.lingniu.ingest.api.spi; + +import com.lingniu.ingest.api.event.VehicleEvent; + +import java.util.List; + +/** + * 协议消息 → 领域事件的映射器(Adapter 模式)。 + * + *

实现类负责把"我这个协议特有的 Java 对象"翻译成统一的 {@link VehicleEvent}。 + * 允许一条协议消息展开为多条领域事件(例如 32960 的一次实时上报同时产出 Realtime + Location)。 + */ +@FunctionalInterface +public interface EventMapper { + List toEvents(I message); +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameDecoder.java b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameDecoder.java new file mode 100644 index 00000000..9aa9eb90 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameDecoder.java @@ -0,0 +1,13 @@ +package com.lingniu.ingest.api.spi; + +import java.nio.ByteBuffer; + +/** + * 协议帧解码器:字节流 → 协议消息对象。 + * + *

实现类应是无状态的(拆包/粘包由上游 Netty 的帧解码器处理),此处只负责一条完整帧的解析。 + */ +@FunctionalInterface +public interface FrameDecoder { + I decode(ByteBuffer buffer) throws DecodeException; +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameEncoder.java b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameEncoder.java new file mode 100644 index 00000000..ded9040d --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameEncoder.java @@ -0,0 +1,9 @@ +package com.lingniu.ingest.api.spi; + +import java.nio.ByteBuffer; + +/** 下行帧编码器。无下行的协议返回 null 即可。 */ +@FunctionalInterface +public interface FrameEncoder { + ByteBuffer encode(O message); +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/MessageHandler.java b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/MessageHandler.java new file mode 100644 index 00000000..13b5278d --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/MessageHandler.java @@ -0,0 +1,16 @@ +package com.lingniu.ingest.api.spi; + +import com.lingniu.ingest.api.event.VehicleEvent; + +import java.util.List; + +/** + * 编程式 Handler 接口。推荐使用 {@code @ProtocolHandler + @MessageMapping} 注解方式代替。 + * 此接口保留是为了 SPI 场景下的脚本式扩展(例如动态加载的 groovy handler)。 + */ +public interface MessageHandler { + + boolean supports(I message); + + List handle(I message); +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/ProtocolPlugin.java b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/ProtocolPlugin.java new file mode 100644 index 00000000..49947910 --- /dev/null +++ b/ingest-api/src/main/java/com/lingniu/ingest/api/spi/ProtocolPlugin.java @@ -0,0 +1,31 @@ +package com.lingniu.ingest.api.spi; + +import com.lingniu.ingest.api.ProtocolId; + +import java.util.List; + +/** + * 协议插件 SPI。每个协议模块提供一个实现,由 {@code ProtocolPluginRegistry} 通过 ServiceLoader + * 或 Spring Bean 方式发现并装配。 + * + * @param 协议解码后的消息类型 + * @param 下行消息类型(无下行的协议用 {@link Void}) + */ +public interface ProtocolPlugin { + + ProtocolId id(); + + FrameDecoder decoder(); + + FrameEncoder encoder(); + + EventMapper eventMapper(); + + /** + * 插件自带的协议特定 Handler 列表(可选)。 + * 通常推荐使用 {@code @ProtocolHandler} + Spring Bean 的方式注册,此处返回空列表。 + */ + default List> builtinHandlers() { + return List.of(); + } +} diff --git a/ingest-codec-common/pom.xml b/ingest-codec-common/pom.xml new file mode 100644 index 00000000..cc116cd7 --- /dev/null +++ b/ingest-codec-common/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + ingest-codec-common + ingest-codec-common + BCD / CRC / BCC / bit utils 等公共编解码工具。 + + + + io.netty + netty-buffer + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + diff --git a/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BccChecksum.java b/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BccChecksum.java new file mode 100644 index 00000000..5dc49274 --- /dev/null +++ b/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BccChecksum.java @@ -0,0 +1,19 @@ +package com.lingniu.ingest.codec; + +/** BCC(异或校验)。GB/T 32960 与 JT/T 808 都使用此方式做帧尾校验。 */ +public final class BccChecksum { + + private BccChecksum() {} + + public static byte compute(byte[] data, int offset, int length) { + byte sum = 0; + for (int i = offset; i < offset + length; i++) { + sum ^= data[i]; + } + return sum; + } + + public static boolean verify(byte[] data, int offset, int length, byte expected) { + return compute(data, offset, length) == expected; + } +} diff --git a/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BcdCodec.java b/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BcdCodec.java new file mode 100644 index 00000000..32fdddbd --- /dev/null +++ b/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BcdCodec.java @@ -0,0 +1,30 @@ +package com.lingniu.ingest.codec; + +/** BCD (Binary-Coded Decimal) 编解码。JT/T 808 的手机号、时间戳常用。 */ +public final class BcdCodec { + + private BcdCodec() {} + + public static byte[] encode(String decimal) { + int len = (decimal.length() + 1) / 2; + byte[] out = new byte[len]; + int idx = decimal.length() % 2; + if (idx == 1) { + out[0] = (byte) Character.digit(decimal.charAt(0), 10); + } + for (int i = idx; i < decimal.length(); i += 2) { + int hi = Character.digit(decimal.charAt(i), 10); + int lo = Character.digit(decimal.charAt(i + 1), 10); + out[(i + idx) / 2] = (byte) ((hi << 4) | lo); + } + return out; + } + + public static String decode(byte[] bcd) { + StringBuilder sb = new StringBuilder(bcd.length * 2); + for (byte b : bcd) { + sb.append((b >> 4) & 0x0F).append(b & 0x0F); + } + return sb.toString(); + } +} diff --git a/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BitUtils.java b/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BitUtils.java new file mode 100644 index 00000000..cf3034fd --- /dev/null +++ b/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BitUtils.java @@ -0,0 +1,34 @@ +package com.lingniu.ingest.codec; + +/** 位运算工具:从字节数组读写无符号整数。 */ +public final class BitUtils { + + private BitUtils() {} + + public static int readUint8(byte[] buf, int offset) { + return buf[offset] & 0xFF; + } + + public static int readUint16BE(byte[] buf, int offset) { + return ((buf[offset] & 0xFF) << 8) | (buf[offset + 1] & 0xFF); + } + + public static long readUint32BE(byte[] buf, int offset) { + return ((long) (buf[offset] & 0xFF) << 24) + | ((long) (buf[offset + 1] & 0xFF) << 16) + | ((long) (buf[offset + 2] & 0xFF) << 8) + | (buf[offset + 3] & 0xFF); + } + + public static void writeUint16BE(byte[] buf, int offset, int value) { + buf[offset] = (byte) ((value >> 8) & 0xFF); + buf[offset + 1] = (byte) (value & 0xFF); + } + + public static void writeUint32BE(byte[] buf, int offset, long value) { + buf[offset] = (byte) ((value >> 24) & 0xFF); + buf[offset + 1] = (byte) ((value >> 16) & 0xFF); + buf[offset + 2] = (byte) ((value >> 8) & 0xFF); + buf[offset + 3] = (byte) (value & 0xFF); + } +} diff --git a/ingest-codec-common/src/test/java/com/lingniu/ingest/codec/BcdCodecTest.java b/ingest-codec-common/src/test/java/com/lingniu/ingest/codec/BcdCodecTest.java new file mode 100644 index 00000000..7ee9f94f --- /dev/null +++ b/ingest-codec-common/src/test/java/com/lingniu/ingest/codec/BcdCodecTest.java @@ -0,0 +1,28 @@ +package com.lingniu.ingest.codec; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BcdCodecTest { + + @Test + void roundTripEvenLength() { + // 偶数位直接等值 + byte[] bcd = BcdCodec.encode("1380013800"); + assertThat(BcdCodec.decode(bcd)).isEqualTo("1380013800"); + } + + @Test + void roundTripOddLengthPadsLeadingZero() { + // 奇数位会在高位补 0 + byte[] bcd = BcdCodec.encode("13800138000"); + assertThat(BcdCodec.decode(bcd)).isEqualTo("013800138000"); + } + + @Test + void checksumMatches() { + byte[] data = {0x01, 0x02, 0x03, 0x04}; + assertThat(BccChecksum.compute(data, 0, data.length)).isEqualTo((byte) 0x04); + } +} diff --git a/ingest-core/pom.xml b/ingest-core/pom.xml new file mode 100644 index 00000000..6a497004 --- /dev/null +++ b/ingest-core/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + ingest-core + ingest-core + Dispatcher / Interceptor Chain / Disruptor Event Bus / 注解扫描与自动注册。 + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + ingest-codec-common + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-configuration-processor + true + + + com.lmax + disruptor + + + com.github.ben-manes.caffeine + caffeine + + + io.github.resilience4j + resilience4j-ratelimiter + + + io.github.resilience4j + resilience4j-circuitbreaker + + + io.netty + netty-all + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java b/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java new file mode 100644 index 00000000..aebb16a9 --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java @@ -0,0 +1,164 @@ +package com.lingniu.ingest.core.concurrency; + +import com.lingniu.ingest.api.annotation.AsyncBatch; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.core.dispatcher.HandlerDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * {@link AsyncBatch} 注解的执行器:把 Handler 方法从"单条调用"变成"批量调用"。 + * + *

每个 Handler 方法对应一个 {@link Batcher},背后是一个固定容量的 {@link BlockingQueue} + * 和 {@link AsyncBatch#poolSize()} 条守护虚拟线程。批量触发条件: + *

    + *
  • 累积 {@link AsyncBatch#size()} 条立即 flush + *
  • 从第一条入队起等待 {@link AsyncBatch#waitMs()} 毫秒后强制 flush + *
+ * + *

目标 Handler 方法签名约定: + *

{@code
+ *   @MessageMapping(...)
+ *   @AsyncBatch(size = 4000, waitMs = 1000, poolSize = 2)
+ *   public List onBatch(List batch) { ... }
+ * }
+ * + *

flush 产出的事件由构造器注入的 {@code eventPublisher} 异步投递(通常是 + * {@link DisruptorEventBus#publish}),不阻塞 Netty EventLoop。 + */ +public final class AsyncBatchExecutor implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(AsyncBatchExecutor.class); + + private final Consumer eventPublisher; + private final ConcurrentMap batchers = new ConcurrentHashMap<>(); + + public AsyncBatchExecutor(Consumer eventPublisher) { + this.eventPublisher = eventPublisher; + } + + /** 供 Dispatcher 调用:把单条消息交给目标 Handler 的 batcher。 */ + public void submit(HandlerDefinition def, Object message) { + Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher)); + b.offer(message); + } + + @Override + public void close() { + batchers.values().forEach(Batcher::close); + batchers.clear(); + log.info("AsyncBatchExecutor closed"); + } + + // ===== internals ===== + + private static final class Batcher implements AutoCloseable { + private final HandlerDefinition def; + private final Consumer publisher; + private final int batchSize; + private final long waitMs; + private final BlockingQueue queue; + private final Thread[] workers; + private volatile boolean running = true; + + Batcher(HandlerDefinition def, Consumer publisher) { + AsyncBatch cfg = def.asyncBatch(); + this.def = def; + this.publisher = publisher; + this.batchSize = Math.max(1, cfg.size()); + this.waitMs = Math.max(1, cfg.waitMs()); + this.queue = new ArrayBlockingQueue<>(Math.max(batchSize * 4, 16)); + int pool = Math.max(1, cfg.poolSize()); + this.workers = new Thread[pool]; + for (int i = 0; i < pool; i++) { + Thread t = Thread.ofVirtual() + .name("batcher-" + def.method().getName() + "-" + i) + .unstarted(this::loop); + workers[i] = t; + t.start(); + } + log.info("batcher started method={} size={} waitMs={} pool={}", + def.method().getName(), batchSize, waitMs, pool); + } + + void offer(Object item) { + try { + if (!queue.offer(item, 100, TimeUnit.MILLISECONDS)) { + log.warn("batcher queue full, dropping item for {}", def.method().getName()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void loop() { + while (running) { + try { + Object first = queue.poll(200, TimeUnit.MILLISECONDS); + if (first == null) continue; + List buf = new ArrayList<>(batchSize); + buf.add(first); + long deadlineNanos = System.nanoTime() + waitMs * 1_000_000L; + while (buf.size() < batchSize) { + long remain = deadlineNanos - System.nanoTime(); + if (remain <= 0) break; + Object next = queue.poll(remain, TimeUnit.NANOSECONDS); + if (next == null) break; + buf.add(next); + } + flush(buf); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (Exception e) { + log.error("batcher loop error method={}", def.method().getName(), e); + } + } + } + + @SuppressWarnings("unchecked") + private void flush(List buf) { + List events; + try { + Object result = def.method().invoke(def.bean(), buf); + events = switch (result) { + case null -> List.of(); + case List list -> (List) list; + case VehicleEvent e -> List.of(e); + default -> throw new IllegalStateException( + "@AsyncBatch method must return List: " + def.method()); + }; + } catch (InvocationTargetException e) { + log.error("batcher invoke failed method={}", def.method().getName(), e.getCause()); + return; + } catch (IllegalAccessException e) { + log.error("batcher access denied method={}", def.method().getName(), e); + return; + } + for (VehicleEvent e : events) { + try { + publisher.accept(e); + } catch (Exception ex) { + log.warn("batcher publish failed eventId={}", e.eventId(), ex); + } + } + } + + @Override + public void close() { + running = false; + for (Thread t : workers) t.interrupt(); + } + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java b/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java new file mode 100644 index 00000000..54a34c51 --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java @@ -0,0 +1,87 @@ +package com.lingniu.ingest.core.concurrency; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.sink.EventSink; +import com.lmax.disruptor.BlockingWaitStrategy; +import com.lmax.disruptor.BusySpinWaitStrategy; +import com.lmax.disruptor.EventHandler; +import com.lmax.disruptor.SleepingWaitStrategy; +import com.lmax.disruptor.WaitStrategy; +import com.lmax.disruptor.YieldingWaitStrategy; +import com.lmax.disruptor.dsl.Disruptor; +import com.lmax.disruptor.dsl.ProducerType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 基于 Disruptor 的事件总线:Dispatcher 投递事件 → RingBuffer → Sink 扇出。 + * + *

使用虚拟线程作为 Sink 消费线程,避免阻塞 IO 占用平台线程。 + * 单 VIN 有序性由上游 Netty + 分区投递保证,RingBuffer 不做 per-vin 排序。 + */ +public final class DisruptorEventBus implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(DisruptorEventBus.class); + + private final Disruptor disruptor; + private final AtomicLong published = new AtomicLong(); + private final AtomicLong failed = new AtomicLong(); + + public DisruptorEventBus(int ringBufferSize, String waitStrategyName, List sinks) { + ThreadFactory tf = Thread.ofVirtual().name("ingest-bus-", 0).factory(); + this.disruptor = new Disruptor<>( + VehicleEventSlot::new, + ringBufferSize, + tf, + ProducerType.MULTI, + waitStrategy(waitStrategyName)); + + EventHandler[] handlers = sinks.stream() + .map(this::toHandler) + .toArray(EventHandler[]::new); + disruptor.handleEventsWith(handlers); + disruptor.start(); + log.info("DisruptorEventBus started ringBuffer={} wait={} sinks={}", + ringBufferSize, waitStrategyName, sinks.size()); + } + + public void publish(VehicleEvent event) { + disruptor.getRingBuffer().publishEvent((slot, seq, e) -> slot.event = e, event); + published.incrementAndGet(); + } + + @Override + public void close() { + disruptor.shutdown(); + log.info("DisruptorEventBus stopped published={} failed={}", published.get(), failed.get()); + } + + private EventHandler toHandler(EventSink sink) { + return (slot, seq, endOfBatch) -> { + VehicleEvent e = slot.event; + if (e == null || !sink.accepts(e)) return; + try { + sink.publish(e).exceptionally(ex -> { + failed.incrementAndGet(); + log.warn("sink {} publish failed", sink.name(), ex); + return null; + }); + } finally { + if (endOfBatch) slot.clear(); + } + }; + } + + private static WaitStrategy waitStrategy(String name) { + return switch (name == null ? "yielding" : name.toLowerCase()) { + case "blocking" -> new BlockingWaitStrategy(); + case "sleeping" -> new SleepingWaitStrategy(); + case "busy-spin" -> new BusySpinWaitStrategy(); + default -> new YieldingWaitStrategy(); + }; + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/VehicleEventSlot.java b/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/VehicleEventSlot.java new file mode 100644 index 00000000..5a3b0ec2 --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/VehicleEventSlot.java @@ -0,0 +1,12 @@ +package com.lingniu.ingest.core.concurrency; + +import com.lingniu.ingest.api.event.VehicleEvent; + +/** Disruptor RingBuffer 槽位:持有可变引用,避免每次发布都分配新对象。 */ +public final class VehicleEventSlot { + public VehicleEvent event; + + public void clear() { + this.event = null; + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java b/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java new file mode 100644 index 00000000..43076a91 --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java @@ -0,0 +1,87 @@ +package com.lingniu.ingest.core.config; + +import com.lingniu.ingest.api.pipeline.IngestInterceptor; +import com.lingniu.ingest.api.sink.EventSink; +import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor; +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.dispatcher.AnnotationHandlerBeanPostProcessor; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.core.dispatcher.HandlerInvoker; +import com.lingniu.ingest.core.dispatcher.HandlerRegistry; +import com.lingniu.ingest.core.pipeline.InterceptorChain; +import com.lingniu.ingest.core.pipeline.builtin.DedupInterceptor; +import com.lingniu.ingest.core.pipeline.builtin.RateLimitInterceptor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +/** + * ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。 + */ +@AutoConfiguration +@EnableConfigurationProperties(IngestCoreProperties.class) +public class IngestCoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public HandlerRegistry handlerRegistry() { + return new HandlerRegistry(); + } + + @Bean + @ConditionalOnMissingBean + public HandlerInvoker handlerInvoker() { + return new HandlerInvoker(); + } + + @Bean + public AnnotationHandlerBeanPostProcessor annotationHandlerBeanPostProcessor(HandlerRegistry registry) { + return new AnnotationHandlerBeanPostProcessor(registry); + } + + @Bean + @ConditionalOnProperty(prefix = "lingniu.ingest.pipeline.dedup", name = "enabled", havingValue = "true", matchIfMissing = true) + public DedupInterceptor dedupInterceptor(IngestCoreProperties props) { + return new DedupInterceptor(props.getDedup().getCacheSize(), props.getDedup().getTtlSeconds()); + } + + @Bean + public RateLimitInterceptor rateLimitInterceptor(IngestCoreProperties props) { + return new RateLimitInterceptor(props.getRateLimit().getPerVinQps(), props.getRateLimit().getMaxVins()); + } + + @Bean + @ConditionalOnMissingBean + public InterceptorChain interceptorChain(List interceptors) { + return new InterceptorChain(interceptors); + } + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean + public DisruptorEventBus disruptorEventBus(IngestCoreProperties props, List sinks) { + return new DisruptorEventBus( + props.getDisruptor().getRingBufferSize(), + props.getDisruptor().getWaitStrategy(), + sinks); + } + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean + public AsyncBatchExecutor asyncBatchExecutor(DisruptorEventBus eventBus) { + return new AsyncBatchExecutor(eventBus::publish); + } + + @Bean + @ConditionalOnMissingBean + public Dispatcher dispatcher(HandlerRegistry registry, + InterceptorChain chain, + HandlerInvoker invoker, + DisruptorEventBus eventBus, + AsyncBatchExecutor batchExecutor) { + return new Dispatcher(registry, chain, invoker, eventBus, batchExecutor); + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java b/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java new file mode 100644 index 00000000..0d3e11be --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java @@ -0,0 +1,56 @@ +package com.lingniu.ingest.core.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "lingniu.ingest.pipeline") +public class IngestCoreProperties { + + private Disruptor disruptor = new Disruptor(); + private Dedup dedup = new Dedup(); + private RateLimit rateLimit = new RateLimit(); + + public Disruptor getDisruptor() { return disruptor; } + public void setDisruptor(Disruptor disruptor) { this.disruptor = disruptor; } + + public Dedup getDedup() { return dedup; } + public void setDedup(Dedup dedup) { this.dedup = dedup; } + + public RateLimit getRateLimit() { return rateLimit; } + public void setRateLimit(RateLimit rateLimit) { this.rateLimit = rateLimit; } + + public static class Disruptor { + private int ringBufferSize = 131072; + private String waitStrategy = "yielding"; + private String producerType = "multi"; + + public int getRingBufferSize() { return ringBufferSize; } + public void setRingBufferSize(int ringBufferSize) { this.ringBufferSize = ringBufferSize; } + public String getWaitStrategy() { return waitStrategy; } + public void setWaitStrategy(String waitStrategy) { this.waitStrategy = waitStrategy; } + public String getProducerType() { return producerType; } + public void setProducerType(String producerType) { this.producerType = producerType; } + } + + public static class Dedup { + private boolean enabled = true; + private int cacheSize = 200000; + private long ttlSeconds = 600; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public int getCacheSize() { return cacheSize; } + public void setCacheSize(int cacheSize) { this.cacheSize = cacheSize; } + public long getTtlSeconds() { return ttlSeconds; } + public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; } + } + + public static class RateLimit { + private int perVinQps = 50; + private int maxVins = 100000; + + public int getPerVinQps() { return perVinQps; } + public void setPerVinQps(int perVinQps) { this.perVinQps = perVinQps; } + public int getMaxVins() { return maxVins; } + public void setMaxVins(int maxVins) { this.maxVins = maxVins; } + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java new file mode 100644 index 00000000..c6e1a0f8 --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java @@ -0,0 +1,64 @@ +package com.lingniu.ingest.core.dispatcher; + +import com.lingniu.ingest.api.annotation.AsyncBatch; +import com.lingniu.ingest.api.annotation.IdempotentKey; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.annotation.ProtocolHandler; +import com.lingniu.ingest.api.annotation.RateLimited; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.annotation.AnnotatedElementUtils; + +import java.lang.reflect.Method; + +/** + * Spring BeanPostProcessor:扫描带 {@link ProtocolHandler} 注解的 Bean, + * 把每个带 {@link MessageMapping} 的方法注册到 {@link HandlerRegistry}。 + * + *

替代旧代码里遍布的 {@code if (msgId == 0x0100) ... else if (msgId == 0x0102) ...} 风格。 + */ +public class AnnotationHandlerBeanPostProcessor implements BeanPostProcessor { + + private static final Logger log = LoggerFactory.getLogger(AnnotationHandlerBeanPostProcessor.class); + + private final HandlerRegistry registry; + + public AnnotationHandlerBeanPostProcessor(HandlerRegistry registry) { + this.registry = registry; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + Class type = bean.getClass(); + ProtocolHandler classAnno = AnnotatedElementUtils.findMergedAnnotation(type, ProtocolHandler.class); + if (classAnno == null) return bean; + + for (Method method : type.getMethods()) { + MessageMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, MessageMapping.class); + if (mapping == null) continue; + + Class paramType = method.getParameterCount() > 0 ? method.getParameterTypes()[0] : Object.class; + int[] commands = mapping.command().length == 0 ? new int[]{0} : mapping.command(); + int[] infoTypes = mapping.infoType().length == 0 ? new int[]{0} : mapping.infoType(); + + RateLimited rl = AnnotatedElementUtils.findMergedAnnotation(method, RateLimited.class); + IdempotentKey ik = AnnotatedElementUtils.findMergedAnnotation(method, IdempotentKey.class); + AsyncBatch ab = AnnotatedElementUtils.findMergedAnnotation(method, AsyncBatch.class); + + for (int cmd : commands) { + for (int info : infoTypes) { + HandlerDefinition def = new HandlerDefinition( + classAnno.protocol(), cmd, info, mapping.desc(), + bean, method, paramType, rl, ik, ab); + registry.register(def); + log.info("registered handler {} protocol={} command=0x{} info=0x{}", + type.getSimpleName() + "#" + method.getName(), + classAnno.protocol(), Integer.toHexString(cmd), Integer.toHexString(info)); + } + } + } + return bean; + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java new file mode 100644 index 00000000..720467bd --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java @@ -0,0 +1,79 @@ +package com.lingniu.ingest.core.dispatcher; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor; +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.pipeline.InterceptorChain; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.UUID; + +/** + * 核心分发器:RawFrame → 拦截链 → Handler → 事件 → Disruptor EventBus。 + * + *

所有 Inbound Adapter(Netty / MQTT / PushClient)都调用 {@link #dispatch(RawFrame)}, + * 协议差异在这里被彻底抹平。 + * + *

对于带 {@code @AsyncBatch} 的 Handler,Dispatcher 不直接反射调用,而是把消息交给 + * {@link AsyncBatchExecutor} 进行聚合 → 批量调用 → 异步发布事件。 + */ +public final class Dispatcher { + + private static final Logger log = LoggerFactory.getLogger(Dispatcher.class); + + private final HandlerRegistry registry; + private final InterceptorChain interceptors; + private final HandlerInvoker invoker; + private final DisruptorEventBus eventBus; + private final AsyncBatchExecutor batchExecutor; + + public Dispatcher(HandlerRegistry registry, + InterceptorChain interceptors, + HandlerInvoker invoker, + DisruptorEventBus eventBus, + AsyncBatchExecutor batchExecutor) { + this.registry = registry; + this.interceptors = interceptors; + this.invoker = invoker; + this.eventBus = eventBus; + this.batchExecutor = batchExecutor; + } + + public void dispatch(RawFrame frame) { + IngestContext ctx = new IngestContext(UUID.randomUUID().toString()); + try { + if (!interceptors.before(frame, ctx)) { + log.debug("frame aborted: {}", ctx.abortReason()); + return; + } + + List handlers = registry.resolve( + frame.protocolId(), frame.command(), frame.infoType()); + if (handlers.isEmpty()) { + log.debug("no handler for {} cmd=0x{} info=0x{}", + frame.protocolId(), Integer.toHexString(frame.command()), + Integer.toHexString(frame.infoType())); + return; + } + + for (HandlerDefinition def : handlers) { + if (def.asyncBatch() != null) { + batchExecutor.submit(def, frame.payload()); + continue; + } + List events = invoker.invoke(def, frame, ctx); + for (VehicleEvent e : events) { + interceptors.after(e, ctx); + eventBus.publish(e); + } + } + } catch (Throwable t) { + log.error("dispatch failure traceId={}", ctx.traceId(), t); + interceptors.onError(t, ctx); + } + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerDefinition.java b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerDefinition.java new file mode 100644 index 00000000..71bd179a --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerDefinition.java @@ -0,0 +1,31 @@ +package com.lingniu.ingest.core.dispatcher; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.annotation.AsyncBatch; +import com.lingniu.ingest.api.annotation.IdempotentKey; +import com.lingniu.ingest.api.annotation.RateLimited; + +import java.lang.reflect.Method; + +/** + * 一个被注解扫描到的 Handler 方法的静态描述。不可变。 + */ +public record HandlerDefinition( + ProtocolId protocol, + int command, + int infoType, + String desc, + Object bean, + Method method, + Class parameterType, + RateLimited rateLimited, + IdempotentKey idempotentKey, + AsyncBatch asyncBatch +) { + public boolean matches(ProtocolId p, int cmd, int info) { + if (p != protocol) return false; + if (command != 0 && command != cmd) return false; + if (infoType != 0 && infoType != info) return false; + return true; + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java new file mode 100644 index 00000000..9587828e --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java @@ -0,0 +1,33 @@ +package com.lingniu.ingest.core.dispatcher; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.RawFrame; + +import java.lang.reflect.InvocationTargetException; +import java.util.Collections; +import java.util.List; + +/** + * 反射调用 Handler。单独抽出是为了后续可插拔:未来可替换为 MethodHandle 或字节码生成。 + */ +public class HandlerInvoker { + + @SuppressWarnings("unchecked") + public List invoke(HandlerDefinition def, RawFrame frame, IngestContext ctx) { + try { + Object result = def.method().invoke(def.bean(), frame.payload()); + return switch (result) { + case null -> Collections.emptyList(); + case VehicleEvent e -> List.of(e); + case List list -> (List) list; + default -> throw new IllegalStateException( + "Handler return type must be VehicleEvent or List: " + def.method()); + }; + } catch (InvocationTargetException e) { + throw new RuntimeException("Handler threw exception: " + def.method(), e.getCause()); + } catch (IllegalAccessException e) { + throw new RuntimeException("Handler not accessible: " + def.method(), e); + } + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java new file mode 100644 index 00000000..d8d7524e --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java @@ -0,0 +1,48 @@ +package com.lingniu.ingest.core.dispatcher; + +import com.lingniu.ingest.api.ProtocolId; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Handler 注册中心。按 {@code (ProtocolId, command)} 索引,O(1) 查找;同 key 可注册多个 Handler,由 + * {@link HandlerDefinition#matches} 进一步精确匹配 {@code infoType}。 + */ +public final class HandlerRegistry { + + private final Map> byRoute = new ConcurrentHashMap<>(); + private final List wildcardHandlers = Collections.synchronizedList(new ArrayList<>()); + + public void register(HandlerDefinition def) { + if (def.command() == 0) { + wildcardHandlers.add(def); + return; + } + byRoute.computeIfAbsent(new RoutingKey(def.protocol(), def.command()), + k -> Collections.synchronizedList(new ArrayList<>())).add(def); + } + + public List resolve(ProtocolId protocol, int command, int infoType) { + List exact = byRoute.get(new RoutingKey(protocol, command)); + List result = new ArrayList<>(); + if (exact != null) { + for (HandlerDefinition d : exact) { + if (d.matches(protocol, command, infoType)) result.add(d); + } + } + for (HandlerDefinition d : wildcardHandlers) { + if (d.matches(protocol, command, infoType)) result.add(d); + } + return result; + } + + public int size() { + return byRoute.values().stream().mapToInt(List::size).sum() + wildcardHandlers.size(); + } + + private record RoutingKey(ProtocolId protocol, int command) {} +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java b/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java new file mode 100644 index 00000000..37d468d2 --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java @@ -0,0 +1,45 @@ +package com.lingniu.ingest.core.pipeline; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.IngestInterceptor; +import com.lingniu.ingest.api.pipeline.RawFrame; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; + +import java.util.ArrayList; +import java.util.List; + +/** + * 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。 + */ +public final class InterceptorChain { + + private final List interceptors; + + public InterceptorChain(List interceptors) { + List sorted = new ArrayList<>(interceptors); + AnnotationAwareOrderComparator.sort(sorted); + this.interceptors = List.copyOf(sorted); + } + + public boolean before(RawFrame frame, IngestContext ctx) { + for (IngestInterceptor i : interceptors) { + if (!i.before(frame, ctx) || ctx.aborted()) { + return false; + } + } + return true; + } + + public void after(VehicleEvent event, IngestContext ctx) { + for (IngestInterceptor i : interceptors) { + i.after(event, ctx); + } + } + + public void onError(Throwable error, IngestContext ctx) { + for (IngestInterceptor i : interceptors) { + i.onError(error, ctx); + } + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java b/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java new file mode 100644 index 00000000..e9c91a86 --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java @@ -0,0 +1,45 @@ +package com.lingniu.ingest.core.pipeline.builtin; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.IngestInterceptor; +import com.lingniu.ingest.api.pipeline.RawFrame; +import org.springframework.core.Ordered; + +import java.time.Duration; + +/** + * 基于 Caffeine 的本地幂等去重。 + * + *

Key 构造:{@code protocolId + command + sourceMeta.seq} —— 真实实现可以接入 Redis 做多节点一致性, + * 本实现只覆盖单节点场景,满足第一阶段 PoC 需求。 + */ +public class DedupInterceptor implements IngestInterceptor, Ordered { + + private final Cache seen; + + public DedupInterceptor(int maxSize, long ttlSeconds) { + this.seen = Caffeine.newBuilder() + .maximumSize(maxSize) + .expireAfterWrite(Duration.ofSeconds(ttlSeconds)) + .build(); + } + + @Override + public boolean before(RawFrame frame, IngestContext ctx) { + String vin = frame.sourceMeta().getOrDefault("vin", "unknown"); + String seq = frame.sourceMeta().getOrDefault("seq", "0"); + String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq; + if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) { + ctx.abort("duplicate:" + key); + return false; + } + return true; + } + + @Override + public int getOrder() { + return 100; + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java b/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java new file mode 100644 index 00000000..70945c79 --- /dev/null +++ b/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java @@ -0,0 +1,49 @@ +package com.lingniu.ingest.core.pipeline.builtin; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.IngestInterceptor; +import com.lingniu.ingest.api.pipeline.RawFrame; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import org.springframework.core.Ordered; + +import java.time.Duration; + +/** + * 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter,由 Caffeine 按 LRU 管理。 + */ +public class RateLimitInterceptor implements IngestInterceptor, Ordered { + + private final Cache limiters; + private final RateLimiterConfig defaultConfig; + + public RateLimitInterceptor(int perVinQps, int maxVins) { + this.defaultConfig = RateLimiterConfig.custom() + .limitForPeriod(perVinQps) + .limitRefreshPeriod(Duration.ofSeconds(1)) + .timeoutDuration(Duration.ZERO) + .build(); + this.limiters = Caffeine.newBuilder() + .maximumSize(maxVins) + .expireAfterAccess(Duration.ofMinutes(10)) + .build(); + } + + @Override + public boolean before(RawFrame frame, IngestContext ctx) { + String vin = frame.sourceMeta().getOrDefault("vin", "unknown"); + RateLimiter rl = limiters.get(vin, k -> RateLimiter.of("vin-" + k, defaultConfig)); + if (!rl.acquirePermission()) { + ctx.abort("rate-limited:" + vin); + return false; + } + return true; + } + + @Override + public int getOrder() { + return 200; + } +} diff --git a/ingest-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ingest-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..c5adbd90 --- /dev/null +++ b/ingest-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.core.config.IngestCoreAutoConfiguration diff --git a/observability/pom.xml b/observability/pom.xml new file mode 100644 index 00000000..bb7b166f --- /dev/null +++ b/observability/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + observability + observability + Micrometer + Prometheus + 业务指标封装(事件计数、Dispatcher 耗时、DLQ 计数等)。 + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + ingest-core + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + diff --git a/observability/src/main/java/com/lingniu/ingest/observability/IngestMetrics.java b/observability/src/main/java/com/lingniu/ingest/observability/IngestMetrics.java new file mode 100644 index 00000000..57125bdf --- /dev/null +++ b/observability/src/main/java/com/lingniu/ingest/observability/IngestMetrics.java @@ -0,0 +1,84 @@ +package com.lingniu.ingest.observability; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.IngestInterceptor; +import com.lingniu.ingest.api.pipeline.RawFrame; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; +import org.springframework.core.Ordered; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * 指标拦截器:自动以 {@link IngestInterceptor} 形式接入拦截链,无侵入采集核心流量指标。 + * + *

暴露指标: + *

    + *
  • {@code ingest_frames_total{protocol}} - 入站帧计数 + *
  • {@code ingest_events_total{protocol,type}} - 事件产出计数 + *
  • {@code ingest_errors_total{protocol}} - 异常计数 + *
  • {@code ingest_frame_duration_seconds{protocol}} - 单帧处理耗时 + *
+ */ +public class IngestMetrics implements IngestInterceptor, Ordered { + + private static final String ATTR_TIMER_SAMPLE = "metrics.timer.sample"; + + private final MeterRegistry registry; + private final ConcurrentMap frameCounters = new ConcurrentHashMap<>(); + private final ConcurrentMap errorCounters = new ConcurrentHashMap<>(); + private final ConcurrentMap frameTimers = new ConcurrentHashMap<>(); + private final ConcurrentMap eventCounters = new ConcurrentHashMap<>(); + + public IngestMetrics(MeterRegistry registry) { + this.registry = registry; + } + + @Override + public boolean before(RawFrame frame, IngestContext ctx) { + ProtocolId p = frame.protocolId(); + frameCounters.computeIfAbsent(p, k -> + Counter.builder("ingest_frames_total").tag("protocol", k.name()).register(registry)).increment(); + Timer.Sample sample = Timer.start(registry); + ctx.attr(ATTR_TIMER_SAMPLE, sample); + return true; + } + + @Override + public void after(VehicleEvent event, IngestContext ctx) { + String key = event.source().name() + ":" + event.getClass().getSimpleName(); + eventCounters.computeIfAbsent(key, k -> { + String[] parts = k.split(":"); + return Counter.builder("ingest_events_total") + .tags(Tags.of("protocol", parts[0], "type", parts[1])) + .register(registry); + }).increment(); + + Timer.Sample s = ctx.attr(ATTR_TIMER_SAMPLE); + if (s != null) { + Timer t = frameTimers.computeIfAbsent(event.source(), p -> + Timer.builder("ingest_frame_duration_seconds").tag("protocol", p.name()).register(registry)); + s.stop(t); + ctx.attr(ATTR_TIMER_SAMPLE, null); + } + } + + @Override + public void onError(Throwable error, IngestContext ctx) { + // 无协议上下文时归到 UNKNOWN + Counter c = errorCounters.computeIfAbsent( + ProtocolId.GB32960, // 默认桶:由上层拦截器设置具体协议更精确 + p -> Counter.builder("ingest_errors_total").tag("protocol", "unknown").register(registry)); + c.increment(); + } + + @Override + public int getOrder() { + return 10; // 尽早介入,晚于 Tracing(order=0) + } +} diff --git a/observability/src/main/java/com/lingniu/ingest/observability/ObservabilityAutoConfiguration.java b/observability/src/main/java/com/lingniu/ingest/observability/ObservabilityAutoConfiguration.java new file mode 100644 index 00000000..a6a5f63f --- /dev/null +++ b/observability/src/main/java/com/lingniu/ingest/observability/ObservabilityAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.lingniu.ingest.observability; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnBean(MeterRegistry.class) +public class ObservabilityAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public IngestMetrics ingestMetrics(MeterRegistry registry) { + return new IngestMetrics(registry); + } +} diff --git a/observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..b3367068 --- /dev/null +++ b/observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.observability.ObservabilityAutoConfiguration diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..e9462c4a --- /dev/null +++ b/pom.xml @@ -0,0 +1,296 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + pom + + lingniu-vehicle-ingest + + 羚牛车辆数据接入平台 v2。 + 模块化原子能力 + 协议解耦 + 业务/实时解耦 + 数据出 Kafka 不落业务库。 + Java 25 + Spring Boot 3.4 + Netty + Disruptor + Virtual Threads。 + + + + ingest-api + ingest-codec-common + ingest-core + session-core + observability + sink-mq + sink-archive + protocol-gb32960 + protocol-jt808 + protocol-jt1078 + protocol-jsatl12 + inbound-mqtt + inbound-xinda-push + command-gateway + bootstrap-all + + + + UTF-8 + UTF-8 + 25 + 25 + 25 + 25 + + 3.5.3 + 4.2.9.Final + 4.0.0 + 4.28.3 + 3.8.1 + 3.1.8 + 2.2.0 + 1.13.6 + 1.42.1 + 1.3.3 + 1.18.34 + 5.11.3 + 3.26.3 + 5.14.2 + 1.3.0 + 1.20.3 + + + + + + + + io.netty + netty-bom + ${netty.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + io.github.resilience4j + resilience4j-bom + ${resilience4j.version} + pom + import + + + io.micrometer + micrometer-bom + ${micrometer.version} + pom + import + + + io.opentelemetry + opentelemetry-bom + ${otel.version} + pom + import + + + org.junit + junit-bom + ${junit.version} + pom + import + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + + com.lingniu.ingest + ingest-api + ${project.version} + + + com.lingniu.ingest + ingest-core + ${project.version} + + + com.lingniu.ingest + ingest-codec-common + ${project.version} + + + com.lingniu.ingest + session-core + ${project.version} + + + com.lingniu.ingest + observability + ${project.version} + + + com.lingniu.ingest + sink-mq + ${project.version} + + + com.lingniu.ingest + sink-archive + ${project.version} + + + com.lingniu.ingest + protocol-gb32960 + ${project.version} + + + com.lingniu.ingest + protocol-jt808 + ${project.version} + + + com.lingniu.ingest + protocol-jt1078 + ${project.version} + + + com.lingniu.ingest + protocol-jsatl12 + ${project.version} + + + com.lingniu.ingest + inbound-mqtt + ${project.version} + + + com.lingniu.ingest + inbound-xinda-push + ${project.version} + + + com.lingniu.ingest + command-gateway + ${project.version} + + + + + com.lmax + disruptor + ${disruptor.version} + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + + com.google.protobuf + protobuf-java-util + ${protobuf.version} + + + org.apache.kafka + kafka-clients + ${kafka.version} + + + com.hivemq + hivemq-mqtt-client + ${hivemq-mqtt.version} + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + org.projectlombok + lombok + ${lombok.version} + provided + + + org.assertj + assertj-core + ${assertj.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + + + + + + + + lingniu-nexus + Lingniu Private Maven Repository + https://nexus.lnh2e.com/repository/maven-public/ + + true + + + true + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + true + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + + diff --git a/protocol-gb32960/pom.xml b/protocol-gb32960/pom.xml new file mode 100644 index 00000000..b7f03abe --- /dev/null +++ b/protocol-gb32960/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + protocol-gb32960 + protocol-gb32960 + GB/T 32960 新能源车国标协议接入(Netty Server,默认 TCP 9000)。 + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + ingest-core + + + com.lingniu.ingest + ingest-codec-common + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-configuration-processor + true + + + io.netty + netty-all + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java new file mode 100644 index 00000000..4e52142d --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java @@ -0,0 +1,94 @@ +package com.lingniu.ingest.protocol.gb32960.auth; + +import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 平台登入(0x05)账号白名单校验器。 + * + *

与 {@link Gb32960VinAuthorizer} 区别: + *

    + *
  • 车辆登入靠 VIN 白名单(无密码) + *
  • 平台登入有 12B 用户名 + 20B 密码 + 可选 IP 白名单 + *
+ * + *

匹配规则: + *

    + *
  1. 配置列表为空 → 返回 {@link Result#ALLOW_NO_POLICY}(兼容老行为,放行但标记未鉴权) + *
  2. 用户名不存在 → {@link Result#DENY_UNKNOWN_USER} + *
  3. 密码不匹配 → {@link Result#DENY_BAD_PASSWORD} + *
  4. IP 白名单非空且 peer IP 不在其中 → {@link Result#DENY_IP_NOT_ALLOWED} + *
  5. 全部通过 → {@link Result#ALLOW} + *
+ */ +public final class Gb32960PlatformAuthorizer { + + private static final Logger log = LoggerFactory.getLogger(Gb32960PlatformAuthorizer.class); + + private final Map byUsername; + + public Gb32960PlatformAuthorizer(List platforms) { + Map map = new HashMap<>(); + for (Gb32960Properties.Auth.Platform p : platforms) { + if (p.getUsername() == null || p.getUsername().isBlank()) continue; + map.put(p.getUsername().trim(), p); + } + this.byUsername = Collections.unmodifiableMap(map); + log.info("[gb32960] platform authorizer loaded {} platform(s)", this.byUsername.size()); + } + + /** + * 鉴权一次平台登入请求。 + * + * @param username 终端发来的用户名 + * @param password 终端发来的密码 + * @param peerIp 连接对端 IP(纯 IPv4/IPv6,不含端口) + */ + public Result authenticate(String username, String password, String peerIp) { + if (byUsername.isEmpty()) return Result.ALLOW_NO_POLICY; + Gb32960Properties.Auth.Platform entry = byUsername.get(username == null ? "" : username.trim()); + if (entry == null) return Result.DENY_UNKNOWN_USER; + if (!Objects.equals(entry.getPassword(), password)) return Result.DENY_BAD_PASSWORD; + Set allowed = entry.getAllowedIps() == null + ? Set.of() + : Set.copyOf(entry.getAllowedIps()); + if (!allowed.isEmpty() && peerIp != null && !allowed.contains(peerIp)) { + return Result.DENY_IP_NOT_ALLOWED; + } + return Result.ALLOW; + } + + public int size() { + return byUsername.size(); + } + + public boolean isEnforcing() { + return !byUsername.isEmpty(); + } + + /** 鉴权结果枚举,调用方根据结果选择应答标志和日志级别。 */ + public enum Result { + /** 匹配成功。 */ + ALLOW(true), + /** 配置列表为空,放行但标记未鉴权。 */ + ALLOW_NO_POLICY(true), + /** 用户名不存在。 */ + DENY_UNKNOWN_USER(false), + /** 密码错误。 */ + DENY_BAD_PASSWORD(false), + /** IP 白名单拒绝。 */ + DENY_IP_NOT_ALLOWED(false); + + private final boolean accepted; + Result(boolean accepted) { this.accepted = accepted; } + public boolean accepted() { return accepted; } + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java new file mode 100644 index 00000000..9a3d777e --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java @@ -0,0 +1,63 @@ +package com.lingniu.ingest.protocol.gb32960.auth; + +import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +/** + * GB/T 32960 VIN 白名单校验器。 + * + *

线程安全:内部持有 {@code AtomicReference},支持运行时热替换白名单 + * (未来可扩展为从 Nacos / 数据库定时刷新)。 + */ +public final class Gb32960VinAuthorizer { + + private static final Logger log = LoggerFactory.getLogger(Gb32960VinAuthorizer.class); + + private final boolean enabled; + private final boolean caseSensitive; + private final AtomicReference> whitelist; + + public Gb32960VinAuthorizer(Gb32960Properties.Auth cfg) { + this.enabled = cfg.isEnabled(); + this.caseSensitive = cfg.isCaseSensitive(); + this.whitelist = new AtomicReference<>(cfg.normalizedWhitelist()); + log.info("[gb32960] VIN authorizer enabled={} caseSensitive={} whitelistSize={}", + enabled, caseSensitive, this.whitelist.get().size()); + } + + /** + * 鉴权。 + * + * @return true 通过;false 拒绝(调用方应发送 0x04 VIN_NOT_EXIST 应答并关闭连接) + */ + public boolean isAllowed(String vin) { + if (!enabled) return true; + if (vin == null || vin.isBlank()) return false; + String key = caseSensitive ? vin.trim() : vin.trim().toUpperCase(); + return whitelist.get().contains(key); + } + + public boolean isEnforcing() { + return enabled; + } + + public int whitelistSize() { + return whitelist.get().size(); + } + + /** 运行时热更新。接入 Nacos/DB 监听后调用。 */ + public void replaceWhitelist(Set newVins) { + Set norm = Collections.newSetFromMap(new java.util.concurrent.ConcurrentHashMap<>(newVins.size())); + for (String v : newVins) { + if (v == null || v.isBlank()) continue; + norm.add(caseSensitive ? v.trim() : v.trim().toUpperCase()); + } + this.whitelist.set(norm); + log.info("[gb32960] VIN whitelist replaced size={}", norm.size()); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java new file mode 100644 index 00000000..7dc69a62 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java @@ -0,0 +1,150 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.api.spi.DecodeException; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 实时/补发上报数据单元循环解析器。 + * + *

数据单元布局(表 7):{@code timestamp(6B) | (typeFlag(1B) + infoBody)* | [0xFF 签名信息]}。 + * 其中时间戳由 {@link Gb32960MessageDecoder} 负责剥离,本 parser 只处理信息体循环。 + * + *

支持的信息类型标志(表 9): + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
20162025含义
0x010x01整车数据
0x020x02驱动电机数据
0x030x03燃料电池发动机及车载氢系统数据
0x040x04发动机数据
0x050x05车辆位置数据(2025 版增加坐标系字段)
0x06 极值0x06 报警 
0x07 报警0x07 动力蓄电池最小并联单元电压 
0x08 储能电压0x08 动力蓄电池温度 
0x09 储能温度 
0x30燃料电池电堆
0x31超级电容器
0x32超级电容器极值
0xFF签名数据开始标识(2025 版新增)
+ * + *

处理策略: + *

    + *
  • 遇到 {@code 0xFF}(2025 版签名起始标识):停止循环,剩余字节作为签名原始字节返回 + *
  • 遇到未注册的类型:作为单个 {@link InfoBlock.Raw} 包装剩余字节并终止循环,避免丢失整帧 + *
+ */ +public final class Gb32960BodyParser { + + private static final Logger log = LoggerFactory.getLogger(Gb32960BodyParser.class); + + /** 2025 版实时上报尾部签名标识(表 9)。 */ + private static final int SIGNATURE_MARKER = 0xFF; + + private final InfoBlockParserRegistry registry; + + public Gb32960BodyParser(InfoBlockParserRegistry registry) { + this.registry = registry; + } + + public Gb32960MessageDecoder.BodyParseResult parse(ProtocolVersion version, ByteBuffer body) { + List blocks = new ArrayList<>(8); + byte[] signature = null; + // 捕获 info-block 区间的起止位置,用于未知块出现时打 dump 帮助手动定位漂移点。 + // body 由 Gb32960MessageDecoder 通过 ByteBuffer.wrap(frame, bodyStart+6, dataLength-6) 构造, + // arrayOffset()=0,position() = bodyStart+6,limit() = bodyStart + dataLength。 + final int infoBlocksStart = body.position(); + final int infoBlocksEnd = body.limit(); + + while (body.hasRemaining()) { + int typeStartPos = body.position(); + int typeCode = body.get() & 0xFF; + + if (typeCode == SIGNATURE_MARKER && version == ProtocolVersion.V2025) { + // 剩余全部字节即为签名内容(表 8) + signature = new byte[body.remaining()]; + body.get(signature); + break; + } + + InfoBlockParser parser = registry.find(version, typeCode); + if (parser == null) { + // lenient:未知类型无法安全跳过,吞剩余字节作为 Raw 后终止。 + // 真实 typeCode 透传到 Raw 字段,便于在日志/JSON 里直接看到漂移落点。 + int remaining = body.remaining(); + byte[] rest = new byte[remaining]; + body.get(rest); + log.warn("[gb32960] unknown info block typeCode=0x{} version={} bodyPos={} parsedBlocks={} remaining={} preview={}", + Integer.toHexString(typeCode), version, typeStartPos, + blocks.size(), remaining, hexPreview(rest, 16)); + // 一次性把整段 info-block 区间的原始字节打出来,并在 typeStartPos 处插入标记, + // 帮助人工对比规范字段长度、定位是哪个早期块对齐错。 + if (body.hasArray()) { + log.warn("[gb32960] body dump (info-block region) infoBlocksStart={} infoBlocksEnd={} typeStartPos={} totalLen={} alreadyParsed={}\n{}", + infoBlocksStart, infoBlocksEnd, typeStartPos, + infoBlocksEnd - infoBlocksStart, blocks.size(), + hexDumpWithMarker(body.array(), infoBlocksStart, infoBlocksEnd, typeStartPos)); + } + blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, rest)); + break; + } + + int fixedLen = parser.fixedLength(); + int posBefore = body.position(); + if (fixedLen >= 0 && body.remaining() < fixedLen) { + throw new DecodeException( + "info block 0x" + Integer.toHexString(typeCode) + + " needs " + fixedLen + " bytes but got " + body.remaining()); + } + InfoBlock block = parser.parse(body); + int consumed = body.position() - posBefore; + if (fixedLen >= 0 && consumed != fixedLen) { + throw new DecodeException( + "parser " + parser.getClass().getSimpleName() + + " consumed " + consumed + + " bytes but declared " + fixedLen); + } + if (log.isDebugEnabled()) { + log.debug("[gb32960] block typeCode=0x{} parser={} pos {}→{} consumed={} declaredFixed={}", + Integer.toHexString(typeCode), parser.getClass().getSimpleName(), + typeStartPos, body.position(), consumed, fixedLen); + } + blocks.add(block); + } + + return new Gb32960MessageDecoder.BodyParseResult(blocks, signature); + } + + private static String hexPreview(byte[] bytes, int max) { + int n = Math.min(bytes.length, max); + StringBuilder sb = new StringBuilder(n * 2); + for (int i = 0; i < n; i++) sb.append(String.format("%02x", bytes[i])); + if (bytes.length > max) sb.append(".."); + return sb.toString(); + } + + /** + * 16 字节/行的 hex dump,遇到 typeStartPos 时在该字节后插入 {@code |<< + *
  • 0x01 车辆登入(表 6) + *
  • 0x04 车辆登出(表 28) + *
  • 0x05 平台登入(表 29) + *
  • 0x06 平台登出(表 30) + *
  • 0x07 心跳(空) + *
  • 0x08 终端校时(空) + *
  • 0x09 激活(表 B.3) + *
  • 0x0A 激活应答(表 B.4) + *
  • 0x0B 密钥交换(表 31) + * + * + *

    其它命令(查询/设置/控制/自定义)保留为 {@link CommandBody.RawCommand}。 + */ +public final class Gb32960CommandParser { + + private static final ZoneId ZONE_GMT8 = ZoneId.of("Asia/Shanghai"); + + public CommandBody parse(CommandType command, ByteBuffer buf) { + return switch (command) { + case VEHICLE_LOGIN -> parseVehicleLogin(buf); + case VEHICLE_LOGOUT -> parseVehicleLogout(buf); + case PLATFORM_LOGIN -> parsePlatformLogin(buf); + case PLATFORM_LOGOUT -> parsePlatformLogout(buf); + case HEARTBEAT -> new CommandBody.Heartbeat(); + case TIME_CALIBRATION -> new CommandBody.TimeCalibration(); + case ACTIVATION -> parseActivation(buf); + case ACTIVATION_RESPONSE -> parseActivationResponse(buf); + case KEY_EXCHANGE -> parseKeyExchange(buf); + case QUERY -> parseQueryParams(buf); + case SET_PARAMS -> parseSetParams(buf); + case TERMINAL_CONTROL -> parseTerminalControl(buf); + default -> captureRaw(command, buf); + }; + } + + /** 表 6:车辆登入。 */ + private CommandBody.VehicleLogin parseVehicleLogin(ByteBuffer buf) { + Instant time = readTime(buf); + int serialNo = buf.getShort() & 0xFFFF; + String iccid = readAscii(buf, 20); + // 2025 版新增:电池管理系统数 n + 各系统包数 + 编码列表 + int batterySystemCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0; + List packCounts = new ArrayList<>(); + int totalPacks = 0; + for (int i = 0; i < batterySystemCount && buf.hasRemaining(); i++) { + int c = buf.get() & 0xFF; + packCounts.add(c); + totalPacks += c; + } + List codes = new ArrayList<>(totalPacks); + for (int i = 0; i < totalPacks && buf.remaining() >= 24; i++) { + codes.add(readAscii(buf, 24)); + } + return new CommandBody.VehicleLogin(time, serialNo, iccid, batterySystemCount, packCounts, codes); + } + + private CommandBody.VehicleLogout parseVehicleLogout(ByteBuffer buf) { + return new CommandBody.VehicleLogout(readTime(buf), buf.getShort() & 0xFFFF); + } + + private CommandBody.PlatformLogin parsePlatformLogin(ByteBuffer buf) { + Instant time = readTime(buf); + int serial = buf.getShort() & 0xFFFF; + String user = readAscii(buf, 12); + String pwd = readAscii(buf, 20); + EncryptType enc = EncryptType.of(buf.get() & 0xFF); + return new CommandBody.PlatformLogin(time, serial, user, pwd, enc); + } + + private CommandBody.PlatformLogout parsePlatformLogout(ByteBuffer buf) { + return new CommandBody.PlatformLogout(readTime(buf), buf.getShort() & 0xFFFF); + } + + private CommandBody.Activation parseActivation(ByteBuffer buf) { + Instant time = readTime(buf); + String chipId = readAscii(buf, 16); + int keyLen = buf.getShort() & 0xFFFF; + byte[] pubKey = new byte[Math.min(keyLen, buf.remaining())]; + buf.get(pubKey); + String vin = readAscii(buf, 17); + byte[] signature = new byte[buf.remaining()]; + buf.get(signature); + return new CommandBody.Activation(time, chipId, pubKey, vin, signature); + } + + private CommandBody.ActivationResponse parseActivationResponse(ByteBuffer buf) { + return new CommandBody.ActivationResponse(buf.get() & 0xFF, buf.get() & 0xFF); + } + + private CommandBody.KeyExchange parseKeyExchange(ByteBuffer buf) { + EncryptType keyType = EncryptType.of(buf.get() & 0xFF); + int keyLen = buf.getShort() & 0xFFFF; + byte[] key = new byte[Math.min(keyLen, buf.remaining() - 12)]; + buf.get(key); + Instant activateAt = readTime(buf); + Instant expireAt = readTime(buf); + return new CommandBody.KeyExchange(keyType, key, activateAt, expireAt); + } + + private CommandBody.RawCommand captureRaw(CommandType command, ByteBuffer buf) { + byte[] bytes = new byte[buf.remaining()]; + buf.get(bytes); + return new CommandBody.RawCommand(command.code(), bytes); + } + + // ========================================================================= + // 表 B.5-B.13 下行命令 + // ========================================================================= + + /** + * 参数查询 0x80(表 B.5):{@code time(6) + count(1) + paramId(count)}。 + * 如果 count > 0,则是查询命令;如果 count == 0 可能是终端应答(表 B.6), + * 应答带参数项列表(表 B.7),paramValue 长度根据 paramId 查表 B.8 决定。 + */ + private CommandBody parseQueryParams(ByteBuffer buf) { + Instant time = readTime(buf); + int count = buf.get() & 0xFF; + + // 判定是查询命令还是应答:命令方向下 count 后紧跟 paramId 列表(每项 1 字节); + // 应答方向下 count 后是完整的 paramId + paramValue 列表(表 B.7)。 + // 通过剩余字节数判断:命令形态 remaining == count,应答形态 remaining > count + if (buf.remaining() == count) { + List ids = new ArrayList<>(count); + for (int i = 0; i < count; i++) ids.add(buf.get() & 0xFF); + return new CommandBody.QueryParams(time, ids); + } + // 作为应答处理 + Map params = readParamItems(buf, count); + return new CommandBody.QueryParamsResponse(time, params); + } + + /** 参数设置 0x81(表 B.9):{@code time(6) + count(1) + paramItems}。 */ + private CommandBody.SetParams parseSetParams(ByteBuffer buf) { + Instant time = readTime(buf); + int count = buf.get() & 0xFF; + Map params = readParamItems(buf, count); + return new CommandBody.SetParams(time, params); + } + + /** 读取 N 个参数项(表 B.7):{@code paramId(1) + paramValue(按表 B.8 解释)}。 */ + private Map readParamItems(ByteBuffer buf, int count) { + Map out = new HashMap<>(count); + for (int i = 0; i < count && buf.hasRemaining(); i++) { + int paramId = buf.get() & 0xFF; + int len = paramValueLength(paramId, buf); + if (len < 0 || len > buf.remaining()) { + // 长度未知或越界:吞剩余字节作为该参数值后终止 + byte[] rest = new byte[buf.remaining()]; + buf.get(rest); + out.put(paramId, rest); + break; + } + byte[] value = new byte[len]; + buf.get(value); + out.put(paramId, value); + } + return out; + } + + /** + * 按表 B.8 返回参数值长度。 + * + *

    对于变长参数(0x05 域名 / 0x0E 公共平台域名),前一个参数(0x04/0x0D)给出长度; + * 这里简化处理:变长参数使用 {@code buf.get() & 0xFF + 1} 预读 + 取值。 + */ + private int paramValueLength(int paramId, ByteBuffer buf) { + return switch (paramId) { + case 0x01 -> 2; // 本地存储时间周期 WORD + case 0x02 -> 1; // 正常信息上报时间周期 + case 0x03 -> 1; // 报警信息上报时间周期 + case 0x04 -> 1; // 平台域名长度 M + case 0x05 -> { + // 平台域名 M 字节,M 由前一个参数的值给出。 + // 安全起见这里一次吞掉剩余字节(实际场景参数 0x04 和 0x05 通常一起下发) + yield buf.remaining(); + } + case 0x06 -> 2; // 平台端口 WORD + case 0x07, 0x08 -> 5; // 硬件/固件版本 5 字节 + case 0x09 -> 1; // 心跳发送周期 + case 0x0A -> 2; // 终端应答超时时间 + case 0x0B -> 2; // 平台应答超时时间 + case 0x0C -> 1; // 登入失败后下次登入间隔 + case 0x0D -> 1; // 公共平台域名长度 N + case 0x0E -> buf.remaining(); // 公共平台域名 + case 0x0F -> 2; // 公共平台端口 + case 0x10 -> 1; // 是否抽样监测中 + default -> -1; // 未知参数:无法决定长度 + }; + } + + /** 车载终端控制 0x82(表 B.10)。 */ + private CommandBody.TerminalControl parseTerminalControl(ByteBuffer buf) { + Instant time = readTime(buf); + int cmdId = buf.get() & 0xFF; + CommandBody.ControlCommand commandId = CommandBody.ControlCommand.of(cmdId); + CommandBody.ControlPayload payload = switch (commandId) { + case REMOTE_UPGRADE -> parseRemoteUpgrade(buf); + case ALARM_COMMAND -> parseAlarmCommand(buf); + case SHUTDOWN, RESET, FACTORY_RESET, DISCONNECT, SAMPLING_LINK_ON -> new CommandBody.ControlPayload.None(); + case UNKNOWN -> captureRawPayload(buf); + }; + return new CommandBody.TerminalControl(time, commandId, payload); + } + + /** + * 表 B.12 远程升级参数。字段之间用半角分号 {@code ;} 分隔。 + * + *

    字段顺序:URL;apn;dialUser;dialPwd;serverAddress;serverPort;manufacturerCode;hwVer;fwVer;timeoutMin + */ + private CommandBody.ControlPayload.RemoteUpgrade parseRemoteUpgrade(ByteBuffer buf) { + byte[] all = new byte[buf.remaining()]; + buf.get(all); + String s = new String(all, Charset.forName("GBK")); + String[] parts = s.split(";", -1); + String url = parts.length > 0 ? parts[0] : ""; + String apn = parts.length > 1 ? parts[1] : ""; + String user = parts.length > 2 ? parts[2] : ""; + String pwd = parts.length > 3 ? parts[3] : ""; + byte[] addr = parts.length > 4 ? parts[4].getBytes(StandardCharsets.US_ASCII) : new byte[0]; + int port = parts.length > 5 ? parseIntSafe(parts[5]) : 0; + String manu = parts.length > 6 ? parts[6] : ""; + String hw = parts.length > 7 ? parts[7] : ""; + String fw = parts.length > 8 ? parts[8] : ""; + int timeoutMin = parts.length > 9 ? parseIntSafe(parts[9]) : 0; + return new CommandBody.ControlPayload.RemoteUpgrade( + apn, user, pwd, addr, port, manu, hw, fw, url, timeoutMin); + } + + /** 表 B.13 报警/预警命令。 */ + private CommandBody.ControlPayload.AlarmCommand parseAlarmCommand(ByteBuffer buf) { + int warningLevel = buf.hasRemaining() ? (buf.get() & 0xFF) : 0; + byte[] info = new byte[buf.remaining()]; + buf.get(info); + return new CommandBody.ControlPayload.AlarmCommand(warningLevel, info); + } + + private CommandBody.ControlPayload.RawPayload captureRawPayload(ByteBuffer buf) { + byte[] bytes = new byte[buf.remaining()]; + buf.get(bytes); + return new CommandBody.ControlPayload.RawPayload(bytes); + } + + private static int parseIntSafe(String s) { + try { return Integer.parseInt(s.trim()); } catch (Exception e) { return 0; } + } + + /** 表 5:时间定义 YY MM DD HH MM SS,GMT+8。 */ + private Instant readTime(ByteBuffer buf) { + if (buf.remaining() < 6) { + throw new DecodeException("time field requires 6 bytes, got " + buf.remaining()); + } + int year = 2000 + (buf.get() & 0xFF); + int month = buf.get() & 0xFF; + int day = buf.get() & 0xFF; + int hour = buf.get() & 0xFF; + int minute = buf.get() & 0xFF; + int second = buf.get() & 0xFF; + try { + return LocalDateTime.of(year, month, day, hour, minute, second).atZone(ZONE_GMT8).toInstant(); + } catch (Exception e) { + throw new DecodeException("invalid time: " + year + "-" + month + "-" + day + + " " + hour + ":" + minute + ":" + second, e); + } + } + + private String readAscii(ByteBuffer buf, int len) { + int safeLen = Math.min(len, buf.remaining()); + byte[] b = new byte[safeLen]; + buf.get(b); + return new String(b, StandardCharsets.US_ASCII).trim(); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java new file mode 100644 index 00000000..3be0594f --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java @@ -0,0 +1,83 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * Netty 层的帧分帧器:从字节流里提取一条完整的 32960 帧。 + * + *

    支持两种起始符:{@code 0x23 0x23 (##)} 2016 版,{@code 0x24 0x24 ($$)} 2025 版。 + * 只负责拆包粘包,不做业务解析。具体字段解析交给 {@link Gb32960MessageDecoder}。 + * + *

    开启 DEBUG 日志可以看到每次读到的字节、每条产出的完整帧以及因长度/起始符不匹配被丢弃的字节数。 + */ +public class Gb32960FrameDecoder extends ByteToMessageDecoder { + + private static final Logger log = LoggerFactory.getLogger(Gb32960FrameDecoder.class); + + private static final int HEADER_LEN = 24; // 起始(2) + cmd(1) + rsp(1) + vin(17) + enc(1) + len(2) + private static final int MAX_FRAME = 8 * 1024; + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + if (log.isDebugEnabled() && in.readableBytes() > 0) { + int peek = Math.min(in.readableBytes(), 64); + log.debug("[gb32960] frame-decoder rx bytes={} head={}", + in.readableBytes(), + ByteBufUtil.hexDump(in, in.readerIndex(), peek)); + } + while (in.readableBytes() >= HEADER_LEN) { + int startIdx = findStart(in); + if (startIdx < 0 || startIdx > in.writerIndex() - HEADER_LEN) { + int drop = Math.max(0, in.readableBytes() - 1); + if (drop > 0) { + log.debug("[gb32960] frame-decoder skip {} bytes (no start symbol)", drop); + in.skipBytes(drop); + } + return; + } + if (startIdx != in.readerIndex()) { + int drop = startIdx - in.readerIndex(); + log.debug("[gb32960] frame-decoder skip {} leading bytes before start", drop); + in.skipBytes(drop); + } + int dataLen = in.getUnsignedShort(in.readerIndex() + 22); + int frameLen = HEADER_LEN + dataLen + 1; + if (frameLen > MAX_FRAME) { + log.warn("[gb32960] frame-decoder oversized frame len={} peer={}, skip start", + frameLen, ctx.channel().remoteAddress()); + in.skipBytes(2); + continue; + } + if (in.readableBytes() < frameLen) return; + + byte[] frame = new byte[frameLen]; + in.readBytes(frame); + if (log.isDebugEnabled()) { + log.debug("[gb32960] frame-decoder emit len={} start={} peer={}", + frameLen, + String.format("%02x%02x", frame[0], frame[1]), + ctx.channel().remoteAddress()); + } + out.add(frame); + } + } + + /** 查找下一个合法起始位置:两字节连续 0x23 0x23 或 0x24 0x24。 */ + private static int findStart(ByteBuf in) { + int limit = in.writerIndex() - 1; + for (int i = in.readerIndex(); i < limit; i++) { + byte b = in.getByte(i); + if ((b == 0x23 || b == 0x24) && in.getByte(i + 1) == b) { + return i; + } + } + return -1; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java new file mode 100644 index 00000000..81f1ce51 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java @@ -0,0 +1,111 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.protocol.gb32960.model.CommandType; +import com.lingniu.ingest.protocol.gb32960.model.EncryptType; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; +import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * GB/T 32960.3 下行帧编码器。主要用于应答场景:平台收到终端上行命令后发送应答。 + * + *

    应答规则(§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、 + * 重新计算校验位。 + */ +public final class Gb32960FrameEncoder { + + private static final ZoneId ZONE_GMT8 = ZoneId.of("Asia/Shanghai"); + + private Gb32960FrameEncoder() {} + + /** + * 构造简易应答帧:复用原命令的 command/vin/encryptType,修改应答标志, + * 可选携带数据单元(通常只带 6 字节采集时间)。 + * + * @param version 协议版本(决定起始符) + * @param command 命令标识(与原命令相同) + * @param responseFlag 应答标志:{@link ResponseFlag#SUCCESS}/{@link ResponseFlag#VIN_NOT_EXIST} 等 + * @param vin 17 字节 VIN + * @param collectTime 数据采集时间(保留原帧时间,若无则传 {@code Instant.now()}) + * @param extraBody 附加数据(可为 null) + */ + public static byte[] buildResponse(ProtocolVersion version, + CommandType command, + ResponseFlag responseFlag, + String vin, + Instant collectTime, + byte[] extraBody) { + return buildResponse(version, command, responseFlag, vinPadded(vin), collectTime, extraBody); + } + + /** + * 同 {@link #buildResponse(ProtocolVersion, CommandType, ResponseFlag, String, Instant, byte[])}, + * 但直接接收 17 字节原始 VIN。用于回执时原样回显请求帧的 VIN 字节,避免 + * 平台登入这类 VIN 全 0 的场景被我们用空格重写后导致对端判定 VIN 不匹配。 + */ + public static byte[] buildResponse(ProtocolVersion version, + CommandType command, + ResponseFlag responseFlag, + byte[] vin17, + Instant collectTime, + byte[] extraBody) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + // 数据采集时间 6 字节(表 5) + if (collectTime != null) { + LocalDateTime ldt = LocalDateTime.ofInstant(collectTime, ZONE_GMT8); + body.write(ldt.getYear() - 2000); + body.write(ldt.getMonthValue()); + body.write(ldt.getDayOfMonth()); + body.write(ldt.getHour()); + body.write(ldt.getMinute()); + body.write(ldt.getSecond()); + } + if (extraBody != null && extraBody.length > 0) { + body.write(extraBody, 0, extraBody.length); + } + + byte[] bodyBytes = body.toByteArray(); + int dataLength = bodyBytes.length; + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + // 起始符 + if (version == ProtocolVersion.V2025) { + out.write(0x24); out.write(0x24); + } else { + out.write(0x23); out.write(0x23); + } + out.write(command.code()); + out.write(responseFlag.code()); + // VIN(17 字节,由调用方给出;平台登入等场景直接回显请求的原始字节) + byte[] vinBytes = vin17 != null && vin17.length == 17 ? vin17 : vinPadded(null); + out.write(vinBytes, 0, 17); + // 加密方式:应答不加密 + out.write(EncryptType.UNENCRYPTED.code()); + // 数据单元长度 WORD 大端 + out.write((dataLength >> 8) & 0xFF); + out.write(dataLength & 0xFF); + out.write(bodyBytes, 0, bodyBytes.length); + // BCC:从 cmd(offset 2) 到数据单元结尾 + byte[] almost = out.toByteArray(); + byte bcc = BccChecksum.compute(almost, 2, almost.length - 2); + out.write(bcc & 0xFF); + return out.toByteArray(); + } + + private static byte[] vinPadded(String vin) { + byte[] out = new byte[17]; + // 填充空格 + java.util.Arrays.fill(out, (byte) ' '); + if (vin == null) return out; + byte[] src = vin.getBytes(StandardCharsets.US_ASCII); + int len = Math.min(src.length, 17); + System.arraycopy(src, 0, out, 0, len); + return out; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java new file mode 100644 index 00000000..bf63672e --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java @@ -0,0 +1,160 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.api.spi.DecodeException; +import com.lingniu.ingest.api.spi.FrameDecoder; +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.protocol.gb32960.model.CommandBody; +import com.lingniu.ingest.protocol.gb32960.model.CommandType; +import com.lingniu.ingest.protocol.gb32960.model.EncryptType; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Header; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; +import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; + +/** + * 完整帧 → {@link Gb32960Message} 的解码器。 + * + *

    支持 GB/T 32960.3 两个版本: + *

      + *
    • {@code 0x23 0x23 (##)} → 2016 版({@link ProtocolVersion#V2016}) + *
    • {@code 0x24 0x24 ($$)} → 2025 版({@link ProtocolVersion#V2025}) + *
    + * + *

    完整数据包结构(表 2 / 表 B.1): + *

    + *   offset 0:  起始符 (2B)
    + *   offset 2:  命令标识 (1B)
    + *   offset 3:  应答标志 (1B)
    + *   offset 4:  唯一识别码 VIN (17B ASCII)
    + *   offset 21: 数据单元加密方式 (1B)
    + *   offset 22: 数据单元长度 (2B WORD 大端)
    + *   offset 24: 数据单元 (dataLength 字节)
    + *   last byte: 校验码 (1B BCC)
    + * 
    + * + *

    BCC 校验范围:从命令单元的第一个字节开始(offset 2),同后一字节异或, + * 直到校验码前一字节为止。注意:当数据单元存在加密时,应先加密后校验,解析时先校验后解密。 + * + *

    上游 Netty 的 frame handler 已经保证 {@code buffer} 内是一条完整帧。 + */ +public final class Gb32960MessageDecoder implements FrameDecoder { + + /** 最小帧长:2(起始符)+1(cmd)+1(rsp)+17(vin)+1(enc)+2(len)+0(body)+1(bcc)。 */ + private static final int MIN_FRAME = 25; + private static final ZoneId ZONE_GMT8 = ZoneId.of("Asia/Shanghai"); + + private final Gb32960BodyParser bodyParser; + private final Gb32960CommandParser commandParser; + + public Gb32960MessageDecoder(Gb32960BodyParser bodyParser) { + this(bodyParser, new Gb32960CommandParser()); + } + + public Gb32960MessageDecoder(Gb32960BodyParser bodyParser, Gb32960CommandParser commandParser) { + this.bodyParser = bodyParser; + this.commandParser = commandParser; + } + + @Override + public Gb32960Message decode(ByteBuffer buffer) { + int frameLen = buffer.remaining(); + if (frameLen < MIN_FRAME) { + throw new DecodeException("frame too short: " + frameLen); + } + + byte[] frame = new byte[frameLen]; + buffer.get(frame); + + ProtocolVersion version = detectVersion(frame); + + // BCC 校验:从 cmd(offset 2) 开始到 BCC 前一字节 + byte expectedBcc = frame[frame.length - 1]; + if (!BccChecksum.verify(frame, 2, frame.length - 3, expectedBcc)) { + throw new DecodeException("BCC mismatch"); + } + + int commandCode = frame[2] & 0xFF; + int responseCode = frame[3] & 0xFF; + String vin = new String(frame, 4, 17, StandardCharsets.US_ASCII).trim(); + int encryptCode = frame[21] & 0xFF; + int dataLength = ((frame[22] & 0xFF) << 8) | (frame[23] & 0xFF); + + int bodyStart = 24; + if (bodyStart + dataLength + 1 != frame.length) { + throw new DecodeException("declared body length " + dataLength + + " does not match frame remainder " + (frame.length - bodyStart - 1)); + } + + CommandType cmdType = CommandType.of(commandCode); + ResponseFlag rspFlag = ResponseFlag.of(responseCode); + EncryptType encType = EncryptType.of(encryptCode); + + // 实时 / 补发上报:解析信息体(和 2025 版尾部签名) + if (cmdType == CommandType.REALTIME_REPORT || cmdType == CommandType.RESEND_REPORT) { + 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); + Gb32960Header header = new Gb32960Header( + version, cmdType, rspFlag, vin, encType, dataLength, eventTime); + return new Gb32960Message(header, result.blocks(), null, result.signature()); + } + + // 其他命令:解析 command body + // 车辆/平台登入登出 body 首 6B 为采集时间(表 5),预解析并写入 header.eventTime, + // 以便 handler 构造应答帧时按 §6.3.2 保留原报文时间。 + Instant eventTime = null; + if (hasLeadingTimestamp(cmdType) && dataLength >= 6) { + eventTime = parseTimestamp(frame, bodyStart); + } + Gb32960Header headerStub = new Gb32960Header( + version, cmdType, rspFlag, vin, encType, dataLength, eventTime); + ByteBuffer body = ByteBuffer.wrap(frame, bodyStart, dataLength); + CommandBody cmdBody = commandParser.parse(cmdType, body); + return new Gb32960Message(headerStub, Collections.emptyList(), cmdBody, null); + } + + private static ProtocolVersion detectVersion(byte[] frame) { + if (frame[0] == 0x23 && frame[1] == 0x23) return ProtocolVersion.V2016; + if (frame[0] == 0x24 && frame[1] == 0x24) return ProtocolVersion.V2025; + throw new DecodeException("unknown start symbol: " + + String.format("%02x %02x", frame[0] & 0xFF, frame[1] & 0xFF)); + } + + private static boolean hasLeadingTimestamp(CommandType cmd) { + return cmd == CommandType.VEHICLE_LOGIN + || cmd == CommandType.VEHICLE_LOGOUT + || cmd == CommandType.PLATFORM_LOGIN + || cmd == CommandType.PLATFORM_LOGOUT; + } + + /** 时间定义(表 5):YY MM DD HH MM SS,各 1 字节,GMT+8。 */ + private static Instant parseTimestamp(byte[] frame, int offset) { + int year = 2000 + (frame[offset] & 0xFF); + int month = frame[offset + 1] & 0xFF; + int day = frame[offset + 2] & 0xFF; + int hour = frame[offset + 3] & 0xFF; + int minute = frame[offset + 4] & 0xFF; + int second = frame[offset + 5] & 0xFF; + try { + return LocalDateTime.of(year, month, day, hour, minute, second) + .atZone(ZONE_GMT8) + .toInstant(); + } catch (Exception e) { + throw new DecodeException("invalid timestamp " + year + "-" + month + "-" + day + + " " + hour + ":" + minute + ":" + second, e); + } + } + + /** 实时上报 body 解析结果:信息体列表 + 可选签名字节。 */ + public record BodyParseResult(List blocks, byte[] signature) {} +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParser.java new file mode 100644 index 00000000..e8bc5790 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParser.java @@ -0,0 +1,34 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 单一信息体解析器 SPI。 + * + *

    替代旧代码里 2500 行 {@code informationReporting()} 巨方法。每个 + * {@code (ProtocolVersion, typeCode)} 组合对应一个独立实现。同一个协议 typeCode + * 在 2016 / 2025 可能语义不同(如 0x07),因此需要两个 Parser 分别声明 version。 + * + *

    实现约定: + *

      + *
    • {@link #parse(ByteBuffer)} 被调用时 buffer 的 position 已指向信息体第一个字节(不含 1 字节类型前缀) + *
    • 解析完成后 position 应恰好移动到信息体末尾 + *
    • 实现应无状态,可被单例复用 + *
    + */ +public interface InfoBlockParser { + + /** 本 Parser 适用的协议版本({@link ProtocolVersion#V2016} 或 {@link ProtocolVersion#V2025})。 */ + ProtocolVersion version(); + + /** 本 Parser 对应的协议信息类型标志字节(表 9)。 */ + int typeCode(); + + /** 信息体固定长度;变长类型返回 -1 并自行在 parse 中读取长度字段。 */ + int fixedLength(); + + InfoBlock parse(ByteBuffer buffer); +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java new file mode 100644 index 00000000..ad9a1718 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java @@ -0,0 +1,34 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 双键策略注册中心:按 {@code (ProtocolVersion, typeCode)} 索引解析器。 + * + *

    同一 typeCode 在 2016 / 2025 版本可能语义不同(如 0x07 在 2016 是报警,在 2025 是 + * 动力蓄电池最小并联单元电压),必须按版本分派。 + */ +public final class InfoBlockParserRegistry { + + private final Map parsers = new HashMap<>(); + + public InfoBlockParserRegistry(List list) { + for (InfoBlockParser p : list) { + parsers.put(new Key(p.version(), p.typeCode()), p); + } + } + + public InfoBlockParser find(ProtocolVersion version, int typeCode) { + return parsers.get(new Key(version, typeCode)); + } + + public int size() { + return parsers.size(); + } + + private record Key(ProtocolVersion version, int typeCode) {} +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java new file mode 100644 index 00000000..fc4f22e6 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java @@ -0,0 +1,80 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import java.nio.ByteBuffer; + +/** + * 规范异常值处理辅助:GB/T 32960.3 约定 {@code 0xFE}/{@code 0xFFFE}/{@code 0xFFFFFFFE} 为异常, + * {@code 0xFF}/{@code 0xFFFF}/{@code 0xFFFFFFFF} 为无效。本类把字段读取 + 异常检查封装起来, + * 异常或无效时返回 {@code null},让 record 字段保持 {@link Integer}/{@link Double} 装箱类型。 + */ +public final class ValueDecoder { + + private ValueDecoder() {} + + // ------------ 1 字节无符号 ------------ + + public static Integer u8(ByteBuffer buf) { + int v = buf.get() & 0xFF; + if (v == 0xFE || v == 0xFF) return null; + return v; + } + + /** 原始值,不做异常处理(状态位等不能以 null 表达时用此方法)。 */ + public static int u8raw(ByteBuffer buf) { + return buf.get() & 0xFF; + } + + // ------------ 2 字节无符号大端 ------------ + + public static Integer u16(ByteBuffer buf) { + int v = buf.getShort() & 0xFFFF; + if (v == 0xFFFE || v == 0xFFFF) return null; + return v; + } + + public static int u16raw(ByteBuffer buf) { + return buf.getShort() & 0xFFFF; + } + + // ------------ 4 字节无符号大端 ------------ + + public static Long u32(ByteBuffer buf) { + long v = buf.getInt() & 0xFFFFFFFFL; + if (v == 0xFFFFFFFEL || v == 0xFFFFFFFFL) return null; + return v; + } + + public static long u32raw(ByteBuffer buf) { + return buf.getInt() & 0xFFFFFFFFL; + } + + // ------------ 业务标度辅助 ------------ + + /** 读 u16,scale 后返回(例如 0.1 转换)。异常/无效返回 null。 */ + public static Double scaledU16(ByteBuffer buf, double scale) { + Integer raw = u16(buf); + return raw == null ? null : raw * scale; + } + + /** 读 u16 + 偏移 + scale。例:读总电流 -> scaledU16WithOffset(buf, 0.1, -1000) */ + public static Double scaledU16WithOffset(ByteBuffer buf, double scale, double offset) { + Integer raw = u16(buf); + return raw == null ? null : raw * scale + offset; + } + + public static Double scaledU32(ByteBuffer buf, double scale) { + Long raw = u32(buf); + return raw == null ? null : raw * scale; + } + + public static Double scaledU32WithOffset(ByteBuffer buf, double scale, double offset) { + Long raw = u32(buf); + return raw == null ? null : raw * scale + offset; + } + + /** 温度字段:原始值 - 40 ℃,范围 -40~+210。 */ + public static Integer tempOffset40(ByteBuffer buf) { + Integer raw = u8(buf); + return raw == null ? null : raw - 40; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java new file mode 100644 index 00000000..ca64e866 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java @@ -0,0 +1,45 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 报警数据 (0x07) —— GB/T 32960.3-2016 附录 B 表 B.17。变长。 + * maxAlarmLevel(1) + generalAlarmFlag(4) + 4 组故障列表(count(1)+entries(4*N))。 + * + *

    2025 版报警数据 typeCode 改为 0x06 且尾部多 N5 个 (flagBit,level) —— 见 v2025 包。 + */ +public final class AlarmV2016BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x07; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Integer maxLevel = ValueDecoder.u8(buf); + long generalFlag = ValueDecoder.u32raw(buf); + List battery = readFaultList(buf); + List motor = readFaultList(buf); + List engine = readFaultList(buf); + List other = readFaultList(buf); + return new InfoBlock.Alarm( + ProtocolVersion.V2016, maxLevel, generalFlag, + battery, motor, engine, other, null); + } + + static List readFaultList(ByteBuffer buf) { + int count = buf.get() & 0xFF; + List out = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + out.add(buf.getInt() & 0xFFFFFFFFL); + } + return out; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java new file mode 100644 index 00000000..ffb8eab1 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java @@ -0,0 +1,45 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 驱动电机数据 (0x02) —— GB/T 32960.3-2016 表 B.16。变长。 + * 1 字节电机个数 + N × 12 字节电机: + * serialNo(1) state(1) ctrlTempC(1) rpm(2,offset -20000) + * torque(2,offset -20000,0.1 N·m) motorTempC(1) + * ctrlVoltageV(2,0.1V) ctrlCurrentA(2,0.1A,offset -1000)。 + */ +public final class DriveMotorV2016BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x02; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int count = buf.get() & 0xFF; + List motors = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + Integer serialNo = ValueDecoder.u8(buf); + Integer state = ValueDecoder.u8(buf); + Integer ctrlTempC = ValueDecoder.tempOffset40(buf); + Integer rawRpm = ValueDecoder.u16(buf); + Integer rpm = rawRpm == null ? null : rawRpm - 20_000; + Integer rawTq = ValueDecoder.u16(buf); + Double torque = rawTq == null ? null : (rawTq - 20_000) * 0.1; + Integer motorTempC = ValueDecoder.tempOffset40(buf); + Double ctrlVoltage = ValueDecoder.scaledU16(buf, 0.1); + Double ctrlCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0); + motors.add(new InfoBlock.DriveMotor.Motor( + serialNo, state, ctrlTempC, rpm, torque, motorTempC, ctrlVoltage, ctrlCurrent)); + } + return new InfoBlock.DriveMotor(motors); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/EngineV2016BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/EngineV2016BlockParser.java new file mode 100644 index 00000000..6f453b5f --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/EngineV2016BlockParser.java @@ -0,0 +1,28 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 发动机数据 (0x04) —— GB/T 32960.3-2016 表 20。固定 5 字节: + * engineState(1) + crankshaftRpm(2) + fuelConsumptionRate(2,0.01 L/100km)。 + * 停车充电过程和增程式车辆纯电模式无需传输。 + */ +public final class EngineV2016BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x04; } + @Override public int fixedLength() { return 5; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Integer engineState = ValueDecoder.u8(buf); + Integer crankshaftRpm = ValueDecoder.u16(buf); + Double fuelRate = ValueDecoder.scaledU16(buf, 0.01); + return new InfoBlock.Engine(engineState, crankshaftRpm, fuelRate); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java new file mode 100644 index 00000000..f7a92f6b --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java @@ -0,0 +1,40 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 动力蓄电池极值数据 (0x06) —— GB/T 32960.3-2016 表 B.16。固定 14 字节。 + * 2025 版 0x06 语义已变更为报警数据。 + */ +public final class ExtremeValueV2016BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x06; } + @Override public int fixedLength() { return 14; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Integer maxVSub = ValueDecoder.u8(buf); + Integer maxVCell = ValueDecoder.u8(buf); + Double maxV = ValueDecoder.scaledU16(buf, 0.001); + Integer minVSub = ValueDecoder.u8(buf); + Integer minVCell = ValueDecoder.u8(buf); + Double minV = ValueDecoder.scaledU16(buf, 0.001); + Integer maxTSub = ValueDecoder.u8(buf); + Integer maxTProbe = ValueDecoder.u8(buf); + Integer maxT = ValueDecoder.tempOffset40(buf); + Integer minTSub = ValueDecoder.u8(buf); + Integer minTProbe = ValueDecoder.u8(buf); + Integer minT = ValueDecoder.tempOffset40(buf); + return new InfoBlock.Extreme( + maxVSub, maxVCell, maxV, + minVSub, minVCell, minV, + maxTSub, maxTProbe, maxT, + minTSub, minTProbe, minT); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java new file mode 100644 index 00000000..74bbdf2c --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java @@ -0,0 +1,54 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 燃料电池数据 (0x03) —— GB/T 32960.3-2016 表 B.13。变长。 + * fcVoltageV(2) + fcCurrentA(2) + hydrogenConsumption(2,0.01 kg/100km) + * + probeCount N(2) + probes[N](1B,-40 offset) + 后续氢系统 10 字节固定段。 + */ +public final class FuelCellV2016BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x03; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Double fcVoltage = ValueDecoder.scaledU16(buf, 0.1); + Double fcCurrent = ValueDecoder.scaledU16(buf, 0.1); + Double consumption = ValueDecoder.scaledU16(buf, 0.01); + int probeCount = ValueDecoder.u16raw(buf); + + // 防御:probeCount 异常时回退为空列表避免吞 buffer + List probes = new ArrayList<>(); + int tailFixed = 2 + 1 + 2 + 1 + 2 + 1 + 1; // 尾部固定 10 字节 + if (probeCount >= 0 && probeCount <= buf.remaining() - tailFixed) { + for (int i = 0; i < probeCount; i++) { + Integer t = ValueDecoder.tempOffset40(buf); + if (t != null) probes.add(t); + } + } + Double maxHydrogenTempC = ValueDecoder.scaledU16WithOffset(buf, 0.1, -40.0); + Integer maxHydrogenTempProbeId = ValueDecoder.u8(buf); + Double maxHydrogenConcentration = ValueDecoder.scaledU16(buf, 0.000001); + Integer maxHydrogenConcentrationProbeId = ValueDecoder.u8(buf); + Double maxHydrogenPressure = ValueDecoder.scaledU16(buf, 0.1); + Integer maxHydrogenPressureProbeId = ValueDecoder.u8(buf); + Integer hvDcDcStatus = ValueDecoder.u8(buf); + + return new InfoBlock.FuelCellV2016( + fcVoltage, fcCurrent, consumption, probes, + maxHydrogenTempC, maxHydrogenTempProbeId, + maxHydrogenConcentration, maxHydrogenConcentrationProbeId, + maxHydrogenPressure, maxHydrogenPressureProbeId, + hvDcDcStatus); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java new file mode 100644 index 00000000..b0228618 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java @@ -0,0 +1,32 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 车辆位置数据 (0x05) —— GB/T 32960.3-2016 表 22。固定 9 字节: + * 状态位(1) + 经度(4,1e-6 度) + 纬度(4,1e-6 度)。 + * + *

    状态位 bit0 0=有效定位 / bit1 0=北纬 1=南纬 / bit2 0=东经 1=西经。 + */ +public final class PositionV2016BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x05; } + @Override public int fixedLength() { return 9; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int statusFlag = buf.get() & 0xFF; + long lonRaw = buf.getInt() & 0xFFFFFFFFL; + 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.Position(statusFlag, null, longitude, latitude); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java new file mode 100644 index 00000000..4a8d742a --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java @@ -0,0 +1,39 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 可充电储能装置温度数据 (0x09) —— GB/T 32960.3-2016 表 B.20。变长。 + * subSystemCount(1) + 每子系统 [batteryIndex(1) + probeCount(2) + probes(probeCount*1,-40 offset)]。 + * + *

    2025 版已删除该 typeCode(动力蓄电池温度改用 0x08)。 + */ +public final class TemperatureV2016BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x09; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int subCount = buf.get() & 0xFF; + int maxT = Integer.MIN_VALUE; + int minT = Integer.MAX_VALUE; + 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; + if (t < minT) minT = t; + } + } + if (maxT == Integer.MIN_VALUE) maxT = 0; + if (minT == Integer.MAX_VALUE) minT = 0; + return new InfoBlock.Temperature(subCount, maxT, minT); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java new file mode 100644 index 00000000..941c0140 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java @@ -0,0 +1,42 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 整车数据 (0x01) —— GB/T 32960.3-2016 表 B.10。固定 20 字节: + * vehicleState(1) + chargingState(1) + runningMode(1) + speedKmh(2) + * + totalMileageKm(4) + totalVoltageV(2) + totalCurrentA(2) + * + socPercent(1) + dcDcStatus(1) + gearRaw(1) + insulationKohm(2) + * + accelPedal(1) + brakePedal(1)。 + */ +public final class VehicleV2016BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x01; } + @Override public int fixedLength() { return 20; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Integer vehicleState = ValueDecoder.u8(buf); + Integer chargingState = ValueDecoder.u8(buf); + Integer runningMode = ValueDecoder.u8(buf); + Double speedKmh = ValueDecoder.scaledU16(buf, 0.1); + Double mileageKm = ValueDecoder.scaledU32(buf, 0.1); + Double totalVoltageV = ValueDecoder.scaledU16(buf, 0.1); + Double totalCurrentA = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0); + Integer soc = ValueDecoder.u8(buf); + Integer dcDcStatus = ValueDecoder.u8(buf); + Integer gearRaw = ValueDecoder.u8raw(buf); + Integer insulation = ValueDecoder.u16(buf); + Integer accelPedal = ValueDecoder.u8(buf); + Integer brakePedal = ValueDecoder.u8(buf); + return new InfoBlock.Vehicle(vehicleState, chargingState, runningMode, speedKmh, mileageKm, + totalVoltageV, totalCurrentA, soc, dcDcStatus, gearRaw, insulation, + accelPedal, brakePedal); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java new file mode 100644 index 00000000..33733088 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java @@ -0,0 +1,49 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 可充电储能装置电压数据 (0x08) —— GB/T 32960.3-2016 表 B.18。变长。 + * subSystemCount(1) + 每子系统 [batteryIndex(1) + voltage(2,0.1V) + current(2,0.1A,offset -1000) + * + cellCount(2) + frameCellStart(2) + frameCellCount(1) + cellVoltages(frameCellCount*2,0.001V)]。 + * + *

    PoC 汇总:保留子系统数 + 总电压 + 单体最高/最低值。 + * 2025 版 0x08 语义已变更为动力蓄电池温度。 + */ +public final class VoltageV2016BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x08; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int subCount = buf.get() & 0xFF; + double totalV = 0; + double maxCell = Double.MIN_VALUE; + double minCell = Double.MAX_VALUE; + for (int i = 0; i < subCount; i++) { + buf.get(); // batteryIndex + double voltage = (buf.getShort() & 0xFFFF) * 0.1; + totalV += voltage; + buf.getShort(); // current (skip) + 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; + } + for (int c = actual; c < frameCellCount; c++) buf.getShort(); + } + if (maxCell == Double.MIN_VALUE) maxCell = 0; + if (minCell == Double.MAX_VALUE) minCell = 0; + return new InfoBlock.Voltage(subCount, maxCell, minCell, totalV); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java new file mode 100644 index 00000000..6fce0e27 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java @@ -0,0 +1,52 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 报警数据 (0x06) —— GB/T 32960.3-2025 §7.2.4.9 表 23。变长。 + * 前半部分与 2016 版相同,尾部新增"通用报警故障总数(1) + N5×(flagBit(1)+level(1))"。 + */ +public final class AlarmV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x06; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Integer maxLevel = ValueDecoder.u8(buf); + long generalFlag = ValueDecoder.u32raw(buf); + List battery = readFaultList(buf); + List motor = readFaultList(buf); + List engine = readFaultList(buf); + List other = readFaultList(buf); + + int generalAlarmCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0; + List levels = new ArrayList<>(generalAlarmCount); + for (int i = 0; i < generalAlarmCount && buf.remaining() >= 2; i++) { + int bit = buf.get() & 0xFF; + int level = buf.get() & 0xFF; + levels.add(new InfoBlock.Alarm.GeneralAlarmEntry(bit, level)); + } + + return new InfoBlock.Alarm( + ProtocolVersion.V2025, maxLevel, generalFlag, + battery, motor, engine, other, levels); + } + + private static List readFaultList(ByteBuffer buf) { + int count = buf.get() & 0xFF; + List out = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + out.add(buf.getInt() & 0xFFFFFFFFL); + } + return out; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java new file mode 100644 index 00000000..6bf7ec40 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java @@ -0,0 +1,37 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 动力蓄电池温度数据 (0x08) —— GB/T 32960.3-2025 §7.2.4.3。变长。 + * 2016 版 0x08 为可充电储能装置电压,由 v2016 包处理。 + */ +public final class BatteryTemperatureV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x08; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int subCount = buf.get() & 0xFF; + List packs = new ArrayList<>(subCount); + for (int i = 0; i < subCount; i++) { + int packNo = buf.get() & 0xFF; + int probeCount = buf.getShort() & 0xFFFF; + List temps = new ArrayList<>(probeCount); + int safeCount = Math.min(probeCount, buf.remaining()); + for (int k = 0; k < safeCount; k++) { + temps.add((buf.get() & 0xFF) - 40); + } + packs.add(new InfoBlock.BatteryTemperature.Pack(packNo, probeCount, temps)); + } + return new InfoBlock.BatteryTemperature(subCount, packs); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java new file mode 100644 index 00000000..9a7abec9 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java @@ -0,0 +1,47 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 驱动电机数据 (0x02) —— GB/T 32960.3-2025 表 16。变长。 + * 1 字节电机个数 + N × 14 字节电机: + * serialNo(1) state(1) ctrlTempC(1) rpm(2,offset -32000) + * torque(4,offset -20000,0.1 N·m) motorTempC(1) + * ctrlVoltageV(2,0.1V) ctrlCurrentA(2,0.1A,offset -1000)。 + * + *

    相比 2016 版:rpm 偏移变为 -32000;torque 由 2 字节扩展为 4 字节。 + */ +public final class DriveMotorV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x02; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int count = buf.get() & 0xFF; + List motors = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + Integer serialNo = ValueDecoder.u8(buf); + Integer state = ValueDecoder.u8(buf); + Integer ctrlTempC = ValueDecoder.tempOffset40(buf); + Integer rawRpm = ValueDecoder.u16(buf); + Integer rpm = rawRpm == null ? null : rawRpm - 32_000; + Long rawTq = ValueDecoder.u32(buf); + Double torque = rawTq == null ? null : (rawTq - 20_000L) * 0.1; + Integer motorTempC = ValueDecoder.tempOffset40(buf); + Double ctrlVoltage = ValueDecoder.scaledU16(buf, 0.1); + Double ctrlCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0); + motors.add(new InfoBlock.DriveMotor.Motor( + serialNo, state, ctrlTempC, rpm, torque, motorTempC, ctrlVoltage, ctrlCurrent)); + } + return new InfoBlock.DriveMotor(motors); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/EngineV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/EngineV2025BlockParser.java new file mode 100644 index 00000000..8f17f8a2 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/EngineV2025BlockParser.java @@ -0,0 +1,27 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 发动机数据 (0x04) —— GB/T 32960.3-2025。固定 5 字节。 + * 字段与 2016 版完全一致;保留独立类只为遵守"双版本不共用"原则。 + */ +public final class EngineV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x04; } + @Override public int fixedLength() { return 5; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Integer engineState = ValueDecoder.u8(buf); + Integer crankshaftRpm = ValueDecoder.u16(buf); + Double fuelRate = ValueDecoder.scaledU16(buf, 0.01); + return new InfoBlock.Engine(engineState, crankshaftRpm, fuelRate); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java new file mode 100644 index 00000000..32aecb90 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java @@ -0,0 +1,45 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 燃料电池电堆数据 (0x30) —— GB/T 32960.3-2025 §7.2.4.6 表 18/19。变长。 + * + *

    V2016 的同 typeCode 厂商扩展由 v2016 包独立处理。 + */ +public final class FuelCellStackV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x30; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int stackCount = buf.get() & 0xFF; + List stacks = new ArrayList<>(stackCount); + for (int i = 0; i < stackCount; i++) { + int stackNo = buf.get() & 0xFF; + Double voltage = ValueDecoder.scaledU16(buf, 0.1); + Double current = ValueDecoder.scaledU16(buf, 0.1); + Double h2InletPressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0); + Double airInletPressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0); + Integer airInletTemp = ValueDecoder.tempOffset40(buf); + int probeCount = buf.getShort() & 0xFFFF; + List temps = new ArrayList<>(probeCount); + int safeCount = Math.min(probeCount, buf.remaining()); + for (int k = 0; k < safeCount; k++) { + temps.add((buf.get() & 0xFF) - 40); + } + stacks.add(new InfoBlock.FuelCellStack.Stack( + stackNo, voltage, current, h2InletPressure, airInletPressure, airInletTemp, temps)); + } + return new InfoBlock.FuelCellStack(stackCount, stacks); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellV2025BlockParser.java new file mode 100644 index 00000000..9adab4d8 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellV2025BlockParser.java @@ -0,0 +1,36 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 燃料电池发动机及车载氢系统数据 (0x03) —— GB/T 32960.3-2025 §7.2.4.5 表 17。固定 13 字节。 + */ +public final class FuelCellV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x03; } + @Override public int fixedLength() { return 13; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Double maxTempC = ValueDecoder.scaledU16WithOffset(buf, 0.1, -40.0); + Integer maxTempProbeId = ValueDecoder.u8(buf); + Double maxConcentration = ValueDecoder.scaledU16(buf, 0.000001); + Integer maxConcentrationProbeId = ValueDecoder.u8(buf); + Double maxPressure = ValueDecoder.scaledU16(buf, 0.1); + Integer maxPressureProbeId = ValueDecoder.u8(buf); + Integer hvDcDcStatus = ValueDecoder.u8(buf); + Integer remainingH2Percent = ValueDecoder.u8(buf); + Integer hvDcDcControllerTempC = ValueDecoder.tempOffset40(buf); + return new InfoBlock.FuelCellV2025( + maxTempC, maxTempProbeId, + maxConcentration, maxConcentrationProbeId, + maxPressure, maxPressureProbeId, + hvDcDcStatus, remainingH2Percent, hvDcDcControllerTempC); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java new file mode 100644 index 00000000..13db9676 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java @@ -0,0 +1,41 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 动力蓄电池最小并联单元电压数据 (0x07) —— GB/T 32960.3-2025 §7.2.4.2。变长。 + * 2016 版 0x07 为报警,由 v2016 包处理。 + */ +public final class MinParallelVoltageV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x07; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int subCount = buf.get() & 0xFF; + List packs = new ArrayList<>(subCount); + for (int i = 0; i < subCount; i++) { + int packNo = buf.get() & 0xFF; + Double voltage = ValueDecoder.scaledU16(buf, 0.1); + Double current = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0); + int totalUnits = buf.getShort() & 0xFFFF; + List frameVoltages = new ArrayList<>(totalUnits); + int safeCount = Math.min(totalUnits, buf.remaining() / 2); + for (int k = 0; k < safeCount; k++) { + frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001); + } + packs.add(new InfoBlock.MinParallelVoltage.Pack( + packNo, voltage, current, totalUnits, frameVoltages)); + } + return new InfoBlock.MinParallelVoltage(subCount, packs); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/PositionV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/PositionV2025BlockParser.java new file mode 100644 index 00000000..3611f85f --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/PositionV2025BlockParser.java @@ -0,0 +1,34 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 车辆位置数据 (0x05) —— GB/T 32960.3-2025。固定 10 字节, + * 比 2016 版多一个坐标系字节: + * 状态位(1) + 坐标系(1) + 经度(4) + 纬度(4)。 + * + *

    坐标系:0x01=WGS84 / 0x02=GCJ-02 / 0x03=其他。 + */ +public final class PositionV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x05; } + @Override public int fixedLength() { return 10; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int statusFlag = buf.get() & 0xFF; + int coordSystem = buf.get() & 0xFF; + long lonRaw = buf.getInt() & 0xFFFFFFFFL; + 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.Position(statusFlag, coordSystem, longitude, latitude); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java new file mode 100644 index 00000000..cd0f2b83 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java @@ -0,0 +1,39 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 超级电容器极值数据 (0x32) —— GB/T 32960.3-2025 §7.2.4.11 表 26。固定 18 字节。 + */ +public final class SuperCapacitorExtremeV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x32; } + @Override public int fixedLength() { return 18; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Integer maxVSys = ValueDecoder.u8(buf); + Integer maxVCell = ValueDecoder.u16(buf); + Double maxV = ValueDecoder.scaledU16(buf, 0.001); + Integer minVSys = ValueDecoder.u8(buf); + Integer minVCell = ValueDecoder.u16(buf); + Double minV = ValueDecoder.scaledU16(buf, 0.001); + Integer maxTSys = ValueDecoder.u8(buf); + Integer maxTProbe = ValueDecoder.u16(buf); + Integer maxT = ValueDecoder.tempOffset40(buf); + Integer minTSys = ValueDecoder.u8(buf); + Integer minTProbe = ValueDecoder.u16(buf); + Integer minT = ValueDecoder.tempOffset40(buf); + return new InfoBlock.SuperCapacitorExtreme( + maxVSys, maxVCell, maxV, + minVSys, minVCell, minV, + maxTSys, maxTProbe, maxT, + minTSys, minTProbe, minT); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java new file mode 100644 index 00000000..b6411cf1 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java @@ -0,0 +1,40 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * 超级电容器数据 (0x31) —— GB/T 32960.3-2025 §7.2.4.10 表 25。变长。 + */ +public final class SuperCapacitorV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x31; } + @Override public int fixedLength() { return -1; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + int systemNo = buf.get() & 0xFF; + Double totalVoltage = ValueDecoder.scaledU16(buf, 0.1); + Double totalCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0); + int cellCount = buf.getShort() & 0xFFFF; + List cells = new ArrayList<>(cellCount); + 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 temps = new ArrayList<>(probeCount); + int safeProbeCount = Math.min(probeCount, buf.remaining()); + for (int i = 0; i < safeProbeCount; i++) { + temps.add((buf.get() & 0xFF) - 40); + } + return new InfoBlock.SuperCapacitor(systemNo, totalVoltage, totalCurrent, cells, temps); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java new file mode 100644 index 00000000..5721bb71 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java @@ -0,0 +1,37 @@ +package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +import java.nio.ByteBuffer; + +/** + * 整车数据 (0x01) —— GB/T 32960.3-2025。固定 18 字节。 + * 相比 2016 版删除加速踏板和制动踏板。 + */ +public final class VehicleV2025BlockParser implements InfoBlockParser { + + @Override public ProtocolVersion version() { return ProtocolVersion.V2025; } + @Override public int typeCode() { return 0x01; } + @Override public int fixedLength() { return 18; } + + @Override + public InfoBlock parse(ByteBuffer buf) { + Integer vehicleState = ValueDecoder.u8(buf); + Integer chargingState = ValueDecoder.u8(buf); + Integer runningMode = ValueDecoder.u8(buf); + Double speedKmh = ValueDecoder.scaledU16(buf, 0.1); + Double mileageKm = ValueDecoder.scaledU32(buf, 0.1); + Double totalVoltageV = ValueDecoder.scaledU16(buf, 0.1); + Double totalCurrentA = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0); + Integer soc = ValueDecoder.u8(buf); + Integer dcDcStatus = ValueDecoder.u8(buf); + Integer gearRaw = ValueDecoder.u8raw(buf); + Integer insulation = ValueDecoder.u16(buf); + return new InfoBlock.Vehicle(vehicleState, chargingState, runningMode, speedKmh, mileageKm, + totalVoltageV, totalCurrentA, soc, dcDcStatus, gearRaw, insulation, + null, null); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java new file mode 100644 index 00000000..68fed881 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java @@ -0,0 +1,150 @@ +package com.lingniu.ingest.protocol.gb32960.config; + +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer; +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960CommandParser; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.EngineV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.ExtremeValueV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.FuelCellV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.AlarmV2025BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.BatteryTemperatureV2025BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.DriveMotorV2025BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.EngineV2025BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.FuelCellStackV2025BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.FuelCellV2025BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.MinParallelVoltageV2025BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2025.PositionV2025BlockParser; +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.handler.Gb32960RealtimeHandler; +import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer; +import com.lingniu.ingest.protocol.gb32960.mapper.Gb32960EventMapper; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +/** + * GB/T 32960.3 协议接入模块的自动装配。 + * + *

    每个 Parser Bean 都会被 {@link InfoBlockParserRegistry} 按 + * {@code (ProtocolVersion, typeCode)} 注册。2016/2025 可同时加载。 + */ +@AutoConfiguration +@EnableConfigurationProperties(Gb32960Properties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.gb32960", name = "enabled", havingValue = "true") +public class Gb32960AutoConfiguration { + + // ================= 2016 版信息体 Parser(com...parser.v2016 包) ================= + + @Bean public InfoBlockParser gb32960V2016Vehicle() { return new VehicleV2016BlockParser(); } + @Bean public InfoBlockParser gb32960V2016DriveMotor() { return new DriveMotorV2016BlockParser(); } + @Bean public InfoBlockParser gb32960V2016FuelCell() { return new FuelCellV2016BlockParser(); } + @Bean public InfoBlockParser gb32960V2016Engine() { return new EngineV2016BlockParser(); } + @Bean public InfoBlockParser gb32960V2016Position() { return new PositionV2016BlockParser(); } + @Bean public InfoBlockParser gb32960V2016Extreme() { return new ExtremeValueV2016BlockParser(); } + @Bean public InfoBlockParser gb32960V2016Alarm() { return new AlarmV2016BlockParser(); } + @Bean public InfoBlockParser gb32960V2016Voltage() { return new VoltageV2016BlockParser(); } + @Bean public InfoBlockParser gb32960V2016Temperature() { return new TemperatureV2016BlockParser(); } + // 注意:0x30~0x7F 在 GB/T 32960.3-2016 附录 B 表 B.3 是"预留"区段,没有任何字段定义。 + // peer 在 V2016 帧里发 0x30/0x31/0x32 是越界使用,强行用 2025 字段布局解读会得到非物理值。 + // 故 V2016 不注册任何 0x30+ parser;遇到时由 Gb32960BodyParser 走 Raw 兜底并打 WARN。 + + // ================= 2025 版信息体 Parser(com...parser.v2025 包) ================= + + @Bean public InfoBlockParser gb32960V2025Vehicle() { return new VehicleV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025DriveMotor() { return new DriveMotorV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025FuelCell() { return new FuelCellV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025Engine() { return new EngineV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025Position() { return new PositionV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025Alarm() { return new AlarmV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025MinParallelVoltage() { return new MinParallelVoltageV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025BatteryTemp() { return new BatteryTemperatureV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025FuelCellStack() { return new FuelCellStackV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025SuperCap() { return new SuperCapacitorV2025BlockParser(); } + @Bean public InfoBlockParser gb32960V2025SuperCapExtreme() { return new SuperCapacitorExtremeV2025BlockParser(); } + + // ================= Core components ================= + + @Bean + @ConditionalOnMissingBean + public InfoBlockParserRegistry gb32960InfoBlockParserRegistry(List parsers) { + return new InfoBlockParserRegistry(parsers); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960BodyParser gb32960BodyParser(InfoBlockParserRegistry registry) { + return new Gb32960BodyParser(registry); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960CommandParser gb32960CommandParser() { + return new Gb32960CommandParser(); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960MessageDecoder gb32960MessageDecoder(Gb32960BodyParser bodyParser, + Gb32960CommandParser commandParser) { + return new Gb32960MessageDecoder(bodyParser, commandParser); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960EventMapper gb32960EventMapper() { + return new Gb32960EventMapper(); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960RealtimeHandler gb32960RealtimeHandler(Gb32960EventMapper mapper) { + return new Gb32960RealtimeHandler(mapper); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960VinAuthorizer gb32960VinAuthorizer(Gb32960Properties props) { + return new Gb32960VinAuthorizer(props.getAuth()); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960PlatformAuthorizer gb32960PlatformAuthorizer(Gb32960Properties props) { + return new Gb32960PlatformAuthorizer(props.getAuth().getPlatforms()); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960NettyServer gb32960NettyServer(Gb32960Properties props, + Gb32960MessageDecoder decoder, + Dispatcher dispatcher, + Gb32960VinAuthorizer authorizer, + Gb32960PlatformAuthorizer platformAuthorizer) { + return new Gb32960NettyServer( + props.getPort(), + props.getWorkerThreads(), + props.getIdleReadSeconds(), + decoder, + dispatcher, + authorizer, + platformAuthorizer, + props.getTls()); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java new file mode 100644 index 00000000..4c3f3e38 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java @@ -0,0 +1,146 @@ +package com.lingniu.ingest.protocol.gb32960.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@ConfigurationProperties(prefix = "lingniu.ingest.gb32960") +public class Gb32960Properties { + + private boolean enabled = false; + private int port = 9000; + private int workerThreads = 0; + + /** + * 读空闲告警阈值(秒)。连接在该时长内没有任何上行帧则打一条 WARN 日志,方便排查 + * "对端登入成功但不推数据"之类的静默场景。仅告警不断链;0 或负数禁用。 + */ + private int idleReadSeconds = 60; + + /** VIN 白名单认证。关闭时任何 VIN 都能登入。 */ + private Auth auth = new Auth(); + + /** TLS 双向认证。 */ + private Tls tls = new Tls(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + public int getWorkerThreads() { return workerThreads; } + public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; } + public int getIdleReadSeconds() { return idleReadSeconds; } + public void setIdleReadSeconds(int idleReadSeconds) { this.idleReadSeconds = idleReadSeconds; } + public Auth getAuth() { return auth; } + public void setAuth(Auth auth) { this.auth = auth; } + public Tls getTls() { return tls; } + public void setTls(Tls tls) { this.tls = tls; } + + /** + * VIN 白名单认证。 + * + *

    GB/T 32960 协议没有设备账号密码,身份由报文头的 17 字节 VIN + 数据单元里的 ICCID + * 决定。此处通过内存白名单实现最小可用的接入控制: + *

      + *
    • {@code enabled=false}(默认):接受任意 VIN 接入,兼容未注册的测试终端 + *
    • {@code enabled=true}: + *
        + *
      • 车辆登入 0x01:VIN 不在白名单时返回 {@code 0x04 VIN_NOT_EXIST} 应答,关闭连接 + *
      • 实时上报 0x02 / 补发 0x03 / 登出 0x04 / 心跳 0x07:VIN 不在白名单时丢弃,关闭连接 + *
      + *
    + */ + public static class Auth { + private boolean enabled = false; + /** 是否大小写敏感比较 VIN。GB 16735 要求 VIN 大写,默认大小写不敏感以容错。 */ + private boolean caseSensitive = false; + private List whitelist = new ArrayList<>(); + + /** + * 平台登入 (0x05) 账号列表。GB/T 32960.3 表 29 规定平台登入携带 + * 12B 用户名 + 20B 密码 + 1B 加密规则。此处维护允许登入的账号集合, + * 收到 0x05 时比对 username / password: + *
      + *
    • 匹配且(可选)IP 在 {@code allowedIps} 内 → 返回 {@code SUCCESS} 并产出登入事件 + *
    • 不匹配 → 返回 {@code 0x02 OTHER_ERROR} 并关闭连接 + *
    + * 列表为空时默认放行所有平台登入(兼容旧行为)。 + */ + private List platforms = new ArrayList<>(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public boolean isCaseSensitive() { return caseSensitive; } + public void setCaseSensitive(boolean caseSensitive) { this.caseSensitive = caseSensitive; } + public List getWhitelist() { return whitelist; } + public void setWhitelist(List whitelist) { this.whitelist = whitelist; } + public List getPlatforms() { return platforms; } + public void setPlatforms(List platforms) { this.platforms = platforms; } + + /** 转换为规范化 Set 供 O(1) 查找。 */ + public Set normalizedWhitelist() { + Set out = new LinkedHashSet<>(whitelist.size()); + for (String v : whitelist) { + if (v == null || v.isBlank()) continue; + out.add(caseSensitive ? v.trim() : v.trim().toUpperCase()); + } + return out; + } + + /** + * 平台账号定义。对应 GB/T 32960.3 表 29 的字段。 + * + * @param username 12 字节以内的用户名 + * @param password 20 字节以内的密码(明文配置;推荐通过 env var 注入避免入库代码) + * @param allowedIps 可选:允许的客户端 IP 列表,为空不做 IP 限制 + * @param description 可选:平台描述(仅日志用) + */ + public static class Platform { + private String username; + private String password; + private List allowedIps = new ArrayList<>(); + private String description; + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public List getAllowedIps() { return allowedIps; } + public void setAllowedIps(List allowedIps) { this.allowedIps = allowedIps; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + } + } + + /** + * TLS 双向认证。 + * + *

    启用后 Netty pipeline 前置 {@code SslHandler},服务端加载自身证书+私钥, + * 强制要求客户端提供证书({@code NEED_CLIENT_AUTH})并使用 CA 证书验证。 + */ + public static class Tls { + private boolean enabled = false; + /** 服务端证书 PEM 路径。文件路径或 classpath 资源。 */ + private String certPath; + /** 服务端私钥 PEM 路径(PKCS#8 未加密)。 */ + private String keyPath; + /** 受信任的 CA 证书 PEM 路径。用于验证客户端证书。 */ + private String trustCertPath; + /** 是否要求客户端证书。默认 true(双向);false 则变成单向 TLS。 */ + private boolean requireClientAuth = true; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getCertPath() { return certPath; } + public void setCertPath(String certPath) { this.certPath = certPath; } + public String getKeyPath() { return keyPath; } + public void setKeyPath(String keyPath) { this.keyPath = keyPath; } + public String getTrustCertPath() { return trustCertPath; } + public void setTrustCertPath(String trustCertPath) { this.trustCertPath = trustCertPath; } + public boolean isRequireClientAuth() { return requireClientAuth; } + public void setRequireClientAuth(boolean requireClientAuth) { this.requireClientAuth = requireClientAuth; } + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java new file mode 100644 index 00000000..2a870318 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java @@ -0,0 +1,52 @@ +package com.lingniu.ingest.protocol.gb32960.handler; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.annotation.EventEmit; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.annotation.ProtocolHandler; +import com.lingniu.ingest.api.annotation.RateLimited; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.protocol.gb32960.mapper.Gb32960EventMapper; +import com.lingniu.ingest.protocol.gb32960.model.CommandType; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; + +import java.util.List; + +/** + * 示例 Handler:演示注解驱动的消息路由与事件产出。 + * + *

    构造器注入 {@link Gb32960EventMapper},全部业务逻辑限制在纯函数里,不触碰数据库 / 不做 IO。 + * 产出的事件由 Dispatcher 统一投递到 Disruptor → Kafka。 + */ +@ProtocolHandler(protocol = ProtocolId.GB32960, version = "2017") +public class Gb32960RealtimeHandler { + + private final Gb32960EventMapper mapper; + + public Gb32960RealtimeHandler(Gb32960EventMapper mapper) { + this.mapper = mapper; + } + + /** 实时 + 补发上报。 */ + @MessageMapping(command = {0x02, 0x03}, desc = "实时/补发上报") + @RateLimited(perVin = 50) + @EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class}) + public List onReport(Gb32960Message msg) { + return mapper.toEvents(msg); + } + + /** 车辆登入 / 登出 / 心跳:统一映射为 session 事件。 */ + @MessageMapping(command = {0x01, 0x04, 0x07}, desc = "登入/登出/心跳") + @EventEmit({VehicleEvent.Login.class, VehicleEvent.Logout.class, VehicleEvent.Heartbeat.class}) + public List onSession(Gb32960Message msg) { + return mapper.toEvents(msg); + } + + /** 平台登入 / 登出:仅用于 OEM 平台间握手,不产出业务事件。 */ + @MessageMapping(command = {0x05, 0x06}, desc = "平台登入/登出") + public List onPlatform(Gb32960Message msg) { + CommandType cmd = msg.header().command(); + // 留个 hook:后续可在此触发 session-core 的平台会话事件 + return List.of(); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java new file mode 100644 index 00000000..dfd3ab92 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java @@ -0,0 +1,297 @@ +package com.lingniu.ingest.protocol.gb32960.inbound; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer; +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameEncoder; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import com.lingniu.ingest.protocol.gb32960.model.CommandBody; +import com.lingniu.ingest.protocol.gb32960.model.CommandType; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.timeout.IdleState; +import io.netty.handler.timeout.IdleStateEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * Netty 入站处理器:字节 → Gb32960Message → 认证 → RawFrame → Dispatcher。 + * + *

    认证逻辑: + *

      + *
    • {@link Gb32960VinAuthorizer#isEnforcing()} = false:放行所有 VIN + *
    • {@link Gb32960VinAuthorizer#isEnforcing()} = true: + *
        + *
      • 车辆登入 {@code 0x01}:VIN 在白名单则返回成功应答(保留原帧时间)并派发; + * 不在则返回 {@code 0x04 VIN_NOT_EXIST} 应答并关闭连接 + *
      • 其他命令:VIN 在白名单则派发;不在则 DEBUG 日志后关闭连接 + *
      + *
    + * 注意应答帧使用 {@link Gb32960FrameEncoder#buildResponse},由 Channel 直接写回。 + */ +public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { + + private static final Logger log = LoggerFactory.getLogger(Gb32960ChannelHandler.class); + + /** 仅用于调试:把解析后的 Gb32960Message 序列化成 JSON 打到日志。 */ + private static final ObjectMapper DEBUG_JSON = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private final Gb32960MessageDecoder decoder; + private final Dispatcher dispatcher; + private final Gb32960VinAuthorizer authorizer; + private final Gb32960PlatformAuthorizer platformAuthorizer; + + public Gb32960ChannelHandler(Gb32960MessageDecoder decoder, + Dispatcher dispatcher, + Gb32960VinAuthorizer authorizer, + Gb32960PlatformAuthorizer platformAuthorizer) { + this.decoder = decoder; + this.dispatcher = dispatcher; + this.authorizer = authorizer; + this.platformAuthorizer = platformAuthorizer; + } + + @Override + public void channelActive(ChannelHandlerContext ctx) { + log.info("[gb32960] connection opened peer={}", addr(ctx)); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) { + log.info("[gb32960] connection closed peer={}", addr(ctx)); + } + + /** + * 读空闲告警:仅记录日志,不断链。用于排查"对端登入成功后长时间不推数据"场景。 + * 由 pipeline 中的 {@link io.netty.handler.timeout.IdleStateHandler}(可选)触发。 + */ + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt instanceof IdleStateEvent idle && idle.state() == IdleState.READER_IDLE) { + log.warn("[gb32960] read idle peer={} noInboundFrameFor>=thresholdSeconds (see idleReadSeconds)", + addr(ctx)); + } + super.userEventTriggered(ctx, evt); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, byte[] frame) { + Gb32960Message msg; + try { + msg = decoder.decode(ByteBuffer.wrap(frame)); + } catch (Exception e) { + log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e); + return; + } + + String vin = msg.header().vin(); + CommandType cmd = msg.header().command(); + // 原样回显请求帧的 17 字节 VIN(frame[4..20]),避免平台登入 VIN 全 0 + // 被 vinPadded 重写为空格导致对端判定 VIN 不匹配。 + byte[] rawVin = new byte[17]; + System.arraycopy(frame, 4, rawVin, 0, 17); + + // 平台登入/登出(0x05/0x06)没有 VIN,不进 VIN 白名单校验 + boolean isPlatformCommand = cmd == CommandType.PLATFORM_LOGIN || cmd == CommandType.PLATFORM_LOGOUT; + if (!isPlatformCommand && !authorizer.isAllowed(vin)) { + handleUnauthorized(ctx, msg); + return; + } + + // 鉴权通过:对 0x01 登入主动下发成功应答(某些终端不收到应答会一直重发) + if (cmd == CommandType.VEHICLE_LOGIN) { + byte[] ack = Gb32960FrameEncoder.buildResponse( + msg.header().protocolVersion(), + CommandType.VEHICLE_LOGIN, + ResponseFlag.SUCCESS, + rawVin, + msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(), + null); + writeAck(ctx, ack, "vehicle-login-ack"); + log.info("[gb32960] vehicle login peer={} vin={} protocolVersion={}", + addr(ctx), vin, msg.header().protocolVersion()); + } else if (cmd == CommandType.VEHICLE_LOGOUT) { + log.info("[gb32960] vehicle logout peer={} vin={}", addr(ctx), vin); + writeAck(ctx, Gb32960FrameEncoder.buildResponse( + msg.header().protocolVersion(), + CommandType.VEHICLE_LOGOUT, + ResponseFlag.SUCCESS, + rawVin, + msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(), + null), "vehicle-logout-ack"); + } else if (cmd == CommandType.PLATFORM_LOGIN) { + // 平台登入 0x05(表 29):含 12B username + 20B password + 加密规则 + if (!(msg.commandBody() instanceof CommandBody.PlatformLogin pl)) { + log.warn("[gb32960] platform login peer={} body unparsed, closing", addr(ctx)); + ctx.close(); + return; + } + String peerIp = peerIp(ctx); + Gb32960PlatformAuthorizer.Result result = platformAuthorizer.authenticate( + pl.username(), pl.password(), peerIp); + if (!result.accepted()) { + log.warn("[gb32960] platform login REJECTED peer={} username={} reason={}", + addr(ctx), pl.username(), result); + byte[] nack = Gb32960FrameEncoder.buildResponse( + msg.header().protocolVersion(), + CommandType.PLATFORM_LOGIN, + ResponseFlag.OTHER_ERROR, + rawVin, + msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(), + null); + ctx.writeAndFlush(Unpooled.wrappedBuffer(nack)).addListener(f -> { + log.info("[gb32960] platform login NACK flushed success={} peer={}", + f.isSuccess(), addr(ctx)); + ctx.close(); + }); + return; + } + log.info("[gb32960] platform login peer={} username={} encryptRule={} serial={} time={} policy={}", + addr(ctx), pl.username(), pl.encryptRule(), pl.serialNo(), pl.loginTime(), result); + byte[] ack = Gb32960FrameEncoder.buildResponse( + msg.header().protocolVersion(), + CommandType.PLATFORM_LOGIN, + ResponseFlag.SUCCESS, + rawVin, + msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(), + null); + writeAck(ctx, ack, "platform-login-ack"); + } else if (cmd == CommandType.PLATFORM_LOGOUT) { + log.info("[gb32960] platform logout peer={}", addr(ctx)); + writeAck(ctx, Gb32960FrameEncoder.buildResponse( + msg.header().protocolVersion(), + CommandType.PLATFORM_LOGOUT, + ResponseFlag.SUCCESS, + rawVin, + msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(), + null), "platform-logout-ack"); + } else if (cmd == CommandType.REALTIME_REPORT + || cmd == CommandType.RESEND_REPORT + || cmd == CommandType.HEARTBEAT) { + // 实时 / 补发 / 心跳:按 §6.3.2 回应答帧,保留原帧采集时间,data 段仅 6B 时间。 + // 部分车端实现严格按规范,没收到应答会停止后续上报或重连。 + writeAck(ctx, Gb32960FrameEncoder.buildResponse( + msg.header().protocolVersion(), + cmd, + ResponseFlag.SUCCESS, + rawVin, + msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(), + null), "report-ack-0x" + Integer.toHexString(cmd.code())); + if (log.isDebugEnabled()) { + log.debug("[gb32960] frame peer={} vin={} cmd=0x{} infoBlocks={} json={}", + addr(ctx), vin, Integer.toHexString(cmd.code()), + msg.infoBlocks().size(), toJson(msg)); + } + } else if (cmd == CommandType.TIME_CALIBRATION) { + // 0x08 终端校时:平台应答 data 段为平台当前时间 6B(用 Instant.now())。 + writeAck(ctx, Gb32960FrameEncoder.buildResponse( + msg.header().protocolVersion(), + CommandType.TIME_CALIBRATION, + ResponseFlag.SUCCESS, + rawVin, + Instant.now(), + null), "time-calibration-ack"); + } else if (log.isDebugEnabled()) { + log.debug("[gb32960] frame peer={} vin={} cmd=0x{} infoBlocks={} json={}", + addr(ctx), vin, Integer.toHexString(cmd.code()), + msg.infoBlocks().size(), toJson(msg)); + } + + Map sourceMeta = new HashMap<>(4); + sourceMeta.put("vin", vin); + sourceMeta.put("peer", addr(ctx)); + RawFrame rf = new RawFrame( + ProtocolId.GB32960, + cmd.code(), + 0, + msg, + frame, + sourceMeta, + Instant.now()); + dispatcher.dispatch(rf); + } + + /** 统一的 ack 写出 + 日志确认(含完整 hex dump 便于排查对端行为)。 */ + private static void writeAck(ChannelHandlerContext ctx, byte[] ack, String tag) { + String hex = hex(ack); + ctx.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> { + if (f.isSuccess()) { + log.info("[gb32960] {} flushed peer={} len={} hex={}", tag, addr(ctx), ack.length, hex); + } else { + log.warn("[gb32960] {} flush FAILED peer={} len={} hex={}", tag, addr(ctx), ack.length, hex, f.cause()); + } + }); + } + + private static String toJson(Gb32960Message msg) { + try { + return DEBUG_JSON.writeValueAsString(msg); + } catch (Exception e) { + return ""; + } + } + + private static String hex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + /** VIN 未授权:对登入命令下发 VIN_NOT_EXIST 应答;其他命令静默关闭。 */ + private void handleUnauthorized(ChannelHandlerContext ctx, Gb32960Message msg) { + String vin = msg.header().vin(); + CommandType cmd = msg.header().command(); + if (cmd == CommandType.VEHICLE_LOGIN) { + byte[] nack = Gb32960FrameEncoder.buildResponse( + msg.header().protocolVersion(), + CommandType.VEHICLE_LOGIN, + ResponseFlag.VIN_NOT_EXIST, + vin, + msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(), + null); + ctx.writeAndFlush(Unpooled.wrappedBuffer(nack)) + .addListener(f -> ctx.close()); + log.info("[gb32960] reject login peer={} vin={} (not in whitelist)", addr(ctx), vin); + } else { + log.debug("[gb32960] drop frame peer={} vin={} cmd=0x{} (not in whitelist)", + addr(ctx), vin, Integer.toHexString(cmd.code())); + ctx.close(); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + log.warn("[gb32960] channel error peer={}", addr(ctx), cause); + ctx.close(); + } + + private static String addr(ChannelHandlerContext ctx) { + if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { + return i.getAddress().getHostAddress() + ":" + i.getPort(); + } + return "unknown"; + } + + private static String peerIp(ChannelHandlerContext ctx) { + if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { + return i.getAddress().getHostAddress(); + } + return null; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java new file mode 100644 index 00000000..f5bbe720 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java @@ -0,0 +1,173 @@ +package com.lingniu.ingest.protocol.gb32960.inbound; + +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer; +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameDecoder; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.timeout.IdleStateHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; + +import java.io.File; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.concurrent.ThreadFactory; + +/** + * GB/T 32960 Netty Server 启动器。 + * + *

    EventLoop 只做解码与投递 {@link Dispatcher},业务处理由 Disruptor 侧的虚拟线程承担。 + * + *

    支持可选 TLS:{@link Gb32960Properties.Tls#isEnabled()} = true 时,pipeline 前置 + * {@code SslHandler},加载服务端证书 + 私钥 + 受信 CA,默认要求客户端证书(双向 TLS)。 + */ +public class Gb32960NettyServer implements InitializingBean, DisposableBean { + + private static final Logger log = LoggerFactory.getLogger(Gb32960NettyServer.class); + + private final int port; + private final int workerThreads; + private final int idleReadSeconds; + private final Gb32960MessageDecoder messageDecoder; + private final Dispatcher dispatcher; + private final Gb32960VinAuthorizer authorizer; + private final Gb32960PlatformAuthorizer platformAuthorizer; + private final Gb32960Properties.Tls tlsConfig; + + private EventLoopGroup boss; + private EventLoopGroup workers; + private Channel serverChannel; + private SslContext sslContext; + + public Gb32960NettyServer(int port, int workerThreads, int idleReadSeconds, + Gb32960MessageDecoder messageDecoder, + Dispatcher dispatcher, + Gb32960VinAuthorizer authorizer, + Gb32960PlatformAuthorizer platformAuthorizer, + Gb32960Properties.Tls tlsConfig) { + this.port = port; + this.workerThreads = workerThreads; + this.idleReadSeconds = idleReadSeconds; + this.messageDecoder = messageDecoder; + this.dispatcher = dispatcher; + this.authorizer = authorizer; + this.platformAuthorizer = platformAuthorizer; + this.tlsConfig = tlsConfig; + } + + @Override + public void afterPropertiesSet() throws Exception { + if (tlsConfig != null && tlsConfig.isEnabled()) { + this.sslContext = buildSslContext(tlsConfig); + log.info("[gb32960] TLS enabled cert={} clientAuth={}", + tlsConfig.getCertPath(), + tlsConfig.isRequireClientAuth() ? "REQUIRED" : "OPTIONAL"); + } + + ThreadFactory bossTf = runnable -> new Thread(runnable, "gb32960-boss"); + ThreadFactory wkTf = runnable -> new Thread(runnable, "gb32960-worker"); + this.boss = new NioEventLoopGroup(1, bossTf); + this.workers = new NioEventLoopGroup(workerThreads == 0 + ? Runtime.getRuntime().availableProcessors() * 2 : workerThreads, wkTf); + + ServerBootstrap b = new ServerBootstrap(); + b.group(boss, workers) + .channel(NioServerSocketChannel.class) + .option(ChannelOption.SO_BACKLOG, 1024) + .childOption(ChannelOption.TCP_NODELAY, true) + .childOption(ChannelOption.SO_KEEPALIVE, true) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + if (sslContext != null) { + ch.pipeline().addLast("ssl", sslContext.newHandler(ch.alloc())); + } + if (idleReadSeconds > 0) { + // 只监听读空闲:readerIdle=N,writerIdle=0,allIdle=0。 + // 触发后由 Gb32960ChannelHandler#userEventTriggered 仅打告警日志,不断链。 + ch.pipeline().addLast("idle", + new IdleStateHandler(idleReadSeconds, 0, 0)); + } + ch.pipeline() + .addLast("frame", new Gb32960FrameDecoder()) + .addLast("dispatch", + new Gb32960ChannelHandler(messageDecoder, dispatcher, + authorizer, platformAuthorizer)); + } + }); + this.serverChannel = b.bind(port).sync().channel(); + log.info("[gb32960] Netty server listening on :{} (tls={}, vinWhitelist={}, platformAuth={}, idleReadSeconds={})", + getBoundPort(), + sslContext != null, + authorizer.isEnforcing() ? authorizer.whitelistSize() : "DISABLED", + platformAuthorizer.isEnforcing() ? platformAuthorizer.size() : "DISABLED", + idleReadSeconds > 0 ? idleReadSeconds : "DISABLED"); + } + + /** 实际绑定端口(支持 port=0 随机分配后查询)。 */ + public int getBoundPort() { + if (serverChannel != null && serverChannel.localAddress() instanceof InetSocketAddress a) { + return a.getPort(); + } + return port; + } + + private static SslContext buildSslContext(Gb32960Properties.Tls cfg) throws Exception { + if (cfg.getCertPath() == null || cfg.getKeyPath() == null) { + throw new IllegalStateException("TLS enabled but cert-path/key-path not configured"); + } + SslContextBuilder builder = SslContextBuilder.forServer( + openPem(cfg.getCertPath()), + openPem(cfg.getKeyPath())); + if (cfg.getTrustCertPath() != null && !cfg.getTrustCertPath().isBlank()) { + try (InputStream trust = openPem(cfg.getTrustCertPath())) { + builder.trustManager(trust); + } + } + builder.clientAuth(cfg.isRequireClientAuth() ? ClientAuth.REQUIRE : ClientAuth.OPTIONAL); + return builder.build(); + } + + /** 读取 PEM 文件,支持 file:// 绝对路径或相对路径。 */ + private static InputStream openPem(String path) throws Exception { + String stripped = path.startsWith("file://") ? path.substring("file://".length()) : path; + File f = new File(stripped); + if (!f.isAbsolute()) { + f = Paths.get(System.getProperty("user.dir"), stripped).toFile(); + } + if (!f.exists()) { + throw new IllegalStateException("TLS PEM not found: " + f.getAbsolutePath()); + } + return Files.newInputStream(f.toPath()); + } + + @Override + public void destroy() { + try { + if (serverChannel != null) serverChannel.close().sync(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + if (boss != null) boss.shutdownGracefully(); + if (workers != null) workers.shutdownGracefully(); + log.info("[gb32960] Netty server stopped"); + } + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java new file mode 100644 index 00000000..ce1a0a65 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java @@ -0,0 +1,235 @@ +package com.lingniu.ingest.protocol.gb32960.mapper; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.AlarmPayload; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.RealtimePayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.spi.EventMapper; +import com.lingniu.ingest.protocol.gb32960.model.CommandBody; +import com.lingniu.ingest.protocol.gb32960.model.CommandType; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.GeneralAlarmFlagBit; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 32960 → 领域事件 Adapter。 + * + *

    实时/补发上报产出 {@code Realtime + Location}(可选)+ {@code Alarm}(可选); + * 车辆登入/登出/心跳产出对应的会话事件。 + */ +public final class Gb32960EventMapper implements EventMapper { + + @Override + public List toEvents(Gb32960Message message) { + var header = message.header(); + Instant eventTime = header.eventTime() != null ? header.eventTime() : Instant.now(); + Instant ingestTime = Instant.now(); + + List out = new ArrayList<>(3); + Map baseMeta = Map.of( + "command", header.command().name(), + "protocolVersion", header.protocolVersion().name()); + + CommandType cmd = header.command(); + if (cmd == CommandType.REALTIME_REPORT || cmd == CommandType.RESEND_REPORT) { + buildRealtime(message, header, baseMeta, eventTime, ingestTime, out); + return out; + } + if (cmd == CommandType.VEHICLE_LOGIN) { + String iccid = ""; + if (message.commandBody() instanceof CommandBody.VehicleLogin vl) iccid = vl.iccid(); + out.add(new VehicleEvent.Login( + UUID.randomUUID().toString(), + header.vin(), ProtocolId.GB32960, eventTime, ingestTime, + null, baseMeta, iccid, header.protocolVersion().name())); + } else if (cmd == CommandType.VEHICLE_LOGOUT) { + out.add(new VehicleEvent.Logout( + UUID.randomUUID().toString(), + header.vin(), ProtocolId.GB32960, eventTime, ingestTime, + null, baseMeta)); + } else if (cmd == CommandType.HEARTBEAT) { + out.add(new VehicleEvent.Heartbeat( + UUID.randomUUID().toString(), + header.vin(), ProtocolId.GB32960, eventTime, ingestTime, + null, baseMeta)); + } else if (cmd == CommandType.PLATFORM_LOGIN) { + // 平台登入 0x05:映射为 VehicleEvent.Login + metadata.kind=platform。 + // 使用 "platform:" + username 作为 vin 字段,既能送到 vehicle.session topic + // 下游消费者又能靠 metadata.kind 区分车辆/平台登入。 + if (message.commandBody() instanceof CommandBody.PlatformLogin pl) { + Map meta = new java.util.HashMap<>(baseMeta); + meta.put("kind", "platform"); + meta.put("username", pl.username() == null ? "" : pl.username()); + meta.put("encryptRule", pl.encryptRule() == null ? "" : pl.encryptRule().name()); + meta.put("serial", String.valueOf(pl.serialNo())); + out.add(new VehicleEvent.Login( + UUID.randomUUID().toString(), + "platform:" + (pl.username() == null ? "" : pl.username()), + ProtocolId.GB32960, + pl.loginTime() != null ? pl.loginTime() : eventTime, + ingestTime, + null, meta, "", header.protocolVersion().name())); + } + } else if (cmd == CommandType.PLATFORM_LOGOUT) { + if (message.commandBody() instanceof CommandBody.PlatformLogout plo) { + Map meta = new java.util.HashMap<>(baseMeta); + meta.put("kind", "platform"); + meta.put("serial", String.valueOf(plo.serialNo())); + out.add(new VehicleEvent.Logout( + UUID.randomUUID().toString(), + "platform:unknown", + ProtocolId.GB32960, + plo.logoutTime() != null ? plo.logoutTime() : eventTime, + ingestTime, + null, meta)); + } + } + return out; + } + + private void buildRealtime(Gb32960Message message, + com.lingniu.ingest.protocol.gb32960.model.Gb32960Header header, + Map meta, + Instant eventTime, Instant ingestTime, + List out) { + InfoBlock.Vehicle v = message.findBlock(InfoBlock.Vehicle.class).orElse(null); + InfoBlock.Position p = message.findBlock(InfoBlock.Position.class).orElse(null); + InfoBlock.FuelCellV2016 fc16 = message.findBlock(InfoBlock.FuelCellV2016.class).orElse(null); + InfoBlock.FuelCellV2025 fc25 = message.findBlock(InfoBlock.FuelCellV2025.class).orElse(null); + InfoBlock.Alarm alarm = message.findBlock(InfoBlock.Alarm.class).orElse(null); + + if (v != null) { + Double fcVoltageV = fc16 != null ? fc16.fcVoltageV() : null; + Double fcCurrentA = fc16 != null ? fc16.fcCurrentA() : null; + Double fcTempC = fc16 != null ? fc16.maxHydrogenSystemTempC() + : (fc25 != null ? fc25.maxHydrogenSystemTempC() : null); + Double hydrogenHighPressure = fc16 != null ? fc16.maxHydrogenPressureMpa() + : (fc25 != null ? fc25.maxHydrogenPressureMpa() : null); + Double hydrogenRemaining = fc25 != null && fc25.remainingHydrogenPercent() != null + ? fc25.remainingHydrogenPercent().doubleValue() : null; + + RealtimePayload payload = new RealtimePayload( + v.speedKmh(), + v.totalMileageKm(), + v.socPercent() != null ? v.socPercent().doubleValue() : null, + v.totalVoltageV(), + v.totalCurrentA(), + fcVoltageV, + fcCurrentA, + fcTempC, + hydrogenRemaining, + hydrogenHighPressure, + null, + mapVehicleState(v.vehicleState()), + mapChargingState(v.chargingState()), + mapRunningMode(v.runningMode()), + extractGearLevel(v.gearRaw()), + v.acceleratorPedal() != null ? v.acceleratorPedal().doubleValue() : null, + v.brakePedal() != null ? v.brakePedal().doubleValue() : null, + p != null ? p.longitude() : null, + p != null ? p.latitude() : null, + null, + null, + null, + null); + out.add(new VehicleEvent.Realtime( + UUID.randomUUID().toString(), + header.vin(), ProtocolId.GB32960, eventTime, ingestTime, + null, meta, payload)); + } + if (p != null) { + LocationPayload loc = new LocationPayload( + p.longitude(), p.latitude(), 0.0, + v != null && v.speedKmh() != null ? v.speedKmh() : 0.0, + 0.0, 0, p.statusFlag()); + out.add(new VehicleEvent.Location( + UUID.randomUUID().toString(), + header.vin(), ProtocolId.GB32960, eventTime, ingestTime, + null, meta, loc)); + } + if (alarm != null && alarm.maxLevel() != null && alarm.maxLevel() > 0) { + List faultCodes = new ArrayList<>(); + addFaultCodes(faultCodes, "BAT", alarm.batteryFaults()); + addFaultCodes(faultCodes, "MOT", alarm.motorFaults()); + addFaultCodes(faultCodes, "ENG", alarm.engineFaults()); + addFaultCodes(faultCodes, "OTH", alarm.otherFaults()); + Set activeBits = GeneralAlarmFlagBit.parse(alarm.generalAlarmFlag()).stream() + .map(Enum::name) + .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + AlarmPayload ap = new AlarmPayload( + mapAlarmLevel(alarm.maxLevel()), + (int) (alarm.generalAlarmFlag() & 0xFFFF), + "GB32960_ALARM", + faultCodes, + activeBits, + p != null ? p.longitude() : null, + p != null ? p.latitude() : null); + out.add(new VehicleEvent.Alarm( + UUID.randomUUID().toString(), + header.vin(), ProtocolId.GB32960, eventTime, ingestTime, + null, meta, ap)); + } + } + + /** 挡位字节按附录 A.1 解析:低 4 位为挡位值 0000-1111。 */ + private static Integer extractGearLevel(Integer gearRaw) { + if (gearRaw == null) return null; + if ((gearRaw & 0x80) != 0) return null; // bit7: 挡位无效 + return gearRaw & 0x0F; + } + + private static RealtimePayload.VehicleState mapVehicleState(Integer code) { + if (code == null) return null; + return switch (code) { + case 0x01 -> RealtimePayload.VehicleState.STARTED; + case 0x02 -> RealtimePayload.VehicleState.SHUTDOWN; + case 0x03 -> RealtimePayload.VehicleState.OTHER; + default -> RealtimePayload.VehicleState.INVALID; + }; + } + + private static RealtimePayload.ChargingState mapChargingState(Integer code) { + if (code == null) return null; + return switch (code) { + case 0x01 -> RealtimePayload.ChargingState.PARKED_CHARGING; + case 0x02 -> RealtimePayload.ChargingState.DRIVING_CHARGING; + case 0x03 -> RealtimePayload.ChargingState.UNCHARGED; + case 0x04 -> RealtimePayload.ChargingState.CHARGED; + default -> RealtimePayload.ChargingState.INVALID; + }; + } + + private static RealtimePayload.RunningMode mapRunningMode(Integer code) { + if (code == null) return null; + return switch (code) { + case 0x01 -> RealtimePayload.RunningMode.ELECTRIC; + case 0x02 -> RealtimePayload.RunningMode.HYBRID; + case 0x03 -> RealtimePayload.RunningMode.FUEL; + default -> RealtimePayload.RunningMode.INVALID; + }; + } + + private static AlarmPayload.AlarmLevel mapAlarmLevel(int code) { + return switch (code) { + case 1 -> AlarmPayload.AlarmLevel.MINOR; + case 2 -> AlarmPayload.AlarmLevel.MAJOR; + case 3, 4 -> AlarmPayload.AlarmLevel.CRITICAL; + default -> AlarmPayload.AlarmLevel.INFO; + }; + } + + private static void addFaultCodes(List out, String prefix, List codes) { + if (codes == null || codes.isEmpty()) return; + for (Long c : codes) out.add(prefix + "-" + Long.toHexString(c)); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandBody.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandBody.java new file mode 100644 index 00000000..ca80804f --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandBody.java @@ -0,0 +1,225 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * 命令数据单元(非实时上报)。sealed 集合;每个子类型对应一条 {@link CommandType}。 + * + *

    实时/补发上报的数据单元不走这里,而是产出 {@link InfoBlock} 列表。 + * + *

    上行类型(由车载终端发起):VehicleLogin/VehicleLogout/PlatformLogin/PlatformLogout/ + * Heartbeat/TimeCalibration/Activation/KeyExchange。 + * + *

    下行类型(由平台发起):QueryParams/SetParams/TerminalControl/ActivationResponse。 + * 参见 GB/T 32960.3-2025 附录 B 表 B.5~B.13。 + */ +public sealed interface CommandBody + permits CommandBody.VehicleLogin, + CommandBody.VehicleLogout, + CommandBody.PlatformLogin, + CommandBody.PlatformLogout, + CommandBody.Heartbeat, + CommandBody.TimeCalibration, + CommandBody.Activation, + CommandBody.ActivationResponse, + CommandBody.KeyExchange, + CommandBody.QueryParams, + CommandBody.QueryParamsResponse, + CommandBody.SetParams, + CommandBody.TerminalControl, + CommandBody.RawCommand { + + /** + * 车辆登入 0x01(表 6)。 + * + * @param loginTime 数据采集时间(表 5) + * @param serialNo 登入流水号 1~65531,按天循环累加 + * @param iccid 20 字节 SIM 卡 ICCID,终端从 SIM 卡获取 + * @param batterySystemCount 电池管理系统数 n (0~20,2025 版新增) + * @param batteryPackCounts 每个电池管理系统对应的动力蓄电池包个数 + * @param batteryCodes 动力蓄电池包编码列表(GB/T 34014,24 字节/个) + */ + record VehicleLogin( + Instant loginTime, + int serialNo, + String iccid, + int batterySystemCount, + List batteryPackCounts, + List batteryCodes + ) implements CommandBody {} + + /** 车辆登出 0x04(表 28)。 */ + record VehicleLogout(Instant logoutTime, int serialNo) implements CommandBody {} + + /** 平台登入 0x05(表 29)。 */ + record PlatformLogin( + Instant loginTime, + int serialNo, + String username, + String password, + EncryptType encryptRule + ) implements CommandBody {} + + /** 平台登出 0x06(表 30)。 */ + record PlatformLogout(Instant logoutTime, int serialNo) implements CommandBody {} + + /** 心跳 0x07:数据单元为空(B.3.5.10)。 */ + record Heartbeat() implements CommandBody {} + + /** 终端校时 0x08:信息标志及信息体为空(B.3.5.11)。 */ + record TimeCalibration() implements CommandBody {} + + /** 激活 0x09(表 B.3)。 */ + record Activation( + Instant collectTime, + String chipId, + byte[] publicKey, + String vin, + byte[] signature + ) implements CommandBody {} + + /** 激活结果应答 0x0A(表 B.4)。 */ + record ActivationResponse(int status, int info) implements CommandBody {} + + /** + * 数据单元加密密钥交换 0x0B(表 31)。 + * + * @param keyType 密钥类型({@link EncryptType}) + * @param key 密钥字节 + * @param activateAt 启用时间 + * @param expireAt 失效时间 + */ + record KeyExchange( + EncryptType keyType, + byte[] key, + Instant activateAt, + Instant expireAt + ) implements CommandBody {} + + // ======================================================================== + // 下行命令 0x80 / 0x81 / 0x82 —— 附录 B 表 B.5 ~ B.13 + // ======================================================================== + + /** + * 查询命令 0x80(表 B.5)。平台向车载终端发送,请求查询指定参数。 + * + * @param queryTime 参数查询时间 + * @param paramIds 待查询的参数 ID 列表(表 B.8:0x01-0x10,2025 版新增 0x0D-0x10) + */ + record QueryParams(Instant queryTime, List paramIds) implements CommandBody {} + + /** + * 参数查询返回 0x80 应答(表 B.6/B.7/B.8)。车载终端响应平台。 + * + * @param respondTime 返回查询参数时间 + * @param params 参数项键值对(key = paramId,value = bytes,需按表 B.8 解释) + */ + record QueryParamsResponse(Instant respondTime, Map params) implements CommandBody {} + + /** + * 设置命令 0x81(表 B.9)。平台向车载终端发送,修改车载终端参数。 + * + *

    参数 ID 范围见表 B.8,参数值定义见表 B.8 各字段说明。 + * 其中 0x07 硬件版本、0x08 固件版本为只读,不在 set 命令中出现。 + * + * @param setTime 参数设置时间 + * @param params 参数项键值对 + */ + record SetParams(Instant setTime, Map params) implements CommandBody {} + + /** + * 车载终端控制命令 0x82(表 B.10)。 + * + * @param time 时间 + * @param commandId 控制命令({@link ControlCommand}) + * @param payload 根据不同控制命令的负载 + */ + record TerminalControl( + Instant time, + ControlCommand commandId, + ControlPayload payload + ) implements CommandBody {} + + /** 控制命令枚举(表 B.11)。 */ + enum ControlCommand { + /** 0x01:远程升级,参数见表 B.12。 */ + REMOTE_UPGRADE(0x01), + /** 0x02:车载终端关机。 */ + SHUTDOWN(0x02), + /** 0x03:车载终端复位。 */ + RESET(0x03), + /** 0x04:车载终端恢复出厂设置。 */ + FACTORY_RESET(0x04), + /** 0x05:断开数据通信链路。 */ + DISCONNECT(0x05), + /** 0x06:车载终端报警/预警,参数见表 B.13。 */ + ALARM_COMMAND(0x06), + /** 0x07:开启抽样监测链路。 */ + SAMPLING_LINK_ON(0x07), + /** 未知命令 / 用户自定义 0x80-0xFE。 */ + UNKNOWN(-1); + + private final int code; + ControlCommand(int code) { this.code = code; } + public int code() { return code; } + + public static ControlCommand of(int code) { + for (ControlCommand c : values()) if (c.code == code) return c; + return UNKNOWN; + } + } + + /** 控制命令的负载。sealed:每种控制命令对应一种 payload 类型。 */ + sealed interface ControlPayload + permits ControlPayload.None, ControlPayload.RemoteUpgrade, ControlPayload.AlarmCommand, ControlPayload.RawPayload { + + /** 无参数(SHUTDOWN / RESET / FACTORY_RESET / DISCONNECT / SAMPLING_LINK_ON)。 */ + record None() implements ControlPayload {} + + /** + * 远程升级参数(表 B.12)。 + * + * @param apn 拨号点名称 + * @param dialUser 拨号用户名 + * @param dialPassword 拨号密码 + * @param serverAddress 升级服务器地址(IP 或域名,IPv4 时前 2 字节为 0) + * @param serverPort 升级服务器端口 + * @param manufacturerCode 车载终端制造商 ID(4 字节英文大写字母或数字) + * @param hardwareVersion 硬件版本(5 字节) + * @param firmwareVersion 固件版本(5 字节) + * @param upgradeUrl 升级 URL 地址(建议 FTP) + * @param connectTimeoutMinutes 连接到升级服务器时限(0~60000 分钟) + */ + record RemoteUpgrade( + String apn, + String dialUser, + String dialPassword, + byte[] serverAddress, + int serverPort, + String manufacturerCode, + String hardwareVersion, + String firmwareVersion, + String upgradeUrl, + int connectTimeoutMinutes + ) implements ControlPayload {} + + /** + * 报警/预警命令(表 B.13)。 + * + * @param warningLevel 警告等级:0x00 无报警 / 0x01-0x04 1-4 级报警 / 0xFF 无效 + * @param info 预留字节,可变长 + */ + record AlarmCommand(int warningLevel, byte[] info) implements ControlPayload {} + + /** 未解析的自定义 payload(0x80-0xFE 用户自定义等)。 */ + record RawPayload(byte[] bytes) implements ControlPayload {} + } + + /** + * 未解析或厂商自定义的命令体(0xC0~0xFE 等)。 + * 保留原始字节以便下游冷存分析。 + */ + record RawCommand(int commandCode, byte[] bytes) implements CommandBody {} +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandType.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandType.java new file mode 100644 index 00000000..0bcc3fb5 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandType.java @@ -0,0 +1,60 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +/** + * 命令标识。GB/T 32960.3-2025 表 3 和附录 B 表 B.2 "命令标识定义"。 + * + *

    命令标识是发起方的唯一标识,区分数据传输的方向与种类。 + * + *

    方向约定: + *

      + *
    • 上行:车载终端 → 远程服务与管理平台 + *
    • 下行:远程服务与管理平台 → 车载终端 + *
    • 上行/下行:双向使用 + *
    + */ +public enum CommandType { + /** 0x01:车辆登入(上行)—— 见表 6。 */ + VEHICLE_LOGIN(0x01), + /** 0x02:实时信息上报(上行)—— 见表 7。 */ + REALTIME_REPORT(0x02), + /** 0x03:补发信息上报(上行)—— 数据格式与实时上报一致,用于通信链路恢复后补发 7d 内存储的数据。 */ + RESEND_REPORT(0x03), + /** 0x04:车辆登出(上行)—— 见表 28。 */ + VEHICLE_LOGOUT(0x04), + /** 0x05:平台登入(上行)—— 见表 29;2025 版附录 B.2 中 0x05/0x06 改为"平台传输数据占用,自定义"。 */ + PLATFORM_LOGIN(0x05), + /** 0x06:平台登出(上行)—— 见表 30。 */ + PLATFORM_LOGOUT(0x06), + /** 0x07:心跳(上行)—— 数据单元为空,见 B.3.5.10。 */ + HEARTBEAT(0x07), + /** 0x08:终端校时(上行)—— 信息标志及信息体为空,见 B.3.5.11。 */ + TIME_CALIBRATION(0x08), + /** 0x09:激活(上行)—— 见表 B.3。 */ + ACTIVATION(0x09), + /** 0x0A:激活应答(下行)—— 见表 B.4。 */ + ACTIVATION_RESPONSE(0x0A), + /** 0x0B:数据单元加密密钥交换(上行/下行)—— 见表 31。 */ + KEY_EXCHANGE(0x0B), + /** 0x80:查询命令(下行)—— 见表 B.5 ~ B.8。 */ + QUERY(0x80), + /** 0x81:设置命令(下行)—— 见表 B.9。 */ + SET_PARAMS(0x81), + /** 0x82:车载终端控制命令(下行)—— 见表 B.10 ~ B.13。 */ + TERMINAL_CONTROL(0x82); + + private final int code; + + CommandType(int code) { + this.code = code; + } + + public int code() { + return code; + } + + /** 按协议编码查找命令类型;未识别时抛异常以便 DLQ 路由。 */ + public static CommandType of(int code) { + for (CommandType c : values()) if (c.code == code) return c; + throw new IllegalArgumentException("unknown gb32960 command: 0x" + Integer.toHexString(code)); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/EncryptType.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/EncryptType.java new file mode 100644 index 00000000..769a0b5f --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/EncryptType.java @@ -0,0 +1,38 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +/** + * 数据单元加密方式。GB/T 32960.3-2025 表 2 "数据单元加密方式"。 + * + *

    当数据单元存在加密时,应先加密后校验,解析端先校验后解密。 + */ +public enum EncryptType { + /** 0x01:数据不加密。 */ + UNENCRYPTED(0x01), + /** 0x02:RSA 算法加密。 */ + RSA(0x02), + /** 0x03:AES(高级加密标准)算法加密。 */ + AES(0x03), + /** 0x04:SM2 算法加密(2025 版新增)。 */ + SM2(0x04), + /** 0x05:SM4 算法加密(2025 版新增)。 */ + SM4(0x05), + /** 0xFE:异常。 */ + ABNORMAL(0xFE), + /** 0xFF:无效。 */ + INVALID(0xFF); + + private final int code; + + EncryptType(int code) { + this.code = code; + } + + public int code() { + return code; + } + + public static EncryptType of(int code) { + for (EncryptType e : values()) if (e.code == code) return e; + return INVALID; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Header.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Header.java new file mode 100644 index 00000000..765e2880 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Header.java @@ -0,0 +1,36 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +import java.time.Instant; + +/** + * GB/T 32960.3 数据包报文头(含起始符/命令单元/VIN/加密方式/数据长度),见表 2 / 表 B.1。 + * + *

    完整数据包结构: + *

    + *   起始符 (2B)            ## (0x23 0x23) 或 $$ (0x24 0x24)
    + *   命令标识 (1B)          CommandType
    + *   应答标志 (1B)          ResponseFlag
    + *   唯一识别码 (17B STRING) VIN 或自定义编码
    + *   数据单元加密方式 (1B)   EncryptType
    + *   数据单元长度 (2B WORD)  0 ~ 65531
    + *   数据单元 (NB)          见 §7
    + *   校验码 (1B BCC)        对命令单元到数据单元结尾异或
    + * 
    + * + * @param protocolVersion 协议版本,由起始符推断 + * @param command 命令标识(表 3 / B.2) + * @param responseFlag 应答标志(表 4) + * @param vin 唯一识别码(17 字节 ASCII) + * @param encryptType 数据单元加密方式(表 2) + * @param dataLength 数据单元长度(字节) + * @param eventTime 数据采集时间。仅实时/补发上报携带,其它命令为 null + */ +public record Gb32960Header( + ProtocolVersion protocolVersion, + CommandType command, + ResponseFlag responseFlag, + String vin, + EncryptType encryptType, + int dataLength, + Instant eventTime +) {} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Message.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Message.java new file mode 100644 index 00000000..39ace8d5 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Message.java @@ -0,0 +1,43 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * 完整解析后的 32960 消息。一条消息要么是实时/补发上报(携带 {@link InfoBlock} 列表), + * 要么是登入/登出/平台/心跳/校时/激活/密钥交换等命令(携带 {@link CommandBody}), + * 二者互斥。 + * + * @param header 报文头 + * @param infoBlocks 实时/补发上报的信息体列表;其他命令为空列表 + * @param commandBody 非实时上报的命令数据单元;实时/补发上报为 null + * @param signature 实时上报尾部的签名原始字节(2025 版新增,2016 版为 null) + */ +public record Gb32960Message( + Gb32960Header header, + List infoBlocks, + CommandBody commandBody, + byte[] signature +) { + + /** 便捷构造:只含信息体(实时/补发上报,2016 版常用)。 */ + public Gb32960Message(Gb32960Header header, List infoBlocks) { + this(header, infoBlocks == null ? Collections.emptyList() : infoBlocks, null, null); + } + + /** 便捷构造:非实时命令。 */ + public Gb32960Message(Gb32960Header header, CommandBody commandBody) { + this(header, Collections.emptyList(), commandBody, null); + } + + public boolean isReport() { + return header.command() == CommandType.REALTIME_REPORT + || header.command() == CommandType.RESEND_REPORT; + } + + @SuppressWarnings("unchecked") + public Optional findBlock(Class type) { + return (Optional) infoBlocks.stream().filter(type::isInstance).findFirst(); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/GeneralAlarmFlagBit.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/GeneralAlarmFlagBit.java new file mode 100644 index 00000000..0843becd --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/GeneralAlarmFlagBit.java @@ -0,0 +1,104 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * 通用报警标志位定义(GB/T 32960.3-2025 表 24)。4 字节 32 位位图中的每个位对应一种报警, + * 2025 版将 2016 版 bit0-15 扩展到 bit0-27,新增了热事件、氢系统、超级电容等位定义。 + * + *

    "1" 表示报警,"0" 表示正常;标志维持到报警条件解除。 + */ +public enum GeneralAlarmFlagBit { + /** bit0:温度差异报警 */ + TEMP_DIFFERENCE(0), + /** bit1:电池高温报警 */ + BATTERY_HIGH_TEMP(1), + /** bit2:车载储能装置类型过压报警 */ + STORAGE_OVER_VOLTAGE(2), + /** bit3:车载储能装置类型欠压报警 */ + STORAGE_UNDER_VOLTAGE(3), + /** bit4:SOC 低报警 */ + SOC_LOW(4), + /** bit5:最小并联单元过压报警 */ + MIN_PARALLEL_UNIT_OVER_VOLTAGE(5), + /** bit6:最小并联单元欠压报警 */ + MIN_PARALLEL_UNIT_UNDER_VOLTAGE(6), + /** bit7:SOC 过高报警 */ + SOC_HIGH(7), + /** bit8:SOC 跳变报警 */ + SOC_JUMP(8), + /** bit9:可充电储能系统不匹配报警 */ + STORAGE_MISMATCH(9), + /** bit10:最小并联单元一致性差报警 */ + MIN_PARALLEL_UNIT_INCONSISTENT(10), + /** bit11:绝缘电阻失效报警 */ + INSULATION_FAILURE(11), + /** bit12:DC-DC 温度报警 */ + DCDC_TEMP(12), + /** bit13:制动系统报警 */ + BRAKE_SYSTEM(13), + /** bit14:DC-DC 状态报警 */ + DCDC_STATUS(14), + /** bit15:驱动电机控制器温度报警 */ + MOTOR_CONTROLLER_TEMP(15), + /** bit16:高压互锁状态报警 */ + HV_INTERLOCK(16), + /** bit17:驱动电机温度报警 */ + MOTOR_TEMP(17), + /** bit18:车载储能装置类型过充报警 */ + STORAGE_OVER_CHARGE(18), + /** bit19:驱动电机超速报警(2025 版新增) */ + MOTOR_OVER_SPEED(19), + /** bit20:驱动电机过流报警(2025 版新增) */ + MOTOR_OVER_CURRENT(20), + /** bit21:超级电容过温报警(2025 版新增) */ + SUPER_CAP_OVER_TEMP(21), + /** bit22:超级电容过压报警(2025 版新增) */ + SUPER_CAP_OVER_VOLTAGE(22), + /** bit23:可充电储能装置热事件报警(2025 版新增,出现即为 4 级故障) */ + STORAGE_THERMAL_EVENT(23), + /** bit24:氢气泄漏异常报警(2025 版新增) */ + HYDROGEN_LEAK(24), + /** bit25:车载氢系统压力异常报警(2025 版新增) */ + HYDROGEN_PRESSURE_ABNORMAL(25), + /** bit26:车载氢系统温度异常报警(2025 版新增) */ + HYDROGEN_TEMP_ABNORMAL(26), + /** bit27:燃料电池电堆超温报警(2025 版新增) */ + FUEL_CELL_STACK_OVER_TEMP(27); + + private final int bitIndex; + + GeneralAlarmFlagBit(int bitIndex) { + this.bitIndex = bitIndex; + } + + public int bitIndex() { + return bitIndex; + } + + /** 当前位是否在 flag 中置位。 */ + public boolean isSet(long flag) { + return (flag & (1L << bitIndex)) != 0; + } + + /** 从 32 位标志解出所有置位的报警。 */ + public static Set parse(long flag) { + EnumSet out = EnumSet.noneOf(GeneralAlarmFlagBit.class); + for (GeneralAlarmFlagBit bit : values()) { + if (bit.isSet(flag)) out.add(bit); + } + return out; + } + + /** 作为可读 fault code 列表(形如 {@code SOC_LOW, MOTOR_TEMP})。 */ + public static List asCodeList(long flag) { + List out = new ArrayList<>(); + for (GeneralAlarmFlagBit bit : values()) { + if (bit.isSet(flag)) out.add(bit.name()); + } + return out; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java new file mode 100644 index 00000000..0660f48b --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java @@ -0,0 +1,417 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +import java.util.List; + +/** + * 已解析的信息体基接口。每种语义对应一种 record 实现。 + * + *

    规范来源:GB/T 32960.3-2016 附录 B 表 B.10-B.17,GB/T 32960.3-2025 §7.2.4 表 10-27。 + * 字段命名使用规范中的中文直译以便对照查阅。 + * + *

    异常值约定: + *

      + *
    • 单字节:{@code 0xFE} 异常、{@code 0xFF} 无效 + *
    • 双字节:{@code 0xFFFE} 异常、{@code 0xFFFF} 无效 + *
    • 四字节:{@code 0xFFFFFFFE} 异常、{@code 0xFFFFFFFF} 无效 + *
    + * 对外 record 使用 {@link Double}/{@link Integer} 等装箱类型,异常或无效时设为 {@code null}。 + */ +public sealed interface InfoBlock + permits InfoBlock.Vehicle, + InfoBlock.DriveMotor, + InfoBlock.FuelCellV2016, + InfoBlock.FuelCellV2025, + InfoBlock.Engine, + InfoBlock.Position, + InfoBlock.Extreme, + InfoBlock.Alarm, + InfoBlock.Voltage, + InfoBlock.Temperature, + InfoBlock.MinParallelVoltage, + InfoBlock.BatteryTemperature, + InfoBlock.FuelCellStack, + InfoBlock.SuperCapacitor, + InfoBlock.SuperCapacitorExtreme, + InfoBlock.Raw { + + InfoBlockType type(); + + // ======================================================================== + // 0x01 整车数据 —— 2016/2025 共享(表 10) + // ======================================================================== + + /** + * 整车数据。固定 21 字节(2025 版)/ 20 字节(2016 版,无 brake)。 + * + * @param vehicleState 车辆状态:0x01 启动 / 0x02 熄火 / 0x03 其他 + * @param chargingState 充电状态:0x01 停车充电 / 0x02 行驶充电 / 0x03 未充电 / 0x04 充电完成 + * @param runningMode 运行模式:0x01 纯电 / 0x02 混动 / 0x03 燃油 + * @param speedKmh 车速 km/h,0~500,最小计量 0.1 km/h + * @param totalMileageKm 累计里程 km,0~999999.9,最小计量 0.1 km + * @param totalVoltageV 总电压 V,0~6000,最小计量 0.1 V + * @param totalCurrentA 总电流 A,-3000~+3000(原始值偏移 3000 A),最小计量 0.1 A + * @param socPercent SOC 百分比,0~100 + * @param dcDcStatus DC-DC 状态:0x01 工作 / 0x02 断开 + * @param gearRaw 挡位原始字节,按附录 A.1 解析 + * @param insulationResistanceKohm 高压对地绝缘电阻 kΩ,0~60000 + * @param acceleratorPedal 加速踏板行程值 0~100(仅 2016 版) + * @param brakePedal 制动踏板状态 0~100(仅 2016 版) + */ + record Vehicle( + Integer vehicleState, + Integer chargingState, + Integer runningMode, + Double speedKmh, + Double totalMileageKm, + Double totalVoltageV, + Double totalCurrentA, + Integer socPercent, + Integer dcDcStatus, + Integer gearRaw, + Integer insulationResistanceKohm, + Integer acceleratorPedal, + Integer brakePedal + ) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.VEHICLE; } + } + + // ======================================================================== + // 0x02 驱动电机数据 —— 表 15/16 + // ======================================================================== + + /** 驱动电机数据。变长:1 字节电机个数 + N × 电机信息。 */ + record DriveMotor(List motors) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.DRIVE_MOTOR; } + + /** + * 单个驱动电机数据。 + * + *

    2016 版字段大小(表 B.16):转速 2 字节偏移 20000,转矩 2 字节偏移 20000 单位 0.1 N·m,共 12 字节。 + *

    2025 版字段大小(表 16):转速 2 字节偏移 32000,转矩 4 字节偏移 20000 N·m 单位 0.1 N·m,共 14 字节。 + * + * @param serialNo 电机顺序号 1~253 + * @param state 电机状态:0x01 耗电 / 0x02 发电 / 0x03 关闭 / 0x04 准备 + * @param controllerTempC 电机控制器温度 ℃,-40~+210 + * @param rpm 电机转速 r/min + * @param torqueNm 电机转矩 N·m,最小计量 0.1 N·m + * @param motorTempC 电机温度 ℃,-40~+210 + * @param controllerInputVoltageV 电机控制器输入电压 V,0~6000,最小计量 0.1 V + * @param controllerDcCurrentA 电机控制器直流母线电流 A,-1000~+5000,最小计量 0.1 A + */ + public record Motor( + Integer serialNo, + Integer state, + Integer controllerTempC, + Integer rpm, + Double torqueNm, + Integer motorTempC, + Double controllerInputVoltageV, + Double controllerDcCurrentA + ) {} + } + + // ======================================================================== + // 0x03 燃料电池数据(2016 版) + // ======================================================================== + + /** + * 燃料电池数据(2016 版)。变长。 + * + *

    2016 版字段顺序:燃料电池电压(2) + 电流(2) + 氢气消耗率(2) + 探针数(2) + * + N×探针温度(1) + 最高温度(2) + 最高温度探针代号(1) + 最高浓度(2) + 最高浓度代号(1) + * + 最高压力(2) + 最高压力代号(1) + DCDC 状态(1)。 + */ + record FuelCellV2016( + Double fcVoltageV, + Double fcCurrentA, + Double hydrogenConsumptionKgPer100km, + List probeTempC, + Double maxHydrogenSystemTempC, + Integer maxHydrogenSystemTempProbeId, + Double maxHydrogenConcentrationPercent, + Integer maxHydrogenConcentrationProbeId, + Double maxHydrogenPressureMpa, + Integer maxHydrogenPressureProbeId, + Integer hvDcDcStatus + ) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.FUEL_CELL_V2016; } + } + + // ======================================================================== + // 0x03 燃料电池发动机及车载氢系统数据(2025 版,表 17) + // ======================================================================== + + /** + * 燃料电池发动机及车载氢系统数据(2025 版)。固定 13 字节。 + * + *

    较 2016 版的差异(见 2025 规范前言 o): + *

      + *
    • 删除:燃料电池电压、燃料电池电流、氢气消耗率、温度探针列表 + *
    • 新增:剩余氢量百分比、高压 DC/DC 控制器温度 + *
    • 温度探针数据迁移至 0x30 燃料电池电堆 + *
    + */ + record FuelCellV2025( + Double maxHydrogenSystemTempC, + Integer maxHydrogenSystemTempProbeId, + Double maxHydrogenConcentrationPercent, + Integer maxHydrogenConcentrationProbeId, + Double maxHydrogenPressureMpa, + Integer maxHydrogenPressureProbeId, + Integer hvDcDcStatus, + Integer remainingHydrogenPercent, + Integer hvDcDcControllerTempC + ) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.FUEL_CELL_V2025; } + } + + // ======================================================================== + // 0x04 发动机数据 —— 表 20 + // ======================================================================== + + /** + * 发动机数据。固定 5 字节。 + * + * @param engineState 发动机状态:0x01 启动 / 0x02 关闭 + * @param crankshaftRpm 曲轴转速 r/min,0~60000 + * @param fuelConsumptionRate 燃料消耗率 L/100km,0~600,最小计量 0.01 + */ + record Engine( + Integer engineState, + Integer crankshaftRpm, + Double fuelConsumptionRate + ) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.ENGINE; } + } + + // ======================================================================== + // 0x05 车辆位置数据 —— 表 21 + // ======================================================================== + + /** + * 车辆位置数据。 + * + *

    2016 版固定 9 字节:{@code 状态位(1) + 经度(4) + 纬度(4)}。 + *

    2025 版固定 10 字节:{@code 状态位(1) + 坐标系(1) + 经度(4) + 纬度(4)}。 + * + * @param statusFlag 状态位:bit0 0=有效/1=无效;bit1 0=北纬/1=南纬;bit2 0=东经/1=西经 + * @param coordSystem 坐标系(2025 新增):0x01 WGS84 / 0x02 GCJ-02 / 0x03 其他;2016 版为 null + * @param longitude 经度(十进制度) + * @param latitude 纬度(十进制度) + */ + record Position( + int statusFlag, + Integer coordSystem, + double longitude, + double latitude + ) implements InfoBlock { + @Override public InfoBlockType type() { + return coordSystem == null ? InfoBlockType.POSITION_V2016 : InfoBlockType.POSITION_V2025; + } + } + + // ======================================================================== + // 0x06 动力蓄电池极值数据 —— 仅 2016 版 + // ======================================================================== + + /** 动力蓄电池极值数据(2016 版)。固定 14 字节。2025 版已删除。 */ + record Extreme( + Integer maxCellVoltageSubSysNo, + Integer maxCellVoltageCellId, + Double maxCellVoltageV, + Integer minCellVoltageSubSysNo, + Integer minCellVoltageCellId, + Double minCellVoltageV, + Integer maxTempSubSysNo, + Integer maxTempProbeId, + Integer maxTempC, + Integer minTempSubSysNo, + Integer minTempProbeId, + Integer minTempC + ) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.EXTREME_V2016; } + } + + // ======================================================================== + // 报警数据 —— 2016 的 0x07 / 2025 的 0x06(表 23) + // ======================================================================== + + /** + * 报警数据。 + * + *

    2016 版 typeCode=0x07,2025 版 typeCode=0x06。前半部分(maxLevel + generalAlarmFlag + * + 4 类故障代码列表)一致,2025 版在尾部额外增加"通用报警故障等级列表"。 + * + * @param protocolVersion 协议版本,决定 typeCode 映射 + * @param maxLevel 最高报警等级 0~4(0 无故障;4 热事件为最高级) + * @param generalAlarmFlag 通用报警标志(表 24 的 28 个位) + * @param batteryFaults 可充电储能装置故障代码列表 + * @param motorFaults 驱动电机故障代码列表 + * @param engineFaults 发动机故障代码列表 + * @param otherFaults 其他故障代码列表 + * @param generalAlarmLevels 2025 新增:{bit, level} 列表;2016 版为 null 或空列表 + */ + record Alarm( + ProtocolVersion protocolVersion, + Integer maxLevel, + long generalAlarmFlag, + List batteryFaults, + List motorFaults, + List engineFaults, + List otherFaults, + List generalAlarmLevels + ) implements InfoBlock { + @Override public InfoBlockType type() { + return protocolVersion == ProtocolVersion.V2025 + ? InfoBlockType.ALARM_V2025 : InfoBlockType.ALARM_V2016; + } + + /** 2025 版通用报警故障等级列表项(2 字节):bit 对应表 24 位序号,level 为故障等级。 */ + public record GeneralAlarmEntry(int bit, int level) {} + } + + // ======================================================================== + // 2016 版 0x08 可充电储能装置电压数据 + // ======================================================================== + + /** 可充电储能装置电压数据(2016 版)。变长。PoC 仅保留汇总。 */ + record Voltage( + int subSystemCount, + double maxCellVoltageV, + double minCellVoltageV, + double totalVoltageV + ) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.VOLTAGE_V2016; } + } + + // ======================================================================== + // 2016 版 0x09 可充电储能装置温度数据 + // ======================================================================== + + /** 可充电储能装置温度数据(2016 版)。变长。 */ + record Temperature( + int subSystemCount, + int maxTempC, + int minTempC + ) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.TEMPERATURE_V2016; } + } + + // ======================================================================== + // 2025 版 0x07 动力蓄电池最小并联单元电压数据(表 11/12) + // ======================================================================== + + /** 动力蓄电池最小并联单元电压数据(2025 版)。变长。 */ + record MinParallelVoltage(int subSystemCount, List packs) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.MIN_PARALLEL_VOLTAGE_V2025; } + + /** + * @param packNo 电池包号 1~50 + * @param packVoltageV 动力蓄电池包电压 V,0~6000,最小 0.1 V + * @param packCurrentA 动力蓄电池包电流 A,-3000~+3000,最小 0.1 A + * @param totalUnits 最小并联单元总数 1~65531 + * @param frameVoltages 本帧最小并联单元电压列表,0~60 V,最小 0.001 V + */ + public record Pack( + int packNo, + Double packVoltageV, + Double packCurrentA, + int totalUnits, + List frameVoltages + ) {} + } + + // ======================================================================== + // 2025 版 0x08 动力蓄电池温度数据(表 13/14) + // ======================================================================== + + /** 动力蓄电池温度数据(2025 版)。变长。 */ + record BatteryTemperature(int subSystemCount, List packs) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.BATTERY_TEMPERATURE_V2025; } + + /** + * @param packNo 电池包号 1~50 + * @param probeCount 温度探针个数 1~65531 + * @param probeTempsC 各探针温度 ℃,-40~+210 + */ + public record Pack(int packNo, int probeCount, List probeTempsC) {} + } + + // ======================================================================== + // 2025 版 0x30 燃料电池电堆数据(表 18/19) + // ======================================================================== + + /** 燃料电池电堆数据(2025 版)。变长:1 字节电堆个数 + N × 电堆信息。 */ + record FuelCellStack(int stackCount, List stacks) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.FUEL_CELL_STACK; } + + /** + * @param stackNo 电堆数据序号 1~253 + * @param voltageV 燃料电池电堆电压 V,0~2000,最小 0.1 V + * @param currentA 燃料电池电堆电流 A,0~2000,最小 0.1 A + * @param hydrogenInletPressureKpa 氢气入口压力 kPa,-100~+400(偏移 100 kPa),最小 0.1 kPa + * @param airInletPressureKpa 空气入口压力 kPa,-100~+400,最小 0.1 kPa + * @param airInletTempC 空气入口温度 ℃,-40~+210 + * @param coolantOutletProbeTempsC 冷却水出水口温度探针温度列表 ℃ + */ + public record Stack( + int stackNo, + Double voltageV, + Double currentA, + Double hydrogenInletPressureKpa, + Double airInletPressureKpa, + Integer airInletTempC, + List coolantOutletProbeTempsC + ) {} + } + + // ======================================================================== + // 2025 版 0x31 超级电容器数据(表 25) + // ======================================================================== + + /** 超级电容器数据(2025 版)。变长。 */ + record SuperCapacitor( + int systemNo, + Double totalVoltageV, + Double totalCurrentA, + List cellVoltages, + List probeTempsC + ) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.SUPER_CAPACITOR; } + } + + // ======================================================================== + // 2025 版 0x32 超级电容器极值数据(表 26) + // ======================================================================== + + /** 超级电容器极值数据(2025 版)。固定 14 字节。 */ + record SuperCapacitorExtreme( + Integer maxVoltageSystemNo, + Integer maxVoltageCellId, + Double maxCellVoltageV, + Integer minVoltageSystemNo, + Integer minVoltageCellId, + Double minCellVoltageV, + Integer maxTempSystemNo, + Integer maxTempProbeId, + Integer maxTempC, + Integer minTempSystemNo, + Integer minTempProbeId, + Integer minTempC + ) implements InfoBlock { + @Override public InfoBlockType type() { return InfoBlockType.SUPER_CAPACITOR_EXTREME; } + } + + // ======================================================================== + // Raw / Unknown + // ======================================================================== + + /** + * 尚未实现 Parser 的原始信息体(含自定义 0x80~0xFE),保留字节透传到冷存。 + * + * @param typeCode 触发兜底时的真实信息体类型字节,便于诊断未知段或确认 parser 漂移落点 + * @param type 枚举形态,目前固定 {@link InfoBlockType#RAW}(保留字段为后续真正分类预留) + * @param bytes 自该未知 type 字节之后到 body 结尾的剩余原始字节 + */ + record Raw(int typeCode, InfoBlockType type, byte[] bytes) implements InfoBlock {} +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java new file mode 100644 index 00000000..d9d1f0b8 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java @@ -0,0 +1,49 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +/** + * 信息体语义类型(逻辑分类)。不与协议 typeCode 一一对应。 + * + *

    因为 2016 和 2025 的 typeCode 0x06/0x07/0x08/0x09 语义不同,本枚举按逻辑含义 + * 分类,而不是按 typeCode。真正的协议 typeCode 路由由 {@link com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser#typeCode()} + * 和 {@link com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry} 负责。 + */ +public enum InfoBlockType { + /** 整车数据(表 10)。 */ + VEHICLE, + /** 驱动电机数据(表 15/16)。 */ + DRIVE_MOTOR, + /** 燃料电池数据(2016 版,带电压/电流/温度探针列表)。 */ + FUEL_CELL_V2016, + /** 燃料电池发动机及车载氢系统数据(2025 版,表 17)。 */ + FUEL_CELL_V2025, + /** 发动机数据(表 20)。 */ + ENGINE, + /** 车辆位置数据(2016 版,表 B.15)。 */ + POSITION_V2016, + /** 车辆位置数据(2025 版,表 21,带坐标系字段)。 */ + POSITION_V2025, + /** 动力蓄电池极值数据(2016 版,仅 2016 存在)。 */ + EXTREME_V2016, + /** 报警数据(2016 版)。 */ + ALARM_V2016, + /** 报警数据(2025 版,表 23,新增通用报警故障等级列表)。 */ + ALARM_V2025, + /** 可充电储能装置电压数据(2016 版)。 */ + VOLTAGE_V2016, + /** 可充电储能装置温度数据(2016 版)。 */ + TEMPERATURE_V2016, + /** 动力蓄电池最小并联单元电压数据(2025 版,表 11/12)。 */ + MIN_PARALLEL_VOLTAGE_V2025, + /** 动力蓄电池温度数据(2025 版,表 13/14)。 */ + BATTERY_TEMPERATURE_V2025, + /** 燃料电池电堆数据(2025 版,表 18/19)。 */ + FUEL_CELL_STACK, + /** 超级电容器数据(2025 版,表 25)。 */ + SUPER_CAPACITOR, + /** 超级电容器极值数据(2025 版,表 26)。 */ + SUPER_CAPACITOR_EXTREME, + /** 自定义数据 0x80~0xFE(表 27)。 */ + USER_DEFINED, + /** 原始/未解析。 */ + RAW +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ProtocolVersion.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ProtocolVersion.java new file mode 100644 index 00000000..12a304b8 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ProtocolVersion.java @@ -0,0 +1,37 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +/** + * GB/T 32960.3 协议版本。 + * + *

    通过数据包起始符区分: + *

      + *
    • {@code ## (0x23 0x23)} → {@link #V2016} 对应 GB/T 32960.3-2016 + *
    • {@code $$ (0x24 0x24)} → {@link #V2025} 对应 GB/T 32960.3-2025 + *
    + * + *

    两版核心差异(GB/T 32960.3-2025 前言): + *

      + *
    • 信息类型 0x06/0x07/0x08 语义重构: + * + * + * + * + * + * + *
      类型20162025
      0x06动力蓄电池极值报警数据
      0x07报警数据动力蓄电池最小并联单元电压
      0x08可充电储能装置电压动力蓄电池温度
      0x09可充电储能装置温度(已删除,合入 0x08)
      + *
    • 新增 0x30 燃料电池电堆 / 0x31 超级电容 / 0x32 超级电容极值 + *
    • 0x05 位置数据新增 1 字节坐标系(表 21) + *
    • 0x03 燃料电池数据字段重构:拆分为"燃料电池发动机及车载氢系统" + * + 独立 0x30"燃料电池电堆"(表 17/18/19) + *
    • 驱动电机 0x02 转速偏移由 {@code 20000} 改为 {@code 32000},转矩由 2 字节扩展为 4 字节 + *
    • 实时上报数据单元尾部增加签名信息(以类型 {@code 0xFF} 标识,表 8) + *
    • 加密方式新增 {@code 0x04 SM2} / {@code 0x05 SM4} + *
    • 命令标识新增 {@code 0x07 心跳 / 0x08 校时 / 0x09 激活 / 0x0A 激活应答 / 0x0B 密钥交换} + *
    + */ +public enum ProtocolVersion { + /** GB/T 32960.3-2016。起始符 {@code 0x23 0x23 (##)}。 */ + V2016, + /** GB/T 32960.3-2025。起始符 {@code 0x24 0x24 ($$)}。 */ + V2025 +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ResponseFlag.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ResponseFlag.java new file mode 100644 index 00000000..e58845a1 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ResponseFlag.java @@ -0,0 +1,46 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +/** + * 应答标志。GB/T 32960.3-2025 表 4 "应答标志定义"。 + * + *

    命令的主动发起方应答标志为 {@code 0xFE}({@link #COMMAND}),表示此包为命令包; + * 当应答标志不是 {@code 0xFE} 时,此包表示为应答包。服务端发送应答时,应变更应答标志, + * 保留报文时间,删除其余报文内容,并重新计算校验位。 + */ +public enum ResponseFlag { + /** 0x01:成功 —— 接收到的信息正确。 */ + SUCCESS(0x01), + /** 0x02:其他错误 —— 其他收到的信息存在格式及内容错误。 */ + OTHER_ERROR(0x02), + /** 0x03:VIN 重复。 */ + VIN_DUPLICATED(0x03), + /** 0x04:VIN 不存在(2025 版新增)。 */ + VIN_NOT_EXIST(0x04), + /** 0x05:验签错误(2025 版新增)。 */ + SIGNATURE_ERROR(0x05), + /** 0x06:数据结构错误(2025 版新增)。 */ + STRUCTURE_ERROR(0x06), + /** 0x07:解密错误(2025 版新增)。 */ + DECRYPT_ERROR(0x07), + /** 0xFE:命令 —— 表示数据包为命令包,而非应答包。 */ + COMMAND(0xFE); + + private final int code; + + ResponseFlag(int code) { + this.code = code; + } + + public int code() { + return code; + } + + public boolean isCommand() { + return this == COMMAND; + } + + public static ResponseFlag of(int code) { + for (ResponseFlag r : values()) if (r.code == code) return r; + return OTHER_ERROR; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/SignatureInfo.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/SignatureInfo.java new file mode 100644 index 00000000..2b961ed0 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/SignatureInfo.java @@ -0,0 +1,66 @@ +package com.lingniu.ingest.protocol.gb32960.model; + +import java.nio.ByteBuffer; + +/** + * 实时信息的车端数字签名(GB/T 32960.3-2025 表 8)。 + * + *

    签名信息出现在实时上报数据单元的末尾,紧跟在信息体循环后,由 {@code 0xFF} + * 作为"签名数据开始标识"引出(见表 9)。签名范围:从数据采集时间的第一字节到签名 + * 信息前一个字节的数字签名。 + * + *

    表 8 字段: + *

    + *   signType(1)    签名类型:0x01 SM2 / 0x02 RSA / 0x03 ECC,其他预留
    + *   rLen(2 WORD)   签名 R 值长度
    + *   r(rLen)        签名 R 值
    + *   sLen(2 WORD)   签名 S 值长度
    + *   s(sLen)        签名 S 值
    + * 
    + */ +public record SignatureInfo(SignType signType, byte[] r, byte[] s) { + + public enum SignType { + /** 0x01:SM2 国密签名。 */ + SM2(0x01), + /** 0x02:RSA 签名。 */ + RSA(0x02), + /** 0x03:ECC 签名。 */ + ECC(0x03), + /** 其他预留。 */ + UNKNOWN(0xFF); + + private final int code; + SignType(int code) { this.code = code; } + public int code() { return code; } + + public static SignType of(int code) { + for (SignType t : values()) if (t.code == code) return t; + return UNKNOWN; + } + } + + /** + * 尝试解析签名字节。非法数据时返回 null 并保留原始字节由冷存分析。 + * + * @param bytes 签名原始字节(不含前缀的 0xFF 签名开始标识) + */ + public static SignatureInfo tryParse(byte[] bytes) { + if (bytes == null || bytes.length < 5) return null; + ByteBuffer buf = ByteBuffer.wrap(bytes); + try { + SignType type = SignType.of(buf.get() & 0xFF); + int rLen = buf.getShort() & 0xFFFF; + if (rLen > buf.remaining() - 2) return null; + byte[] r = new byte[rLen]; + buf.get(r); + int sLen = buf.getShort() & 0xFFFF; + if (sLen > buf.remaining()) return null; + byte[] s = new byte[sLen]; + buf.get(s); + return new SignatureInfo(type, r, s); + } catch (Exception e) { + return null; + } + } +} diff --git a/protocol-gb32960/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/protocol-gb32960/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..2a10e523 --- /dev/null +++ b/protocol-gb32960/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.protocol.gb32960.config.Gb32960AutoConfiguration diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderGoldenTest.java b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderGoldenTest.java new file mode 100644 index 00000000..fcbe1a52 --- /dev/null +++ b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderGoldenTest.java @@ -0,0 +1,106 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.EngineV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.ExtremeValueV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.FuelCellV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 黄金样本集回放测试:遍历 {@code src/test/resources/samples/gb32960/*.hex}, + * 逐帧解码并断言命令类型来自 {@link com.lingniu.ingest.protocol.gb32960.model.CommandType} 有效值。 + * + *

    样本文件目前为空,该测试会优雅地跳过。加入样本后会自动变为 N 条动态用例。 + */ +class Gb32960DecoderGoldenTest { + + private final Gb32960MessageDecoder decoder = new Gb32960MessageDecoder( + new Gb32960BodyParser(new InfoBlockParserRegistry(List.of( + new VehicleV2016BlockParser(), + new PositionV2016BlockParser(), + new DriveMotorV2016BlockParser(), + new FuelCellV2016BlockParser(), + new EngineV2016BlockParser(), + new ExtremeValueV2016BlockParser(), + new AlarmV2016BlockParser(), + new VoltageV2016BlockParser(), + new TemperatureV2016BlockParser())))); + + @TestFactory + Collection replaySamples() throws URISyntaxException, IOException { + var url = getClass().getClassLoader().getResource("samples/gb32960"); + if (url == null) return List.of(); + Path dir = Paths.get(url.toURI()); + try (Stream s = Files.list(dir)) { + return s.filter(p -> p.toString().endsWith(".hex")) + .sorted() + .map(this::toTest) + .collect(Collectors.toList()); + } + } + + private DynamicTest toTest(Path sample) { + return DynamicTest.dynamicTest(sample.getFileName().toString(), () -> { + byte[] frame = readHex(sample); + Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame)); + assertThat(msg.header().vin()).hasSize(17); + assertThat(msg.header().command()).isNotNull(); + // 749 字节真实生产帧(realtime_002/003/010/200):peer 在 V2016 帧里下发了 + // 0x30/0x31/0x32 等 typeCode,但这些在 GB/T 32960.3-2016 附录 B 表 B.3 是"预留"区, + // 没有任何字段定义。因此**期望产生 Raw 兜底块**——这是符合规范的正确行为。 + // 若未来对端切换到 V2025 (2424 起始) 或者迁移到 0x80~0xFE 用户自定义区,可再调整断言。 + String name = sample.getFileName().toString(); + boolean isRealProductionFrame = frame.length == 774; + if (isRealProductionFrame) { + assertThat(msg.findBlock(InfoBlock.Raw.class)) + .as("[%s] 749字节真实生产帧应有 Raw 兜底块(peer 越界使用 0x30+ 预留 typeCode)", name) + .isPresent(); + } + // 总电流必须落在协议规定的 -1000~+1000 A 区间,防回归到 -3000 偏移 + msg.findBlock(InfoBlock.Vehicle.class).ifPresent(v -> { + if (v.totalCurrentA() != null) { + assertThat(v.totalCurrentA()) + .as("[%s] vehicle.totalCurrentA 越界,疑似偏移常量回归", name) + .isBetween(-1000.0, 1000.0); + } + }); + }); + } + + private static byte[] readHex(Path path) throws IOException { + StringBuilder sb = new StringBuilder(); + for (String line : Files.readAllLines(path)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; + sb.append(trimmed); + } + String hex = sb.toString(); + int len = hex.length() / 2; + byte[] out = new byte[len]; + for (int i = 0; i < len; i++) { + out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return out; + } +} diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java new file mode 100644 index 00000000..ca027018 --- /dev/null +++ b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java @@ -0,0 +1,110 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.model.CommandType; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 构造一条合成 32960 实时上报帧,跑通 Frame 解码 → Body 解析 → InfoBlock 的全链路。 + * + *

    样本无需外部文件:本测试既验证解码正确性,也作为 {@code samples/} 黄金样本的期望值参考实现。 + */ +public class Gb32960DecoderTest { + + @Test + void decodesSyntheticRealtimeReport() { + byte[] frame = buildRealtimeFrame("LTEST000000000001"); + + InfoBlockParserRegistry registry = new InfoBlockParserRegistry( + List.of(new VehicleV2016BlockParser(), new PositionV2016BlockParser())); + Gb32960BodyParser bodyParser = new Gb32960BodyParser(registry); + Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(bodyParser); + + Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame)); + + assertThat(msg.header().command()).isEqualTo(CommandType.REALTIME_REPORT); + assertThat(msg.header().vin()).isEqualTo("LTEST000000000001"); + assertThat(msg.header().eventTime()).isNotNull(); + + InfoBlock.Vehicle v = msg.findBlock(InfoBlock.Vehicle.class).orElseThrow(); + assertThat(v.socPercent()).isEqualTo(70); + assertThat(v.speedKmh()).isEqualTo(52.3, org.assertj.core.data.Offset.offset(0.01)); + assertThat(v.gearRaw()).isEqualTo(0x0F); + + InfoBlock.Position p = msg.findBlock(InfoBlock.Position.class).orElseThrow(); + assertThat(p.longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); + assertThat(p.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001)); + } + + /** + * 构造一条合法的 32960 实时上报帧: + * header + 6B 时间戳(2024-01-02 03:04:05) + 0x01 整车 + 0x05 位置 + BCC + */ + public static byte[] buildRealtimeFrame(String vin) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + // 时间戳 + body.write(24); body.write(1); body.write(2); body.write(3); body.write(4); body.write(5); + + // 0x01 整车 20 字节 + body.write(0x01); + body.write(0x01); // vehicle state = 启动 + body.write(0x03); // charging = 未充电 + body.write(0x01); // 纯电 + writeU16(body, 523); // 车速 52.3 km/h + writeU32(body, 1234567); // 里程 123456.7 km + writeU16(body, 6000); // 600.0 V + writeU16(body, 1100); // 110.0 A (110 - (1000-1000) = 10) 实际计算:1100*0.1 - 1000 = -890;此处只为结构测试 + body.write(70); // SOC + body.write(0x01); + body.write(0x0F); + writeU16(body, 500); + body.write(30); + body.write(0); + + // 0x05 位置 9 字节 + body.write(0x05); + body.write(0x00); // 有效 + 北纬 + 东经 + writeU32(body, 116_397_128L); + writeU32(body, 39_916_527L); + + byte[] bodyBytes = body.toByteArray(); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x23); out.write(0x23); + out.write(0x02); // 实时上报 + out.write(0xFE); // 应答 / 命令 + byte[] vinBytes = vin.getBytes(StandardCharsets.US_ASCII); + out.write(vinBytes, 0, 17); + out.write(0x01); // 不加密 + writeU16(out, bodyBytes.length); + out.write(bodyBytes, 0, bodyBytes.length); + + byte[] almost = out.toByteArray(); + byte bcc = BccChecksum.compute(almost, 2, almost.length - 2); + out.write(bcc & 0xFF); + return out.toByteArray(); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(ByteArrayOutputStream os, long v) { + os.write((int) ((v >> 24) & 0xFF)); + os.write((int) ((v >> 16) & 0xFF)); + os.write((int) ((v >> 8) & 0xFF)); + os.write((int) (v & 0xFF)); + } +} diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FullBlocksTest.java b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FullBlocksTest.java new file mode 100644 index 00000000..03e40c7c --- /dev/null +++ b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FullBlocksTest.java @@ -0,0 +1,152 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 验证新增的变长信息体 Parser(0x02 驱动电机 / 0x07 报警 / 0x08 电压 / 0x09 温度) + * 与主解码器协同工作。 + */ +class Gb32960FullBlocksTest { + + private final Gb32960MessageDecoder decoder = new Gb32960MessageDecoder( + new Gb32960BodyParser(new InfoBlockParserRegistry(List.of( + new VehicleV2016BlockParser(), + new PositionV2016BlockParser(), + new DriveMotorV2016BlockParser(), + new AlarmV2016BlockParser(), + new VoltageV2016BlockParser(), + new TemperatureV2016BlockParser())))); + + @Test + void parsesDriveMotorBlock() { + byte[] frame = buildFrame(os -> { + os.write(0x02); // drive motor + os.write(1); // 1 motor + os.write(1); // serial + os.write(0x01); // state + os.write(100); // controllerTemp = 60 + writeU16(os, 23_000); // rpm = 3000 + writeU16(os, 21_000); // torque raw, actual = 100 Nm + os.write(90); // motorTemp = 50 + writeU16(os, 5400); // voltage 540.0V + writeU16(os, 11000); // current raw → 100 A + }); + Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame)); + InfoBlock.DriveMotor dm = msg.findBlock(InfoBlock.DriveMotor.class).orElseThrow(); + assertThat(dm.motors()).hasSize(1); + var m = dm.motors().get(0); + assertThat(m.rpm()).isEqualTo(3000); + assertThat(m.torqueNm()).isEqualTo(100.0); + assertThat(m.controllerInputVoltageV()).isEqualTo(540.0); + } + + @Test + void parsesAlarmBlock() { + byte[] frame = buildFrame(os -> { + os.write(0x07); // alarm + os.write(2); // max level + writeU32(os, 0x0000_0003L);// general flag + os.write(1); writeU32(os, 0xDEAD_BEEFL); // battery faults 1 + os.write(0); // motor faults 0 + os.write(0); // engine faults 0 + os.write(1); writeU32(os, 0xCAFE_BABEL); // other faults 1 + }); + Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame)); + InfoBlock.Alarm a = msg.findBlock(InfoBlock.Alarm.class).orElseThrow(); + assertThat(a.maxLevel()).isEqualTo(2); + assertThat(a.batteryFaults()).hasSize(1); + assertThat(a.otherFaults()).hasSize(1); + } + + @Test + void parsesVoltageBlock() { + byte[] frame = buildFrame(os -> { + os.write(0x08); // voltage + os.write(1); // 1 subsystem + os.write(1); // battery index + writeU16(os, 3800); // 380.0 V + writeU16(os, 11_000); // current raw + writeU16(os, 96); // total cells + writeU16(os, 1); // start + os.write(3); // frame cells + writeU16(os, 3500); // 3.5V + writeU16(os, 3600); // 3.6V + writeU16(os, 3300); // 3.3V + }); + Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame)); + InfoBlock.Voltage v = msg.findBlock(InfoBlock.Voltage.class).orElseThrow(); + assertThat(v.subSystemCount()).isEqualTo(1); + assertThat(v.maxCellVoltageV()).isEqualTo(3.6, org.assertj.core.data.Offset.offset(0.001)); + assertThat(v.minCellVoltageV()).isEqualTo(3.3, org.assertj.core.data.Offset.offset(0.001)); + } + + @Test + void parsesTemperatureBlock() { + byte[] frame = buildFrame(os -> { + os.write(0x09); // temperature + os.write(1); // subsystems + os.write(1); // battery index + writeU16(os, 4); // probes + os.write(60); // actual 20 + os.write(65); // actual 25 + os.write(70); // actual 30 + os.write(55); // actual 15 + }); + Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame)); + InfoBlock.Temperature t = msg.findBlock(InfoBlock.Temperature.class).orElseThrow(); + assertThat(t.maxTempC()).isEqualTo(30); + assertThat(t.minTempC()).isEqualTo(15); + } + + // ===== frame builder helpers ===== + + private static byte[] buildFrame(java.util.function.Consumer bodyWriter) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + // 时间戳 + body.write(24); body.write(1); body.write(2); body.write(3); body.write(4); body.write(5); + bodyWriter.accept(body); + + byte[] bodyBytes = body.toByteArray(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x23); out.write(0x23); + out.write(0x02); // cmd: realtime + out.write(0xFE); // response flag + byte[] vin = "LTEST000000000010".getBytes(StandardCharsets.US_ASCII); + out.write(vin, 0, 17); + out.write(0x01); // not encrypted + writeU16(out, bodyBytes.length); + out.write(bodyBytes, 0, bodyBytes.length); + byte[] almost = out.toByteArray(); + byte bcc = BccChecksum.compute(almost, 2, almost.length - 2); + out.write(bcc & 0xFF); + return out.toByteArray(); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(ByteArrayOutputStream os, long v) { + os.write((int) ((v >> 24) & 0xFF)); + os.write((int) ((v >> 16) & 0xFF)); + os.write((int) ((v >> 8) & 0xFF)); + os.write((int) (v & 0xFF)); + } +} diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java new file mode 100644 index 00000000..8fe4a847 --- /dev/null +++ b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java @@ -0,0 +1,37 @@ +package com.lingniu.ingest.protocol.gb32960.mapper; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960DecoderTest; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Gb32960EventMapperTest { + + @Test + void realtimeReportProducesRealtimeAndLocationEvents() { + byte[] frame = Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000002"); + + Gb32960BodyParser body = new Gb32960BodyParser(new InfoBlockParserRegistry( + List.of(new VehicleV2016BlockParser(), new PositionV2016BlockParser()))); + Gb32960Message msg = new Gb32960MessageDecoder(body).decode(ByteBuffer.wrap(frame)); + + List events = new Gb32960EventMapper().toEvents(msg); + + assertThat(events).hasSize(2); + assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Realtime); + assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Location); + assertThat(events).allMatch(e -> e.source() == ProtocolId.GB32960); + assertThat(events).allMatch(e -> e.vin().equals("LTEST000000000002")); + } +} diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/README.md b/protocol-gb32960/src/test/resources/samples/gb32960/README.md new file mode 100644 index 00000000..1b3628c4 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/README.md @@ -0,0 +1,41 @@ +# GB/T 32960 黄金样本集 + +> 本目录用于回放测试与双跑对账。所有样本从旧服务 `lingniu-vehicle-data-reception` 线上抓包后脱敏(VIN 替换为 `LTEST<序号>XXXXXXXX`)。 + +## 文件命名 + +``` +<命令类型>_<场景>_<序号>.hex +``` + +命令类型取自 `CommandType` 枚举: +- `vehicle_login` (0x01) +- `realtime_report` (0x02) +- `resend_report` (0x03) +- `vehicle_logout` (0x04) +- `heartbeat` (0x07) + +## 文件格式 + +每个 `.hex` 文件是一行紧凑十六进制,字节之间**无分隔符**。 +行首允许以 `#` 开头写注释,测试代码忽略注释行与空行。 + +示例 `realtime_report_basic_001.hex`: +``` +# 2017-05-21 10:00:00 宇通 YT01,SOC 70%,车速 52.3 km/h +2323020000010203040506070809101112131415160100352016051510000001010012350000037102710000A064010505050A0005010A00102003E8... +``` + +## 新增样本步骤 + +1. 从生产抓包工具(tcpdump / rg-samples)导出一帧完整字节(含 `0x23 0x23` 起始和 BCC 尾) +2. VIN 替换为测试段:`LTEST0000<9位序号>` +3. 放入本目录,文件名按规范 +4. 在 `Gb32960DecoderGoldenTest` 中添加断言(期望 VIN / 命令 / 关键字段) +5. 运行 `mvn -pl protocol-gb32960 test` + +## 验证目标 + +- 100% 解析一致性(新服务解析结果 === 旧服务解析结果) +- BCC 校验通过 +- 事件映射字段单位一致(车速 km/h、里程 km、经纬度十进制度) diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_001.hex b/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_001.hex new file mode 100644 index 00000000..f827bd45 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_001.hex @@ -0,0 +1 @@ +232307fe4c54455354303030303030303030303131010000a2 diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_002.hex b/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_002.hex new file mode 100644 index 00000000..d9711570 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_002.hex @@ -0,0 +1 @@ +232307fe4c54455354303030303030303030303231010000a1 diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex new file mode 100644 index 00000000..5d175a1f --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex @@ -0,0 +1 @@ +232302fe4c544553543030303030303030303030310102101a040d0c213901010302037a000531c2157825fb44012e27103400020101025575304eca6b157a288b030c82050a00aa0002686c02940200000100630101050006c4f414015eedea06018c0f0701310ebf01014a01054907000000000000000000080101157825fb00900001900eea0eea0eea0ee60ee60ee60ef00eef0ef10eeb0eec0eeb0eed0eee0eee0eeb0eed0eea0ed30ed20ed20ed30ed50ed40ecc0ecd0ecc0ece0ece0ece0ed40ed40ed40ed30ed10ed00ed20ed10ed00ece0ece0ecf0ed40ed40ed40eda0eda0edb0ebf0ed40ed30ed60ed60ed50ed40ed50ede0edf0ede0ede0eee0eed0eec0ee80ee80ee50ef00eef0eef0ee80ee90eea0ee40ee70ee80eeb0eeb0eea0eee0eef0eef0ef60ef60ef50ef80ef70ef50ef60ef50ef60eef0ef00ef10ef60ef80ef90efa0ef70ef90efa0ef80ef80efc0efc0efc0ef50ef50ef50ef20ef30ef40ef50ef50ef40ef50ef50ef70ef40ef40ef50ef80ef80ef70ef70ef50ef40ef70ef70ef70efa0efd0efc0ef70ef80ef70ef80ef60ef50f050f070f070ef00eed0ef009010100084a4a4a4a494a4a493001026c08fcffffff0003003a02ee02e402e5000000000031010c800006ffffffff0c80ffffffff0088320c8004c2159002c35333ffffffffff34ffffffffff001c8000096c0c802742ffffffff8300250019000e0b002300020000000000014f00ffffffffffff00000282ffffffff1ffe1fedff1fa0 diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_002.hex b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_002.hex new file mode 100644 index 00000000..8eb90545 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_002.hex @@ -0,0 +1 @@ +232302fe4c544553543030303030303030303030320102ed1a040d0c213a01010302013600036758162826284c012e2710010002010101485bf84de44b165526ec030da9017400d200026a6b023a0200000100b101010402ffff0015050107373d6c01d2e6880601020f8701520efe010141010540070000000000000000000801011628262800900001900f860f870f860f6d0f6c0f6c0f870f860f870f6d0f700f6e0f7e0f7d0f710f640f630f650f6e0f800f7e0f260f250f250f810f810f820f620f660f670f810f820f830f670f680f670f780f770f770f670f660f670f640f630f620f1a0f1c0f1c0f500f500f4d0f690f6b0f690f540f530f5a0f420f440f440f6e0f6e0f6d0f460f450f450f720f6c0f6b0f650f640f670f500f4f0f4c0f150f140f150f780f760f780efe0eff0eff0f7b0f7c0f7d0f640f640f640f6e0f6d0f6e0f360f390f390f5c0f5e0f5c0f650f660f660f7e0f800f800f640f620f640f810f800f800f300f330f2f0f800f820f830f670f660f680f820f840f820f650f670f660f820f840f830f680f660f660f820f810f810f680f660f660f720f720f710f680f670f67090101000841414141404040403001026b092eff00ff000100730ca80c800ca4006c00016c0ca40ca80ca30ca30ca30ca30ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca431010dc00005ffffffff0dc0ffffffff008a320dc000ff15e7009e4833ffffffffff34ffffffffff002f8000096b0dc02715000d000e83002402040100002700080000000000c900000000ffffffffffffffffffffffff1ffe15c8631f18 diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_003.hex b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_003.hex new file mode 100644 index 00000000..b038497c --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_003.hex @@ -0,0 +1 @@ +232302fe4c544553543030303030303030303030330102ed1a040d0c2200010203010000000418d914ed27103a020007d0000002010104454e204e204814be2710030006000000b400024a4902bc02000001006701000400ffff0012050006be89500161217106013d0e91012c0e860101470105460700000000000000000008010114ed271000900001900e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e8a0e870e870e880e880e880e880e880e880e880e880e880e870e860e860e880e880e8a0e880e880e880e880e880e880e880e880e880e880e880e880e910e900e900e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e88090101000847474747464746463001004907c6ff00ff00000039000000000004006c00016c00000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040005000500050005000500050005000531010eb00005ffffffff0eb0ffffffff0081320eb01b3b14e613234733ffffffffff34ffffffffff001d800009490eb02710000d000e83002402040100002700080000000000d500000000ffffffffffffffffffffffff1ffe167c021f7b diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_010.hex b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_010.hex new file mode 100644 index 00000000..43eab7b3 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_010.hex @@ -0,0 +1 @@ +232302fe4c544553543030303030303030303031300102ed1a040d0c220501020301006400049992163027df4f012e27101e00020101015952da51365215f4279f030005000000000002625f02a80200000100a801000400ffff0000050106c3d5cf016240a406010b0f90015b0f4001014a01054907000000000000000000080101163027df00900001900f8c0f8d0f8d0f8d0f8e0f8e0f8f0f8f0f8e0f8f0f900f900f8f0f900f7a0f7c0f800f800f7f0f7c0f7c0f7c0f7e0f7e0f810f7f0f7f0f7e0f820f830f500f500f500f840f850f840f840f820f850f850f820f820f730f750f730f690f6a0f680f620f5f0f600f500f500f500f630f5a0f6d0f6c0f6c0f6d0f500f500f500f6b0f6b0f6c0f6a0f5d0f5f0f5c0f5f0f610f5c0f5e0f5a0f5c0f5c0f610f630f630f640f510f520f540f500f500f500f610f550f500f400f430f410f500f510f500f4e0f4f0f530f500f500f4f0f550f550f560f550f570f560f540f570f560f510f540f530f540f560f5a0f5a0f590f570f7e0f7e0f810f580f4e0f530f570f550f510f500f480f4d0f870f7e0f7e0f7c0f7e0f7f0f7c0f7b0f810f7d0f7e0f7f09010100084a4a4a4a494949493001005f07daff00ff00000038000000000004006c00016c0000000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000500050005000500050005000500053101003c0005ffffffff003cffffffff008a32003c00000000ffff4f33ffffffffff34ffffffffff002c8000095f003c2710000d000e830024020401000027000800000000000000000000ffffffffffffffffffffffff1ffe17ae631ff9 diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_020.hex b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_020.hex new file mode 100644 index 00000000..d07198d2 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_020.hex @@ -0,0 +1 @@ +232302fe4c544553543030303030303030303032330101d61a040d0c22070101030203010004c1ee17fc261337012e271032000201010157705a4fe26e17f2260c0309d50bba021a00027f6e022b030000010106010105000733a7d401d832920601840eda011c0ebf01044b0101480700000000000000000008010117fc261300a20001a20ecc0eca0ec30ecb0ec90ec70eca0ec90ec20ec20ec40ec20ec70eca0eca0ec90ece0ecd0ec80ecd0eca0ecc0ec90ec80ece0ec70ec50ebf0ed40ed20ed40ed30ed40ed30ed50ed40ed30ed50ed20ed50ed50ed30ecc0ecb0ec80ecf0ec90ed00ec70ecd0ec70ec50ec70ece0eca0eca0ec90eca0ed10ec90ed00ecf0eca0ed10ed30ed10ecf0ed20ed00ed20ed40ed50ed20ed20ed60ed20ed30ed20ed50ed50ed70ed40ed10ed40ed30ed20ed30ed20ecc0ed00ed10ecc0ed30ed10ed00ecb0ec90ecd0ec80ed10ec50ec80ed20ec10ec90ec50ec90ec30ec50ed40ed40ed40ed40ed40ed40ed60ed50ed30ed50ed50ed50ed50ed40ed50ed40ed30ed20ed20ed30ed60ed50eda0ed00ed70ed80ed60ed70ed50ed60ece0ed50ed40ed60ed40ed10ed30ed20ed40ed40ed40ecf0ed80ed60ed50ed60ed70ed60ed60ed40ed50ed70eda0901010020484a4a4b4b4b4b4a484b4b4b4b4b4b4a484a4b4b4b4b4b4a484a4b4b4b4b4b4acc diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_100.hex b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_100.hex new file mode 100644 index 00000000..25de83e9 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_100.hex @@ -0,0 +1 @@ +232302fe4c544553543030303030303030303130370101ae1a040d0c39263001016908fc09c4410003007403200320032001b000c9c803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e8cb diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_200.hex b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_200.hex new file mode 100644 index 00000000..0069f5b0 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/realtime_200.hex @@ -0,0 +1 @@ +232302fe4c544553543030303030303030303231310102ed1a040d0c3a120101030200000002533c150727203e01002710000002010104464e204e204814fc2710030004000200be00025353023a01000001010201010402ffff00130500072eb49401de012e06013f0ea7012a0e9301014001033f070000000000000000000801011507272000900001900ea60ea60ea60ea60ea60ea60ea50ea60ea60ea60ea60ea60ea60ea50e960e990e960e980e980e990e970e990e970e960e980e980e990e980e950e970e950e940e950e960e970e940e990e960e960e950e950e930e960e9b0e9c0e9c0e990e9b0e940e950e930e980e9a0e9a0e940e950ea50ea50ea50ea50ea50ea60ea70ea60ea50ea50ea50e9d0e9e0e9e0e9e0e990e970e970e990e980e9a0e9d0e9a0e9c0e9a0e990e9a0e9a0e9b0e9c0e9c0e980e950e980e990e990e990e980e970e970e980e970e970e990e990e9a0e990e990e990e9a0e980e960e960e990e970e970e970e970e980e950e930e9b0e980e9a0e990e9a0e990e940e940e950e940e950e950e990e990e9b0e9a0e9b0e9b0e960e950e960e950e950e950e950e940e99090101000840403f403f3f3f3f300102530708ff00ff00000037000000000000006c00016c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000031010e880005ffffffff0e88ffffffff008a320e880002150e00014733ffffffffff34ffffffffff0044800009530e882710000d000e830024020401000027000800000000005401000000ffffffffffffffffffffffff1ffe1d7b011fdd diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/resend_001.hex b/protocol-gb32960/src/test/resources/samples/gb32960/resend_001.hex new file mode 100644 index 00000000..bf30e283 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/resend_001.hex @@ -0,0 +1 @@ +232303fe4c544553543030303030303030303032320102101a040d0d0b0a010203010000000532ad156d271c45011007d0006502010103514e204e205c003f27100300070000000000025e5c03b601000001014d0100050106c3aa530160540f0601010ee301120ede01014801054707000000000000000000080101156d271c00900001900ee30ee30ee30ee00ee20ee20ee20ee10ee20ee30ee20ee20ee20ee10ee30ee10ee10ede0ee10ee10ee10ee00ee10ee10ee10ee00ee00ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee1090101000848484848474848473001005c0816ffffff0000003a000a0000000100000000003101003c0005ffffffff003cffffffff008b32003c00000000ffff5233ffffffffff34ffffffffff004c8000095c003c2710ffffffff8300250019000e0b002300020000000000000000ffffffffffff0000000affffffff1ffe1fedff0089 diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/resend_005.hex b/protocol-gb32960/src/test/resources/samples/gb32960/resend_005.hex new file mode 100644 index 00000000..55197f67 --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/resend_005.hex @@ -0,0 +1 @@ +232303fe4c544553543030303030303030303230390101ae1a040d0c391b3001005307ee09c4410037000100000000000001b000c9c803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e8be diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/vehicle_login_001.hex b/protocol-gb32960/src/test/resources/samples/gb32960/vehicle_login_001.hex new file mode 100644 index 00000000..0583697c --- /dev/null +++ b/protocol-gb32960/src/test/resources/samples/gb32960/vehicle_login_001.hex @@ -0,0 +1 @@ +232301fe4c5445535430303030303030303030313901001e1a040d0d0b1a000c38393836303332313435323039303735363530390100bd diff --git a/protocol-jsatl12/pom.xml b/protocol-jsatl12/pom.xml new file mode 100644 index 00000000..5e6a7baa --- /dev/null +++ b/protocol-jsatl12/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + protocol-jsatl12 + protocol-jsatl12 + + 苏标(JSATL12)主动安全报警附件上传协议。独立 TCP 端口(默认 7612), + 与 JT808 使用相同的转义规则,但数据帧带 01cd 魔数前缀,用于大文件分块上传。 + + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + ingest-core + + + com.lingniu.ingest + protocol-jt808 + + + com.lingniu.ingest + sink-archive + + + org.springframework.boot + spring-boot-starter + + + io.netty + netty-all + + + com.github.ben-manes.caffeine + caffeine + + + diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java new file mode 100644 index 00000000..8afc2820 --- /dev/null +++ b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java @@ -0,0 +1,80 @@ +package com.lingniu.ingest.protocol.jsatl12.codec; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; + +import java.util.List; + +/** + * 苏标报警附件协议分帧。 + * + *

    两种帧并存: + *

      + *
    1. 标准 JT/T 808 帧:以 {@code 0x7e} 为分界,承载 0x1210 / 0x1211 / 0x9212 等信令 + *
    2. 数据帧:以 {@code 0x30 0x31 0x63 0x64}(即 ASCII "01cd")为魔数,后续 4 字节大端长度 + 数据 + *
    + * + *

    本 decoder 合并处理:根据第一个字节决定分帧策略,输出带类型标记的 {@link Jsatl12RawFrame}。 + */ +public class Jsatl12FrameDecoder extends ByteToMessageDecoder { + + private static final byte[] DATA_MAGIC = {0x30, 0x31, 0x63, 0x64}; + private static final int MAX_FRAME = 1024 * 65; + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + while (in.readableBytes() >= 1) { + byte first = in.getByte(in.readerIndex()); + if (first == 0x7e) { + if (!decodeJtFrame(in, out)) return; + } else if (first == DATA_MAGIC[0]) { + if (!decodeDataFrame(in, out)) return; + } else { + in.skipBytes(1); + } + } + } + + private boolean decodeJtFrame(ByteBuf in, List out) { + if (in.readableBytes() < 2) return false; + int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e); + if (endIdx < 0) { + if (in.readableBytes() > MAX_FRAME) in.skipBytes(in.readableBytes() - 1); + return false; + } + int len = endIdx - (in.readerIndex() + 1); + if (len <= 0) { + in.skipBytes(1); + return true; + } + byte[] raw = new byte[len]; + in.skipBytes(1); + in.readBytes(raw); + in.skipBytes(1); + out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.JT_MESSAGE, raw)); + return true; + } + + private boolean decodeDataFrame(ByteBuf in, List out) { + if (in.readableBytes() < DATA_MAGIC.length + 4) return false; + for (int i = 0; i < DATA_MAGIC.length; i++) { + if (in.getByte(in.readerIndex() + i) != DATA_MAGIC[i]) { + in.skipBytes(1); + return true; + } + } + // 4B length (big-endian) 之后是负载 + long dataLen = in.getUnsignedInt(in.readerIndex() + DATA_MAGIC.length); + if (dataLen > MAX_FRAME) { + in.skipBytes(1); + return true; + } + int totalFrameLen = DATA_MAGIC.length + 4 + (int) dataLen; + if (in.readableBytes() < totalFrameLen) return false; + byte[] frame = new byte[totalFrameLen]; + in.readBytes(frame); + out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.DATA_CHUNK, frame)); + return true; + } +} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java new file mode 100644 index 00000000..7960b249 --- /dev/null +++ b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java @@ -0,0 +1,8 @@ +package com.lingniu.ingest.protocol.jsatl12.codec; + +/** + * JSATL12 原始帧:区分信令帧与数据帧。 + */ +public record Jsatl12RawFrame(Type type, byte[] bytes) { + public enum Type { JT_MESSAGE, DATA_CHUNK } +} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java new file mode 100644 index 00000000..923f22df --- /dev/null +++ b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java @@ -0,0 +1,23 @@ +package com.lingniu.ingest.protocol.jsatl12.config; + +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12NettyServer; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@EnableConfigurationProperties(Jsatl12Properties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.jsatl12", name = "enabled", havingValue = "true") +@ConditionalOnBean(ArchiveStore.class) +public class Jsatl12AutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public Jsatl12NettyServer jsatl12NettyServer(Jsatl12Properties props, ArchiveStore archive) { + return new Jsatl12NettyServer(props.getPort(), archive); + } +} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java new file mode 100644 index 00000000..5afebbf4 --- /dev/null +++ b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java @@ -0,0 +1,21 @@ +package com.lingniu.ingest.protocol.jsatl12.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * JSATL12 配置。实现尚在 TODO 状态,当前只做 Bean 占位避免空依赖。 + */ +@ConfigurationProperties(prefix = "lingniu.ingest.jsatl12") +public class Jsatl12Properties { + private boolean enabled = false; + private int port = 7612; + /** 附件落盘根目录,支持 file:// / s3:// / oss:// 形式,由 sink-archive 解析。 */ + private String fileStore = "file:///var/lingniu/alarm-files"; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + public String getFileStore() { return fileStore; } + public void setFileStore(String fileStore) { this.fileStore = fileStore; } +} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java new file mode 100644 index 00000000..e5d612d2 --- /dev/null +++ b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java @@ -0,0 +1,89 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12RawFrame; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * JSATL12 入站处理器。 + * + *

    当前实现:把 DATA_CHUNK 帧写入 {@link ArchiveStore},文件名按日期 + 对端 IP + + * 时间戳生成;JT_MESSAGE 帧暂仅打日志(后续批次可接入 jt808 的 BodyParserRegistry 做完整解析)。 + * + *

    路径模板:{@code yyyy/MM/dd//.bin}。 + */ +public class Jsatl12ChannelHandler extends SimpleChannelInboundHandler { + + private static final Logger log = LoggerFactory.getLogger(Jsatl12ChannelHandler.class); + private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.systemDefault()); + + /** 为每个 Channel 持久化一个文件 key,保证同一次传输追加到同一个文件。 */ + private static final Cache KEYS = Caffeine.newBuilder() + .expireAfterAccess(Duration.ofMinutes(30)) + .maximumSize(10_000) + .build(); + + private final ArchiveStore archive; + + public Jsatl12ChannelHandler(ArchiveStore archive) { + this.archive = archive; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Jsatl12RawFrame frame) { + switch (frame.type()) { + case JT_MESSAGE -> { + // 信令帧:打印摘要。完整解析留到 protocol-jt808 BodyParserRegistry 接入后完成。 + log.info("[jsatl12] signaling frame peer={} len={}", addr(ctx), frame.bytes().length); + } + case DATA_CHUNK -> { + String key = KEYS.get(ctx, k -> { + String day = DAY_FMT.format(Instant.now()); + return "jsatl12/" + day + "/" + addr(ctx).replace(':', '_') + + "/" + Instant.now().toEpochMilli() + ".bin"; + }); + try { + // 去除 4B 魔数 + 4B 长度的 header,仅写真实数据 + byte[] all = frame.bytes(); + int payloadOffset = 8; + int payloadLen = all.length - payloadOffset; + if (payloadLen > 0) { + byte[] payload = new byte[payloadLen]; + System.arraycopy(all, payloadOffset, payload, 0, payloadLen); + archive.append(key, payload); + } + } catch (IOException e) { + log.warn("[jsatl12] write chunk failed key={}", key, e); + } + } + } + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) { + String key = KEYS.getIfPresent(ctx); + if (key != null) { + log.info("[jsatl12] channel closed, archived key={}", key); + KEYS.invalidate(ctx); + } + } + + private static String addr(ChannelHandlerContext ctx) { + if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { + return i.getAddress().getHostAddress() + ":" + i.getPort(); + } + return "unknown"; + } +} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java new file mode 100644 index 00000000..7c2c3eae --- /dev/null +++ b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java @@ -0,0 +1,76 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12FrameDecoder; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; + +import java.util.concurrent.ThreadFactory; + +/** + * 苏标报警附件 Netty 服务端。独立端口(默认 7612)。 + */ +public final class Jsatl12NettyServer implements InitializingBean, DisposableBean { + + private static final Logger log = LoggerFactory.getLogger(Jsatl12NettyServer.class); + + private final int port; + private final ArchiveStore archive; + + private EventLoopGroup boss; + private EventLoopGroup workers; + private Channel serverChannel; + + public Jsatl12NettyServer(int port, ArchiveStore archive) { + this.port = port; + this.archive = archive; + } + + @Override + public void afterPropertiesSet() throws Exception { + ThreadFactory bossTf = r -> new Thread(r, "jsatl12-boss"); + ThreadFactory wkTf = r -> new Thread(r, "jsatl12-worker"); + this.boss = new NioEventLoopGroup(1, bossTf); + this.workers = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors(), wkTf); + + ServerBootstrap b = new ServerBootstrap(); + b.group(boss, workers) + .channel(NioServerSocketChannel.class) + .option(ChannelOption.SO_BACKLOG, 256) + .childOption(ChannelOption.TCP_NODELAY, true) + .childOption(ChannelOption.SO_KEEPALIVE, true) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ch.pipeline() + .addLast("frame", new Jsatl12FrameDecoder()) + .addLast("dispatch", new Jsatl12ChannelHandler(archive)); + } + }); + this.serverChannel = b.bind(port).sync().channel(); + log.info("jsatl12 Netty server listening on :{}", port); + } + + @Override + public void destroy() { + try { + if (serverChannel != null) serverChannel.close().sync(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + if (boss != null) boss.shutdownGracefully(); + if (workers != null) workers.shutdownGracefully(); + log.info("jsatl12 Netty server stopped"); + } + } +} diff --git a/protocol-jsatl12/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/protocol-jsatl12/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..1080dfb0 --- /dev/null +++ b/protocol-jsatl12/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.protocol.jsatl12.config.Jsatl12AutoConfiguration diff --git a/protocol-jt1078/pom.xml b/protocol-jt1078/pom.xml new file mode 100644 index 00000000..ded61a56 --- /dev/null +++ b/protocol-jt1078/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + protocol-jt1078 + protocol-jt1078 + + JT/T 1078 音视频信令接入。复用 protocol-jt808 的 Netty server 与会话, + 只通过注册新的 BodyParser 热插拔扩展消息集(0x1003 / 0x1005 / 0x1205 / 0x1206)。 + + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + ingest-core + + + com.lingniu.ingest + protocol-jt808 + + + org.springframework.boot + spring-boot-starter + + + diff --git a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java new file mode 100644 index 00000000..0d08106b --- /dev/null +++ b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java @@ -0,0 +1,20 @@ +package com.lingniu.ingest.protocol.jt1078.codec.parser; + +import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId; +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; + +import java.nio.ByteBuffer; + +/** 0x1206 文件上传完成通知。PoC 只取 raw。 */ +public final class FileUploadCompleteBodyParser implements BodyParser { + @Override public int messageId() { return Jt1078MessageId.TERMINAL_FILE_UPLOAD_DONE; } + + @Override + public Jt808Body parse(Jt808Header header, ByteBuffer body) { + byte[] raw = new byte[body.remaining()]; + body.get(raw); + return new Jt808Body.Raw(messageId(), raw); + } +} diff --git a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java new file mode 100644 index 00000000..34ace26d --- /dev/null +++ b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java @@ -0,0 +1,23 @@ +package com.lingniu.ingest.protocol.jt1078.codec.parser; + +import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId; +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; + +import java.nio.ByteBuffer; + +/** + * 0x1003 终端上传音视频属性 —— PoC 阶段先返回原始字节由下游分析。 + * 真实解析字段:音视频编码 / 音频通道数 / 采样率 / 位深 / 最大音视频通道数 / 视频编码 / 分辨率 等。 + */ +public final class MediaAttributesBodyParser implements BodyParser { + @Override public int messageId() { return Jt1078MessageId.TERMINAL_MEDIA_ATTRS_REPORT; } + + @Override + public Jt808Body parse(Jt808Header header, ByteBuffer body) { + byte[] raw = new byte[body.remaining()]; + body.get(raw); + return new Jt808Body.Raw(messageId(), raw); + } +} diff --git a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java new file mode 100644 index 00000000..9aacaf29 --- /dev/null +++ b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java @@ -0,0 +1,28 @@ +package com.lingniu.ingest.protocol.jt1078.config; + +import com.lingniu.ingest.protocol.jt1078.codec.parser.FileUploadCompleteBodyParser; +import com.lingniu.ingest.protocol.jt1078.codec.parser.MediaAttributesBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +/** + * jt1078 在 jt808 之前装配它自己的 Parser Bean,Spring 会把它们注入到 jt808 的 + * {@code BodyParserRegistry} 里,实现"同一个 Netty 端口复用、消息集热插拔"。 + */ +@AutoConfiguration(before = Jt808AutoConfiguration.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.jt1078", name = "enabled", havingValue = "true") +public class Jt1078AutoConfiguration { + + @Bean + public BodyParser jt1078MediaAttributesBodyParser() { + return new MediaAttributesBodyParser(); + } + + @Bean + public BodyParser jt1078FileUploadCompleteBodyParser() { + return new FileUploadCompleteBodyParser(); + } +} diff --git a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java new file mode 100644 index 00000000..7f8557d9 --- /dev/null +++ b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java @@ -0,0 +1,12 @@ +package com.lingniu.ingest.protocol.jt1078.model; + +/** JT/T 1078 消息 ID 常量(上行部分核心子集)。 */ +public final class Jt1078MessageId { + + private Jt1078MessageId() {} + + public static final int TERMINAL_MEDIA_ATTRS_REPORT = 0x1003; + public static final int TERMINAL_PASSENGER_VOLUME = 0x1005; + public static final int TERMINAL_FILE_LIST = 0x1205; + public static final int TERMINAL_FILE_UPLOAD_DONE = 0x1206; +} diff --git a/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..236a8ad5 --- /dev/null +++ b/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.protocol.jt1078.config.Jt1078AutoConfiguration diff --git a/protocol-jt808/pom.xml b/protocol-jt808/pom.xml new file mode 100644 index 00000000..6f4f1eee --- /dev/null +++ b/protocol-jt808/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + protocol-jt808 + protocol-jt808 + JT/T 808 协议接入(Netty Server,默认 TCP 10808)。 + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + ingest-core + + + com.lingniu.ingest + ingest-codec-common + + + com.lingniu.ingest + session-core + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-configuration-processor + true + + + io.netty + netty-all + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParser.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParser.java new file mode 100644 index 00000000..c8ed4678 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParser.java @@ -0,0 +1,19 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; + +import java.nio.ByteBuffer; + +/** + * 单一消息体解析器 SPI。每个消息 ID 一个实现,替代旧代码里的巨型 switch。 + * + *

    约定:{@code parse} 调用时 buffer.position 指向 body 第 0 字节, + * 解析完成后 position 停在 body 末尾(不消费校验码)。 + */ +public interface BodyParser { + + int messageId(); + + Jt808Body parse(Jt808Header header, ByteBuffer body); +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParserRegistry.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParserRegistry.java new file mode 100644 index 00000000..ea4751c2 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParserRegistry.java @@ -0,0 +1,25 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * JT808 消息体解析器注册中心。 + */ +public final class BodyParserRegistry { + + private final Map parsers = new HashMap<>(); + + public BodyParserRegistry(List list) { + for (BodyParser p : list) parsers.put(p.messageId(), p); + } + + public BodyParser find(int messageId) { + return parsers.get(messageId); + } + + public int size() { + return parsers.size(); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808Escape.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808Escape.java new file mode 100644 index 00000000..75f00215 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808Escape.java @@ -0,0 +1,59 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +/** + * JT/T 808 转义规则: + *

    + *   0x7e → 0x7d 0x02
    + *   0x7d → 0x7d 0x01
    + * 
    + * 转义仅作用于 消息头 + 消息体 + 校验码,不包含首尾的 0x7e 标识位。 + */ +public final class Jt808Escape { + + private Jt808Escape() {} + + /** 反转义:从 framed 字节数组中剥除 0x7e 首尾后,恢复原始字节。 */ + public static byte[] unescape(byte[] framed, int offset, int length) { + byte[] out = new byte[length]; + int outLen = 0; + for (int i = offset; i < offset + length; i++) { + byte b = framed[i]; + if (b == 0x7d && i + 1 < offset + length) { + byte next = framed[i + 1]; + if (next == 0x02) { + out[outLen++] = 0x7e; + i++; + continue; + } else if (next == 0x01) { + out[outLen++] = 0x7d; + i++; + continue; + } + } + out[outLen++] = b; + } + byte[] trimmed = new byte[outLen]; + System.arraycopy(out, 0, trimmed, 0, outLen); + return trimmed; + } + + /** 转义:在原始字节上应用 7d/7e 替换,结果不含首尾 0x7e。 */ + public static byte[] escape(byte[] raw) { + byte[] out = new byte[raw.length * 2]; + int outLen = 0; + for (byte b : raw) { + if (b == 0x7e) { + out[outLen++] = 0x7d; + out[outLen++] = 0x02; + } else if (b == 0x7d) { + out[outLen++] = 0x7d; + out[outLen++] = 0x01; + } else { + out[outLen++] = b; + } + } + byte[] trimmed = new byte[outLen]; + System.arraycopy(out, 0, trimmed, 0, outLen); + return trimmed; + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java new file mode 100644 index 00000000..47c1186b --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java @@ -0,0 +1,51 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; + +import java.util.List; + +/** + * Netty 层 JT808 分帧 + 反转义。 + * + *

    寻找配对的 {@code 0x7e ... 0x7e},提取中间字节并调用 {@link Jt808Escape#unescape} 还原, + * 输出到 pipeline 的是 {@code byte[]}(反转义后的原始数据,不含首尾 0x7e)。 + */ +public class Jt808FrameDecoder extends ByteToMessageDecoder { + + private static final int MAX_FRAME = 16 * 1024; + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + while (in.readableBytes() >= 2) { + int startIdx = in.indexOf(in.readerIndex(), in.writerIndex(), (byte) 0x7e); + if (startIdx < 0) { + in.skipBytes(in.readableBytes()); + return; + } + if (startIdx != in.readerIndex()) { + in.skipBytes(startIdx - in.readerIndex()); + } + int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e); + if (endIdx < 0) { + if (in.readableBytes() > MAX_FRAME) { + in.skipBytes(in.readableBytes() - 1); + } + return; + } + int rawLen = endIdx - (in.readerIndex() + 1); + if (rawLen == 0) { + // 两个相邻 0x7e:视作空帧,跳过一个 + in.skipBytes(1); + continue; + } + byte[] raw = new byte[rawLen]; + in.skipBytes(1); + in.readBytes(raw); + in.skipBytes(1); // 结束 0x7e + byte[] unescaped = Jt808Escape.unescape(raw, 0, raw.length); + out.add(unescaped); + } + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java new file mode 100644 index 00000000..af8fe0c3 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java @@ -0,0 +1,81 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.codec.BcdCodec; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; + +import java.io.ByteArrayOutputStream; + +/** + * JT/T 808 下行帧编码器:把 header + body 字节拼装 → 加 XOR 校验 → 转义 → 外包 0x7e。 + * + *

    只支持 2013 版(6 字节 BCD phone)。分包暂不支持(PoC 阶段下行多数是短命令)。 + */ +public final class Jt808FrameEncoder { + + private Jt808FrameEncoder() {} + + public static byte[] encode(int messageId, String phone, int serialNo, byte[] body) { + return encode(messageId, phone, serialNo, body, Jt808Header.ProtocolVersion.V2013); + } + + public static byte[] encode(int messageId, String phone, int serialNo, byte[] body, + Jt808Header.ProtocolVersion version) { + byte[] raw = buildRaw(messageId, phone, serialNo, body, version); + byte bcc = BccChecksum.compute(raw, 0, raw.length); + + ByteArrayOutputStream almost = new ByteArrayOutputStream(raw.length + 1); + almost.write(raw, 0, raw.length); + almost.write(bcc & 0xFF); + + byte[] escaped = Jt808Escape.escape(almost.toByteArray()); + byte[] out = new byte[escaped.length + 2]; + out[0] = 0x7e; + System.arraycopy(escaped, 0, out, 1, escaped.length); + out[out.length - 1] = 0x7e; + return out; + } + + private static byte[] buildRaw(int messageId, String phone, int serialNo, byte[] body, + Jt808Header.ProtocolVersion version) { + boolean v2019 = version == Jt808Header.ProtocolVersion.V2019; + int phoneLen = v2019 ? 10 : 6; + int len = 2 /* msgId */ + 2 /* attrs */ + (v2019 ? 1 : 0) + phoneLen + 2 /* serial */ + body.length; + ByteArrayOutputStream os = new ByteArrayOutputStream(len); + + // msgId + os.write((messageId >> 8) & 0xFF); + os.write(messageId & 0xFF); + + // attrs: 2019 版 bit14 置 1 + int attrs = body.length & 0x03FF; + if (v2019) attrs |= 0x4000; + os.write((attrs >> 8) & 0xFF); + os.write(attrs & 0xFF); + + // 2019 版多一个 protocolVersion 字节(值 1) + if (v2019) os.write(0x01); + + // phone BCD + String padded = padLeft(phone, phoneLen * 2); + byte[] bcd = BcdCodec.encode(padded); + os.write(bcd, 0, phoneLen); + + // serial + os.write((serialNo >> 8) & 0xFF); + os.write(serialNo & 0xFF); + + // body + os.write(body, 0, body.length); + return os.toByteArray(); + } + + private static String padLeft(String s, int len) { + if (s == null) s = ""; + if (s.length() >= len) return s.substring(s.length() - len); + StringBuilder sb = new StringBuilder(len); + for (int i = s.length(); i < len; i++) sb.append('0'); + sb.append(s); + return sb.toString(); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java new file mode 100644 index 00000000..fa1d199e --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java @@ -0,0 +1,93 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import com.lingniu.ingest.api.spi.DecodeException; +import com.lingniu.ingest.api.spi.FrameDecoder; +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.codec.BcdCodec; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** + * JT808 完整帧(已反转义、含校验码,不含首尾 0x7e) → {@link Jt808Message}。 + * + *

    入参 {@link ByteBuffer} 的生命周期由调用方管理,本类只读取。 + */ +public final class Jt808MessageDecoder implements FrameDecoder { + + private static final int HEADER_LEN_2013 = 12; + private static final int HEADER_LEN_2019 = 16; // + phone 延长 4 字节 + + private final BodyParserRegistry registry; + + public Jt808MessageDecoder(BodyParserRegistry registry) { + this.registry = registry; + } + + @Override + public Jt808Message decode(ByteBuffer buffer) { + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + + if (bytes.length < HEADER_LEN_2013 + 1) { + throw new DecodeException("jt808 frame too short: " + bytes.length); + } + + // 校验码:从 msgId 开始到校验码前一字节 + byte expected = bytes[bytes.length - 1]; + if (!BccChecksum.verify(bytes, 0, bytes.length - 1, expected)) { + throw new DecodeException("jt808 XOR checksum mismatch"); + } + + int messageId = ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF); + int attrs = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF); + int bodyLength = attrs & 0x03FF; + int encryptType = (attrs >> 10) & 0x07; + boolean subpacket = (attrs & 0x2000) != 0; + boolean v2019 = (attrs & 0x4000) != 0; + + Jt808Header.ProtocolVersion version = v2019 + ? Jt808Header.ProtocolVersion.V2019 + : Jt808Header.ProtocolVersion.V2013; + int phoneLen = v2019 ? 10 : 6; + int headerLen = 4 /* msgId+attrs */ + (v2019 ? 1 : 0) /* protocol version */ + phoneLen + 2 /* serial */; + int phoneOffset = 4 + (v2019 ? 1 : 0); + + String phone = BcdCodec.decode(Arrays.copyOfRange(bytes, phoneOffset, phoneOffset + phoneLen)) + .replaceAll("^0+", ""); + int serial = ((bytes[phoneOffset + phoneLen] & 0xFF) << 8) + | (bytes[phoneOffset + phoneLen + 1] & 0xFF); + + 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; + } + + int bodyEnd = bodyStart + bodyLength; + if (bodyEnd > bytes.length - 1) { + throw new DecodeException("jt808 body length " + bodyLength + " exceeds frame"); + } + + Jt808Header header = new Jt808Header( + messageId, bodyLength, encryptType, subpacket, version, + phone, serial, totalPkgs, pkgSeq); + + ByteBuffer bodyBuf = ByteBuffer.wrap(bytes, bodyStart, bodyLength); + BodyParser parser = registry.find(messageId); + Jt808Body body; + if (parser == null) { + byte[] raw = new byte[bodyLength]; + bodyBuf.get(raw); + body = new Jt808Body.Raw(messageId, raw); + } else { + body = parser.parse(header, bodyBuf); + } + return new Jt808Message(header, body); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/HeartbeatBodyParser.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/HeartbeatBodyParser.java new file mode 100644 index 00000000..e1133fc5 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/HeartbeatBodyParser.java @@ -0,0 +1,14 @@ +package com.lingniu.ingest.protocol.jt808.codec.parser; + +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; + +import java.nio.ByteBuffer; + +/** 0x0002 心跳,无 body。 */ +public final class HeartbeatBodyParser implements BodyParser { + @Override public int messageId() { return Jt808MessageId.TERMINAL_HEARTBEAT; } + @Override public Jt808Body parse(Jt808Header header, ByteBuffer body) { return new Jt808Body.Heartbeat(); } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java new file mode 100644 index 00000000..df0ee8fa --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java @@ -0,0 +1,66 @@ +package com.lingniu.ingest.protocol.jt808.codec.parser; + +import com.lingniu.ingest.codec.BcdCodec; +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; + +import java.nio.ByteBuffer; +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * 0x0200 位置信息汇报。 + * + *

    固定 28 字节 + 可选附加项(本 PoC 先忽略附加项)。 + *

    + *   alarmFlag(4)
    + *   statusFlag(4)
    + *   latitude(4)   // 1/1000000 度
    + *   longitude(4)  // 1/1000000 度
    + *   altitudeM(2)
    + *   speed(2)      // 1/10 km/h
    + *   direction(2)
    + *   time(6 BCD, YYMMDDHHMMSS)
    + * 
    + */ +public final class LocationBodyParser implements BodyParser { + + @Override + public int messageId() { + return Jt808MessageId.TERMINAL_LOCATION; + } + + @Override + public Jt808Body parse(Jt808Header header, ByteBuffer buf) { + long alarmFlag = buf.getInt() & 0xFFFFFFFFL; + long statusFlag = buf.getInt() & 0xFFFFFFFFL; + long latRaw = buf.getInt() & 0xFFFFFFFFL; + long lonRaw = buf.getInt() & 0xFFFFFFFFL; + int altitudeM = buf.getShort(); + double speed = (buf.getShort() & 0xFFFF) * 0.1; + int direction = buf.getShort() & 0xFFFF; + + byte[] timeBcd = new byte[6]; + buf.get(timeBcd); + String ts = BcdCodec.decode(timeBcd); + + LocalDateTime ldt = LocalDateTime.of( + 2000 + Integer.parseInt(ts.substring(0, 2)), + Integer.parseInt(ts.substring(2, 4)), + Integer.parseInt(ts.substring(4, 6)), + Integer.parseInt(ts.substring(6, 8)), + Integer.parseInt(ts.substring(8, 10)), + Integer.parseInt(ts.substring(10, 12))); + + // 跳过剩余附加项 + if (buf.hasRemaining()) buf.position(buf.limit()); + + return new Jt808Body.Location( + alarmFlag, statusFlag, + lonRaw / 1_000_000.0, latRaw / 1_000_000.0, + altitudeM, speed, direction, + ldt.atZone(ZoneId.of("Asia/Shanghai")).toInstant()); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/RegisterBodyParser.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/RegisterBodyParser.java new file mode 100644 index 00000000..bdd76ca4 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/RegisterBodyParser.java @@ -0,0 +1,54 @@ +package com.lingniu.ingest.protocol.jt808.codec.parser; + +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; + +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** + * 0x0100 终端注册。 + * + *

    2013 body 格式: + *

    + *   provinceId(2)
    + *   cityId(2)
    + *   makerId(5 ASCII)
    + *   deviceType(20 ASCII) -- 2011 版为 8
    + *   deviceId(7 ASCII)
    + *   plateColor(1)
    + *   plate(GBK, 剩余全部字节)
    + * 
    + */ +public final class RegisterBodyParser implements BodyParser { + + private static final Charset GBK = Charset.forName("GBK"); + + @Override + public int messageId() { + return Jt808MessageId.TERMINAL_REGISTER; + } + + @Override + public Jt808Body parse(Jt808Header header, ByteBuffer buf) { + int province = buf.getShort() & 0xFFFF; + int city = buf.getShort() & 0xFFFF; + String maker = readAscii(buf, 5); + String deviceType = readAscii(buf, 20); + String deviceId = readAscii(buf, 7); + int plateColor = buf.get() & 0xFF; + byte[] plateBytes = new byte[buf.remaining()]; + buf.get(plateBytes); + String plate = new String(plateBytes, GBK).trim(); + return new Jt808Body.Register(province, city, maker, deviceType, deviceId, plateColor, plate); + } + + private static String readAscii(ByteBuffer buf, int len) { + byte[] b = new byte[len]; + buf.get(b); + return new String(b, StandardCharsets.US_ASCII).trim(); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java new file mode 100644 index 00000000..cdae3b4c --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java @@ -0,0 +1,96 @@ +package com.lingniu.ingest.protocol.jt808.config; + +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry; +import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; +import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser; +import com.lingniu.ingest.protocol.jt808.downlink.Jt808CommandDispatcher; +import com.lingniu.ingest.protocol.jt808.handler.Jt808LocationHandler; +import com.lingniu.ingest.protocol.jt808.inbound.Jt808NettyServer; +import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper; +import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry; +import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests; +import com.lingniu.ingest.session.CommandDispatcher; +import com.lingniu.ingest.session.SessionStore; +import com.lingniu.ingest.session.config.SessionCoreAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +@AutoConfiguration(before = SessionCoreAutoConfiguration.class) +@EnableConfigurationProperties(Jt808Properties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.jt808", name = "enabled", havingValue = "true") +public class Jt808AutoConfiguration { + + @Bean public BodyParser jt808RegisterBodyParser() { return new RegisterBodyParser(); } + @Bean public BodyParser jt808LocationBodyParser() { return new LocationBodyParser(); } + @Bean public BodyParser jt808HeartbeatBodyParser() { return new HeartbeatBodyParser(); } + + @Bean + @ConditionalOnMissingBean + public BodyParserRegistry jt808BodyParserRegistry(List parsers) { + return new BodyParserRegistry(parsers); + } + + @Bean + @ConditionalOnMissingBean + public Jt808MessageDecoder jt808MessageDecoder(BodyParserRegistry registry) { + return new Jt808MessageDecoder(registry); + } + + @Bean + @ConditionalOnMissingBean + public Jt808EventMapper jt808EventMapper() { + return new Jt808EventMapper(); + } + + @Bean + @ConditionalOnMissingBean + public Jt808LocationHandler jt808LocationHandler(Jt808EventMapper mapper) { + return new Jt808LocationHandler(mapper); + } + + @Bean + @ConditionalOnMissingBean + public Jt808ChannelRegistry jt808ChannelRegistry() { + return new Jt808ChannelRegistry(); + } + + @Bean + @ConditionalOnMissingBean + public Jt808PendingRequests jt808PendingRequests() { + return new Jt808PendingRequests(); + } + + /** + * JT808 模块启用时抢占 {@link CommandDispatcher} Bean。 + * 通过 {@code @AutoConfiguration(before = SessionCoreAutoConfiguration.class)} 确保本 Bean + * 先注册,session-core 的 {@code @ConditionalOnMissingBean} 会因此跳过 Noop 默认实现。 + */ + @Bean + @ConditionalOnMissingBean + public CommandDispatcher jt808CommandDispatcher(SessionStore sessions, + Jt808ChannelRegistry channelRegistry, + Jt808PendingRequests pendingRequests) { + return new Jt808CommandDispatcher(sessions, channelRegistry, pendingRequests); + } + + @Bean + @ConditionalOnMissingBean + public Jt808NettyServer jt808NettyServer(Jt808Properties props, + Jt808MessageDecoder decoder, + Dispatcher dispatcher, + SessionStore sessions, + Jt808ChannelRegistry channelRegistry, + Jt808PendingRequests pendingRequests) { + return new Jt808NettyServer(props.getPort(), props.getWorkerThreads(), + decoder, dispatcher, sessions, channelRegistry, pendingRequests); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java new file mode 100644 index 00000000..53502be8 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java @@ -0,0 +1,17 @@ +package com.lingniu.ingest.protocol.jt808.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "lingniu.ingest.jt808") +public class Jt808Properties { + private boolean enabled = false; + private int port = 10808; + private int workerThreads = 0; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + public int getWorkerThreads() { return workerThreads; } + public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java new file mode 100644 index 00000000..b203db56 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java @@ -0,0 +1,115 @@ +package com.lingniu.ingest.protocol.jt808.downlink; + +import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry; +import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests; +import com.lingniu.ingest.session.CommandDispatcher; +import com.lingniu.ingest.session.DeviceSession; +import com.lingniu.ingest.session.SessionStore; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; + +/** + * JT808 侧的 {@link CommandDispatcher} 实现:把 session-core 的调用桥接到具体 Netty Channel。 + * + *

    {@link #notify(String, Object)} 异步下发后立即返回; + * {@link #request(String, Object, Class, Duration)} 通过 {@link Jt808PendingRequests} 等待设备应答。 + * + *

    入参 {@code command} 必须是 {@link Jt808Commands.DownlinkCommand},其他类型返回失败 future。 + */ +public final class Jt808CommandDispatcher implements CommandDispatcher { + + private static final Logger log = LoggerFactory.getLogger(Jt808CommandDispatcher.class); + + private final SessionStore sessions; + private final Jt808ChannelRegistry channelRegistry; + private final Jt808PendingRequests pendingRequests; + + public Jt808CommandDispatcher(SessionStore sessions, + Jt808ChannelRegistry channelRegistry, + Jt808PendingRequests pendingRequests) { + this.sessions = sessions; + this.channelRegistry = channelRegistry; + this.pendingRequests = pendingRequests; + } + + @Override + public CompletableFuture notify(String sessionId, Object command) { + CommandContext ctx; + try { + ctx = resolve(sessionId, command); + } catch (IllegalArgumentException e) { + return CompletableFuture.failedFuture(e); + } + byte[] frame = Jt808FrameEncoder.encode(ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body()); + CompletableFuture cf = new CompletableFuture<>(); + ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> { + if (f.isSuccess()) cf.complete(null); + else cf.completeExceptionally(f.cause()); + }); + return cf; + } + + @Override + public CompletableFuture request(String sessionId, Object command, + Class responseType, Duration timeout) { + CommandContext ctx; + try { + ctx = resolve(sessionId, command); + } catch (IllegalArgumentException e) { + return CompletableFuture.failedFuture(e); + } + + CompletableFuture pending = pendingRequests.await(ctx.phone, ctx.serial); + byte[] frame = Jt808FrameEncoder.encode(ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body()); + ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> { + if (!f.isSuccess()) { + pendingRequests.cancel(ctx.phone, ctx.serial); + pending.completeExceptionally(f.cause()); + } + }); + + return pending.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) + .whenComplete((r, ex) -> { + if (ex != null) pendingRequests.cancel(ctx.phone, ctx.serial); + }) + .thenApply(msg -> { + if (responseType.isInstance(msg)) return responseType.cast(msg); + throw new CompletionException(new ClassCastException( + "jt808 response type mismatch, expect=" + responseType.getSimpleName() + + " actual=" + msg.getClass().getSimpleName())); + }); + } + + private CommandContext resolve(String sessionId, Object command) { + if (!(command instanceof Jt808Commands.DownlinkCommand cmd)) { + throw new IllegalArgumentException( + "expected Jt808Commands.DownlinkCommand, got " + command.getClass().getName()); + } + String phone = resolvePhone(sessionId); + Channel channel = channelRegistry.find(phone) + .orElseThrow(() -> new IllegalStateException("no active channel for phone=" + phone)); + int serial = channelRegistry.nextSerial(phone); + return new CommandContext(phone, serial, channel, cmd); + } + + private String resolvePhone(String sessionId) { + Optional s = sessions.findBySessionId(sessionId) + .or(() -> sessions.findByPhone(sessionId)) + .or(() -> sessions.findByVin(sessionId)); + return s.map(DeviceSession::phone) + .filter(p -> p != null && !p.isBlank()) + .orElse(sessionId); // 回落:直接把 sessionId 当 phone + } + + private record CommandContext(String phone, int serial, Channel channel, Jt808Commands.DownlinkCommand cmd) {} +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java new file mode 100644 index 00000000..41e12d13 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java @@ -0,0 +1,91 @@ +package com.lingniu.ingest.protocol.jt808.downlink; + +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** + * 常用下行命令 body 构造器。返回的是 body 字节,外层由 {@link com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder} 包装。 + */ +public final class Jt808Commands { + + private static final Charset GBK = Charset.forName("GBK"); + + private Jt808Commands() {} + + /** 0x8001 平台通用应答(ack 一条终端上行)。 */ + public static DownlinkCommand platformAck(int ackSerial, int ackMsgId, int result) { + ByteArrayOutputStream os = new ByteArrayOutputStream(5); + writeU16(os, ackSerial); + writeU16(os, ackMsgId); + os.write(result & 0xFF); + return new DownlinkCommand(Jt808MessageId.PLATFORM_GENERAL_RESPONSE, os.toByteArray()); + } + + /** 0x8100 注册应答(result=0 成功)。 */ + public static DownlinkCommand registerAck(int ackSerial, int result, String token) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeU16(os, ackSerial); + os.write(result & 0xFF); + if (result == 0 && token != null) { + byte[] tokenBytes = token.getBytes(StandardCharsets.US_ASCII); + os.write(tokenBytes, 0, tokenBytes.length); + } + return new DownlinkCommand(Jt808MessageId.PLATFORM_REGISTER_ACK, os.toByteArray()); + } + + /** 0x8201 位置信息查询。 */ + public static DownlinkCommand queryLocation() { + return new DownlinkCommand(Jt808MessageId.PLATFORM_QUERY_LOCATION, new byte[0]); + } + + /** 0x8104 查询终端参数(全量)。 */ + public static DownlinkCommand queryParameters() { + return new DownlinkCommand(Jt808MessageId.PLATFORM_QUERY_PARAMS, new byte[0]); + } + + /** + * 0x8103 设置终端参数:参数项列表格式。 + * + *

    body 形如 {@code count(1) + N*paramItem},每个 paramItem 为 {@code paramId(4) + length(1) + value}。 + */ + public static DownlinkCommand setParameters(java.util.List items) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + os.write(items.size() & 0xFF); + for (ParamItem item : items) { + writeU32(os, item.paramId()); + os.write(item.value().length & 0xFF); + os.write(item.value(), 0, item.value().length); + } + return new DownlinkCommand(Jt808MessageId.PLATFORM_SET_PARAMS, os.toByteArray()); + } + + /** 0x8105 终端控制:命令字(1) + 命令参数(GBK)。 */ + public static DownlinkCommand terminalControl(int commandWord, String commandParams) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + os.write(commandWord & 0xFF); + if (commandParams != null) { + byte[] bytes = commandParams.getBytes(GBK); + os.write(bytes, 0, bytes.length); + } + return new DownlinkCommand(Jt808MessageId.PLATFORM_TERMINAL_CONTROL, os.toByteArray()); + } + + public record DownlinkCommand(int messageId, byte[] body) {} + + public record ParamItem(long paramId, byte[] value) {} + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(ByteArrayOutputStream os, long v) { + os.write((int) ((v >> 24) & 0xFF)); + os.write((int) ((v >> 16) & 0xFF)); + os.write((int) ((v >> 8) & 0xFF)); + os.write((int) (v & 0xFF)); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java new file mode 100644 index 00000000..d348b48b --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java @@ -0,0 +1,55 @@ +package com.lingniu.ingest.protocol.jt808.handler; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.annotation.AsyncBatch; +import com.lingniu.ingest.api.annotation.EventEmit; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.annotation.ProtocolHandler; +import com.lingniu.ingest.api.annotation.RateLimited; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; + +import java.util.ArrayList; +import java.util.List; + +/** + * JT808 示例 Handler:演示注解驱动的路由与事件产出。 + * + *

    位置上报使用 {@code @AsyncBatch} 批量聚合(对标旧代码里的 4000/1s 批量落库)。 + * 带 {@code @AsyncBatch} 的方法签名约定为 {@code List}。 + */ +@ProtocolHandler(protocol = ProtocolId.JT808) +public class Jt808LocationHandler { + + private final Jt808EventMapper mapper; + + public Jt808LocationHandler(Jt808EventMapper mapper) { + this.mapper = mapper; + } + + @MessageMapping(command = Jt808MessageId.TERMINAL_LOCATION, desc = "位置信息汇报(批量)") + @RateLimited(perVin = 20) + @AsyncBatch(size = 4000, waitMs = 1000, poolSize = 2) + @EventEmit(VehicleEvent.Location.class) + public List onLocationBatch(List batch) { + List all = new ArrayList<>(batch.size()); + for (Jt808Message msg : batch) { + all.addAll(mapper.toEvents(msg)); + } + return all; + } + + @MessageMapping(command = {Jt808MessageId.TERMINAL_REGISTER, Jt808MessageId.TERMINAL_AUTH}, desc = "注册/鉴权") + @EventEmit(VehicleEvent.Login.class) + public List onLogin(Jt808Message msg) { + return mapper.toEvents(msg); + } + + @MessageMapping(command = Jt808MessageId.TERMINAL_HEARTBEAT, desc = "心跳") + @EventEmit(VehicleEvent.Heartbeat.class) + public List onHeartbeat(Jt808Message msg) { + return mapper.toEvents(msg); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java new file mode 100644 index 00000000..908b904b --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java @@ -0,0 +1,165 @@ +package com.lingniu.ingest.protocol.jt808.inbound; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder; +import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; +import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; +import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry; +import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests; +import com.lingniu.ingest.session.DeviceSession; +import com.lingniu.ingest.session.SessionStore; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * JT808 Netty 入站处理器。职责: + *

      + *
    1. 解码 → 构造 RawFrame → 交给 {@link Dispatcher} + *
    2. 对 0x0100 注册 / 0x0102 鉴权 → 创建 / 更新 {@link DeviceSession} 并绑定 Channel + *
    3. 对 0x0001 终端通用应答 → 唤醒 {@link Jt808PendingRequests} 里等待的 future + *
    4. 对 0x0002 心跳 → 下发 0x8001 通用应答(若平台要求) + *
    5. 连接断开 → 清理 Channel 绑定 + *
    + */ +public class Jt808ChannelHandler extends SimpleChannelInboundHandler { + + private static final Logger log = LoggerFactory.getLogger(Jt808ChannelHandler.class); + + private final Jt808MessageDecoder decoder; + private final Dispatcher dispatcher; + private final SessionStore sessions; + private final Jt808ChannelRegistry channelRegistry; + private final Jt808PendingRequests pendingRequests; + + public Jt808ChannelHandler(Jt808MessageDecoder decoder, Dispatcher dispatcher, + SessionStore sessions, Jt808ChannelRegistry channelRegistry, + Jt808PendingRequests pendingRequests) { + this.decoder = decoder; + this.dispatcher = dispatcher; + this.sessions = sessions; + this.channelRegistry = channelRegistry; + this.pendingRequests = pendingRequests; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, byte[] frame) { + Jt808Message msg; + try { + msg = decoder.decode(ByteBuffer.wrap(frame)); + } catch (Exception e) { + log.warn("[jt808] decode failed peer={} len={}", addr(ctx), frame.length, e); + return; + } + + // ====== 会话维护 & 同步应答匹配 ====== + String phone = msg.header().phone(); + switch (msg.header().messageId()) { + case Jt808MessageId.TERMINAL_REGISTER -> { + DeviceSession session = upsertSession(ctx, msg); + channelRegistry.bind(phone, ctx.channel()); + byte[] registerAck = Jt808FrameEncoder.encode( + Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).messageId(), + phone, + channelRegistry.nextSerial(phone), + Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).body()); + ctx.writeAndFlush(Unpooled.wrappedBuffer(registerAck)); + } + case Jt808MessageId.TERMINAL_AUTH -> { + upsertSession(ctx, msg); + channelRegistry.bind(phone, ctx.channel()); + ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0); + } + case Jt808MessageId.TERMINAL_HEARTBEAT -> { + channelRegistry.bind(phone, ctx.channel()); + ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0); + } + case Jt808MessageId.TERMINAL_GENERAL_RESPONSE -> { + // body: ackSerial(2) + ackMsgId(2) + result(1) + if (msg.body() instanceof Jt808Body.Raw raw && raw.bytes().length >= 5) { + int ackSerial = ((raw.bytes()[0] & 0xFF) << 8) | (raw.bytes()[1] & 0xFF); + pendingRequests.complete(phone, ackSerial, msg); + } + } + default -> { + // 定位/其他上行:也可能是对同步命令的应答 + pendingRequests.complete(phone, msg.header().serialNo(), msg); + } + } + + // ====== 派发到 Dispatcher ====== + Map meta = new HashMap<>(4); + meta.put("vin", phone); + meta.put("seq", Integer.toString(msg.header().serialNo())); + meta.put("peer", addr(ctx)); + RawFrame rf = new RawFrame( + ProtocolId.JT808, + msg.header().messageId(), + 0, + msg, + frame, + meta, + Instant.now()); + dispatcher.dispatch(rf); + } + + private DeviceSession upsertSession(ChannelHandlerContext ctx, Jt808Message msg) { + String phone = msg.header().phone(); + String sessionId = "jt808-" + phone; + DeviceSession session = sessions.findBySessionId(sessionId) + .map(s -> s.touch(Instant.now())) + .orElseGet(() -> new DeviceSession( + sessionId, ProtocolId.JT808, null, phone, + generateToken(phone), + addr(ctx), + Instant.now(), Instant.now(), + Map.of("protocolVersion", msg.header().version().name()))); + if (msg.body() instanceof Jt808Body.Register reg && reg.plate() != null) { + session = session.withVin(reg.deviceId()); // 没有 VIN 则用 deviceId 占位 + } + sessions.put(session); + return session; + } + + private void ackTerminal(ChannelHandlerContext ctx, String phone, int ackSerial, int ackMsgId, int result) { + var cmd = Jt808Commands.platformAck(ackSerial, ackMsgId, result); + byte[] frame = Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body()); + ctx.writeAndFlush(Unpooled.wrappedBuffer(frame)); + } + + private static String generateToken(String phone) { + return UUID.nameUUIDFromBytes(("jt808:" + phone).getBytes()).toString().replace("-", "").substring(0, 16); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) { + channelRegistry.unbind(ctx.channel()); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + log.warn("[jt808] channel error peer={}", addr(ctx), cause); + ctx.close(); + } + + private static String addr(ChannelHandlerContext ctx) { + if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { + return i.getAddress().getHostAddress() + ":" + i.getPort(); + } + return "unknown"; + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java new file mode 100644 index 00000000..0c456f54 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java @@ -0,0 +1,95 @@ +package com.lingniu.ingest.protocol.jt808.inbound; + +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameDecoder; +import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; +import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry; +import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests; +import com.lingniu.ingest.session.SessionStore; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; + +import java.util.concurrent.ThreadFactory; + +public class Jt808NettyServer implements InitializingBean, DisposableBean { + + private static final Logger log = LoggerFactory.getLogger(Jt808NettyServer.class); + + private final int port; + private final int workerThreads; + private final Jt808MessageDecoder messageDecoder; + private final Dispatcher dispatcher; + private final SessionStore sessions; + private final Jt808ChannelRegistry channelRegistry; + private final Jt808PendingRequests pendingRequests; + + private EventLoopGroup boss; + private EventLoopGroup workers; + private Channel serverChannel; + + public Jt808NettyServer(int port, int workerThreads, + Jt808MessageDecoder messageDecoder, Dispatcher dispatcher, + SessionStore sessions, + Jt808ChannelRegistry channelRegistry, + Jt808PendingRequests pendingRequests) { + this.port = port; + this.workerThreads = workerThreads; + this.messageDecoder = messageDecoder; + this.dispatcher = dispatcher; + this.sessions = sessions; + this.channelRegistry = channelRegistry; + this.pendingRequests = pendingRequests; + } + + @Override + public void afterPropertiesSet() throws Exception { + ThreadFactory bossTf = r -> new Thread(r, "jt808-boss"); + ThreadFactory wkTf = r -> new Thread(r, "jt808-worker"); + this.boss = new NioEventLoopGroup(1, bossTf); + this.workers = new NioEventLoopGroup( + workerThreads == 0 ? Runtime.getRuntime().availableProcessors() * 2 : workerThreads, + wkTf); + + ServerBootstrap b = new ServerBootstrap(); + b.group(boss, workers) + .channel(NioServerSocketChannel.class) + .option(ChannelOption.SO_BACKLOG, 1024) + .childOption(ChannelOption.TCP_NODELAY, true) + .childOption(ChannelOption.SO_KEEPALIVE, true) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ch.pipeline() + .addLast("frame", new Jt808FrameDecoder()) + .addLast("dispatch", new Jt808ChannelHandler( + messageDecoder, dispatcher, sessions, + channelRegistry, pendingRequests)); + } + }); + this.serverChannel = b.bind(port).sync().channel(); + log.info("[jt808] Netty server listening on :{}", port); + } + + @Override + public void destroy() { + try { + if (serverChannel != null) serverChannel.close().sync(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + if (boss != null) boss.shutdownGracefully(); + if (workers != null) workers.shutdownGracefully(); + log.info("[jt808] Netty server stopped"); + } + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java new file mode 100644 index 00000000..abcbc4a0 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java @@ -0,0 +1,58 @@ +package com.lingniu.ingest.protocol.jt808.mapper; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.spi.EventMapper; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * JT808 消息 → 领域事件映射。sealed body 允许 switch 穷尽处理。 + * + *

    备注:JT808 里没有强制的 VIN 字段 —— 我们用 {@code phone}(终端手机号/设备号) + * 作为 VIN 占位,真实 VIN 需要通过后续的 session-core 从注册消息里补齐。 + */ +public final class Jt808EventMapper implements EventMapper { + + @Override + public List toEvents(Jt808Message message) { + var header = message.header(); + Instant ingestTime = Instant.now(); + Map meta = Map.of( + "messageId", "0x" + Integer.toHexString(header.messageId()), + "protocolVersion", header.version().name(), + "phone", header.phone()); + String vin = header.phone(); // TODO: 由 session-core 注入真实 VIN + + return switch (message.body()) { + case Jt808Body.Location loc -> List.of(new VehicleEvent.Location( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, loc.deviceTime(), ingestTime, null, meta, + new LocationPayload(loc.longitude(), loc.latitude(), + loc.altitudeM(), loc.speedKmh(), loc.directionDeg(), + loc.alarmFlag(), loc.statusFlag()))); + + case Jt808Body.Register reg -> List.of(new VehicleEvent.Login( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta, + reg.deviceId(), header.version().name())); + + case Jt808Body.Auth auth -> List.of(new VehicleEvent.Login( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta, + auth.imei() == null ? "" : auth.imei(), header.version().name())); + + case Jt808Body.Heartbeat _ -> List.of(new VehicleEvent.Heartbeat( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta)); + + case Jt808Body.Raw _ -> List.of(); // 未解析的消息体:暂不产出事件 + }; + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java new file mode 100644 index 00000000..e9334bb5 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java @@ -0,0 +1,43 @@ +package com.lingniu.ingest.protocol.jt808.model; + +import java.time.Instant; + +/** + * 已解析的消息体。sealed,新增消息体时必须同步新增子类型与 Parser。 + */ +public sealed interface Jt808Body + permits Jt808Body.Register, Jt808Body.Auth, Jt808Body.Location, + Jt808Body.Heartbeat, Jt808Body.Raw { + + /** 终端注册 0x0100。 */ + record Register( + int province, + int city, + String maker, + String deviceType, + String deviceId, + int plateColor, + String plate + ) implements Jt808Body {} + + /** 终端鉴权 0x0102。 */ + record Auth(String token, String imei, String softwareVersion) implements Jt808Body {} + + /** 位置信息汇报 0x0200。 */ + record Location( + long alarmFlag, + long statusFlag, + double longitude, + double latitude, + int altitudeM, + double speedKmh, + int directionDeg, + Instant deviceTime + ) implements Jt808Body {} + + /** 心跳 0x0002(无 body)。 */ + record Heartbeat() implements Jt808Body {} + + /** 未实现 Parser 的消息体,透传原始字节。 */ + record Raw(int messageId, byte[] bytes) implements Jt808Body {} +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Header.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Header.java new file mode 100644 index 00000000..e1ce252b --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Header.java @@ -0,0 +1,28 @@ +package com.lingniu.ingest.protocol.jt808.model; + +/** + * JT/T 808 消息头。 + * + * @param messageId 消息 ID + * @param bodyLength 消息体长度(从 attrs 低 10 位解出) + * @param encryptType 加密类型(0=不加密 / 1=RSA) + * @param subpacket 是否分包 + * @param version 协议版本(2013 或 2019) + * @param phone 终端手机号 / 设备识别号(2013:12 位,2019:20 位) + * @param serialNo 消息流水号 + * @param totalPackets 分包总数(不分包时为 0) + * @param packetSeq 分包序号(从 1 开始) + */ +public record Jt808Header( + int messageId, + int bodyLength, + int encryptType, + boolean subpacket, + ProtocolVersion version, + String phone, + int serialNo, + int totalPackets, + int packetSeq +) { + public enum ProtocolVersion { V2013, V2019 } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Message.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Message.java new file mode 100644 index 00000000..61a60bb2 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Message.java @@ -0,0 +1,6 @@ +package com.lingniu.ingest.protocol.jt808.model; + +/** + * 完整解析后的 JT808 消息。 + */ +public record Jt808Message(Jt808Header header, Jt808Body body) {} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java new file mode 100644 index 00000000..90b45866 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java @@ -0,0 +1,36 @@ +package com.lingniu.ingest.protocol.jt808.model; + +/** + * JT/T 808 消息 ID 常量集(节选核心消息,按需继续扩展)。 + * + *

    使用常量类而非 enum:808 消息种类多(超过 80 个),新增高频,常量类比 enum 维护成本更低。 + */ +public final class Jt808MessageId { + + private Jt808MessageId() {} + + // ===== 终端上行 ===== + public static final int TERMINAL_GENERAL_RESPONSE = 0x0001; + public static final int TERMINAL_HEARTBEAT = 0x0002; + public static final int TERMINAL_UNREGISTER = 0x0003; + public static final int TERMINAL_REGISTER = 0x0100; + public static final int TERMINAL_AUTH = 0x0102; + public static final int TERMINAL_PARAMS_REPORT = 0x0104; + public static final int TERMINAL_ATTRS_REPORT = 0x0107; + public static final int TERMINAL_LOCATION = 0x0200; + public static final int TERMINAL_LOCATION_BATCH = 0x0704; + public static final int TERMINAL_EVENT_REPORT = 0x0301; + public static final int TERMINAL_CAN_DATA_REPORT = 0x0705; + public static final int TERMINAL_MEDIA_EVENT = 0x0800; + public static final int TERMINAL_MEDIA_UPLOAD = 0x0801; + public static final int TERMINAL_PASSTHROUGH = 0x0900; + + // ===== 平台下行 ===== + public static final int PLATFORM_GENERAL_RESPONSE = 0x8001; + public static final int PLATFORM_REGISTER_ACK = 0x8100; + public static final int PLATFORM_SET_PARAMS = 0x8103; + public static final int PLATFORM_QUERY_PARAMS = 0x8104; + public static final int PLATFORM_TERMINAL_CONTROL = 0x8105; + public static final int PLATFORM_QUERY_LOCATION = 0x8201; + public static final int PLATFORM_TRACK_CONTROL = 0x8202; +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java new file mode 100644 index 00000000..6b38c2d9 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java @@ -0,0 +1,62 @@ +package com.lingniu.ingest.protocol.jt808.session; + +import io.netty.channel.Channel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * JT808 终端 Channel 注册表。按 {@code phone} 索引,一个 phone 对应一个活跃 Channel。 + * + *

    {@link Jt808CommandDispatcher} 通过本类查 Channel;{@code Jt808ChannelHandler} 在 + * 连接建立 / 断开 / 鉴权完成时维护映射。 + * + *

    线程安全:{@link ConcurrentHashMap}。 + */ +public final class Jt808ChannelRegistry { + + private static final Logger log = LoggerFactory.getLogger(Jt808ChannelRegistry.class); + + private final ConcurrentMap byPhone = new ConcurrentHashMap<>(); + private final ConcurrentMap reverse = new ConcurrentHashMap<>(); + private final ConcurrentMap serials = new ConcurrentHashMap<>(); + + public void bind(String phone, Channel channel) { + if (phone == null || phone.isBlank() || channel == null) return; + Channel old = byPhone.put(phone, channel); + reverse.put(channel, phone); + if (old != null && old != channel) { + reverse.remove(old); + old.close(); + log.info("channel rebound phone={} oldChannel={} newChannel={}", phone, old.id(), channel.id()); + } + } + + public Optional find(String phone) { + if (phone == null) return Optional.empty(); + Channel c = byPhone.get(phone); + if (c == null || !c.isActive()) { + if (c != null) unbind(c); + return Optional.empty(); + } + return Optional.of(c); + } + + public void unbind(Channel channel) { + String phone = reverse.remove(channel); + if (phone != null) byPhone.remove(phone, channel); + } + + public int nextSerial(String phone) { + return serials.computeIfAbsent(phone, k -> new AtomicInteger()) + .updateAndGet(i -> (i + 1) & 0xFFFF); + } + + public int size() { + return byPhone.size(); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java new file mode 100644 index 00000000..58b57f93 --- /dev/null +++ b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java @@ -0,0 +1,51 @@ +package com.lingniu.ingest.protocol.jt808.session; + +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * 下行命令的请求/应答同步匹配表。 + * + *

    调用 {@link #await(String, int)} 时注册一个 {@link CompletableFuture}, + * 对应 key = {@code phone + ':' + serial};当 {@code Jt808ChannelHandler} 收到 0x0001 通用应答 + * 或其它同步应答消息时调用 {@link #complete(String, int, Jt808Message)} 触发 future 完成。 + */ +public final class Jt808PendingRequests { + + private static final Logger log = LoggerFactory.getLogger(Jt808PendingRequests.class); + + private final ConcurrentMap> pending = new ConcurrentHashMap<>(); + + public CompletableFuture await(String phone, int serial) { + CompletableFuture cf = new CompletableFuture<>(); + pending.put(key(phone, serial), cf); + return cf; + } + + public void complete(String phone, int serial, Jt808Message response) { + CompletableFuture cf = pending.remove(key(phone, serial)); + if (cf != null) { + cf.complete(response); + } else { + log.debug("no pending request for phone={} serial={}", phone, serial); + } + } + + public void cancel(String phone, int serial) { + CompletableFuture cf = pending.remove(key(phone, serial)); + if (cf != null) cf.cancel(true); + } + + public int size() { + return pending.size(); + } + + private static String key(String phone, int serial) { + return phone + ":" + serial; + } +} diff --git a/protocol-jt808/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/protocol-jt808/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..9af2ea7e --- /dev/null +++ b/protocol-jt808/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderGoldenTest.java b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderGoldenTest.java new file mode 100644 index 00000000..94c366fb --- /dev/null +++ b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderGoldenTest.java @@ -0,0 +1,79 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * JT808 黄金样本集回放测试:遍历 {@code src/test/resources/samples/jt808/*.hex}。 + * + *

    样本文件格式:一行十六进制(完整 0x7e 包裹帧),脚本脱敏产出。 + * 测试会去掉首尾 0x7e,执行反转义后调用 {@link Jt808MessageDecoder}。 + */ +class Jt808DecoderGoldenTest { + + private final Jt808MessageDecoder decoder = new Jt808MessageDecoder( + new BodyParserRegistry(List.of( + new RegisterBodyParser(), + new LocationBodyParser(), + new HeartbeatBodyParser()))); + + @TestFactory + Collection replaySamples() throws URISyntaxException, IOException { + var url = getClass().getClassLoader().getResource("samples/jt808"); + if (url == null) return List.of(); + Path dir = Paths.get(url.toURI()); + try (Stream s = Files.list(dir)) { + return s.filter(p -> p.toString().endsWith(".hex")) + .sorted() + .map(this::toTest) + .collect(Collectors.toList()); + } + } + + private DynamicTest toTest(Path sample) { + return DynamicTest.dynamicTest(sample.getFileName().toString(), () -> { + byte[] framed = readHex(sample); + assertThat(framed[0]).as("must start with 0x7e").isEqualTo((byte) 0x7e); + assertThat(framed[framed.length - 1]).as("must end with 0x7e").isEqualTo((byte) 0x7e); + + byte[] unescaped = Jt808Escape.unescape(framed, 1, framed.length - 2); + Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped)); + + assertThat(msg.header().messageId()).isNotNegative(); + assertThat(msg.header().phone()).isNotEmpty(); + }); + } + + private static byte[] readHex(Path path) throws IOException { + StringBuilder sb = new StringBuilder(); + for (String line : Files.readAllLines(path)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; + sb.append(trimmed); + } + String hex = sb.toString(); + int len = hex.length() / 2; + byte[] out = new byte[len]; + for (int i = 0; i < len; i++) { + out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return out; + } +} diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java new file mode 100644 index 00000000..bcca163e --- /dev/null +++ b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java @@ -0,0 +1,98 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.codec.BcdCodec; +import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808DecoderTest { + + private final Jt808MessageDecoder decoder = new Jt808MessageDecoder( + new BodyParserRegistry(List.of( + new RegisterBodyParser(), + new LocationBodyParser(), + new HeartbeatBodyParser()))); + + @Test + void decodesSyntheticLocationFrame() { + byte[] body = buildLocationBody(); + byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 0x0001, body); + Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame)); + + assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION); + assertThat(msg.header().phone()).isEqualTo("123456789012"); + assertThat(msg.header().serialNo()).isEqualTo(1); + + assertThat(msg.body()).isInstanceOf(Jt808Body.Location.class); + Jt808Body.Location loc = (Jt808Body.Location) msg.body(); + assertThat(loc.longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); + assertThat(loc.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001)); + assertThat(loc.speedKmh()).isEqualTo(52.3, org.assertj.core.data.Offset.offset(0.01)); + } + + @Test + void decodesSyntheticHeartbeatFrame() { + byte[] frame = buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 0x0002, new byte[0]); + Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame)); + assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT); + assertThat(msg.body()).isInstanceOf(Jt808Body.Heartbeat.class); + } + + // ===== helpers ===== + + private static byte[] buildLocationBody() { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeU32(os, 0); // alarmFlag + writeU32(os, 0x00030000); // statusFlag + writeU32(os, 39_916_527L); // lat + writeU32(os, 116_397_128L); // lon + writeU16(os, 50); // altitude + writeU16(os, 523); // speed 52.3 + writeU16(os, 90); // direction + byte[] bcd = BcdCodec.encode("240102030405"); + os.write(bcd, 0, 6); + return os.toByteArray(); + } + + private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) { + // header: msgId(2) attrs(2) phone(6 BCD) serial(2) + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeU16(os, msgId); + int attrs = body.length & 0x03FF; // 2013 版,无子包、无加密 + writeU16(os, attrs); + byte[] phoneBcd = BcdCodec.encode(phone); + os.write(phoneBcd, 0, 6); + writeU16(os, serial); + os.write(body, 0, body.length); + byte[] raw = os.toByteArray(); + byte bcc = BccChecksum.compute(raw, 0, raw.length); + + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + framed.write(raw, 0, raw.length); + framed.write(bcc & 0xFF); + return framed.toByteArray(); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(ByteArrayOutputStream os, long v) { + os.write((int) ((v >> 24) & 0xFF)); + os.write((int) ((v >> 16) & 0xFF)); + os.write((int) ((v >> 8) & 0xFF)); + os.write((int) (v & 0xFF)); + } +} diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808EscapeTest.java b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808EscapeTest.java new file mode 100644 index 00000000..677e8276 --- /dev/null +++ b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808EscapeTest.java @@ -0,0 +1,25 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808EscapeTest { + + @Test + void roundTrip() { + byte[] raw = {0x30, 0x7e, 0x08, 0x7d, (byte) 0xFF}; + byte[] escaped = Jt808Escape.escape(raw); + assertThat(escaped).containsExactly(0x30, 0x7d, 0x02, 0x08, 0x7d, 0x01, 0xFF); + + byte[] back = Jt808Escape.unescape(escaped, 0, escaped.length); + assertThat(back).containsExactly(raw[0], raw[1], raw[2], raw[3], raw[4]); + } + + @Test + void noEscapeNeeded() { + byte[] raw = {0x01, 0x02, 0x03}; + assertThat(Jt808Escape.escape(raw)).containsExactly(0x01, 0x02, 0x03); + assertThat(Jt808Escape.unescape(raw, 0, raw.length)).containsExactly(0x01, 0x02, 0x03); + } +} diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java new file mode 100644 index 00000000..e6e93933 --- /dev/null +++ b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java @@ -0,0 +1,57 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser; +import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 端到端验证:encoder 生成的下行帧可以被 decoder 反向解析,且字段对称。 + */ +class Jt808FrameEncoderTest { + + private final Jt808MessageDecoder decoder = new Jt808MessageDecoder( + new BodyParserRegistry(List.of( + new RegisterBodyParser(), + new LocationBodyParser(), + new HeartbeatBodyParser()))); + + @Test + void encodeThenDecodePlatformAck() { + var cmd = Jt808Commands.platformAck(0x1234, 0x0200, 0); + byte[] framed = Jt808FrameEncoder.encode(cmd.messageId(), "123456789012", 7, cmd.body()); + + // strip outer 0x7e and unescape + byte[] unescaped = Jt808Escape.unescape(framed, 1, framed.length - 2); + Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped)); + + assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.PLATFORM_GENERAL_RESPONSE); + assertThat(msg.header().phone()).isEqualTo("123456789012"); + assertThat(msg.header().serialNo()).isEqualTo(7); + assertThat(msg.body()).isInstanceOf(Jt808Body.Raw.class); + Jt808Body.Raw raw = (Jt808Body.Raw) msg.body(); + // body: ackSerial(2) + ackMsgId(2) + result(1) = 5 bytes + assertThat(raw.bytes()).hasSize(5); + } + + @Test + void encodeQueryLocationProducesFramedWith7eBoundary() { + byte[] framed = Jt808FrameEncoder.encode( + Jt808MessageId.PLATFORM_QUERY_LOCATION, "123456789012", 1, new byte[0]); + assertThat(framed[0]).isEqualTo((byte) 0x7e); + assertThat(framed[framed.length - 1]).isEqualTo((byte) 0x7e); + // 中间不应出现裸 0x7e + for (int i = 1; i < framed.length - 1; i++) { + assertThat(framed[i]).isNotEqualTo((byte) 0x7e); + } + } +} diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java new file mode 100644 index 00000000..53dbb73e --- /dev/null +++ b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java @@ -0,0 +1,46 @@ +package com.lingniu.ingest.protocol.jt808.mapper; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808EventMapperTest { + + private final Jt808EventMapper mapper = new Jt808EventMapper(); + + @Test + void locationBodyProducesLocationEvent() { + var header = new Jt808Header( + Jt808MessageId.TERMINAL_LOCATION, 28, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + var body = new Jt808Body.Location( + 0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z")); + var msg = new Jt808Message(header, body); + + List events = mapper.toEvents(msg); + + assertThat(events).hasSize(1); + assertThat(events.get(0)).isInstanceOf(VehicleEvent.Location.class); + assertThat(events.get(0).source()).isEqualTo(ProtocolId.JT808); + assertThat(events.get(0).vin()).isEqualTo("123456789012"); + } + + @Test + void heartbeatBodyProducesHeartbeatEvent() { + var header = new Jt808Header( + Jt808MessageId.TERMINAL_HEARTBEAT, 0, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + var msg = new Jt808Message(header, new Jt808Body.Heartbeat()); + assertThat(mapper.toEvents(msg)).hasSize(1) + .first().isInstanceOf(VehicleEvent.Heartbeat.class); + } +} diff --git a/protocol-jt808/src/test/resources/samples/jt808/auth_001.hex b/protocol-jt808/src/test/resources/samples/jt808/auth_001.hex new file mode 100644 index 00000000..48bcc762 --- /dev/null +++ b/protocol-jt808/src/test/resources/samples/jt808/auth_001.hex @@ -0,0 +1 @@ +7e010200070000000000230000687569746f6e67417e diff --git a/protocol-jt808/src/test/resources/samples/jt808/auth_002.hex b/protocol-jt808/src/test/resources/samples/jt808/auth_002.hex new file mode 100644 index 00000000..9ef924e7 --- /dev/null +++ b/protocol-jt808/src/test/resources/samples/jt808/auth_002.hex @@ -0,0 +1 @@ +7e0102001800000000006500016e65382f51525a50664738596f7145575155577236513d3d607e diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_001.hex b/protocol-jt808/src/test/resources/samples/jt808/location_001.hex new file mode 100644 index 00000000..c515a631 --- /dev/null +++ b/protocol-jt808/src/test/resources/samples/jt808/location_001.hex @@ -0,0 +1 @@ +7e02000036000000000001072d000000000008000301ceb03b0732f26e000d02af0010260413123353010400052425020200000302000025040000000030011f3101192c7e diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_002.hex b/protocol-jt808/src/test/resources/samples/jt808/location_002.hex new file mode 100644 index 00000000..1832fb3a --- /dev/null +++ b/protocol-jt808/src/test/resources/samples/jt808/location_002.hex @@ -0,0 +1 @@ +7e020040460100000000000000000002002c00000000000c000301bb0e2107213304003f0000010d26041312335514040000000017020000010400006722030200002504000000002a020000300115310118ea0402008000fb7e diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_010.hex b/protocol-jt808/src/test/resources/samples/jt808/location_010.hex new file mode 100644 index 00000000..2819da10 --- /dev/null +++ b/protocol-jt808/src/test/resources/samples/jt808/location_010.hex @@ -0,0 +1 @@ +7e020000360000000000100278000000000008000301d596aa0735b9910001004b01472604131234030104000a42c10202000003020000250400000000300115310117107e diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_100.hex b/protocol-jt808/src/test/resources/samples/jt808/location_100.hex new file mode 100644 index 00000000..fa3a3be1 --- /dev/null +++ b/protocol-jt808/src/test/resources/samples/jt808/location_100.hex @@ -0,0 +1 @@ +7e020000360000000001020443000000000008000301d2d74907376cde000f00000000260413123354010400078789020200000302000025040000000030011b310112db7e diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_200.hex b/protocol-jt808/src/test/resources/samples/jt808/location_200.hex new file mode 100644 index 00000000..ce0d2a99 --- /dev/null +++ b/protocol-jt808/src/test/resources/samples/jt808/location_200.hex @@ -0,0 +1 @@ +7e02004048010000000000000000020204d2000000000048000301cc1d4f073ed6640010033301492604131234001404000000001702000001040000f0542504000000002a02000030011f310110ea04020c8300ef0400000000d67e diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_400.hex b/protocol-jt808/src/test/resources/samples/jt808/location_400.hex new file mode 100644 index 00000000..3540edbf --- /dev/null +++ b/protocol-jt808/src/test/resources/samples/jt808/location_400.hex @@ -0,0 +1 @@ +7e02000036000000000413013d000000000008000201d392280736f1e30007000000002604131311220104000f334a020200000302000025040000000030011931010a097e diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_batch_001.hex b/protocol-jt808/src/test/resources/samples/jt808/location_batch_001.hex new file mode 100644 index 00000000..104ef178 --- /dev/null +++ b/protocol-jt808/src/test/resources/samples/jt808/location_batch_001.hex @@ -0,0 +1 @@ +7e0704003b000000000058034e0001010036000000000008000301e26c120725c15100110000000726041313111301040006ffc6030200002504000000002a02000030010a31010c3c7e diff --git a/reference/GBT+32960.3-2016.pdf b/reference/GBT+32960.3-2016.pdf new file mode 100644 index 00000000..8b9a945e Binary files /dev/null and b/reference/GBT+32960.3-2016.pdf differ diff --git a/reference/GBT+32960.3-2025.pdf b/reference/GBT+32960.3-2025.pdf new file mode 100644 index 00000000..c29abb89 Binary files /dev/null and b/reference/GBT+32960.3-2025.pdf differ diff --git a/session-core/pom.xml b/session-core/pom.xml new file mode 100644 index 00000000..a24f02ab --- /dev/null +++ b/session-core/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + session-core + session-core + 设备会话 + 鉴权 + Token + Redis 兜底。 + + + + com.lingniu.ingest + ingest-api + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-autoconfigure + + + com.github.ben-manes.caffeine + caffeine + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + diff --git a/session-core/src/main/java/com/lingniu/ingest/session/CommandDispatcher.java b/session-core/src/main/java/com/lingniu/ingest/session/CommandDispatcher.java new file mode 100644 index 00000000..30958922 --- /dev/null +++ b/session-core/src/main/java/com/lingniu/ingest/session/CommandDispatcher.java @@ -0,0 +1,23 @@ +package com.lingniu.ingest.session; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; + +/** + * 下行命令调度器 SPI。 + * + *

    用于 command-gateway 把 HTTP 请求转为设备命令,等待设备应答。真正的实现需要与协议 + * 模块(如 jt808)的 Netty 通道绑定:按 sessionId 找到 Channel → 发送命令字节 → 用 + * {@code CompletableFuture + 序列号 map} 实现同步请求/应答。 + * + *

    当前阶段提供接口 + 占位实现,具体 Netty 绑定在 command-gateway / protocol-jt808 + * 的下一批次落地。 + */ +public interface CommandDispatcher { + + /** 异步下发命令,不等应答。 */ + CompletableFuture notify(String sessionId, Object command); + + /** 同步下发命令并等待应答。 */ + CompletableFuture request(String sessionId, Object command, Class responseType, Duration timeout); +} diff --git a/session-core/src/main/java/com/lingniu/ingest/session/DeviceSession.java b/session-core/src/main/java/com/lingniu/ingest/session/DeviceSession.java new file mode 100644 index 00000000..b2261608 --- /dev/null +++ b/session-core/src/main/java/com/lingniu/ingest/session/DeviceSession.java @@ -0,0 +1,46 @@ +package com.lingniu.ingest.session; + +import com.lingniu.ingest.api.ProtocolId; + +import java.time.Instant; +import java.util.Map; + +/** + * 设备会话领域模型。不可变;更新通过 {@link SessionStore#update} 原子替换。 + * + * @param sessionId 会话 ID(通常由 Netty channel id + 终端号生成) + * @param protocol 协议 ID + * @param vin 车架号(注册/鉴权后补齐,注册前为 null) + * @param phone 终端手机号 / 设备号 + * @param token 鉴权 Token(平台下发) + * @param peerAddress 对端 IP:port + * @param registeredAt 注册时间 + * @param lastSeenAt 最后活跃时间 + * @param attributes 协议特定属性(协议版本、型号等) + */ +public record DeviceSession( + String sessionId, + ProtocolId protocol, + String vin, + String phone, + String token, + String peerAddress, + Instant registeredAt, + Instant lastSeenAt, + Map attributes +) { + public DeviceSession touch(Instant now) { + return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress, + registeredAt, now, attributes); + } + + public DeviceSession withVin(String vin) { + return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress, + registeredAt, lastSeenAt, attributes); + } + + public DeviceSession withToken(String token) { + return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress, + registeredAt, lastSeenAt, attributes); + } +} diff --git a/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java b/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java new file mode 100644 index 00000000..3b59d0c2 --- /dev/null +++ b/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java @@ -0,0 +1,82 @@ +package com.lingniu.ingest.session; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.UnaryOperator; + +/** + * 纯内存会话存储。支持按 sessionId / vin / phone 三种 key 查询。 + * + *

    失活清理:基于 {@link Caffeine} 的 {@code expireAfterAccess 30 分钟}。 + * 生产环境若需多节点一致性,可用 Redis 实现替换此 Bean。 + */ +public final class InMemorySessionStore implements SessionStore { + + private final Cache byId; + private final ConcurrentMap vinToId = new ConcurrentHashMap<>(); + private final ConcurrentMap phoneToId = new ConcurrentHashMap<>(); + + public InMemorySessionStore() { + this.byId = Caffeine.newBuilder() + .expireAfterAccess(Duration.ofMinutes(30)) + .removalListener((String sid, DeviceSession s, com.github.benmanes.caffeine.cache.RemovalCause c) -> { + if (s == null) return; + if (s.vin() != null) vinToId.remove(s.vin(), sid); + if (s.phone() != null) phoneToId.remove(s.phone(), sid); + }) + .build(); + } + + @Override + public void put(DeviceSession session) { + byId.put(session.sessionId(), session); + if (session.vin() != null) vinToId.put(session.vin(), session.sessionId()); + if (session.phone() != null) phoneToId.put(session.phone(), session.sessionId()); + } + + @Override + public Optional findBySessionId(String sessionId) { + return Optional.ofNullable(byId.getIfPresent(sessionId)); + } + + @Override + public Optional findByVin(String vin) { + String sid = vinToId.get(vin); + return sid == null ? Optional.empty() : findBySessionId(sid); + } + + @Override + public Optional findByPhone(String phone) { + String sid = phoneToId.get(phone); + return sid == null ? Optional.empty() : findBySessionId(sid); + } + + @Override + public synchronized Optional update(String sessionId, UnaryOperator updater) { + DeviceSession old = byId.getIfPresent(sessionId); + if (old == null) return Optional.empty(); + DeviceSession next = updater.apply(old); + put(next); + return Optional.of(next); + } + + @Override + public void remove(String sessionId) { + DeviceSession s = byId.getIfPresent(sessionId); + if (s != null) { + byId.invalidate(sessionId); + if (s.vin() != null) vinToId.remove(s.vin(), sessionId); + if (s.phone() != null) phoneToId.remove(s.phone(), sessionId); + } + } + + @Override + public int size() { + return (int) byId.estimatedSize(); + } +} diff --git a/session-core/src/main/java/com/lingniu/ingest/session/NoopCommandDispatcher.java b/session-core/src/main/java/com/lingniu/ingest/session/NoopCommandDispatcher.java new file mode 100644 index 00000000..6539dabe --- /dev/null +++ b/session-core/src/main/java/com/lingniu/ingest/session/NoopCommandDispatcher.java @@ -0,0 +1,31 @@ +package com.lingniu.ingest.session; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; + +/** + * 占位命令调度器:返回 {@link UnsupportedOperationException}, + * 待 command-gateway 与协议模块的下行通道打通后替换为真实实现。 + */ +public final class NoopCommandDispatcher implements CommandDispatcher { + + private static final Logger log = LoggerFactory.getLogger(NoopCommandDispatcher.class); + + @Override + public CompletableFuture notify(String sessionId, Object command) { + log.warn("[noop] notify sessionId={} command={} dropped", sessionId, command); + return CompletableFuture.failedFuture(new UnsupportedOperationException( + "CommandDispatcher not yet implemented; configure a real one in protocol module.")); + } + + @Override + public CompletableFuture request(String sessionId, Object command, Class responseType, Duration timeout) { + log.warn("[noop] request sessionId={} command={} responseType={} dropped", + sessionId, command, responseType.getSimpleName()); + return CompletableFuture.failedFuture(new UnsupportedOperationException( + "CommandDispatcher not yet implemented; configure a real one in protocol module.")); + } +} diff --git a/session-core/src/main/java/com/lingniu/ingest/session/SessionStore.java b/session-core/src/main/java/com/lingniu/ingest/session/SessionStore.java new file mode 100644 index 00000000..32c94941 --- /dev/null +++ b/session-core/src/main/java/com/lingniu/ingest/session/SessionStore.java @@ -0,0 +1,25 @@ +package com.lingniu.ingest.session; + +import java.util.Optional; +import java.util.function.UnaryOperator; + +/** + * 设备会话存储 SPI。默认实现是内存 + Caffeine,生产可替换为 Redis 兜底。 + */ +public interface SessionStore { + + void put(DeviceSession session); + + Optional findBySessionId(String sessionId); + + Optional findByVin(String vin); + + Optional findByPhone(String phone); + + /** 原子更新:读 → 修改 → 写。返回更新后的会话;若原会话不存在返回 empty。 */ + Optional update(String sessionId, UnaryOperator updater); + + void remove(String sessionId); + + int size(); +} diff --git a/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java b/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java new file mode 100644 index 00000000..8c63d795 --- /dev/null +++ b/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.lingniu.ingest.session.config; + +import com.lingniu.ingest.session.CommandDispatcher; +import com.lingniu.ingest.session.InMemorySessionStore; +import com.lingniu.ingest.session.NoopCommandDispatcher; +import com.lingniu.ingest.session.SessionStore; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +public class SessionCoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public SessionStore sessionStore() { + return new InMemorySessionStore(); + } + + @Bean + @ConditionalOnMissingBean + public CommandDispatcher commandDispatcher() { + return new NoopCommandDispatcher(); + } +} diff --git a/session-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/session-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..0f53ea0f --- /dev/null +++ b/session-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.session.config.SessionCoreAutoConfiguration diff --git a/sink-archive/pom.xml b/sink-archive/pom.xml new file mode 100644 index 00000000..9669e047 --- /dev/null +++ b/sink-archive/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + sink-archive + sink-archive + 原始报文冷存:本地文件系统实现 + S3/OSS 扩展点。 + + + + com.lingniu.ingest + ingest-api + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + diff --git a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveStore.java b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveStore.java new file mode 100644 index 00000000..1e7f7205 --- /dev/null +++ b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveStore.java @@ -0,0 +1,32 @@ +package com.lingniu.ingest.sink.archive; + +import java.io.IOException; +import java.io.InputStream; + +/** + * 冷存后端 SPI。 + * + *

    当前提供本地文件系统实现 {@link LocalArchiveStore},未来可加 S3/OSS 实现。 + * 同一接口既用于 JSATL12 报警附件的分块写入,也用于 Envelope 的原始报文存档。 + */ +public interface ArchiveStore { + + /** + * 写入一段字节流。 + * + * @param key 逻辑路径,例如 {@code 2026/04/13/LTEST000.../session-abc/file.bin} + * @param data 数据流(调用方负责关闭) + * @return 可回传给下游的引用 URI({@code file://...} 或 {@code s3://...}) + */ + String put(String key, InputStream data, long length) throws IOException; + + /** 追加写:用于分块上传场景。key 应在同一文件上稳定。 */ + String append(String key, byte[] chunk) throws IOException; + + /** 读取(用于重放 / 回放测试)。 */ + InputStream get(String key) throws IOException; + + boolean exists(String key); + + long size(String key) throws IOException; +} diff --git a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/LocalArchiveStore.java b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/LocalArchiveStore.java new file mode 100644 index 00000000..039d908e --- /dev/null +++ b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/LocalArchiveStore.java @@ -0,0 +1,83 @@ +package com.lingniu.ingest.sink.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +/** + * 本地文件系统冷存实现。根路径支持 {@code file:///abs/path} 或相对路径。 + */ +public final class LocalArchiveStore implements ArchiveStore { + + private static final Logger log = LoggerFactory.getLogger(LocalArchiveStore.class); + + private final Path root; + + public LocalArchiveStore(String rootUri) { + Path path; + if (rootUri == null || rootUri.isBlank()) { + path = Paths.get(System.getProperty("java.io.tmpdir"), "lingniu-archive"); + } else if (rootUri.startsWith("file://")) { + path = Paths.get(rootUri.substring("file://".length())); + } else { + path = Paths.get(rootUri); + } + this.root = path.toAbsolutePath(); + try { + Files.createDirectories(this.root); + } catch (IOException e) { + throw new IllegalStateException("archive root init failed: " + this.root, e); + } + log.info("local archive store initialized root={}", this.root); + } + + @Override + public String put(String key, InputStream data, long length) throws IOException { + Path target = resolve(key); + Files.createDirectories(target.getParent()); + try (InputStream in = data) { + Files.copy(in, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + return "file://" + target; + } + + @Override + public String append(String key, byte[] chunk) throws IOException { + Path target = resolve(key); + Files.createDirectories(target.getParent()); + Files.write(target, chunk, + StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE); + return "file://" + target; + } + + @Override + public InputStream get(String key) throws IOException { + return Files.newInputStream(resolve(key)); + } + + @Override + public boolean exists(String key) { + return Files.exists(resolve(key)); + } + + @Override + public long size(String key) throws IOException { + return Files.size(resolve(key)); + } + + public Path root() { + return root; + } + + private Path resolve(String key) { + // 防目录穿越 + String normalized = key.replace("..", "_").replaceAll("^/+", ""); + return root.resolve(normalized); + } +} diff --git a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java new file mode 100644 index 00000000..f455b55e --- /dev/null +++ b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java @@ -0,0 +1,26 @@ +package com.lingniu.ingest.sink.archive.config; + +import com.lingniu.ingest.sink.archive.ArchiveStore; +import com.lingniu.ingest.sink.archive.LocalArchiveStore; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@EnableConfigurationProperties(SinkArchiveProperties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.sink.archive", name = "enabled", havingValue = "true", matchIfMissing = true) +public class SinkArchiveAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public ArchiveStore archiveStore(SinkArchiveProperties props) { + return switch (props.getType() == null ? "local" : props.getType().toLowerCase()) { + case "local" -> new LocalArchiveStore(props.getPath()); + default -> throw new IllegalStateException( + "archive type not supported yet: " + props.getType() + + " (only 'local' is implemented; s3/oss TBD)"); + }; + } +} diff --git a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java new file mode 100644 index 00000000..a7174655 --- /dev/null +++ b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java @@ -0,0 +1,20 @@ +package com.lingniu.ingest.sink.archive.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "lingniu.ingest.sink.archive") +public class SinkArchiveProperties { + + private boolean enabled = true; + /** local / s3 / oss (仅 local 有实现,其余待补) */ + private String type = "local"; + /** 根路径(URI 或绝对路径)。 */ + private String path = System.getProperty("java.io.tmpdir") + "/lingniu-archive"; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getType() { return type; } + public void setType(String type) { this.type = type; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } +} diff --git a/sink-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sink-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..4dcf699d --- /dev/null +++ b/sink-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration diff --git a/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/LocalArchiveStoreTest.java b/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/LocalArchiveStoreTest.java new file mode 100644 index 00000000..3dbb60b7 --- /dev/null +++ b/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/LocalArchiveStoreTest.java @@ -0,0 +1,38 @@ +package com.lingniu.ingest.sink.archive; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class LocalArchiveStoreTest { + + @Test + void putThenGet(@TempDir Path tmp) throws Exception { + LocalArchiveStore store = new LocalArchiveStore(tmp.toString()); + byte[] data = "hello-raw".getBytes(); + String ref = store.put("2026/04/13/sample.bin", new ByteArrayInputStream(data), data.length); + assertThat(ref).startsWith("file://"); + assertThat(store.exists("2026/04/13/sample.bin")).isTrue(); + assertThat(store.size("2026/04/13/sample.bin")).isEqualTo(data.length); + } + + @Test + void appendChunksAccumulate(@TempDir Path tmp) throws Exception { + LocalArchiveStore store = new LocalArchiveStore(tmp.toString()); + store.append("chunks/a.bin", new byte[]{1, 2, 3}); + store.append("chunks/a.bin", new byte[]{4, 5}); + assertThat(store.size("chunks/a.bin")).isEqualTo(5); + } + + @Test + void rejectsPathTraversal(@TempDir Path tmp) throws Exception { + LocalArchiveStore store = new LocalArchiveStore(tmp.toString()); + store.append("../../evil.bin", new byte[]{1}); + // 目录穿越:每个 .. 被替换为 _ + assertThat(store.exists("_/_/evil.bin")).isTrue(); + } +} diff --git a/sink-mq/pom.xml b/sink-mq/pom.xml new file mode 100644 index 00000000..639dc7d3 --- /dev/null +++ b/sink-mq/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + + sink-mq + sink-mq + Kafka producer + Protobuf Envelope + 重试/熔断/DLQ。 + + + + com.lingniu.ingest + ingest-api + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.apache.kafka + kafka-clients + + + com.google.protobuf + protobuf-java + + + com.google.protobuf + protobuf-java-util + + + io.github.resilience4j + resilience4j-retry + + + io.github.resilience4j + resilience4j-circuitbreaker + + + org.testcontainers + kafka + test + + + + + + + kr.motd.maven + os-maven-plugin + 1.7.1 + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier} + + + + + compile + test-compile + + + + + + + diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java new file mode 100644 index 00000000..3eb4a645 --- /dev/null +++ b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java @@ -0,0 +1,119 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.event.AlarmPayload; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.RealtimePayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; + +/** + * 领域事件 → Protobuf Envelope 的纯函数映射。通过 switch 模式匹配穷尽处理, + * 新增 {@link VehicleEvent} 子类型时编译期就会提示补齐分支。 + */ +public final class EnvelopeMapper { + + private static final String SCHEMA_VERSION = "1.0"; + private final String nodeId; + + public EnvelopeMapper(String nodeId) { + this.nodeId = nodeId; + } + + public VehicleEnvelope toEnvelope(VehicleEvent event) { + VehicleEnvelope.Builder b = VehicleEnvelope.newBuilder() + .setSchemaVersion(SCHEMA_VERSION) + .setEventId(event.eventId()) + .setTraceId(event.traceId() == null ? "" : event.traceId()) + .setVin(event.vin()) + .setSource(event.source().name()) + .setEventTimeMs(event.eventTime().toEpochMilli()) + .setIngestTimeMs(event.ingestTime().toEpochMilli()) + .setIngestNodeId(nodeId); + if (event.metadata() != null) b.putAllMetadata(event.metadata()); + + switch (event) { + case VehicleEvent.Realtime r -> b.setRealtime(buildRealtime(r.payload())); + case VehicleEvent.Location l -> b.setLocation(buildLocation(l.payload())); + case VehicleEvent.Alarm a -> b.setAlarm(buildAlarm(a.payload())); + case VehicleEvent.Login lg -> b.setLogin( + com.lingniu.ingest.sink.mq.proto.LoginPayload.newBuilder() + .setIccid(nullToEmpty(lg.iccid())) + .setProtocolVersion(nullToEmpty(lg.protocolVersion())) + .build()); + case VehicleEvent.Logout ignored -> b.setLogout( + com.lingniu.ingest.sink.mq.proto.LogoutPayload.getDefaultInstance()); + case VehicleEvent.Heartbeat ignored -> b.setHeartbeat( + com.lingniu.ingest.sink.mq.proto.HeartbeatPayload.getDefaultInstance()); + case VehicleEvent.MediaMeta m -> b.setMediaMeta( + com.lingniu.ingest.sink.mq.proto.MediaMetaPayload.newBuilder() + .setMediaId(nullToEmpty(m.mediaId())) + .setMediaType(nullToEmpty(m.mediaType())) + .setSizeBytes(m.sizeBytes()) + .setArchiveRef(nullToEmpty(m.archiveRef())) + .build()); + case VehicleEvent.Passthrough p -> b.setPassthrough( + com.lingniu.ingest.sink.mq.proto.PassthroughPayload.newBuilder() + .setPassthroughType(p.passthroughType()) + .setData(com.google.protobuf.ByteString.copyFrom( + p.data() == null ? new byte[0] : p.data())) + .build()); + } + return b.build(); + } + + private static com.lingniu.ingest.sink.mq.proto.RealtimePayload buildRealtime(RealtimePayload p) { + var b = com.lingniu.ingest.sink.mq.proto.RealtimePayload.newBuilder(); + if (p.speedKmh() != null) b.setSpeedKmh(p.speedKmh()); + if (p.totalMileageKm() != null) b.setTotalMileageKm(p.totalMileageKm()); + if (p.batterySoc() != null) b.setBatterySoc(p.batterySoc()); + if (p.batteryVoltageV() != null) b.setBatteryVoltageV(p.batteryVoltageV()); + if (p.batteryCurrentA() != null) b.setBatteryCurrentA(p.batteryCurrentA()); + if (p.fcVoltageV() != null) b.setFcVoltageV(p.fcVoltageV()); + if (p.fcCurrentA() != null) b.setFcCurrentA(p.fcCurrentA()); + if (p.fcTempC() != null) b.setFcTempC(p.fcTempC()); + if (p.hydrogenRemainingKg() != null) b.setHydrogenRemainingKg(p.hydrogenRemainingKg()); + if (p.hydrogenHighPressureMpa() != null) b.setHydrogenHighPressureMpa(p.hydrogenHighPressureMpa()); + if (p.hydrogenLowPressureMpa() != null) b.setHydrogenLowPressureMpa(p.hydrogenLowPressureMpa()); + if (p.vehicleState() != null) b.setVehicleState(p.vehicleState().name()); + if (p.chargingState() != null) b.setChargingState(p.chargingState().name()); + if (p.runningMode() != null) b.setRunningMode(p.runningMode().name()); + if (p.gearLevel() != null) b.setGearLevel(p.gearLevel()); + if (p.acceleratorPedal() != null) b.setAcceleratorPedal(p.acceleratorPedal()); + if (p.brakePedal() != null) b.setBrakePedal(p.brakePedal()); + if (p.longitude() != null) b.setLongitude(p.longitude()); + if (p.latitude() != null) b.setLatitude(p.latitude()); + if (p.altitudeM() != null) b.setAltitudeM(p.altitudeM()); + if (p.directionDeg() != null) b.setDirectionDeg(p.directionDeg()); + if (p.ambientTempC() != null) b.setAmbientTempC(p.ambientTempC()); + if (p.coolantTempC() != null) b.setCoolantTempC(p.coolantTempC()); + return b.build(); + } + + private static com.lingniu.ingest.sink.mq.proto.LocationPayload buildLocation(LocationPayload p) { + return com.lingniu.ingest.sink.mq.proto.LocationPayload.newBuilder() + .setLongitude(p.longitude()) + .setLatitude(p.latitude()) + .setAltitudeM(p.altitudeM()) + .setSpeedKmh(p.speedKmh()) + .setDirectionDeg(p.directionDeg()) + .setAlarmFlag(p.alarmFlag()) + .setStatusFlag(p.statusFlag()) + .build(); + } + + private static com.lingniu.ingest.sink.mq.proto.AlarmPayload buildAlarm(AlarmPayload p) { + var b = com.lingniu.ingest.sink.mq.proto.AlarmPayload.newBuilder() + .setLevel(p.level().name()) + .setAlarmTypeCode(p.alarmTypeCode()) + .setAlarmTypeName(nullToEmpty(p.alarmTypeName())); + if (p.faultCodes() != null) b.addAllFaultCodes(p.faultCodes()); + if (p.activeBits() != null) b.addAllActiveBits(p.activeBits()); + if (p.longitude() != null) b.setLongitude(p.longitude()); + if (p.latitude() != null) b.setLatitude(p.latitude()); + return b.build(); + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s; + } +} diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java new file mode 100644 index 00000000..f2dd4540 --- /dev/null +++ b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java @@ -0,0 +1,80 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.sink.EventSink; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; + +/** + * Kafka Sink:序列化为 Protobuf Envelope,按 vin 分区,异步发送。 + * + *

    失败处理:Resilience4j 熔断;熔断开启时事件转投 DLQ topic。 + */ +public final class KafkaEventSink implements EventSink, AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(KafkaEventSink.class); + + private final KafkaProducer producer; + private final EnvelopeMapper mapper; + private final TopicRouter router; + private final String dlqTopic; + private final CircuitBreaker breaker; + + public KafkaEventSink(KafkaProducer producer, + EnvelopeMapper mapper, + TopicRouter router, + String dlqTopic, + CircuitBreaker breaker) { + this.producer = producer; + this.mapper = mapper; + this.router = router; + this.dlqTopic = dlqTopic; + this.breaker = breaker; + } + + @Override + public String name() { + return "kafka"; + } + + @Override + public CompletableFuture publish(VehicleEvent event) { + CompletableFuture cf = new CompletableFuture<>(); + byte[] payload; + try { + payload = mapper.toEnvelope(event).toByteArray(); + } catch (Exception e) { + log.error("envelope build failed eventId={} vin={}", event.eventId(), event.vin(), e); + cf.completeExceptionally(e); + return cf; + } + + String topic = breaker.tryAcquirePermission() ? router.route(event) : dlqTopic; + ProducerRecord record = new ProducerRecord<>(topic, event.vin(), payload); + record.headers().add("event-id", event.eventId().getBytes()); + record.headers().add("trace-id", event.traceId() == null ? new byte[0] : event.traceId().getBytes()); + record.headers().add("source", event.source().name().getBytes()); + + producer.send(record, (metadata, ex) -> { + if (ex != null) { + breaker.onError(0, java.util.concurrent.TimeUnit.MILLISECONDS, ex); + cf.completeExceptionally(ex); + } else { + breaker.onSuccess(0, java.util.concurrent.TimeUnit.MILLISECONDS); + cf.complete(null); + } + }); + return cf; + } + + @Override + public void close() { + producer.flush(); + producer.close(); + } +} diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java new file mode 100644 index 00000000..412cf1a5 --- /dev/null +++ b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java @@ -0,0 +1,85 @@ +package com.lingniu.ingest.sink.mq; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.time.Duration; +import java.util.Properties; + +/** + * MQ Sink 自动装配。 + * + *

    装配前提(两个条件都要满足): + *

      + *
    1. {@code lingniu.ingest.sink.mq.enabled=true}(默认 true)—— 总开关, + * 设为 false 时本模块完全不装配,ingest-core 的 DisruptorEventBus 仍然运行但 + * 没有 Kafka sink。 + *
    2. {@code lingniu.ingest.sink.mq.type=kafka}(默认 kafka)—— 后端选择,预留 + * rocketmq/pulsar 等扩展。 + *
    + */ +@AutoConfiguration +@EnableConfigurationProperties(SinkMqProperties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.sink.mq", name = "enabled", havingValue = "true", matchIfMissing = true) +public class SinkMqAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public EnvelopeMapper envelopeMapper(SinkMqProperties props) { + return new EnvelopeMapper(props.getNodeId()); + } + + @Bean + @ConditionalOnMissingBean + public TopicRouter topicRouter(SinkMqProperties props) { + return new TopicRouter(props.getTopics()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "lingniu.ingest.sink.mq", name = "type", havingValue = "kafka", matchIfMissing = true) + public CircuitBreaker kafkaSinkCircuitBreaker() { + return CircuitBreaker.of("kafka-sink", CircuitBreakerConfig.custom() + .slidingWindowSize(100) + .failureRateThreshold(50) + .waitDurationInOpenState(Duration.ofSeconds(10)) + .permittedNumberOfCallsInHalfOpenState(10) + .build()); + } + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "lingniu.ingest.sink.mq", name = "type", havingValue = "kafka", matchIfMissing = true) + public KafkaProducer kafkaProducer(SinkMqProperties props) { + Properties p = new Properties(); + p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, props.getBootstrapServers()); + p.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + p.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class.getName()); + p.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, props.getCompressionType()); + p.put(ProducerConfig.LINGER_MS_CONFIG, props.getLingerMs()); + p.put(ProducerConfig.BATCH_SIZE_CONFIG, props.getBatchSize()); + p.put(ProducerConfig.ACKS_CONFIG, props.getAcks()); + p.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, props.isEnableIdempotence()); + return new KafkaProducer<>(p); + } + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "lingniu.ingest.sink.mq", name = "type", havingValue = "kafka", matchIfMissing = true) + public KafkaEventSink kafkaEventSink(KafkaProducer producer, + EnvelopeMapper mapper, + TopicRouter router, + SinkMqProperties props, + CircuitBreaker breaker) { + return new KafkaEventSink(producer, mapper, router, props.getTopics().getDlq(), breaker); + } +} diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java new file mode 100644 index 00000000..ed802942 --- /dev/null +++ b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java @@ -0,0 +1,72 @@ +package com.lingniu.ingest.sink.mq; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "lingniu.ingest.sink.mq") +public class SinkMqProperties { + + /** + * MQ Sink 总开关。默认 {@code true}。 + * 设为 {@code false} 时 {@link SinkMqAutoConfiguration} 完全不装配任何 Bean(Kafka Producer、 + * EnvelopeMapper、TopicRouter、KafkaEventSink 都不会创建),ingest-core 的 DisruptorEventBus + * 仍然正常运行但没有外部 sink(事件落地到 sink-archive 或 Noop 吞掉)。 + */ + private boolean enabled = true; + + /** MQ 后端类型。目前仅支持 kafka,预留 rocketmq/pulsar 等。 */ + private String type = "kafka"; + private String bootstrapServers = "localhost:9092"; + private String compressionType = "zstd"; + private int lingerMs = 20; + private int batchSize = 65536; + private String acks = "all"; + private boolean enableIdempotence = true; + private String nodeId = "ingest-local"; + private Topics topics = new Topics(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getType() { return type; } + public void setType(String type) { this.type = type; } + public String getBootstrapServers() { return bootstrapServers; } + public void setBootstrapServers(String bootstrapServers) { this.bootstrapServers = bootstrapServers; } + public String getCompressionType() { return compressionType; } + public void setCompressionType(String compressionType) { this.compressionType = compressionType; } + public int getLingerMs() { return lingerMs; } + public void setLingerMs(int lingerMs) { this.lingerMs = lingerMs; } + public int getBatchSize() { return batchSize; } + public void setBatchSize(int batchSize) { this.batchSize = batchSize; } + public String getAcks() { return acks; } + public void setAcks(String acks) { this.acks = acks; } + public boolean isEnableIdempotence() { return enableIdempotence; } + public void setEnableIdempotence(boolean enableIdempotence) { this.enableIdempotence = enableIdempotence; } + public String getNodeId() { return nodeId; } + public void setNodeId(String nodeId) { this.nodeId = nodeId; } + public Topics getTopics() { return topics; } + public void setTopics(Topics topics) { this.topics = topics; } + + public static class Topics { + private String realtime = "vehicle.realtime"; + private String location = "vehicle.location"; + private String alarm = "vehicle.alarm"; + private String session = "vehicle.session"; + private String mediaMeta = "vehicle.media.meta"; + private String rawArchive = "vehicle.raw.archive"; + private String dlq = "vehicle.dlq"; + + public String getRealtime() { return realtime; } + public void setRealtime(String realtime) { this.realtime = realtime; } + public String getLocation() { return location; } + public void setLocation(String location) { this.location = location; } + public String getAlarm() { return alarm; } + public void setAlarm(String alarm) { this.alarm = alarm; } + public String getSession() { return session; } + public void setSession(String session) { this.session = session; } + public String getMediaMeta() { return mediaMeta; } + public void setMediaMeta(String mediaMeta) { this.mediaMeta = mediaMeta; } + public String getRawArchive() { return rawArchive; } + public void setRawArchive(String rawArchive) { this.rawArchive = rawArchive; } + public String getDlq() { return dlq; } + public void setDlq(String dlq) { this.dlq = dlq; } + } +} diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/TopicRouter.java b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/TopicRouter.java new file mode 100644 index 00000000..2c22b7c9 --- /dev/null +++ b/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/TopicRouter.java @@ -0,0 +1,28 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.event.VehicleEvent; + +/** + * 事件 → Kafka topic 路由。集中管理避免散落。 + */ +public final class TopicRouter { + + private final SinkMqProperties.Topics topics; + + public TopicRouter(SinkMqProperties.Topics topics) { + this.topics = topics; + } + + public String route(VehicleEvent event) { + return switch (event) { + case VehicleEvent.Realtime _ -> topics.getRealtime(); + case VehicleEvent.Location _ -> topics.getLocation(); + case VehicleEvent.Alarm _ -> topics.getAlarm(); + case VehicleEvent.Login _, + VehicleEvent.Logout _, + VehicleEvent.Heartbeat _ -> topics.getSession(); + case VehicleEvent.MediaMeta _ -> topics.getMediaMeta(); + case VehicleEvent.Passthrough _ -> topics.getAlarm(); + }; + } +} diff --git a/sink-mq/src/main/proto/vehicle_envelope.proto b/sink-mq/src/main/proto/vehicle_envelope.proto new file mode 100644 index 00000000..4fed5e91 --- /dev/null +++ b/sink-mq/src/main/proto/vehicle_envelope.proto @@ -0,0 +1,110 @@ +syntax = "proto3"; + +package com.lingniu.ingest.sink.mq.proto; + +option java_multiple_files = true; +option java_package = "com.lingniu.ingest.sink.mq.proto"; +option java_outer_classname = "VehicleEnvelopeProto"; + +// 统一消息外壳:所有 Topic 共用此 Envelope,payload 通过 oneof 区分具体事件类型。 +message VehicleEnvelope { + string schema_version = 1; + string event_id = 2; + string trace_id = 3; + string vin = 4; + string source = 5; // ProtocolId 字符串形式 + string protocol_version = 6; + int64 event_time_ms = 7; + int64 ingest_time_ms = 8; + string ingest_node_id = 9; + map metadata = 10; + + oneof payload { + RealtimePayload realtime = 20; + LocationPayload location = 21; + AlarmPayload alarm = 22; + LoginPayload login = 23; + LogoutPayload logout = 24; + HeartbeatPayload heartbeat = 25; + MediaMetaPayload media_meta = 26; + PassthroughPayload passthrough = 27; + } + + // 可选:原始字节指针 + RawArchiveRef raw_archive = 40; +} + +message RawArchiveRef { + string uri = 1; + string checksum = 2; + int64 size_bytes = 3; +} + +message RealtimePayload { + optional double speed_kmh = 1; + optional double total_mileage_km = 2; + optional double battery_soc = 3; + optional double battery_voltage_v = 4; + optional double battery_current_a = 5; + optional double fc_voltage_v = 6; + optional double fc_current_a = 7; + optional double fc_temp_c = 8; + optional double hydrogen_remaining_kg = 9; + optional double hydrogen_high_pressure_mpa = 10; + optional double hydrogen_low_pressure_mpa = 11; + optional string vehicle_state = 12; + optional string charging_state = 13; + optional string running_mode = 14; + optional int32 gear_level = 15; + optional double accelerator_pedal = 16; + optional double brake_pedal = 17; + optional double longitude = 18; + optional double latitude = 19; + optional double altitude_m = 20; + optional double direction_deg = 21; + optional double ambient_temp_c = 22; + optional double coolant_temp_c = 23; +} + +message LocationPayload { + double longitude = 1; + double latitude = 2; + double altitude_m = 3; + double speed_kmh = 4; + double direction_deg = 5; + int64 alarm_flag = 6; + int64 status_flag = 7; +} + +message AlarmPayload { + string level = 1; + int32 alarm_type_code = 2; + string alarm_type_name = 3; + repeated string fault_codes = 4; + optional double longitude = 5; + optional double latitude = 6; + // 通用报警标志位(2016 版 0~15 / 2025 版 0~27),对照 GB/T 32960.3 表 24。 + // 例:SOC_LOW / BATTERY_HIGH_TEMP / HYDROGEN_LEAK + repeated string active_bits = 7; +} + +message LoginPayload { + string iccid = 1; + string protocol_version = 2; +} + +message LogoutPayload {} + +message HeartbeatPayload {} + +message MediaMetaPayload { + string media_id = 1; + string media_type = 2; + int64 size_bytes = 3; + string archive_ref = 4; +} + +message PassthroughPayload { + int32 passthrough_type = 1; + bytes data = 2; +} diff --git a/sink-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sink-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..94fdb2e7 --- /dev/null +++ b/sink-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.sink.mq.SinkMqAutoConfiguration