车主反馈的原话是"这段路白跑了"。他从滨海大道出发,中间穿过云钟隧道,出了一段七百多米的隧道之后继续开到目的地。行程记录里,这段隧道对应的里程是空白的,能耗曲线也断开了一截。
隧道里没有信号是常态,问题不在这里。问题是车机当时其实什么都知道------车速、电耗、位置一直在采,只是发不出去。等信号恢复,那些数据已经丢了。
这一版把旅程事件先在本地攒起来,等链路恢复再补报。
事件要带单调序号,不能只靠时间戳
最先要解决的是事件的排序依据。车机时钟会因为校时而跳动,进隧道前是 09:42:10,出隧道后可能显示 09:41:58。服务端如果按时间戳排序,补报上来的事件会被插到更早的位置,整段行程的顺序就乱了。
所以每个事件除了发生时刻,还带一个本地单调序号:
ts
export type TripEventKind = 'segment' | 'energy' | 'charging' | 'parking'
export interface TripEvent {
eventId: string // 全局唯一,用于服务端去重
kind: TripEventKind
occurredAt: number // 事件发生的墙上时间,仅作展示
monotonicSeq: number // 本地单调递增,排序依据
payload: string // 事件正文
synced: boolean // 是否已被服务端确认
}
export interface TripState {
events: TripEvent[]
nextSeq: number
lastAckedSeq: number
tunnelEntryAt: number | null
}
monotonicSeq 不能只放在内存里。车机在熄火重启后会从零开始,如果新产生的事件序号比之前小,服务端会拒绝接收。实际做法是把 nextSeq 一起持久化,重启后接着往下走。
tunnelEntryAt 记录断链的起点时刻,它有两个用途:判断断链持续了多久,以及在补报时给位置缺口做插值。
队列满了要丢已同步的,不能丢没发出去的
隧道里可能一待就是几分钟,事件不停产生。队列必须设上限,否则内存会一直涨。
上限设成 500 条。满了之后怎么处理,这里有过一次返工。第一版直接丢最旧的,结果长隧道里排在最前面的恰好是进隧道那一刻产生的、还没发出去的事件,一丢就把断点切在了最不合适的位置。
ts
const MAX_QUEUE_SIZE: number = 500
export function enqueue(state: TripState, event: TripEvent): TripState {
const appended: TripEvent[] = state.events.concat([event])
if (appended.length <= MAX_QUEUE_SIZE) {
return {
...state,
events: appended,
nextSeq: state.nextSeq + 1
}
}
// 优先丢弃已经被确认的事件,未同步的必须保住
const overflow: number = appended.length - MAX_QUEUE_SIZE
let removed: number = 0
const kept: TripEvent[] = []
appended.forEach((item: TripEvent) => {
if (removed < overflow && item.synced) {
removed += 1
return
}
kept.push(item)
})
return {
...state,
events: kept,
nextSeq: state.nextSeq + 1
}
}
如果已同步的事件全部丢完还是超限,说明确实堆得太多。这种情况下宁可让最旧的一条未同步事件也走掉,也不能让队列无限增长把车机拖垮。日志里会记一条丢事件的告警,方便后续排查。
补报要能接上,也要能跳过重复
链路恢复之后,补报的起点由 lastAckedSeq 决定,而不是"队列里所有 synced 为 false 的事件"。这两个条件看起来差不多,实际差很多:服务端可能已经收到了某条事件并写了库,但确认回执在断链时丢了,客户端这边还标着未同步。
ts
export interface SyncBatch {
fromSeq: number
events: TripEvent[]
keepLocal: boolean
}
export function planResync(state: TripState): SyncBatch {
const pending: TripEvent[] = state.events
.filter((item: TripEvent) => item.monotonicSeq > state.lastAckedSeq)
.sort((a: TripEvent, b: TripEvent) => a.monotonicSeq - b.monotonicSeq)
return {
fromSeq: state.lastAckedSeq + 1,
events: pending,
keepLocal: pending.length > 0
}
}
排序再用一次 monotonicSeq。事件在队列里的物理顺序通常是对的,但补报前如果发生过队列裁剪,顺序不一定还可靠。
服务端按 eventId 去重,重复的事件返回 duplicate 而不是 error。客户端拿到回执后,把对应事件的 synced 置为 true,并把 lastAckedSeq 推进到本批最大的序号------只推进到最大值,不能只推进到"最后一条被接受的事件",因为中间可能有被跳过的。
ts
export interface AckReceipt {
eventId: string
status: 'accepted' | 'duplicate' | 'rejected'
}
export function applyAck(state: TripState, receipts: AckReceipt[]): TripState {
const statusMap: Map<string, string> = new Map()
receipts.forEach((receipt: AckReceipt) => {
statusMap.set(receipt.eventId, receipt.status)
})
let maxSeq: number = state.lastAckedSeq
const updated: TripEvent[] = state.events.map((item: TripEvent) => {
const status: string | undefined = statusMap.get(item.eventId)
// 被拒绝的事件不再重试,但仍然读取它的序号用于推进位点
if (status === 'accepted' || status === 'duplicate') {
if (item.monotonicSeq > maxSeq) {
maxSeq = item.monotonicSeq
}
return { ...item, synced: true }
}
return item
})
return { ...state, events: updated, lastAckedSeq: maxSeq }
}
duplicate 和 accepted 在推进位点这件事上待遇相同。区别只在于日志记什么,业务上都是"这条已经落实了"。

位置缺口不能直连
补报解决了数据丢失,但地图上的轨迹还有一处别扭:隧道两端的位置点之间是一条直线。车在隧道里其实是跟着路走的,直线切过去会穿过山体。
断链时间超过阈值时,按平均速度补几个中间点:
ts
const GAP_THRESHOLD_MS: number = 8000
const INTERPOLATE_STEP_MS: number = 2000
export interface TrackPoint {
at: number
x: number
y: number
interpolated: boolean
}
export function fillGap(before: TrackPoint, after: TrackPoint, averageSpeed: number): TrackPoint[] {
const gap: number = after.at - before.at
if (gap < GAP_THRESHOLD_MS || averageSpeed <= 0) {
return []
}
const count: number = Math.min(12, Math.floor(gap / INTERPOLATE_STEP_MS))
if (count <= 0) {
return []
}
const points: TrackPoint[] = []
for (let i: number = 1; i <= count; i++) {
const ratio: number = i / (count + 1)
points.push({
at: before.at + gap * ratio,
x: before.x + (after.x - before.x) * ratio,
y: before.y + (after.y - before.y) * ratio,
interpolated: true
})
}
return points
}
interpolated 这个标记会传到界面上,补出来的点用虚线画,实线部分才是真实采到的位置。用户能看出这一段是估算的,不会以为定位精度突然变差了。
最大补 12 个点是个上限。七百米的隧道按每小时 60 公里开过去大约 42 秒,会补出 12 个点,间隔 3 秒多一点,视觉上够用了。

能耗也要补齐
行程结束后的"本次能耗 14.8 kWh"如果只统计有信号的那部分,会明显偏低。隧道段的能耗用断链前后的平均功率估算:
ts
export function estimateEnergyGap(beforePowerKw: number, afterPowerKw: number, gapMs: number): number {
if (gapMs <= 0) {
return 0
}
const averageKw: number = (beforePowerKw + afterPowerKw) / 2
const hours: number = gapMs / 3600000
return averageKw * hours
}
用两端平均而不是取一个固定值,是因为隧道里通常还在爬坡或者下坡,功率前后差别不小。这个估算值在界面上不单独标注,只在数据导出时附一个 estimated: true 的标记,让后续分析知道这一段的来源。
目录结构
text
EnergyCockpit/
├─ entry/src/main/ets/
│ ├─ pages/Index.ets # 旅程导航与能耗面板
│ ├─ features/
│ │ ├─ TripModel.ets # 事件、队列与位点结构
│ │ ├─ EventQueue.ets # 入队、裁剪与持久化
│ │ ├─ ResyncPlanner.ets # 补报批次与回执处理
│ │ └─ TrackFiller.ets # 位置与能耗缺口估算
│ ├─ entryability/EntryAbility.ets
│ └─ adapters/
│ ├─ VehicleBusAdapter.ets # 车速、电耗与位置读取
│ └─ UplinkAdapter.ets # 链路状态与补报通道
├─ entry/src/main/resources/
└─ build-profile.json5
EventQueue 的持久化用文件而不是偏好存储。事件量在长途行程里可能上千条,偏好存储适合放配置,不适合放这种会持续增长的列表。
真机上车机可能整晚不上电,队列要在熄火前落盘。这一步放在能力生命周期里做,不用定时器,避免熄火流程被打断导致最后几条事件丢失。