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) <noreply@anthropic.com>
This commit is contained in:
lingniu-dev
2026-04-15 16:08:57 +08:00
commit 064ecc479c
220 changed files with 11874 additions and 0 deletions

50
inbound-mqtt/pom.xml Normal file
View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>inbound-mqtt</artifactId>
<name>inbound-mqtt</name>
<description>MQTT 接入模块(支持多 endpoint宇通等HiveMQ MQTT Client + 双向 TLS。</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.hivemq</groupId>
<artifactId>hivemq-mqtt-client</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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 生命周期管理。
*
* <p>为每个 endpoint 创建一个 HiveMQ 异步客户端 → 连接 → 订阅 → 注册回调 →
* 回调里解析 payload → {@link Dispatcher#dispatch(RawFrame)}。
*
* <p>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<Mqtt3AsyncClient> 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<String, String> 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());
}
}

View File

@@ -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();
}
};
}
}

View File

@@ -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<Endpoint> endpoints = new ArrayList<>();
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public List<Endpoint> getEndpoints() { return endpoints; }
public void setEndpoints(List<Endpoint> 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; }
}
}

View File

@@ -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。
*
* <p>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<VehicleEvent> onMqtt(MqttPayload payload) {
return mapper.toEvents(payload);
}
}

View File

@@ -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 → 领域事件。
*
* <p>字段名取自旧项目的 {@code ReceptionDataVO}(大写 + 下划线),对字段映射一致性做出显式断言,
* 便于与旧服务双跑对账。
*/
public final class YutongEventMapper implements EventMapper<MqttPayload> {
@Override
public List<VehicleEvent> toEvents(MqttPayload payload) {
JsonNode d = payload.data();
if (d == null || !d.isObject()) return List.of();
Instant ingestTime = Instant.now();
String vin = payload.vin();
Map<String, String> 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<VehicleEvent> 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;
}
}

View File

@@ -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
) {}

View File

@@ -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})。
*
* <p>约定宇通 MQTT 的 payload JSON 形如:
* <pre>{@code
* {
* "id": "xxx",
* "device": "LTEST000000000001", // VIN
* "code": "C001",
* "time": "20260413100000", // yyyyMMddHHmmss
* "data": { "METER_SPEED": 52, "LATITUDE": 39916527, ... }
* }
* }</pre>
*
* <p>其它厂商 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();
}
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.inbound.mqtt.config.MqttInboundAutoConfiguration

View File

@@ -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<VehicleEvent> 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));
}
}