diff --git a/CONTEXT.md b/CONTEXT.md index 5494fefa3..a3bc287e0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,10 @@ This context defines the shared language for the Vescape app. The app centers on A saved rideable device that can be connected over BLE and may expose one motor controller through CAN. _Avoid_: Device, controller, scooter +**Board Tombstone**: +A deleted Board's surviving row, marked by a deletion stamp. The Board leaves every Rider-facing list but stays resolvable by id, so Ride History can still name the Board that produced it. Its configuration is hard-deleted; its telemetry and Tune Profiles are not (ADR 0027). +_Avoid_: Soft delete, archived Board + **Board Link**: The saved, probe-confirmed reachability details for a Board, including BLE peripheral id, selected Board Transport, and capabilities or firmware facts discovered for that transport. _Avoid_: Pairing, connection settings, device config @@ -460,6 +464,9 @@ _Avoid_: Position update, presence ping, location share, group telemetry - An **Alert Rule** evaluates against live **Telemetry Samples**. - A **One-Shot** or **Repeating Alert Rule** announces only while fired and needs an **Alert Re-Arm** before it can announce again; a **Geiger Alert Rule** has neither, its cadence follows **Alert Range Depth**. - An **Alert Rule** belongs to one **Board**; the alert engine evaluates only the connected **Board**'s rules, and deleting a **Board** deletes its rules. +- Deleting a **Board** leaves a **Board Tombstone**: its configuration goes, its **Ride History** and **Tune Profiles** stay, and the row stays resolvable by id so history can still name it (ADR 0027). +- Every durable record that belongs to a **Board** identifies it by **Board** id, never by BLE identifier. The BLE address survives in exactly one place, the **Board Link**'s `ble_id`, where it is a reachability detail of that Board and not a key anything else joins on (ADR 0028). +- **Ride History** resolves the **Board** name by lookup rather than reading a copy stored at capture time, so renaming a **Board** relabels its whole history (ADR 0028). - An **Alert Preset** is set per metric and produces zero or more **Alert Rules** for that metric; those rules are regenerated wholesale when its level changes and coexist with the rider's manual **Alert Rules**. - A speed **Alert Preset** resolves its km/h thresholds from **Board Top Speed**; changing **Board Top Speed** regenerates the speed preset's **Alert Rules**. - An **Alert Message Template** belongs to one **Alert Rule**. diff --git a/docs/adr/0027-boards-are-tombstoned-never-deleted.md b/docs/adr/0027-boards-are-tombstoned-never-deleted.md new file mode 100644 index 000000000..2eb53d600 --- /dev/null +++ b/docs/adr/0027-boards-are-tombstoned-never-deleted.md @@ -0,0 +1,20 @@ +# Boards Are Tombstoned, Never Deleted + +Deleting a **Board** sets `boards.deleted_at` instead of removing the row. The Board disappears from every Rider-facing list, its configuration (Board settings, Board warnings, **Alert Rules**, Last Known Board Config Values) is hard-deleted as before, and its **Ride History** is untouched — as it already was. + +The reason is that **Ride History** outlives the Board that produced it. The app has always kept telemetry after a Board delete, but the `boards` row vanishing left those rides pointing at a Board id that resolves to nothing. History could only fall back to the `device_name` snapshotted on each row: a frozen label, not an identity. A tombstone keeps the row resolvable, so a deleted Board's rides still name it and still group by it. + +## Considered Options + +- **Cascade** — deleting a Board deletes its Ride History too. Rejected outright: it deletes the thing worth keeping. +- **Leave the hard delete and lean on the snapshotted `device_name`.** Rejected because a name is not an identity: renames before the delete produce rides labelled inconsistently, and nothing links a ride back to the Board it came from. +- **Move Board identity onto the history rows** (denormalize more at write time). Rejected as strictly more storage for strictly less: it still cannot answer "which rides came from this Board" after the Board is gone. + +## Consequences + +- `getBoards()` filters `deleted_at IS NULL`. `getBoard(id)` deliberately does not — **Ride History** must still be able to name a deleted Board. Callers that act on a Board rather than describe one (`buildSessionConfig`, `BoardConnectConfig.resolve`) check `deletedAt` and refuse. +- An ordinary upsert never clears an existing tombstone, so deletion is terminal. Only the delete path stamps one, and deleting an already-tombstoned Board is a no-op. +- **Tune Profiles** are deliberately outside the cascade. Tuning work is expensive to recreate and survives its Board; removing one takes its own deletion. +- Telemetry can carry a stable `board_id` instead of keying on the mutable BLE identifier, because the row it points at never disappears. That unblocks the identity half of the `device_name` question (#274); the label half stays governed by ADR-0005. +- Tombstones accumulate. They are one small row per deleted Board, bounded by how many Boards a rider ever owned, so no pruning rule is warranted. +- The server half of this decision — tombstones crossing the wire, and the Board **Delete Action** that carries the configuration cascade — lands with Ride History backup (#276) and is out of scope here. diff --git a/docs/adr/0028-telemetry-is-keyed-on-board-id.md b/docs/adr/0028-telemetry-is-keyed-on-board-id.md new file mode 100644 index 000000000..ce0b370d1 --- /dev/null +++ b/docs/adr/0028-telemetry-is-keyed-on-board-id.md @@ -0,0 +1,20 @@ +# Telemetry Is Keyed on Board Id, Not on the BLE Identifier + +Every telemetry table — `telemetry_frames`, `telemetry_minute_buckets`, `telemetry_markers`, `diagnostic_events` and `metric_exclusion_ranges` — keys on `board_id` and no longer carries `device_id` (the BLE identifier) or `device_name` (the **Board** name denormalized at capture time). The Board id is already known at capture — `SessionConfig` carries `appBoardId` alongside `deviceId` — it simply was not written down. Board names on **Ride History** are resolved by looking the Board up by id. Resolves issue #274. + +The BLE identifier was never an identity. It is nullable, it moves when a Board is re-linked to a different peripheral, and two different peripherals over a Board's lifetime produced two unjoinable halves of one Board's history. The denormalized name existed to survive that, and to survive Board deletion — but ADR-0027 makes Boards tombstones that never disappear, so the lookup always resolves and the reason for the copy is gone. + +The decisive argument came from backup. The server stores frames and buckets keyed on `boardId` and does not accept `deviceId` or `deviceName` for them, so the denormalized name is data that is never backed up. Keeping it would mean a restored app resolves history labels by lookup while the app that made the backup reads a column — two label sources, where the one that must work is the one the column does not feed. + +**Amended.** This ADR originally exempted `telemetry_markers`, `diagnostic_events` and `metric_exclusion_ranges`, "because that is what crosses the wire for them and they are low-cardinality display rows, not a per-sample cost". The first half was circular — they kept the identifier because the wire carried the identifier, and the wire carried it because they kept it — and the second half measured the wrong thing. Cost was never the argument; identity was. One BLE address can be claimed by two **Boards** (the same peripheral linked twice, which the app supports), so a MAC does not name a Board, and two readers resolving one MAC are free to disagree. That is precisely how a ride came to have its frames under one Board and its buckets under another: stats rendered, route empty. + +Leaving three tables on the identifier kept a live copy of that defect. Session boundary detection compared Board-keyed buckets against MAC-keyed Markers through a `boardId -> bleId` translation, which collapses both claimants of a shared MAC onto one value — so one Board's Markers could bound the other Board's sessions. All five tables now resolve through one shared decision, and the translation is deleted. + +## Consequences + +- Migration adds `board_id` to all five tables, backfilled by matching `boards.ble_id` to `device_id`, then drops both columns. The identifier is resolved **once** into a shared map at the start of the migration and every rebuild reads it, so no two tables can pick different claimants of a duplicated `ble_id`. Where a MAC is ambiguous the pick is arbitrary but stable (lowest `boards.id`) — for rows predating the migration no evidence of the real Board exists, and leaving them unattributed would be worse than mislabelled: an unowned row is never uploaded and is pruned on age. +- `board_id` is nullable on Markers and Diagnostic Events, which can be written with no Board connected, and NOT NULL on Metric Exclusion Ranges, which exclude one Board's samples and have no meaning without one. Minute buckets move their primary key from `(bucket_start_ms, device_id)` to `(bucket_start_ms, board_id)`, which is what the server already uses. +- Rows that backfill to no Board — telemetry from Boards hard-deleted before ADR-0027, or whose BLE identifier moved on a re-link — would otherwise lose both their identity and their label. The migration mints one tombstoned Board per unresolved `device_id`, named from the historical `device_name`, so the history keeps a label, stays joinable, and can be backed up. A tombstoned Board never appears in the Rider's Board list. +- Renaming a Board now retroactively relabels its **Ride History**. Previously history kept the name the Board carried at ride time. This is the intended reading: it is the same Board. +- Read paths resolve the Board name by lookup rather than reading it off the sample row. Permitted by ADR-0005, whose "no reconstruction on read" rule is about replaying raw **Telemetry Samples**, not about bounded configuration lookups. +- Query keys that meant "this Board" while saying `device_id` now say `board_id` — the bucket key, and the frame and bucket range reads. diff --git a/docs/history.md b/docs/history.md index e92a2759d..f4e303370 100644 --- a/docs/history.md +++ b/docs/history.md @@ -54,7 +54,7 @@ list thumbnails, so JS neither groups buckets nor scans all loaded buckets per r `historyStore.selectSession(session)` loads: -- board samples from `getHistoryRange({ fromMs, toMs, deviceId, limit: 10000 })` +- board samples from `getHistoryRange({ fromMs, toMs, boardId, limit: 10000 })` - GPS samples derived from telemetry samples in the same range - markers from same range diff --git a/docs/index.md b/docs/index.md index 454e598bc..14f54adfe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,6 +31,8 @@ - [safety.md](./safety.md) — safety warnings & thresholds: firmware pushbacks, faults, voltage cutoffs - [board-warnings.md](./board-warnings.md) — Board Warnings catalog: every kind's slug, title, severity, trigger, payload, clear semantics - [VESC fault evidence](./adr/0037-vesc-faults-are-board-owned-evidence.md) — live occurrences, past telemetry captures, and the on-demand Controller Fault Log +- [Board tombstones](./adr/0027-boards-are-tombstoned-never-deleted.md) — deleting a Board keeps its row so Ride History can still name it +- [Telemetry keyed on board id](./adr/0028-telemetry-is-keyed-on-board-id.md) — every telemetry table keys on the Board, not the BLE identifier - [legal-mode-speed-limits.md](./legal-mode-speed-limits.md) — legal mode: jurisdictions and speed caps ### Performance diff --git a/docs/native-api.md b/docs/native-api.md index 8a3267f11..9586fa89e 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -8,7 +8,7 @@ Source of truth: `modules/vescape-core/src/index.ts` (types), `VescapeCoreModule | Domain (CONTEXT.md) | Native/API name | | ------------------- | --------------------------------------------------------------- | -| Board | `device_id` in DB, `boardId` in API | +| Board | `board_id` in DB, `boardId` in API | | Telemetry Sample | `telemetry_frames` (DB), `TelemetrySample` (JS) | | Ride Recording | frames + buckets + markers in DB | | Ride History | `getRideHistoryPage` (complete rides), `getHistoryRange` (full) | @@ -92,20 +92,20 @@ Field omitted (null) when change < threshold from previous: ## Telemetry queries -| fn | returns | notes | -| ----------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `getTelemetryHistory(opts?)` | `TelemetryMinuteBucket[]` | 60s bucket aggregates. Pagination via `cursorBeforeMs`. Default limit 100, max 500 | -| `getRideHistoryPage({limit?,cursorBeforeMs?})` | `RideHistoryPage` | Complete stable rides with coarse route points; cursor never cuts through a ride | -| `getTelemetrySamples({fromMs,toMs,deviceId?,limit?})` | `TelemetrySample[]` | Decoded from compressed frames. Reconstructs state from nearest keyframe. Default 2000, max 10000 | -| `getHistoryRange({fromMs,toMs,deviceId?,limit?})` | `{boardSamples, chartSamples, gpsSamples, markers}` | Full decoded range plus a native-decimated chart overview (max 600 samples) | -| `getTelemetrySummary()` | `{sampleCount, gpsPointCount, firstAtMs, lastAtMs, droppedPendingSamples}` | DB-wide stats | -| `getDatabaseSizeBytes()` | number | File size of vescape.db | +| fn | returns | notes | +| ---------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `getTelemetryHistory(opts?)` | `TelemetryMinuteBucket[]` | 60s bucket aggregates. Pagination via `cursorBeforeMs`. Default limit 100, max 500 | +| `getRideHistoryPage({limit?,cursorBeforeMs?})` | `RideHistoryPage` | Complete stable rides with coarse route points; cursor never cuts through a ride | +| `getTelemetrySamples({fromMs,toMs,boardId?,limit?})` | `TelemetrySample[]` | Decoded from compressed frames. Reconstructs state from nearest keyframe. Default 2000, max 10000 | +| `getHistoryRange({fromMs,toMs,boardId?,limit?})` | `{boardSamples, chartSamples, gpsSamples, markers}` | Full decoded range plus a native-decimated chart overview (max 600 samples) | +| `getTelemetrySummary()` | `{sampleCount, gpsPointCount, firstAtMs, lastAtMs, droppedPendingSamples}` | DB-wide stats | +| `getDatabaseSizeBytes()` | number | File size of vescape.db | ### TelemetryMinuteBucket (bucket shape) ```ts { - id, startAtMs, endAtMs, bucketStartMs, deviceId, deviceName, + id, startAtMs, endAtMs, bucketStartMs, boardId, boardName, sampleCount, gpsPointCount, preciseGpsPointCount, maxAbsSpeedKmh, maxGpsSpeedKmh?, avgSpeedKmh, avgSpeedSampleCount, minBatteryVoltage?, maxMotorCurrent, maxBatteryCurrent, maxDuty, @@ -121,7 +121,7 @@ Field omitted (null) when change < threshold from previous: ```ts { - id, capturedAtMs, deviceId, deviceName, + id, capturedAtMs, boardId, boardName, speedKmh, batteryVoltage, motorCurrent, batteryCurrent, dutyCycle, pitch, roll, balancePitch, balanceCurrent, erpm, state, switchState, adc1, adc2, odometer?, @@ -169,11 +169,11 @@ it never creates occurrences, warnings, baselines, or persisted register snapsho ## Telemetry deletion -| fn | returns | -| ----------------------------------------------- | ----------------------------------------------------------------- | -| `deleteTelemetryBefore(beforeMs)` | frames deleted count. Also deletes matching markers + buckets | -| `deleteTelemetryRange({fromMs,toMs,deviceId?})` | frames deleted count. Flushes pending first | -| `clearTelemetryHistory()` | void. Wipes all frames, markers, buckets + resets in-memory state | +| fn | returns | +| ---------------------------------------------- | ----------------------------------------------------------------- | +| `deleteTelemetryBefore(beforeMs)` | frames deleted count. Also deletes matching markers + buckets | +| `deleteTelemetryRange({fromMs,toMs,boardId?})` | frames deleted count. Flushes pending first | +| `clearTelemetryHistory()` | void. Wipes all frames, markers, buckets + resets in-memory state | ## User Profile Stats diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/SessionConfigBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/SessionConfigBuilder.kt index 0c8f07e0c..1417bb024 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/SessionConfigBuilder.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/SessionConfigBuilder.kt @@ -24,6 +24,10 @@ internal suspend fun buildSessionConfig( val repo = AppDataRepository.get(context.applicationContext) val board = repo.getBoard(boardId) ?: throw IllegalArgumentException("Board not found: $boardId") + // Reads resolve tombstones so history can name them (ADR 0027); connecting to one is refused. + if (board["deletedAt"] != null) { + throw IllegalArgumentException("Board is deleted: $boardId") + } @Suppress("UNCHECKED_CAST") val link = board["link"] as? Map val bleId = link?.get("bleId") as? String diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryMapper.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryMapper.kt index 9fa43f0bd..83ee6ce84 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryMapper.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryMapper.kt @@ -34,8 +34,7 @@ internal fun RefloatTelemetry.toCapture(session: SessionConfig, canId: Int?): Te TelemetryCapture( capturedAtMs = lastPacketAt, elapsedRealtimeMs = SystemClock.elapsedRealtime(), - deviceId = session.deviceId, - deviceName = session.deviceName, + boardId = session.appBoardId, canId = canId, pitch = pitch, roll = roll, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/recording/RecordingCoordinator.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/recording/RecordingCoordinator.kt index 1bf013dc5..425f24f91 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/recording/RecordingCoordinator.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/recording/RecordingCoordinator.kt @@ -112,8 +112,7 @@ internal class RecordingCoordinator( connectionLostMarkerAt = markerAt store.recordMarker( type = "connection_lost", - deviceId = config.deviceId, - deviceName = config.deviceName, + boardId = config.appBoardId, message = reason, occurredAtMs = markerAt, ) @@ -172,8 +171,7 @@ internal class RecordingCoordinator( private fun recordMarker(type: String, config: SessionConfig?, message: String? = null) { telemetryStore?.recordMarker( type, - config?.deviceId, - config?.deviceName, + config?.appBoardId, message, ) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt index fd1079d1b..2f59f4c13 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt @@ -207,8 +207,9 @@ class AppDataRepository private constructor(private val context: Context) { notifyDataChanged(AppDataScope.BOARDS) } + /** Tombstones the Board and hard-deletes its configuration; see [TelemetryDao.deleteBoardWithSettings]. */ suspend fun deleteBoard(id: String): Unit = withContext(Dispatchers.IO) { - dao.deleteBoardWithSettings(id) + dao.deleteBoardWithSettings(id, System.currentTimeMillis()) dao.deleteBoardConfigValues(id) dao.deleteBoardConfigChangeNotice(id) notifyDataChanged(AppDataScope.BOARDS) @@ -855,6 +856,7 @@ class AppDataRepository private constructor(private val context: Context) { val settings = getTypedSettings() settings.selectedBoardId ?.let { dao.getBoard(it) } + ?.takeIf { it.deletedAt == null } ?.let { it.toMap(dao.getBoardSettings(it.id)) } ?: dao.getBoards().firstOrNull()?.let { it.toMap(dao.getBoardSettings(it.id)) } } @@ -923,6 +925,7 @@ fun BoardEntity.toMap(settings: List): Map { "matchBoardConfig" to values["matchBoardConfig"], "legalMode" to (values["legalMode"] ?: mapOf("enabled" to false)), "link" to link, + "deletedAt" to deletedAt, ) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/HistoryGpsProjection.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/HistoryGpsProjection.kt index fbae2558e..e0122540c 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/HistoryGpsProjection.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/HistoryGpsProjection.kt @@ -11,13 +11,14 @@ internal data class HistoryGpsPoint( val location: ScaledLocation, val distanceFromPreviousCm: Long?, ) { - fun toSampleMap(): Map { + /** [boardNames] resolves `boards.id` -> name on read; the row never carried one (ADR 0028). */ + fun toSampleMap(boardNames: Map): Map { val telemetry = sample.state return mapOf( "id" to sample.id, "capturedAtMs" to telemetry.capturedAtMs, - "deviceId" to telemetry.deviceId, - "deviceName" to (telemetry.deviceName ?: UNKNOWN_TELEMETRY_DEVICE_NAME), + "boardId" to telemetry.boardId, + "boardName" to (telemetry.boardId?.let { boardNames[it] } ?: UNKNOWN_TELEMETRY_BOARD_NAME), "latitude" to location.latitudeE7 / 10_000_000.0, "longitude" to location.longitudeE7 / 10_000_000.0, "speedMps" to location.gpsSpeedCentiMps?.let { it / 100.0 }, @@ -34,8 +35,7 @@ internal data class HistoryGpsPoint( val telemetry = sample.state return BucketLocationPoint( capturedAtMs = telemetry.capturedAtMs, - deviceId = telemetry.deviceId, - deviceName = telemetry.deviceName, + boardId = telemetry.boardId, precise = true, distanceFromPreviousCm = distanceFromPreviousCm, gpsSpeedCentiMps = location.gpsSpeedCentiMps, @@ -73,8 +73,9 @@ internal fun List.toHistoryGpsPoints(): List.toGpsSampleMaps(): List> = - toHistoryGpsPoints().map { it.toSampleMap() } +internal fun List.toGpsSampleMaps( + boardNames: Map, +): List> = toHistoryGpsPoints().map { it.toSampleMap(boardNames) } internal fun List.toBucketLocationPoints(): List = toHistoryGpsPoints().map { it.toBucketPoint() } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/MetricSanitizer.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/MetricSanitizer.kt index 55ea80e7c..dd0f8604b 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/MetricSanitizer.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/MetricSanitizer.kt @@ -40,7 +40,7 @@ internal fun AppSettings.toMetricSanitizerConfig(): MetricSanitizerConfig = internal data class SanitizedSample( val index: Int, val capturedAtMs: Long, - val deviceId: String?, + val boardId: String?, val excludedFromAvgSpeed: Boolean, val excludedFromMaxSpeed: Boolean, val excludedFromMaxDuty: Boolean, @@ -86,7 +86,7 @@ internal fun sanitizeTelemetrySamples( SanitizedSample( index = index, capturedAtMs = point.capturedAtMs, - deviceId = point.deviceId, + boardId = point.boardId, excludedFromAvgSpeed = results.any { it.excludedFromAvgSpeed }, excludedFromMaxSpeed = results.any { it.excludedFromMaxSpeed }, excludedFromMaxDuty = results.any { it.excludedFromMaxDuty }, @@ -101,9 +101,9 @@ internal fun sanitizeTelemetrySamples( internal fun collapseExclusionSamples(samples: List): List { if (samples.isEmpty()) return emptyList() val ranges = mutableListOf() - val sorted = samples.sortedWith(compareBy({ it.deviceId }, { it.reason }, { it.capturedAtMs })) + val sorted = samples.sortedWith(compareBy({ it.boardId }, { it.reason }, { it.capturedAtMs })) - var deviceId = sorted.first().deviceId + var boardId = sorted.first().boardId var reason = sorted.first().reason var startMs = sorted.first().capturedAtMs var endMs = startMs @@ -112,7 +112,7 @@ internal fun collapseExclusionSamples(samples: List): Lis fun flush() { ranges.add( MetricExclusionRangeEntity( - deviceId = deviceId, + boardId = boardId, reason = reason, startMs = startMs, endMs = endMs, @@ -122,7 +122,7 @@ internal fun collapseExclusionSamples(samples: List): Lis } for (sample in sorted.drop(1)) { - val sameRange = sample.deviceId == deviceId && + val sameRange = sample.boardId == boardId && sample.reason == reason && sample.capturedAtMs - endMs <= METRIC_EXCLUSION_RANGE_MERGE_GAP_MS if (sameRange) { @@ -130,7 +130,7 @@ internal fun collapseExclusionSamples(samples: List): Lis sampleCount++ } else { flush() - deviceId = sample.deviceId + boardId = sample.boardId reason = sample.reason startMs = sample.capturedAtMs endMs = sample.capturedAtMs diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt index 9429aece0..f873d99c6 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt @@ -53,7 +53,7 @@ class ProfileStatsRepository private constructor(private val context: Context) { if (buckets.isEmpty()) return emptyList() val fromMs = buckets.minOf { it.firstSampleAtMs } - gapMs val toMs = buckets.maxOf { it.lastSampleAtMs } + TELEMETRY_BUCKET_SIZE_MS - return dao.getMarkers(fromMs = fromMs, toMs = toMs, deviceId = null) + return dao.getMarkers(fromMs = fromMs, toMs = toMs, boardId = null) } companion object { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/RideHistoryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/RideHistoryRepository.kt index bb393f53a..df8d2e13a 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/RideHistoryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/RideHistoryRepository.kt @@ -10,8 +10,8 @@ private val RIDE_BREAK_BOUNDARIES = setOf("disconnected", "app_stop", "error") internal data class RideRoutePoint(val latitude: Double, val longitude: Double) internal data class RideSessionAggregate( - val deviceId: String, - var deviceName: String, + /** Owning Board (`boards.id`), blank when the buckets match no saved Board (ADR 0028). */ + val boardId: String, var boundaryBefore: String, var firstBucketStartMs: Long, var startAtMs: Long, @@ -88,12 +88,15 @@ internal class RideHistoryRepository private constructor(private val context: Co complete = completeRideSessions(grouped, hasOlderBuckets) } + // Names resolve from `boards` on read, never off the bucket row (ADR 0028), so a rename + // relabels the whole Ride History. + val boardNames = dao.getBoardNames().associate { it.id to it.name } val sorted = complete.sortedByDescending { it.startAtMs } val cutoff = sorted.getOrNull(limit - 1)?.firstBucketStartMs val page = if (cutoff == null) sorted else sorted.filter { it.firstBucketStartMs >= cutoff } val hasMore = hasOlderBuckets || (cutoff != null && sorted.any { it.firstBucketStartMs < cutoff }) mapOf( - "sessions" to page.map(::rideSessionMap), + "sessions" to page.map { rideSessionMap(it, boardNames) }, "hasMore" to hasMore, "nextCursorBeforeMs" to if (hasMore) page.lastOrNull()?.firstBucketStartMs else null, ) @@ -142,7 +145,7 @@ internal fun groupRideSessions( for (bucket in buckets.sortedBy { it.firstSampleAtMs }) { if (bucket.sampleCount <= 0) continue val boundary = rideBoundaryForBucket(bucket, markers) - val split = current == null || current.deviceId != bucket.deviceId || + val split = current == null || current.boardId != bucket.boardId || (previous != null && bucket.firstSampleAtMs - previous.lastSampleAtMs > gapMs) || RIDE_BREAK_BOUNDARIES.contains(boundary) if (split) { @@ -157,8 +160,7 @@ internal fun groupRideSessions( } private fun newRideAggregate(bucket: TelemetryMinuteBucketEntity, boundary: String) = RideSessionAggregate( - deviceId = bucket.deviceId, - deviceName = bucket.deviceName ?: UNKNOWN_TELEMETRY_DEVICE_NAME, + boardId = bucket.boardId, boundaryBefore = boundary, firstBucketStartMs = bucket.bucketStartMs, startAtMs = bucket.firstSampleAtMs, @@ -177,7 +179,7 @@ private fun mergeRideBucket(session: RideSessionAggregate, bucket: TelemetryMinu session.firstBucketStartMs = minOf(session.firstBucketStartMs, bucket.bucketStartMs) session.startAtMs = minOf(session.startAtMs, bucket.firstSampleAtMs) session.endAtMs = maxOf(session.endAtMs, bucket.lastSampleAtMs) - session.blockIds.add("${bucket.deviceId}:${bucket.bucketStartMs}") + session.blockIds.add("${bucket.boardId}:${bucket.bucketStartMs}") session.blockCount++ session.sampleCount += bucket.sampleCount session.gpsPointCount += bucket.gpsPointCount @@ -215,7 +217,7 @@ private fun rideBoundaryForBucket(bucket: TelemetryMinuteBucketEntity, markers: markers.lastOrNull { marker -> marker.occurredAtMs >= bucket.firstSampleAtMs - 5_000L && marker.occurredAtMs <= bucket.firstSampleAtMs + 1_000L && - (marker.deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID) == bucket.deviceId + (marker.boardId ?: "") == bucket.boardId }?.type ?: "none" private fun rideDistanceDeltaM(bucket: TelemetryMinuteBucketEntity): Double? { @@ -225,11 +227,12 @@ private fun rideDistanceDeltaM(bucket: TelemetryMinuteBucketEntity): Double? { } /** @parity /modules/vescape-core/src/index.ts `RideHistorySession` */ -internal fun rideSessionMap(session: RideSessionAggregate): Map { +internal fun rideSessionMap(session: RideSessionAggregate, boardNames: Map): Map { val avgSpeed = if (session.avgSpeedSampleCount > 0) session.avgSpeedWeightedSum / session.avgSpeedSampleCount else 0.0 return mapOf( - "id" to "${session.deviceId.ifBlank { "unknown" }}:${session.startAtMs}:${session.endAtMs}", - "deviceId" to session.deviceId.ifBlank { null }, "deviceName" to session.deviceName, + "id" to "${session.boardId.ifBlank { "unknown" }}:${session.startAtMs}:${session.endAtMs}", + "boardId" to session.boardId.ifBlank { null }, + "boardName" to (boardNames[session.boardId] ?: UNKNOWN_TELEMETRY_BOARD_NAME), "startAtMs" to session.startAtMs, "endAtMs" to session.endAtMs, "movingStartAtMs" to session.movingStartAtMs, "movingEndAtMs" to session.movingEndAtMs, "blockIds" to session.blockIds, "blockCount" to session.blockCount, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt index 43f34d3d8..f4f7d72e4 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt @@ -5,14 +5,25 @@ import kotlin.math.roundToLong // @parity /modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift internal const val TELEMETRY_BUCKET_SIZE_MS = 60_000L -internal const val UNKNOWN_TELEMETRY_DEVICE_ID = "" -internal const val UNKNOWN_TELEMETRY_DEVICE_NAME = "VESC Board" + +/** + * Stand-in Board id for buckets whose samples match no saved Board. `board_id` is part of the + * bucket primary key, so unattributed rows need a value rather than null. + */ +internal const val UNKNOWN_TELEMETRY_BOARD_ID = "" +internal const val UNKNOWN_TELEMETRY_BOARD_NAME = "VESC Board" + +/** + * Id prefix for the tombstoned Boards migration 41→42 mints for telemetry whose BLE identifier + * resolves to nothing. Derived from the identifier rather than random so the mint is idempotent. + */ +internal const val ORPHAN_BOARD_ID_PREFIX = "orphan-" private const val MAX_ENERGY_SAMPLE_GAP_MS = 5_000L internal data class BucketTelemetryPoint( val capturedAtMs: Long, - val deviceId: String?, - val deviceName: String?, + /** Owning Board (`boards.id`); the durable identity telemetry is keyed on (ADR 0028). */ + val boardId: String?, val speedCentiKmh: Int, val batteryVoltageMv: Int, val motorCurrentMa: Int, @@ -31,8 +42,7 @@ internal data class BucketTelemetryPoint( internal data class BucketLocationPoint( val capturedAtMs: Long, - val deviceId: String?, - val deviceName: String?, + val boardId: String?, val precise: Boolean, val distanceFromPreviousCm: Long?, val gpsSpeedCentiMps: Int?, @@ -48,17 +58,15 @@ internal fun buildTelemetryBuckets( val buckets = linkedMapOf, MutableBucket>() for (point in telemetryPoints) { val bucketStart = point.capturedAtMs - (point.capturedAtMs % TELEMETRY_BUCKET_SIZE_MS) - val deviceId = point.deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID - val key = bucketStart to deviceId - val bucket = buckets.getOrPut(key) { - MutableBucket(bucketStart, deviceId, point.deviceName) - } + val boardId = point.boardId ?: UNKNOWN_TELEMETRY_BOARD_ID + val key = bucketStart to boardId + val bucket = buckets.getOrPut(key) { MutableBucket(bucketStart, boardId) } bucket.add(point) } for (point in locationPoints) { val bucketStart = point.capturedAtMs - (point.capturedAtMs % TELEMETRY_BUCKET_SIZE_MS) - val deviceId = point.deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID - val key = bucketStart to deviceId + val boardId = point.boardId ?: UNKNOWN_TELEMETRY_BOARD_ID + val key = bucketStart to boardId val bucket = buckets[key] ?: continue bucket.addLocation(point) } @@ -67,8 +75,7 @@ internal fun buildTelemetryBuckets( private class MutableBucket( private val bucketStartMs: Long, - private val deviceId: String, - private var deviceName: String?, + private val boardId: String, ) { private var sampleCount = 0 private var firstSampleAtMs = Long.MAX_VALUE @@ -99,7 +106,6 @@ private class MutableBucket( fun add(point: BucketTelemetryPoint) { sampleCount++ - if (point.deviceName != null) deviceName = point.deviceName firstSampleAtMs = minOf(firstSampleAtMs, point.capturedAtMs) lastSampleAtMs = maxOf(lastSampleAtMs, point.capturedAtMs) val absSpeed = abs(point.speedCentiKmh) @@ -144,7 +150,6 @@ private class MutableBucket( fun addLocation(point: BucketLocationPoint) { gpsPointCount++ if (point.precise) preciseGpsPointCount++ - if (point.deviceName != null) deviceName = point.deviceName firstSampleAtMs = minOf(firstSampleAtMs, point.capturedAtMs) lastSampleAtMs = maxOf(lastSampleAtMs, point.capturedAtMs) if (firstLatitudeE7 == null && point.latitudeE7 != null) { @@ -161,8 +166,7 @@ private class MutableBucket( fun toEntity(): TelemetryMinuteBucketEntity = TelemetryMinuteBucketEntity( bucketStartMs = bucketStartMs, - deviceId = deviceId, - deviceName = deviceName, + boardId = boardId, sampleCount = sampleCount, firstSampleAtMs = firstSampleAtMs, lastSampleAtMs = lastSampleAtMs, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index ec00d372a..f6a0db6b1 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -19,11 +19,11 @@ interface TelemetryDao { SELECT * FROM metric_exclusion_ranges WHERE start_ms <= :toMs AND end_ms >= :fromMs - AND (:deviceId IS NULL OR device_id = :deviceId) + AND (:boardId IS NULL OR board_id = :boardId) ORDER BY start_ms ASC """, ) - suspend fun getExclusions(fromMs: Long, toMs: Long, deviceId: String?): List + suspend fun getExclusions(fromMs: Long, toMs: Long, boardId: String?): List @Query("DELETE FROM metric_exclusion_ranges WHERE start_ms <= :toMs AND end_ms >= :fromMs") suspend fun deleteExclusionsRange(fromMs: Long, toMs: Long): Int @@ -37,7 +37,7 @@ interface TelemetryDao { @Query( """ SELECT * FROM metric_exclusion_ranges - WHERE device_id = :deviceId + WHERE board_id = :boardId AND reason = :reason AND end_ms >= :startMs - :mergeGapMs ORDER BY end_ms DESC @@ -45,7 +45,7 @@ interface TelemetryDao { """, ) suspend fun getMergeableExclusionRange( - deviceId: String, + boardId: String, reason: String, startMs: Long, mergeGapMs: Long, @@ -88,13 +88,13 @@ interface TelemetryDao { @Update suspend fun updateBucket(bucket: TelemetryMinuteBucketEntity) - @Query("SELECT * FROM telemetry_minute_buckets WHERE bucket_start_ms = :bucketStartMs AND device_id = :deviceId LIMIT 1") - suspend fun getBucket(bucketStartMs: Long, deviceId: String): TelemetryMinuteBucketEntity? + @Query("SELECT * FROM telemetry_minute_buckets WHERE bucket_start_ms = :bucketStartMs AND board_id = :boardId LIMIT 1") + suspend fun getBucket(bucketStartMs: Long, boardId: String): TelemetryMinuteBucketEntity? @Transaction suspend fun upsertBuckets(buckets: Collection) { for (bucket in buckets) { - val existing = getBucket(bucket.bucketStartMs, bucket.deviceId) + val existing = getBucket(bucket.bucketStartMs, bucket.boardId) if (existing == null) { insertBucket(bucket) } else { @@ -118,9 +118,9 @@ interface TelemetryDao { @Transaction suspend fun upsertExclusionRanges(exclusions: List) { - for (exclusion in exclusions.sortedWith(compareBy({ it.deviceId }, { it.reason }, { it.startMs }))) { + for (exclusion in exclusions.sortedWith(compareBy({ it.boardId }, { it.reason }, { it.startMs }))) { val existing = getMergeableExclusionRange( - exclusion.deviceId, + exclusion.boardId, exclusion.reason, exclusion.startMs, METRIC_EXCLUSION_RANGE_MERGE_GAP_MS, @@ -141,7 +141,7 @@ interface TelemetryDao { @Query( """ SELECT * FROM telemetry_minute_buckets - WHERE (:deviceId IS NULL OR device_id = :deviceId) + WHERE (:boardId IS NULL OR board_id = :boardId) AND bucket_start_ms <= :beforeMs AND bucket_start_ms >= :fromMs AND bucket_start_ms <= :toMs @@ -154,7 +154,7 @@ interface TelemetryDao { fromMs: Long, toMs: Long, beforeMs: Long, - deviceId: String?, + boardId: String?, limit: Int, ): List @@ -166,18 +166,18 @@ interface TelemetryDao { SELECT * FROM telemetry_markers WHERE occurred_at_ms >= :fromMs AND occurred_at_ms <= :toMs - AND (:deviceId IS NULL OR device_id = :deviceId) + AND (:boardId IS NULL OR board_id = :boardId) ORDER BY occurred_at_ms ASC """, ) - suspend fun getMarkers(fromMs: Long, toMs: Long, deviceId: String?): List + suspend fun getMarkers(fromMs: Long, toMs: Long, boardId: String?): List @Query( """ SELECT * FROM diagnostic_events WHERE occurred_at_ms >= :fromMs AND occurred_at_ms <= :toMs - AND (:deviceId IS NULL OR device_id = :deviceId) + AND (:boardId IS NULL OR board_id = :boardId) ORDER BY occurred_at_ms DESC LIMIT :limit """, @@ -185,7 +185,7 @@ interface TelemetryDao { suspend fun getDiagnosticEvents( fromMs: Long, toMs: Long, - deviceId: String?, + boardId: String?, limit: Int, ): List @@ -193,7 +193,7 @@ interface TelemetryDao { """ SELECT * FROM telemetry_frames WHERE captured_at_ms <= :fromMs - AND (:deviceId IS NULL OR device_id = :deviceId) + AND (:boardId IS NULL OR board_id = :boardId) AND (flags & :keyframeFlag) != 0 ORDER BY captured_at_ms DESC LIMIT 1 @@ -201,7 +201,7 @@ interface TelemetryDao { ) suspend fun getLatestKeyframeBefore( fromMs: Long, - deviceId: String?, + boardId: String?, keyframeFlag: Int = TELEMETRY_FLAG_KEYFRAME, ): TelemetryFrameEntity? @@ -210,30 +210,30 @@ interface TelemetryDao { SELECT * FROM telemetry_frames WHERE captured_at_ms >= :fromMs AND captured_at_ms <= :toMs - AND (:deviceId IS NULL OR device_id = :deviceId) + AND (:boardId IS NULL OR board_id = :boardId) ORDER BY captured_at_ms ASC LIMIT :limit """, ) - suspend fun getFrames(fromMs: Long, toMs: Long, deviceId: String?, limit: Int): List + suspend fun getFrames(fromMs: Long, toMs: Long, boardId: String?, limit: Int): List @Query( """ - SELECT DISTINCT device_id FROM telemetry_frames + SELECT DISTINCT board_id FROM telemetry_frames WHERE captured_at_ms >= :fromMs AND captured_at_ms <= :toMs - AND device_id IS NOT NULL - ORDER BY device_id ASC + AND board_id IS NOT NULL + ORDER BY board_id ASC """, ) - suspend fun getDeviceIdsInRange(fromMs: Long, toMs: Long): List + suspend fun getBoardIdsInRange(fromMs: Long, toMs: Long): List @Query( """ SELECT * FROM telemetry_frames WHERE captured_at_ms >= :fromMs AND captured_at_ms <= :toMs - AND device_id = :deviceId + AND board_id = :boardId ORDER BY captured_at_ms ASC LIMIT 1 """, @@ -241,7 +241,7 @@ interface TelemetryDao { suspend fun getFirstFrameInRange( fromMs: Long, toMs: Long, - deviceId: String, + boardId: String, ): TelemetryFrameEntity? @Query("SELECT COUNT(*) FROM telemetry_frames") @@ -284,12 +284,12 @@ interface TelemetryDao { WHERE captured_at_ms >= :fromMs AND captured_at_ms <= :toMs AND ( - (:deviceId IS NOT NULL AND device_id = :deviceId) - OR (:deviceId IS NULL AND device_id IS NULL) + (:boardId IS NOT NULL AND board_id = :boardId) + OR (:boardId IS NULL AND board_id IS NULL) ) """, ) - suspend fun deleteFramesRange(fromMs: Long, toMs: Long, deviceId: String?): Int + suspend fun deleteFramesRange(fromMs: Long, toMs: Long, boardId: String?): Int @Query( """ @@ -297,28 +297,29 @@ interface TelemetryDao { WHERE occurred_at_ms >= :fromMs AND occurred_at_ms <= :toMs AND ( - (:deviceId IS NOT NULL AND device_id = :deviceId) - OR (:deviceId IS NULL AND device_id IS NULL) + (:boardId IS NOT NULL AND board_id = :boardId) + OR (:boardId IS NULL AND board_id IS NULL) ) """, ) - suspend fun deleteMarkersRange(fromMs: Long, toMs: Long, deviceId: String?): Int + suspend fun deleteMarkersRange(fromMs: Long, toMs: Long, boardId: String?): Int @Query( """ DELETE FROM telemetry_minute_buckets WHERE last_sample_at_ms >= :fromMs AND first_sample_at_ms <= :toMs - AND device_id = :bucketDeviceId + AND board_id = :bucketBoardId """, ) - suspend fun deleteBucketsRange(fromMs: Long, toMs: Long, bucketDeviceId: String): Int + suspend fun deleteBucketsRange(fromMs: Long, toMs: Long, bucketBoardId: String): Int + /** Every telemetry table keys on [boardId] (ADR 0028). Null means "every Board". */ @Transaction - suspend fun deleteRange(fromMs: Long, toMs: Long, deviceId: String?): Int { - val frames = deleteFramesRange(fromMs, toMs, deviceId) - deleteMarkersRange(fromMs, toMs, deviceId) - deleteBucketsRange(fromMs, toMs, deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID) + suspend fun deleteRange(fromMs: Long, toMs: Long, boardId: String?): Int { + val frames = deleteFramesRange(fromMs, toMs, boardId) + deleteMarkersRange(fromMs, toMs, boardId) + deleteBucketsRange(fromMs, toMs, boardId ?: UNKNOWN_TELEMETRY_BOARD_ID) deleteExclusionsRange(fromMs, toMs) return frames } @@ -368,14 +369,39 @@ interface TelemetryDao { clearExclusions() } - @Query("SELECT * FROM boards ORDER BY created_at ASC") + /** Live Boards only — a tombstoned Board is gone from every Rider-facing list (ADR 0027). */ + @Query("SELECT * FROM boards WHERE deleted_at IS NULL ORDER BY created_at ASC") suspend fun getBoards(): List + /** + * Resolves tombstones too, deliberately: Ride History still has to name a deleted Board. Callers + * that act on a Board rather than describe one check [BoardEntity.deletedAt] and refuse. + */ @Query("SELECT * FROM boards WHERE id = :id LIMIT 1") suspend fun getBoard(id: String): BoardEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBoard(board: BoardEntity) + suspend fun insertBoardRow(board: BoardEntity) + + @Query("SELECT deleted_at FROM boards WHERE id = :id") + suspend fun getBoardDeletedAt(id: String): Long? + + /** + * An existing tombstone survives the write, so an ordinary upsert can never resurrect a deleted + * Board — deletion is terminal (ADR 0027). Only [deleteBoardWithSettings] stamps a new one. + */ + @Transaction + suspend fun upsertBoard(board: BoardEntity) { + insertBoardRow(board.copy(deletedAt = board.deletedAt ?: getBoardDeletedAt(board.id))) + } + + /** + * Every Board including tombstones, for Ride History name resolution. Names are looked up on read + * rather than denormalized onto telemetry rows (ADR 0028), so a rename retroactively relabels the + * history and a deleted Board is still nameable. + */ + @Query("SELECT id, name FROM boards") + suspend fun getBoardNames(): List @Query("SELECT * FROM board_settings WHERE board_id = :boardId") suspend fun getBoardSettings(boardId: String): List @@ -399,16 +425,20 @@ interface TelemetryDao { @Query("DELETE FROM board_settings WHERE board_id = :boardId") suspend fun deleteBoardSettings(boardId: String) - @Query("DELETE FROM boards WHERE id = :id") - suspend fun deleteBoard(id: String) - + /** + * The Rider-facing delete: configuration goes, the Board row stays as a tombstone (ADR 0027). + * Telemetry and Tune Profiles are untouched — both outlive the Board. + * + * An unknown or already-tombstoned id is a no-op. + */ @Transaction - suspend fun deleteBoardWithSettings(id: String) { + suspend fun deleteBoardWithSettings(id: String, deletedAt: Long) { + val board = getBoard(id)?.takeIf { it.deletedAt == null } ?: return deleteBoardSettings(id) deleteBoardWarnings(id) // Alert Rules are Board-owned (#254) — drop them with the Board so no orphan rows survive. deleteAlertRules(id) - deleteBoard(id) + insertBoardRow(board.copy(deletedAt = deletedAt)) } @Query("SELECT * FROM alerts WHERE board_id = :boardId ORDER BY created_at ASC") @@ -730,7 +760,6 @@ interface TelemetryDao { private fun TelemetryMinuteBucketEntity.merge(next: TelemetryMinuteBucketEntity): TelemetryMinuteBucketEntity { return copy( - deviceName = next.deviceName ?: deviceName, sampleCount = sampleCount + next.sampleCount, firstSampleAtMs = minOf(firstSampleAtMs, next.firstSampleAtMs), lastSampleAtMs = maxOf(lastSampleAtMs, next.lastSampleAtMs), diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index 39f9cb0fd..16d1e1afc 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -14,7 +14,7 @@ import java.io.File internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" // @parity /modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift `TELEMETRY_SCHEMA_VERSION` -internal const val TELEMETRY_DATABASE_VERSION = 40 +internal const val TELEMETRY_DATABASE_VERSION = 42 @Database( entities = [ @@ -507,6 +507,481 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * Board tombstones (#279). Deleting a Board stops removing its row and stamps `deleted_at` + * instead, so Ride History outlives the Board that produced it (ADR 0027). Additive: existing + * rows stay null, i.e. alive. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v41_board_deleted_at` + */ + internal val MIGRATION_40_41 = object : Migration(40, 41) { + override fun migrate(db: SupportSQLiteDatabase) { + if (!hasColumn(db, "boards", "deleted_at")) { + db.execSQL("ALTER TABLE boards ADD COLUMN deleted_at INTEGER") + } + } + } + + /** + * Telemetry keys on the Board id (#280, ADR 0028). `telemetry_frames` and + * `telemetry_minute_buckets` gain `board_id` and lose `device_id` (the BLE identifier) and + * `device_name` (the Board name denormalized at capture time); Ride History resolves the name + * by looking the Board up instead. Markers, diagnostic events and metric exclusion ranges are + * deliberately untouched — that is what crosses the wire for them. + * + * Both tables are rebuilt rather than altered: the bucket primary key moves to + * `(bucket_start_ms, board_id)`, and dropping a column in place needs a SQLite newer than the + * oldest supported device ships. The rebuild is a full copy, so it is the expensive step of + * this upgrade on a phone with a long Ride History. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v42_telemetry_board_id` + */ + /** + * Scratch table holding migration 41→42's one and only BLE identifier → Board decision. Temp, + * so it belongs to the connection and never reaches the schema Room validates. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `DEVICE_BOARD_MAP` + */ + private const val DEVICE_BOARD_MAP = "telemetry_device_board_map" + + /** + * Every table migration 41→42 moves off the BLE identifier, with the time column its rows are + * ordered by. All six are minted for and rebuilt together: a Board minted from one table's + * identifiers has to exist before any other table resolves the same identifier, or the two + * disagree about who owns the history — the defect this migration exists to remove. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `telemetryTablesKeyedOnDeviceId` + */ + private val TELEMETRY_TABLES_KEYED_ON_DEVICE_ID = listOf( + "telemetry_frames" to "captured_at_ms", + "telemetry_minute_buckets" to "bucket_start_ms", + "telemetry_markers" to "occurred_at_ms", + "diagnostic_events" to "occurred_at_ms", + "metric_exclusion_ranges" to "start_ms", + ) + + internal val MIGRATION_41_42 = object : Migration(41, 42) { + override fun migrate(db: SupportSQLiteDatabase) { + mintOrphanBoards(db) + buildDeviceBoardMap(db) + rebuildFramesOnBoardId(db) + rebuildBucketsOnBoardId(db) + rebuildMarkersOnBoardId(db) + rebuildDiagnosticEventsOnBoardId(db) + rebuildExclusionRangesOnBoardId(db) + db.execSQL("DROP TABLE IF EXISTS $DEVICE_BOARD_MAP") + } + } + + /** + * Telemetry whose `device_id` matches no Board would lose both its identity and its label: + * either the Board was hard-deleted before tombstones existed (ADR 0027), or it was re-linked + * to a different peripheral and the old identifier no longer resolves. One tombstoned Board is + * minted per unresolved identifier, named from that telemetry's own historical `device_name`, + * so the history stays joinable, keeps a label, and can be backed up. + * + * The minted row is a tombstone with no Board Link: `deleted_at` keeps it out of every + * Rider-facing list, and a null `ble_id` stops it from ever capturing a future re-link. The id + * is derived from the identifier rather than random so re-running the migration is a no-op. + */ + private fun mintOrphanBoards(db: SupportSQLiteDatabase) { + val now = System.currentTimeMillis() + for ((name, timeColumn) in TELEMETRY_TABLES_KEYED_ON_DEVICE_ID) { + // Metric Exclusion Ranges never carried a `device_name`, so there is nothing to name a + // Board after there — a range on an identifier no other table saw falls back to the + // generic name. Every other table names the mint from its own newest label. + val historicalName = + if (name == "metric_exclusion_ranges") { + "NULL" + } else { + "(SELECT n.device_name FROM $name n WHERE n.device_id = t.device_id " + + "AND n.device_name IS NOT NULL ORDER BY n.$timeColumn DESC LIMIT 1)" + } + db.execSQL( + """ + INSERT OR IGNORE INTO boards (id, name, ble_id, created_at, deleted_at) + SELECT + '$ORPHAN_BOARD_ID_PREFIX' || t.device_id, + COALESCE( + $historicalName, + '$UNKNOWN_TELEMETRY_BOARD_NAME' + ), + NULL, + MIN(t.$timeColumn), + $now + FROM $name t + WHERE t.device_id IS NOT NULL + AND t.device_id != '' + AND NOT EXISTS (SELECT 1 FROM boards b WHERE b.ble_id = t.device_id) + GROUP BY t.device_id + """.trimIndent(), + ) + } + } + + /** + * One BLE identifier can be claimed by more than one Board — the same peripheral linked twice, + * which the app supports and a Rider produces by pairing a board they already own a second + * time. Telemetry predating this migration recorded only the identifier, so for such rows there + * is no evidence of which of those Boards was connected, and no rule can recover it. + * + * What must not happen is the two rebuilds below disagreeing. Resolved independently, each + * `SELECT … LIMIT 1` is free to return a different Board for the same identifier, and then the + * frames of a ride sit under one Board while its buckets sit under another: History lists the + * ride from the buckets and finds no frames for it, so stats render over an empty route. + * + * So the choice is made exactly once, here, and both rebuilds read it. `MIN(b.id)` is an + * arbitrary but stable pick among the claimants — arbitrary because the information to do + * better does not exist, stable because re-running the migration reaches the same answer. + * Deliberately not left unattributed: an unowned row is never uploaded and is pruned on age, so + * "unknown" would quietly destroy the history a merely mis-labelled ride keeps intact. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `buildDeviceBoardMap` + */ + private fun buildDeviceBoardMap(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TEMP TABLE $DEVICE_BOARD_MAP ( + device_id TEXT PRIMARY KEY NOT NULL, + board_id TEXT NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO $DEVICE_BOARD_MAP (device_id, board_id) + SELECT b.ble_id, MIN(b.id) + FROM boards b + WHERE b.ble_id IS NOT NULL AND b.ble_id != '' + GROUP BY b.ble_id + """.trimIndent(), + ) + } + + /** + * Resolves a telemetry row's `device_id` to a Board id: the Board [buildDeviceBoardMap] chose + * for the identifier, otherwise the tombstone minted for it above. A row that never carried an + * identifier stays unattributed. + * + * The lookup hits a primary key holding one row per identifier, so unlike a scan over `boards` + * it cannot resolve the same identifier two ways in two statements. + */ + private fun boardIdFromDeviceId(alias: String): String = + """ + CASE + WHEN $alias.device_id IS NULL OR $alias.device_id = '' THEN %s + ELSE COALESCE( + (SELECT m.board_id FROM $DEVICE_BOARD_MAP m WHERE m.device_id = $alias.device_id), + '$ORPHAN_BOARD_ID_PREFIX' || $alias.device_id + ) + END + """.trimIndent() + + private fun rebuildFramesOnBoardId(db: SupportSQLiteDatabase) { + val columns = + "captured_at_ms, elapsed_realtime_ms, can_id, flags, changed_mask_1, changed_mask_2, " + + "speed_centi_kmh, battery_voltage_mv, motor_current_ma, battery_current_ma, duty_permille, " + + "pitch_centi_deg, roll_centi_deg, balance_pitch_centi_deg, balance_current_ma, erpm, state, " + + "switch_state, adc1_milli, adc2_milli, odometer_cm, temp_mosfet_deci_c, temp_motor_deci_c, " + + "latitude_e7, longitude_e7, gps_speed_centi_mps, bearing_centi_deg, accuracy_cm, " + + "altitude_cm, location_timestamp_ms" + db.execSQL("DROP INDEX IF EXISTS index_telemetry_frames_captured_at_ms") + db.execSQL("DROP INDEX IF EXISTS index_telemetry_frames_device_id_captured_at_ms") + db.execSQL( + """ + CREATE TABLE telemetry_frames_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + captured_at_ms INTEGER NOT NULL, + elapsed_realtime_ms INTEGER NOT NULL, + board_id TEXT, + can_id INTEGER, + flags INTEGER NOT NULL, + changed_mask_1 INTEGER NOT NULL, + changed_mask_2 INTEGER NOT NULL, + speed_centi_kmh INTEGER, + battery_voltage_mv INTEGER, + motor_current_ma INTEGER, + battery_current_ma INTEGER, + duty_permille INTEGER, + pitch_centi_deg INTEGER, + roll_centi_deg INTEGER, + balance_pitch_centi_deg INTEGER, + balance_current_ma INTEGER, + erpm INTEGER, + state INTEGER, + switch_state INTEGER, + adc1_milli INTEGER, + adc2_milli INTEGER, + odometer_cm INTEGER, + temp_mosfet_deci_c INTEGER, + temp_motor_deci_c INTEGER, + latitude_e7 INTEGER, + longitude_e7 INTEGER, + gps_speed_centi_mps INTEGER, + bearing_centi_deg INTEGER, + accuracy_cm INTEGER, + altitude_cm INTEGER, + location_timestamp_ms INTEGER + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO telemetry_frames_new (id, board_id, $columns) + SELECT f.id, ${boardIdFromDeviceId("f").format("NULL")}, $columns + FROM telemetry_frames f + """.trimIndent(), + ) + db.execSQL("DROP TABLE telemetry_frames") + db.execSQL("ALTER TABLE telemetry_frames_new RENAME TO telemetry_frames") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_frames_captured_at_ms " + + "ON telemetry_frames(captured_at_ms)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_frames_board_id_captured_at_ms " + + "ON telemetry_frames(board_id, captured_at_ms)", + ) + } + + /** + * The primary key move from `(bucket_start_ms, device_id)` to `(bucket_start_ms, board_id)` is + * a table rebuild, not an `ALTER`. + */ + private fun rebuildBucketsOnBoardId(db: SupportSQLiteDatabase) { + val columns = + "bucket_start_ms, sample_count, first_sample_at_ms, last_sample_at_ms, " + + "sum_abs_speed_centi_kmh, moving_speed_sample_count, sum_moving_abs_speed_centi_kmh, " + + "max_abs_speed_centi_kmh, min_battery_voltage_mv, max_motor_current_abs_ma, " + + "max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, " + + "max_duty_abs_permille, first_odometer_cm, last_odometer_cm, gps_point_count, " + + "precise_gps_point_count, gps_distance_cm, max_gps_speed_centi_mps, max_temp_mosfet_deci_c, " + + "max_temp_motor_deci_c, first_latitude_e7, first_longitude_e7, first_moving_at_ms, " + + "last_moving_at_ms" + db.execSQL("DROP INDEX IF EXISTS index_telemetry_minute_buckets_bucket_start_ms") + db.execSQL( + """ + CREATE TABLE telemetry_minute_buckets_new ( + bucket_start_ms INTEGER NOT NULL, + board_id TEXT NOT NULL, + sample_count INTEGER NOT NULL, + first_sample_at_ms INTEGER NOT NULL, + last_sample_at_ms INTEGER NOT NULL, + sum_abs_speed_centi_kmh INTEGER NOT NULL, + moving_speed_sample_count INTEGER, + sum_moving_abs_speed_centi_kmh INTEGER, + max_abs_speed_centi_kmh INTEGER NOT NULL, + min_battery_voltage_mv INTEGER, + max_motor_current_abs_ma INTEGER NOT NULL, + max_battery_current_abs_ma INTEGER NOT NULL, + battery_used_wh_milli INTEGER NOT NULL, + battery_regen_wh_milli INTEGER NOT NULL, + max_duty_abs_permille INTEGER NOT NULL, + first_odometer_cm INTEGER, + last_odometer_cm INTEGER, + gps_point_count INTEGER NOT NULL, + precise_gps_point_count INTEGER NOT NULL, + gps_distance_cm INTEGER NOT NULL, + max_gps_speed_centi_mps INTEGER, + max_temp_mosfet_deci_c INTEGER, + max_temp_motor_deci_c INTEGER, + first_latitude_e7 INTEGER, + first_longitude_e7 INTEGER, + first_moving_at_ms INTEGER, + last_moving_at_ms INTEGER, + PRIMARY KEY (bucket_start_ms, board_id) + ) + """.trimIndent(), + ) + // Grouped rather than copied row-for-row so the rebuild is total. A `board_id` collision on + // the new key needs two identifiers resolving to one Board inside one minute, which the + // resolver cannot produce — the map is keyed on the identifier and a Board carries one — but + // an ungrouped copy would abort the whole migration on a constraint error if it ever did, + // stranding the database mid-upgrade. The fold sums the additive lanes and takes the extreme + // of the peaks, as an upsert merge would. + db.execSQL( + """ + INSERT INTO telemetry_minute_buckets_new (board_id, $columns) + SELECT + ${boardIdFromDeviceId("b").format("''")} AS board_id, + b.bucket_start_ms, + SUM(b.sample_count), + MIN(b.first_sample_at_ms), + MAX(b.last_sample_at_ms), + SUM(b.sum_abs_speed_centi_kmh), + SUM(b.moving_speed_sample_count), + SUM(b.sum_moving_abs_speed_centi_kmh), + MAX(b.max_abs_speed_centi_kmh), + MIN(b.min_battery_voltage_mv), + MAX(b.max_motor_current_abs_ma), + MAX(b.max_battery_current_abs_ma), + SUM(b.battery_used_wh_milli), + SUM(b.battery_regen_wh_milli), + MAX(b.max_duty_abs_permille), + MIN(b.first_odometer_cm), + MAX(b.last_odometer_cm), + SUM(b.gps_point_count), + SUM(b.precise_gps_point_count), + SUM(b.gps_distance_cm), + MAX(b.max_gps_speed_centi_mps), + MAX(b.max_temp_mosfet_deci_c), + MAX(b.max_temp_motor_deci_c), + MIN(b.first_latitude_e7), + MIN(b.first_longitude_e7), + MIN(b.first_moving_at_ms), + MAX(b.last_moving_at_ms) + FROM telemetry_minute_buckets b + GROUP BY b.bucket_start_ms, board_id + """.trimIndent(), + ) + db.execSQL("DROP TABLE telemetry_minute_buckets") + db.execSQL("ALTER TABLE telemetry_minute_buckets_new RENAME TO telemetry_minute_buckets") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_minute_buckets_bucket_start_ms " + + "ON telemetry_minute_buckets(bucket_start_ms)", + ) + } + + /** + * A Marker notes something that happened while recording — a gap, a resume. It belongs to the + * Board it happened on, and `board_id` stays nullable because a Marker can be written with no + * Board connected. `device_name` goes with the identifier: the Board holds that text once. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `rebuildMarkersOnBoardId` + */ + private fun rebuildMarkersOnBoardId(db: SupportSQLiteDatabase) { + db.execSQL("DROP INDEX IF EXISTS index_telemetry_markers_occurred_at_ms") + db.execSQL("DROP INDEX IF EXISTS index_telemetry_markers_device_id_occurred_at_ms") + db.execSQL( + """ + CREATE TABLE telemetry_markers_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + occurred_at_ms INTEGER NOT NULL, + elapsed_realtime_ms INTEGER NOT NULL, + type TEXT NOT NULL, + board_id TEXT, + message TEXT, + gap_ms INTEGER + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO telemetry_markers_new + (id, occurred_at_ms, elapsed_realtime_ms, type, board_id, message, gap_ms) + SELECT + m.id, m.occurred_at_ms, m.elapsed_realtime_ms, m.type, + ${boardIdFromDeviceId("m").format("NULL")}, + m.message, m.gap_ms + FROM telemetry_markers m + """.trimIndent(), + ) + db.execSQL("DROP TABLE telemetry_markers") + db.execSQL("ALTER TABLE telemetry_markers_new RENAME TO telemetry_markers") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_markers_occurred_at_ms " + + "ON telemetry_markers(occurred_at_ms)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_markers_board_id_occurred_at_ms " + + "ON telemetry_markers(board_id, occurred_at_ms)", + ) + } + + /** + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `rebuildDiagnosticEventsOnBoardId` + */ + private fun rebuildDiagnosticEventsOnBoardId(db: SupportSQLiteDatabase) { + db.execSQL("DROP INDEX IF EXISTS index_diagnostic_events_occurred_at_ms") + db.execSQL("DROP INDEX IF EXISTS index_diagnostic_events_event_name") + db.execSQL("DROP INDEX IF EXISTS index_diagnostic_events_device_id_occurred_at_ms") + db.execSQL( + """ + CREATE TABLE diagnostic_events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + occurred_at_ms INTEGER NOT NULL, + elapsed_realtime_ms INTEGER NOT NULL, + event_name TEXT NOT NULL, + operation TEXT, + phase TEXT, + board_id TEXT, + message TEXT, + properties_json TEXT NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO diagnostic_events_new + (id, occurred_at_ms, elapsed_realtime_ms, event_name, operation, phase, board_id, + message, properties_json) + SELECT + e.id, e.occurred_at_ms, e.elapsed_realtime_ms, e.event_name, e.operation, e.phase, + ${boardIdFromDeviceId("e").format("NULL")}, + e.message, e.properties_json + FROM diagnostic_events e + """.trimIndent(), + ) + db.execSQL("DROP TABLE diagnostic_events") + db.execSQL("ALTER TABLE diagnostic_events_new RENAME TO diagnostic_events") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_diagnostic_events_occurred_at_ms " + + "ON diagnostic_events(occurred_at_ms)", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_diagnostic_events_event_name ON diagnostic_events(event_name)") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_diagnostic_events_board_id_occurred_at_ms " + + "ON diagnostic_events(board_id, occurred_at_ms)", + ) + } + + /** + * A Metric Exclusion Range is a span of *one Board's* samples the app decided not to count, so + * unlike a Marker it has no meaning without one: `board_id` is NOT NULL, as `device_id` was. + * + * A range whose row never named a device takes the same unattributed sentinel a bucket does — + * the column is NOT NULL on both, so both need a value rather than a null, and one sentinel + * across the two keeps "no Board" a single idea. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `rebuildExclusionRangesOnBoardId` + */ + private fun rebuildExclusionRangesOnBoardId(db: SupportSQLiteDatabase) { + db.execSQL("DROP INDEX IF EXISTS index_metric_exclusion_ranges_start_ms_end_ms") + db.execSQL("DROP INDEX IF EXISTS index_metric_exclusion_ranges_device_id_start_ms_end_ms") + db.execSQL( + """ + CREATE TABLE metric_exclusion_ranges_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + board_id TEXT NOT NULL, + reason TEXT NOT NULL, + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + sample_count INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO metric_exclusion_ranges_new + (id, board_id, reason, start_ms, end_ms, sample_count) + SELECT + r.id, ${boardIdFromDeviceId("r").format("''")}, r.reason, r.start_ms, r.end_ms, + r.sample_count + FROM metric_exclusion_ranges r + """.trimIndent(), + ) + db.execSQL("DROP TABLE metric_exclusion_ranges") + db.execSQL("ALTER TABLE metric_exclusion_ranges_new RENAME TO metric_exclusion_ranges") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_metric_exclusion_ranges_start_ms_end_ms " + + "ON metric_exclusion_ranges(start_ms, end_ms)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_metric_exclusion_ranges_board_id_start_ms_end_ms " + + "ON metric_exclusion_ranges(board_id, start_ms, end_ms)", + ) + } + private fun dropMapPointTables(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS map_point_reactions") db.execSQL("DROP TABLE IF EXISTS map_points") @@ -729,8 +1204,7 @@ abstract class TelemetryDatabase : RoomDatabase() { override fun migrate(db: SupportSQLiteDatabase) { createVescFaultOccurrences(db) createVescFaultCaptures(db) - db.execSQL("DROP INDEX IF EXISTS index_telemetry_frames_fault") - if (hasColumn(db, "telemetry_frames", "fault_code")) { + if (hasColumn(db, "telemetry_frames", "fault_code")) { db.execSQL( """ CREATE TABLE telemetry_frames_new ( @@ -933,6 +1407,8 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_34_35, MIGRATION_35_36, MIGRATION_36_40, + MIGRATION_40_41, + MIGRATION_41_42, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index cbab3eba0..84d83c0f9 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -35,7 +35,7 @@ const val TELEMETRY_MASK2_LOCATION = 1 tableName = "telemetry_frames", indices = [ Index(value = ["captured_at_ms"]), - Index(value = ["device_id", "captured_at_ms"]), + Index(value = ["board_id", "captured_at_ms"]), ], ) data class TelemetryFrameEntity( @@ -45,10 +45,13 @@ data class TelemetryFrameEntity( val capturedAtMs: Long, @ColumnInfo(name = "elapsed_realtime_ms") val elapsedRealtimeMs: Long, - @ColumnInfo(name = "device_id") - val deviceId: String?, - @ColumnInfo(name = "device_name") - val deviceName: String?, + /** + * Owning Board (`boards.id`), or null when the samples match no saved Board. Never the BLE + * identifier: it is nullable, it moves when a Board is re-linked, and it is not an identity + * (ADR 0028). The Board name is resolved from `boards` on read, never denormalized here. + */ + @ColumnInfo(name = "board_id") + val boardId: String?, @ColumnInfo(name = "can_id") val canId: Int?, val flags: Int, @@ -106,16 +109,19 @@ data class TelemetryFrameEntity( @Entity( tableName = "telemetry_minute_buckets", - primaryKeys = ["bucket_start_ms", "device_id"], + primaryKeys = ["bucket_start_ms", "board_id"], indices = [Index(value = ["bucket_start_ms"])], ) data class TelemetryMinuteBucketEntity( @ColumnInfo(name = "bucket_start_ms") val bucketStartMs: Long, - @ColumnInfo(name = "device_id") - val deviceId: String, - @ColumnInfo(name = "device_name") - val deviceName: String?, + /** + * Owning Board (`boards.id`), or [UNKNOWN_TELEMETRY_BOARD_ID] when the samples match no saved + * Board — the column is part of the primary key, so it cannot be null. Keyed on the Board rather + * than the BLE identifier (ADR 0028), which is also what the server keys this table on. + */ + @ColumnInfo(name = "board_id") + val boardId: String, @ColumnInfo(name = "sample_count") val sampleCount: Int, @ColumnInfo(name = "first_sample_at_ms") @@ -172,7 +178,7 @@ data class TelemetryMinuteBucketEntity( tableName = "telemetry_markers", indices = [ Index(value = ["occurred_at_ms"]), - Index(value = ["device_id", "occurred_at_ms"]), + Index(value = ["board_id", "occurred_at_ms"]), ], ) data class TelemetryMarkerEntity( @@ -183,10 +189,9 @@ data class TelemetryMarkerEntity( @ColumnInfo(name = "elapsed_realtime_ms") val elapsedRealtimeMs: Long, val type: String, - @ColumnInfo(name = "device_id") - val deviceId: String?, - @ColumnInfo(name = "device_name") - val deviceName: String?, + /** Owning Board (`boards.id`); null when the Marker was written with no Board connected. */ + @ColumnInfo(name = "board_id") + val boardId: String?, val message: String?, @ColumnInfo(name = "gap_ms") val gapMs: Long?, @@ -197,7 +202,7 @@ data class TelemetryMarkerEntity( indices = [ Index(value = ["occurred_at_ms"]), Index(value = ["event_name"]), - Index(value = ["device_id", "occurred_at_ms"]), + Index(value = ["board_id", "occurred_at_ms"]), ], ) data class DiagnosticEventEntity( @@ -211,10 +216,9 @@ data class DiagnosticEventEntity( val eventName: String, val operation: String?, val phase: String?, - @ColumnInfo(name = "device_id") - val deviceId: String?, - @ColumnInfo(name = "device_name") - val deviceName: String?, + /** Owning Board (`boards.id`); null when the event was recorded with no Board connected. */ + @ColumnInfo(name = "board_id") + val boardId: String?, val message: String?, @ColumnInfo(name = "properties_json") val propertiesJson: String, @@ -234,6 +238,21 @@ data class BoardEntity( val bleId: String?, @ColumnInfo(name = "created_at") val createdAt: Long, + /** + * Tombstone stamp: epoch ms of the rider's delete, null while the Board is alive. A deleted Board + * keeps its row so Ride History can still name it; only the Board's configuration is + * hard-deleted (ADR-0027). + * + * Written by the delete path only — an upsert from the bridge never authors it. + */ + @ColumnInfo(name = "deleted_at") + val deletedAt: Long? = null, +) + +/** Projection for Ride History name resolution — see `TelemetryDao.getBoardNames`. */ +data class BoardNameRow( + val id: String, + val name: String, ) @Entity( @@ -301,14 +320,15 @@ data class AlertRuleEntity( tableName = "metric_exclusion_ranges", indices = [ Index(value = ["start_ms", "end_ms"]), - Index(value = ["device_id", "start_ms", "end_ms"]), + Index(value = ["board_id", "start_ms", "end_ms"]), ], ) data class MetricExclusionRangeEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, - @ColumnInfo(name = "device_id") - val deviceId: String, + /** Owning Board (`boards.id`). A range excludes one Board's samples, so it is never absent. */ + @ColumnInfo(name = "board_id") + val boardId: String, val reason: String, @ColumnInfo(name = "start_ms") val startMs: Long, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index f57bb4883..4d6fbef4d 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -63,8 +63,8 @@ data class TelemetryLocationCapture( data class TelemetryCapture( val capturedAtMs: Long, val elapsedRealtimeMs: Long, - val deviceId: String?, - val deviceName: String, + /** Owning Board (`boards.id`) — what every telemetry table is keyed on (ADR 0028). */ + val boardId: String?, val canId: Int?, val pitch: Double, val roll: Double, @@ -141,8 +141,7 @@ class TelemetryRepository private constructor(context: Context) { fun recordMarker( type: String, - deviceId: String?, - deviceName: String?, + boardId: String?, message: String? = null, gapMs: Long? = null, occurredAtMs: Long = System.currentTimeMillis(), @@ -152,8 +151,7 @@ class TelemetryRepository private constructor(context: Context) { occurredAtMs = occurredAtMs, elapsedRealtimeMs = elapsedRealtimeMs, type = type, - deviceId = deviceId, - deviceName = deviceName, + boardId = boardId, message = message, gapMs = gapMs, ) @@ -171,8 +169,7 @@ class TelemetryRepository private constructor(context: Context) { eventName = eventName, operation = properties["operation"] as? String, phase = properties["phase"] as? String, - deviceId = properties["ble_id"] as? String, - deviceName = properties["board_nickname"] as? String, + boardId = properties["board_id"] as? String, message = properties["message"] as? String, propertiesJson = JSONObject(sanitizeDiagnosticProperties(properties)).toString(), ) @@ -216,8 +213,7 @@ class TelemetryRepository private constructor(context: Context) { occurredAtMs = capture.capturedAtMs, elapsedRealtimeMs = capture.elapsedRealtimeMs, type = "gap", - deviceId = capture.deviceId, - deviceName = capture.deviceName, + boardId = capture.boardId, message = null, gapMs = gapMs, ), @@ -283,13 +279,14 @@ class TelemetryRepository private constructor(context: Context) { query.fromMs, query.toMs, query.beforeMs, - query.deviceId, + query.boardId, query.limit, ) if (buckets.isEmpty()) return@withContext emptyList() val markerFrom = buckets.minOf { it.bucketStartMs } - GAP_BOUNDARY_MS val markerTo = buckets.maxOf { it.bucketStartMs } + TELEMETRY_BUCKET_SIZE_MS - val markers = dao.getMarkers(markerFrom, markerTo, query.deviceId) + val markers = dao.getMarkers(markerFrom, markerTo, query.boardId) + val boardNames = boardNamesById() buckets.map { bucket -> val marker = markers.lastOrNull { it.occurredAtMs >= bucket.firstSampleAtMs - 5_000L && @@ -313,12 +310,12 @@ class TelemetryRepository private constructor(context: Context) { val maxGpsSpeedKmh = bucket.maxGpsSpeedCentiMps?.let { it / 100.0 * 3.6 } val distanceM = distanceDeltaM(bucket) ?: bucket.gpsDistanceCm.takeIf { it > 0L }?.let { it / 100.0 } mapOf( - "id" to "${bucket.deviceId}:${bucket.bucketStartMs}", + "id" to "${bucket.boardId}:${bucket.bucketStartMs}", "startAtMs" to bucket.firstSampleAtMs, "endAtMs" to bucket.lastSampleAtMs, "bucketStartMs" to bucket.bucketStartMs, - "deviceId" to bucket.deviceId.ifBlank { null }, - "deviceName" to (bucket.deviceName ?: UNKNOWN_TELEMETRY_DEVICE_NAME), + "boardId" to bucket.boardId.ifBlank { null }, + "boardName" to (boardNames[bucket.boardId] ?: UNKNOWN_TELEMETRY_BOARD_NAME), "sampleCount" to bucket.sampleCount, "gpsPointCount" to bucket.gpsPointCount, "preciseGpsPointCount" to bucket.preciseGpsPointCount, @@ -350,8 +347,8 @@ class TelemetryRepository private constructor(context: Context) { suspend fun getSamples(options: Map): List> = withContext(Dispatchers.IO) { val query = SampleQueryOptions.from(options) smoothedSampleMaps( - getSampleStates(query.fromMs, query.toMs, query.deviceId, query.limit), - batteryConfigByDevice(), + getSampleStates(query.fromMs, query.toMs, query.boardId, query.limit), + batteryConfigByBoard(), ) } @@ -366,12 +363,17 @@ class TelemetryRepository private constructor(context: Context) { ): List> { val windowMs = AppDataRepository.get(appContext).getTypedSettings().socEstimateWindowSeconds * 1000L val windows = HashMap() + val boardNames = boardNamesById() return samples.map { sample -> val estimate = deriveBatteryPercent(sample.state, configs)?.let { - windows.getOrPut(sample.state.deviceId) { SocMedianWindow(windowMs) } + windows.getOrPut(sample.state.boardId) { SocMedianWindow(windowMs) } .median(it, sample.state.capturedAtMs) } - sample.state.toSampleMap(sample.id, estimate) + sample.state.toSampleMap( + sample.id, + boardNames[sample.state.boardId] ?: UNKNOWN_TELEMETRY_BOARD_NAME, + estimate, + ) } } @@ -380,7 +382,7 @@ class TelemetryRepository private constructor(context: Context) { * little-endian Float64 lanes packed row-major into one direct ByteBuffer, returned as a JSI * ArrayBuffer. This replaces ~25 per-field JSI conversions × N samples (the dominant history-load * cost) with a single buffer transfer; JS rebuilds TelemetrySample objects locally. Nullable - * numeric lanes use NaN as the null sentinel; deviceId/deviceName are dictionary-encoded. + * numeric lanes use NaN as the null sentinel; the Board id and name are dictionary-encoded. * * @parity /modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift `sampleColumns` */ @@ -390,9 +392,10 @@ class TelemetryRepository private constructor(context: Context) { ): Map { val windowMs = AppDataRepository.get(appContext).getTypedSettings().socEstimateWindowSeconds * 1000L val windows = HashMap() - val deviceIds = ArrayList() - val deviceNames = ArrayList() - val deviceIndex = HashMap() + val boardNames = boardNamesById() + val boardIds = ArrayList() + val names = ArrayList() + val boardIndex = HashMap() val buffer = ByteBuffer .allocateDirect(samples.size * SAMPLE_COLUMN_COUNT * 8) .order(ByteOrder.LITTLE_ENDIAN) @@ -404,12 +407,12 @@ class TelemetryRepository private constructor(context: Context) { for ((sampleIndex, sample) in samples.withIndex()) { val s = sample.state val estimate = deriveBatteryPercent(s, configs)?.let { - windows.getOrPut(s.deviceId) { SocMedianWindow(windowMs) }.median(it, s.capturedAtMs) + windows.getOrPut(s.boardId) { SocMedianWindow(windowMs) }.median(it, s.capturedAtMs) } - val di = deviceIndex.getOrPut(s.deviceId) { - deviceIds.add(s.deviceId) - deviceNames.add(s.deviceName ?: UNKNOWN_TELEMETRY_DEVICE_NAME) - deviceIds.size - 1 + val di = boardIndex.getOrPut(s.boardId) { + boardIds.add(s.boardId) + names.add(boardNames[s.boardId] ?: UNKNOWN_TELEMETRY_BOARD_NAME) + boardIds.size - 1 } buffer .putDouble(sample.id.toDouble()) @@ -448,8 +451,8 @@ class TelemetryRepository private constructor(context: Context) { return mapOf( "boardColumns" to NativeArrayBuffer.wrap(buffer), "boardCount" to samples.size, - "boardDevices" to deviceIds, - "boardDeviceNames" to deviceNames, + "boardIds" to boardIds, + "boardNames" to names, "chartColumns" to NativeArrayBuffer.wrap(overviewBuffer), "chartCount" to overviewIndices.size, ) @@ -463,27 +466,36 @@ class TelemetryRepository private constructor(context: Context) { } } - /** bleId (telemetry deviceId) -> the board's normalized battery config. */ - private suspend fun batteryConfigByDevice(): Map> { + /** + * `boards.id` -> the Board's normalized battery config. Keyed on the Board rather than its BLE + * identifier now that samples carry the Board id (ADR 0028), so a re-linked Board keeps its + * config across its whole history. + */ + private suspend fun batteryConfigByBoard(): Map> { BatterySocEstimator.ensureInitialized(appContext) val result = mutableMapOf>() for (board in AppDataRepository.get(appContext).getBoards()) { - @Suppress("UNCHECKED_CAST") - val link = board["link"] as? Map ?: continue - val bleId = link["bleId"] as? String ?: continue + val id = board["id"] as? String ?: continue @Suppress("UNCHECKED_CAST") val config = board["batteryConfig"] as? Map ?: continue - result[bleId] = config + result[id] = config } return result } + /** + * `boards.id` -> Board name, tombstones included: Ride History still has to name a Board the + * Rider deleted (ADR 0027), and resolving on read is what makes a rename retroactive. + */ + private suspend fun boardNamesById(): Map = + dao.getBoardNames().associate { it.id to it.name } + /** Derive IR-compensated battery % on read, mirroring the live native path. */ private fun deriveBatteryPercent( state: FullTelemetryState, configs: Map>, ): Double? { - val config = state.deviceId?.let { configs[it] } ?: return null + val config = state.boardId?.let { configs[it] } ?: return null return BatterySocEstimator.estimateBatteryPercent( state.batteryVoltageMv / 1000.0, config, @@ -494,12 +506,12 @@ class TelemetryRepository private constructor(context: Context) { private suspend fun getSampleStates( fromMs: Long, toMs: Long, - deviceId: String?, + boardId: String?, limit: Int, ): List { - val keyframe = dao.getLatestKeyframeBefore(fromMs, deviceId) + val keyframe = dao.getLatestKeyframeBefore(fromMs, boardId) val start = keyframe?.capturedAtMs ?: fromMs - val frames = dao.getFrames(start, toMs, deviceId, limit + 1) + val frames = dao.getFrames(start, toMs, boardId, limit + 1) var state: FullTelemetryState? = null val samples = mutableListOf() for (frame in frames) { @@ -514,12 +526,12 @@ class TelemetryRepository private constructor(context: Context) { suspend fun getRange(options: Map): Map = withContext(Dispatchers.IO) { val query = SampleQueryOptions.from(options) - val samples = getSampleStates(query.fromMs, query.toMs, query.deviceId, query.limit) - val configs = batteryConfigByDevice() + val samples = getSampleStates(query.fromMs, query.toMs, query.boardId, query.limit) + val configs = batteryConfigByBoard() smoothedSampleColumns(samples, configs) + mapOf( - "gpsSamples" to samples.toGpsSampleMaps(), - "markers" to dao.getMarkers(query.fromMs, query.toMs, query.deviceId).map { it.toMap() }, - "exclusions" to dao.getExclusions(query.fromMs, query.toMs, query.deviceId).map { it.toMap() }, + "gpsSamples" to samples.toGpsSampleMaps(boardNamesById()), + "markers" to dao.getMarkers(query.fromMs, query.toMs, query.boardId).map { it.toMap() }, + "exclusions" to dao.getExclusions(query.fromMs, query.toMs, query.boardId).map { it.toMap() }, ) } @@ -535,7 +547,7 @@ class TelemetryRepository private constructor(context: Context) { suspend fun getDiagnosticEvents(options: Map): List> = withContext(Dispatchers.IO) { val query = DiagnosticQueryOptions.from(options) - dao.getDiagnosticEvents(query.fromMs, query.toMs, query.deviceId, query.limit).map { it.toMap() } + dao.getDiagnosticEvents(query.fromMs, query.toMs, query.boardId, query.limit).map { it.toMap() } } suspend fun clearDiagnosticEvents() = withContext(Dispatchers.IO) { @@ -551,9 +563,9 @@ class TelemetryRepository private constructor(context: Context) { flushNow() val requested = TelemetryTimeRange(query.fromMs, query.toMs) val protected = favoriteTelemetryRanges() - promoteProtectedRangeStarts(protected, query.deviceId) + promoteProtectedRangeStarts(protected, query.boardId) val deleted = subtractProtectedTelemetryRanges(requested, protected).sumOf { range -> - dao.deleteRange(range.startMs, range.endMs, query.deviceId) + dao.deleteRange(range.startMs, range.endMs, query.boardId) } deleted } @@ -581,7 +593,7 @@ class TelemetryRepository private constructor(context: Context) { fromMs = fromBucketMs, toMs = favorite.endMs, beforeMs = favorite.endMs, - deviceId = null, + boardId = null, limit = Int.MAX_VALUE, ).asReversed() .filter { it.firstSampleAtMs <= favorite.endMs && it.lastSampleAtMs >= favorite.startMs } @@ -603,17 +615,12 @@ class TelemetryRepository private constructor(context: Context) { val range = favoriteRange(options) ?: return@withContext null val startMs = range.startMs val endMs = range.endMs - val deviceId = options["deviceId"] as? String + val boardId = options["boardId"] as? String val name = (options["name"] as? String)?.trim()?.ifEmpty { null } flushNow() - val states = getSampleStates(startMs, endMs, deviceId, Int.MAX_VALUE) + val states = getSampleStates(startMs, endMs, boardId, Int.MAX_VALUE) val summary = favoriteSummary(states) - val boards = dao.getBoards() - // The ble id is a transport key — it changes on re-link and differs per install — so the - // Favorite keeps the durable `boards.id` instead. - // @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `boardId` - val boardId = deviceId?.let { ble -> boards.firstOrNull { it.bleId == ble }?.id } val nowMs = System.currentTimeMillis() val favorite = FavoriteEntity( id = UUID.randomUUID().toString(), @@ -633,7 +640,7 @@ class TelemetryRepository private constructor(context: Context) { ) dao.insertFavorite(favorite) favorite.toMap( - boards.firstOrNull { it.id == boardId }?.name, + boardId?.let { boardNamesById()[it] }, favoriteRoutePoints(favorite), ) } @@ -652,11 +659,11 @@ class TelemetryRepository private constructor(context: Context) { val range = favoriteRange(options) ?: return@withContext null val startMs = range.startMs val endMs = range.endMs - val deviceId = options["deviceId"] as? String + val boardId = options["boardId"] as? String val name = (options["name"] as? String)?.trim()?.ifEmpty { null } flushNow() - val summary = favoriteSummary(getSampleStates(startMs, endMs, deviceId, Int.MAX_VALUE)) + val summary = favoriteSummary(getSampleStates(startMs, endMs, boardId, Int.MAX_VALUE)) val updated = existing.copy( name = name, startMs = startMs, @@ -784,7 +791,7 @@ class TelemetryRepository private constructor(context: Context) { if (protected.isEmpty()) { dao.clearAll() } else { - promoteProtectedRangeStarts(protected, deviceId = null) + promoteProtectedRangeStarts(protected, boardId = null) val requested = TelemetryTimeRange(Long.MIN_VALUE, Long.MAX_VALUE) for (range in subtractProtectedTelemetryRanges(requested, protected)) { dao.deleteRangeAllDevices(range.startMs, range.endMs) @@ -821,24 +828,24 @@ class TelemetryRepository private constructor(context: Context) { */ private suspend fun promoteProtectedRangeStarts( protected: Collection, - deviceId: String?, + boardId: String?, ) { for (range in protected) { - val devices = if (deviceId != null) { - listOf(deviceId) + val boards = if (boardId != null) { + listOf(boardId) } else { - dao.getDeviceIdsInRange(range.startMs, range.endMs) + dao.getBoardIdsInRange(range.startMs, range.endMs) } - for (protectedDeviceId in devices) { + for (protectedBoardId in boards) { val firstFrame = dao.getFirstFrameInRange( range.startMs, range.endMs, - protectedDeviceId, + protectedBoardId, ) ?: continue val first = getSampleStates( range.startMs, firstFrame.capturedAtMs, - protectedDeviceId, + protectedBoardId, Int.MAX_VALUE, ).firstOrNull { it.id == firstFrame.id } ?: continue dao.updateFrame(first.state.toFrame(previous = null, keyframe = true).copy(id = first.id)) @@ -938,7 +945,7 @@ private data class HistoryQueryOptions( val fromMs: Long, val toMs: Long, val beforeMs: Long, - val deviceId: String?, + val boardId: String?, val limit: Int, ) { companion object { @@ -948,7 +955,7 @@ private data class HistoryQueryOptions( fromMs = options.long("fromMs") ?: 0L, toMs = toMs, beforeMs = options.long("cursorBeforeMs") ?: toMs, - deviceId = options["deviceId"] as? String, + boardId = options["boardId"] as? String, limit = (options.int("limit") ?: DEFAULT_HISTORY_LIMIT).coerceIn(1, 500), ) } @@ -958,7 +965,7 @@ private data class HistoryQueryOptions( private data class DiagnosticQueryOptions( val fromMs: Long, val toMs: Long, - val deviceId: String?, + val boardId: String?, val limit: Int, ) { companion object { @@ -967,7 +974,7 @@ private data class DiagnosticQueryOptions( return DiagnosticQueryOptions( fromMs = options.long("fromMs") ?: 0L, toMs = toMs, - deviceId = options["deviceId"] as? String, + boardId = options["boardId"] as? String, limit = (options.int("limit") ?: 200).coerceIn(1, 1_000), ) } @@ -977,7 +984,7 @@ private data class DiagnosticQueryOptions( private data class SampleQueryOptions( val fromMs: Long, val toMs: Long, - val deviceId: String?, + val boardId: String?, val limit: Int, ) { companion object { @@ -985,7 +992,7 @@ private data class SampleQueryOptions( SampleQueryOptions( fromMs = options.requiredLong("fromMs"), toMs = options.requiredLong("toMs"), - deviceId = options["deviceId"] as? String, + boardId = options["boardId"] as? String, limit = (options.int("limit") ?: DEFAULT_SAMPLE_LIMIT).coerceIn(1, MAX_SAMPLE_LIMIT), ) } @@ -994,7 +1001,7 @@ private data class SampleQueryOptions( private data class RangeMutationOptions( val fromMs: Long, val toMs: Long, - val deviceId: String?, + val boardId: String?, ) { companion object { fun from(options: Map): RangeMutationOptions { @@ -1004,7 +1011,7 @@ private data class RangeMutationOptions( return RangeMutationOptions( fromMs = fromMs, toMs = toMs, - deviceId = options["deviceId"] as? String, + boardId = options["boardId"] as? String, ) } } @@ -1018,8 +1025,7 @@ internal data class HistoryTelemetryState( internal data class FullTelemetryState( val capturedAtMs: Long, val elapsedRealtimeMs: Long, - val deviceId: String?, - val deviceName: String?, + val boardId: String?, val canId: Int?, val speedCentiKmh: Int, val batteryVoltageMv: Int, @@ -1058,8 +1064,7 @@ internal data class FullTelemetryState( return TelemetryFrameEntity( capturedAtMs = capturedAtMs, elapsedRealtimeMs = elapsedRealtimeMs, - deviceId = deviceId, - deviceName = deviceName, + boardId = boardId, canId = canId, flags = flags, changedMask1 = 0, @@ -1091,11 +1096,12 @@ internal data class FullTelemetryState( ).copy(changedMask1 = mask1, changedMask2 = mask2) } - fun toSampleMap(id: Long, batteryPercent: Double? = null): Map = mapOf( + /** Board name is resolved by the caller from `boards`, never read off the row (ADR 0028). */ + fun toSampleMap(id: Long, boardName: String?, batteryPercent: Double? = null): Map = mapOf( "id" to id, "capturedAtMs" to capturedAtMs, - "deviceId" to deviceId, - "deviceName" to (deviceName ?: UNKNOWN_TELEMETRY_DEVICE_NAME), + "boardId" to boardId, + "boardName" to boardName, "speedKmh" to speedCentiKmh / 100.0, "batteryVoltage" to batteryVoltageMv / 1000.0, "batteryPercent" to batteryPercent, @@ -1120,8 +1126,7 @@ internal data class FullTelemetryState( fun toBucketPoint(): BucketTelemetryPoint = BucketTelemetryPoint( capturedAtMs = capturedAtMs, - deviceId = deviceId, - deviceName = deviceName, + boardId = boardId, speedCentiKmh = speedCentiKmh, batteryVoltageMv = batteryVoltageMv, motorCurrentMa = motorCurrentMa, @@ -1139,8 +1144,7 @@ internal data class FullTelemetryState( fun from(capture: TelemetryCapture): FullTelemetryState = FullTelemetryState( capturedAtMs = capture.capturedAtMs, elapsedRealtimeMs = capture.elapsedRealtimeMs, - deviceId = capture.deviceId, - deviceName = capture.deviceName, + boardId = capture.boardId, canId = capture.canId, speedCentiKmh = (capture.speed * 100.0).roundToInt(), batteryVoltageMv = (capture.batteryVoltage * 1000.0).roundToInt(), @@ -1187,8 +1191,7 @@ internal data class FullTelemetryState( return FullTelemetryState( capturedAtMs = frame.capturedAtMs, elapsedRealtimeMs = frame.elapsedRealtimeMs, - deviceId = frame.deviceId ?: base?.deviceId, - deviceName = frame.deviceName ?: base?.deviceName, + boardId = frame.boardId ?: base?.boardId, canId = frame.canId ?: base?.canId, speedCentiKmh = speed, batteryVoltageMv = voltage, @@ -1278,15 +1281,14 @@ private fun TelemetryMarkerEntity.toMap(): Map = mapOf( "id" to id, "occurredAtMs" to occurredAtMs, "type" to type, - "deviceId" to deviceId, - "deviceName" to deviceName, + "boardId" to boardId, "message" to message, "gapMs" to gapMs, ) private fun MetricExclusionRangeEntity.toMap(): Map = mapOf( "id" to id, - "deviceId" to deviceId.ifBlank { null }, + "boardId" to boardId, "reason" to reason, "startMs" to startMs, "endMs" to endMs, @@ -1311,8 +1313,7 @@ private fun DiagnosticEventEntity.toMap(): Map = mapOf( "eventName" to eventName, "operation" to operation, "phase" to phase, - "deviceId" to deviceId, - "deviceName" to deviceName, + "boardId" to boardId, "message" to message, "propertiesJson" to propertiesJson, ) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizer.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizer.kt index 02b48ef69..d26aed11a 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizer.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizer.kt @@ -5,7 +5,7 @@ import expo.modules.vescapecore.telemetry.EXCLUSION_REASON_FREE_SPIN import expo.modules.vescapecore.telemetry.FREE_SPIN_GPS_PRECISE_ACCURACY_CM import expo.modules.vescapecore.telemetry.FREE_SPIN_LOW_GPS_CUTOFF_CENTI_KMH import expo.modules.vescapecore.telemetry.FREE_SPIN_NEAREST_GPS_MAX_AGE_MS -import expo.modules.vescapecore.telemetry.UNKNOWN_TELEMETRY_DEVICE_ID +import expo.modules.vescapecore.telemetry.UNKNOWN_TELEMETRY_BOARD_ID import kotlin.math.abs internal class FreeSpinMetricSanitizer( @@ -36,7 +36,7 @@ internal class FreeSpinMetricSanitizer( exclusions = listOf( MetricExclusionSample( capturedAtMs = point.capturedAtMs, - deviceId = point.deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID, + boardId = point.boardId ?: UNKNOWN_TELEMETRY_BOARD_ID, reason = EXCLUSION_REASON_FREE_SPIN, ), ), diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizer.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizer.kt index 312aa35ce..8549398a5 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizer.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizer.kt @@ -2,7 +2,7 @@ package expo.modules.vescapecore.telemetry.sanitizers import expo.modules.vescapecore.telemetry.BucketTelemetryPoint import expo.modules.vescapecore.telemetry.EXCLUSION_REASON_LOW_SPEED -import expo.modules.vescapecore.telemetry.UNKNOWN_TELEMETRY_DEVICE_ID +import expo.modules.vescapecore.telemetry.UNKNOWN_TELEMETRY_BOARD_ID import kotlin.math.abs internal class LowSpeedAverageSpeedSanitizer( @@ -23,7 +23,7 @@ internal class LowSpeedAverageSpeedSanitizer( exclusions = listOf( MetricExclusionSample( capturedAtMs = point.capturedAtMs, - deviceId = point.deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID, + boardId = point.boardId ?: UNKNOWN_TELEMETRY_BOARD_ID, reason = EXCLUSION_REASON_LOW_SPEED, ), ), diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/MetricSampleSanitizer.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/MetricSampleSanitizer.kt index a9d78bb55..15fc8c54e 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/MetricSampleSanitizer.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/sanitizers/MetricSampleSanitizer.kt @@ -9,7 +9,7 @@ internal data class MetricSanitizationContext( internal data class MetricExclusionSample( val capturedAtMs: Long, - val deviceId: String, + val boardId: String, val reason: String, ) diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt new file mode 100644 index 000000000..93921b357 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt @@ -0,0 +1,127 @@ +package expo.modules.vescapecore.telemetry + +import android.database.Cursor +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.lang.reflect.Proxy + +/** + * Board tombstones (ADR 0027): deleting a Board stamps `boards.deleted_at` instead of removing the + * row, so Ride History outlives the Board that produced it. Configuration still goes; telemetry and + * Tune Profiles never did and still do not. + * + * Room's `@Query` has BINARY retention and its generated implementation keeps the SQL in a + * method-local string, so a JVM unit test has no runtime handle on the statements Room will run — + * the read/delete contracts are asserted against the DAO source. + * + * @parity /modules/vescape-core/ios/telemetry/BoardTombstoneTests.swift + */ +class BoardTombstoneTest { + private fun migrationSql(migration: Migration): List { + val sql = mutableListOf() + val db = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + sql += args?.firstOrNull() as String + null + } + "query" -> emptyCursor() + else -> throw UnsupportedOperationException(method.name) + } + } as SupportSQLiteDatabase + migration.migrate(db) + return sql + } + + private fun emptyCursor(): Cursor = Proxy.newProxyInstance( + Cursor::class.java.classLoader, + arrayOf(Cursor::class.java), + ) { _, method, _ -> + when (method.name) { + "getColumnIndex" -> 0 + "moveToNext" -> false + "close" -> null + else -> throw UnsupportedOperationException(method.name) + } + } as Cursor + + private fun daoSource(): String = + File("src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt").readText() + + /** + * Additive and nullable: existing rows stay null, which is what "alive" means. A `NOT NULL DEFAULT` + * would tombstone every Board on the device the moment it upgraded. + */ + @Test + fun migrationAddsNullableDeletedAtColumn() { + val sql = migrationSql(TelemetryDatabase.MIGRATION_40_41) + + assertEquals(listOf("ALTER TABLE boards ADD COLUMN deleted_at INTEGER"), sql) + } + + @Test + fun migrationTargetsTheCurrentSchemaVersion() { + assertEquals(40, TelemetryDatabase.MIGRATION_40_41.startVersion) + assertEquals(41, TelemetryDatabase.MIGRATION_40_41.endVersion) + } + + /** The Rider-facing list drops tombstones; lookup by id keeps them so history can name them. */ + @Test + fun listReadFiltersTombstonesAndLookupByIdDoesNot() { + val dao = daoSource() + + assertTrue( + "getBoards() does not filter tombstones", + dao.contains("SELECT * FROM boards WHERE deleted_at IS NULL ORDER BY created_at ASC"), + ) + assertTrue( + "getBoard(id) stopped resolving tombstones", + dao.contains("SELECT * FROM boards WHERE id = :id LIMIT 1"), + ) + } + + /** + * The regression this change exists to prevent: a Board delete that still removes the row takes + * Ride History with it on the server, and the phone can never re-upload it. + */ + @Test + fun deleteTombstonesTheBoardInsteadOfRemovingTheRow() { + val dao = daoSource() + + assertFalse("a DELETE on boards survives", dao.contains("DELETE FROM boards")) + assertTrue( + "the delete path does not stamp a tombstone", + dao.contains("insertBoardRow(board.copy(deletedAt = deletedAt))"), + ) + } + + /** Configuration is still hard-deleted — only the Board row survives. */ + @Test + fun deleteStillRemovesBoardConfiguration() { + val dao = daoSource() + val body = dao.substringAfter("suspend fun deleteBoardWithSettings").substringBefore("\n }") + + for (call in listOf("deleteBoardSettings(id)", "deleteBoardWarnings(id)", "deleteAlertRules(id)")) { + assertTrue("the delete path dropped `$call`", body.contains(call)) + } + } + + /** Deletion is terminal: an ordinary upsert must not clear a tombstone already on the row. */ + @Test + fun upsertPreservesAnExistingTombstone() { + val dao = daoSource() + + assertTrue( + "upsertBoard can resurrect a deleted Board", + dao.contains("deletedAt = board.deletedAt ?: getBoardDeletedAt(board.id)"), + ) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt index 3b087cc12..806504aa7 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt @@ -221,8 +221,7 @@ class FavoriteSummaryBuilderTest { val capturedAtMs = startMs + index * intervalMs BucketTelemetryPoint( capturedAtMs = capturedAtMs, - deviceId = "board-1", - deviceName = "VESC Board", + boardId = "board-1", speedCentiKmh = speedCentiKmh, batteryVoltageMv = 50_000, motorCurrentMa = 10_000, @@ -239,8 +238,7 @@ class FavoriteSummaryBuilderTest { lastOdometerCm: Long? = 1_000L, ) = TelemetryMinuteBucketEntity( bucketStartMs = bucketStartMs, - deviceId = "board-1", - deviceName = "VESC Board", + boardId = "board-1", sampleCount = 10, firstSampleAtMs = bucketStartMs, lastSampleAtMs = bucketStartMs + 9_000, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/HistoryGpsProjectionTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/HistoryGpsProjectionTest.kt index 168f1ba4c..65e993546 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/HistoryGpsProjectionTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/HistoryGpsProjectionTest.kt @@ -35,7 +35,7 @@ class HistoryGpsProjectionTest { ), ) - val gpsSample = samples.toGpsSampleMaps().single() + val gpsSample = samples.toGpsSampleMaps(mapOf("board-1" to "ADV2")).single() val bucketPoint = samples.toBucketLocationPoints().single() assertEquals(7L, gpsSample["id"]) @@ -51,8 +51,7 @@ class HistoryGpsProjectionTest { state = FullTelemetryState( capturedAtMs = capturedAtMs, elapsedRealtimeMs = capturedAtMs, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", canId = null, speedCentiKmh = 1_000, batteryVoltageMv = 77_000, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/MetricSanitizerTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/MetricSanitizerTest.kt index 13336b438..929717677 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/MetricSanitizerTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/MetricSanitizerTest.kt @@ -37,8 +37,8 @@ class MetricSanitizerTest { @Test fun producesExclusionRangesForExcludedSamples() { val points = listOf( - point(capturedAtMs = 1000L, deviceId = "board-1", speedCentiKmh = 100), - point(capturedAtMs = 2000L, deviceId = "board-1", speedCentiKmh = 500), + point(capturedAtMs = 1000L, boardId = "board-1", speedCentiKmh = 100), + point(capturedAtMs = 2000L, boardId = "board-1", speedCentiKmh = 500), ) val result = sanitizeTelemetrySamples(points, movingSpeedThresholdCentiKmh = 300) @@ -47,7 +47,7 @@ class MetricSanitizerTest { val exclusion = result.exclusions.single() assertEquals(1000L, exclusion.startMs) assertEquals(1000L, exclusion.endMs) - assertEquals("board-1", exclusion.deviceId) + assertEquals("board-1", exclusion.boardId) assertEquals(EXCLUSION_REASON_LOW_SPEED, exclusion.reason) assertEquals(1, exclusion.sampleCount) } @@ -104,12 +104,12 @@ class MetricSanitizerTest { @Test fun nullDeviceIdUsesUnknownPlaceholder() { val points = listOf( - point(deviceId = null, speedCentiKmh = 100), + point(boardId = null, speedCentiKmh = 100), ) val result = sanitizeTelemetrySamples(points, movingSpeedThresholdCentiKmh = 300) - assertEquals(UNKNOWN_TELEMETRY_DEVICE_ID, result.exclusions.single().deviceId) + assertEquals(UNKNOWN_TELEMETRY_BOARD_ID, result.exclusions.single().boardId) } // --- Free-spin detection tests --- @@ -280,7 +280,7 @@ class MetricSanitizerTest { gpsSpeedCentiMps = 100, capturedAtMs = 1000L, gpsTimestampMs = 1000L, - deviceId = "board-1", + boardId = "board-1", ), ) @@ -290,7 +290,7 @@ class MetricSanitizerTest { assertEquals(EXCLUSION_REASON_FREE_SPIN, exclusion.reason) assertEquals(1000L, exclusion.startMs) assertEquals(1000L, exclusion.endMs) - assertEquals("board-1", exclusion.deviceId) + assertEquals("board-1", exclusion.boardId) assertEquals(1, exclusion.sampleCount) } @@ -351,13 +351,12 @@ class MetricSanitizerTest { private fun point( capturedAtMs: Long = 0L, - deviceId: String? = "board-1", + boardId: String? = "board-1", speedCentiKmh: Int = 0, dutyPermille: Int = 0, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, - deviceId = deviceId, - deviceName = "Test", + boardId = boardId, speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -368,7 +367,7 @@ class MetricSanitizerTest { private fun pointWithGps( capturedAtMs: Long = 0L, - deviceId: String? = "board-1", + boardId: String? = "board-1", speedCentiKmh: Int = 0, dutyPermille: Int = 0, gpsSpeedCentiMps: Int, @@ -376,8 +375,7 @@ class MetricSanitizerTest { gpsAccuracyCm: Int = 500, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, - deviceId = deviceId, - deviceName = "Test", + boardId = boardId, speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt index 00470da67..da54e4098 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt @@ -153,8 +153,7 @@ class ProfileStatsRepositoryTest { longitudeE7: Int? = null, ) = TelemetryMinuteBucketEntity( bucketStartMs = start - (start % TELEMETRY_BUCKET_SIZE_MS), - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", sampleCount = 1, firstSampleAtMs = start, lastSampleAtMs = end, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/RideHistoryPagingTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/RideHistoryPagingTest.kt index 1ae9ecc74..c968059b6 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/RideHistoryPagingTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/RideHistoryPagingTest.kt @@ -45,8 +45,7 @@ class RideHistoryPagingTest { private fun bucket(start: Long, end: Long) = TelemetryMinuteBucketEntity( bucketStartMs = start - (start % TELEMETRY_BUCKET_SIZE_MS), - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", sampleCount = 1, firstSampleAtMs = start, lastSampleAtMs = end, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt new file mode 100644 index 000000000..de5026c5d --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt @@ -0,0 +1,303 @@ +package expo.modules.vescapecore.telemetry + +import android.database.Cursor +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.lang.reflect.Proxy + +/** + * Telemetry keys on the Board id (#280, ADR 0028). Schema 34→35 adds `board_id` to + * `telemetry_frames` and `telemetry_minute_buckets`, backfills it by matching `boards.ble_id`, + * mints a tombstoned Board for every identifier that resolves to nothing, moves the bucket primary + * key onto the new column, and drops `device_id` and `device_name` from both tables. + * + * Asserted against the emitted SQL rather than a live database: Room's `@Query` has BINARY + * retention and this module's JVM test source set has no SQLite, the same constraint + * [SyncCursorMigrationTest] works under. The behavioural half — actual rows after an actual + * migration — runs on the GRDB peer, which does have an in-memory database. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift + */ +class TelemetryBoardIdMigrationTest { + private fun migrationSql(): List { + val sql = mutableListOf() + val db = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + sql += args?.firstOrNull() as String + null + } + "query" -> emptyCursor() + else -> throw UnsupportedOperationException(method.name) + } + } as SupportSQLiteDatabase + TelemetryDatabase.MIGRATION_41_42.migrate(db) + return sql + } + + private fun emptyCursor(): Cursor = Proxy.newProxyInstance( + Cursor::class.java.classLoader, + arrayOf(Cursor::class.java), + ) { _, method, _ -> + when (method.name) { + "getColumnIndex" -> 0 + "moveToNext" -> false + "close" -> null + else -> throw UnsupportedOperationException(method.name) + } + } as Cursor + + private fun daoSource(): String = + File("src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt").readText() + + private fun statement(match: String): String = + migrationSql().firstOrNull { it.contains(match) } + ?: throw AssertionError("no migration statement contains `$match`") + + @Test + fun migrationTargetsTheCurrentSchemaVersion() { + assertEquals(42, TELEMETRY_DATABASE_VERSION) + assertEquals(41, TelemetryDatabase.MIGRATION_41_42.startVersion) + assertEquals(42, TelemetryDatabase.MIGRATION_41_42.endVersion) + } + + // MARK: Backfill + + /** + * The point of shipping this as a migration rather than a column add: a row left without a + * `board_id` is telemetry with no owner, unjoinable and unbackupable. + */ + @Test + fun bothTablesBackfillBoardIdByMatchingTheBleIdentifier() { + for (match in listOf("INSERT INTO telemetry_frames_new", "INSERT INTO telemetry_minute_buckets_new")) { + val sql = statement(match) + assertTrue( + "$match does not resolve device_id through the shared identifier map", + sql.contains("SELECT m.board_id FROM telemetry_device_board_map m WHERE m.device_id ="), + ) + } + } + + /** + * Two Boards may claim one `ble_id` — the same peripheral linked twice, which the app supports. + * Resolved independently, the two rebuilds are each free to pick a different claimant, and a ride + * whose buckets say one Board and whose frames say another renders in History as stats over an + * empty route. Neither rebuild may reach `boards` directly; both read one decision. + */ + @Test + fun aDuplicatedIdentifierIsResolvedOnceSoTheTwoRebuildsCannotDiverge() { + val sql = migrationSql() + val map = sql.indexOfFirst { it.startsWith("INSERT INTO telemetry_device_board_map") } + val firstRebuild = sql.indexOfFirst { it.contains("INSERT INTO telemetry_frames_new") } + + assertTrue("the identifier is never resolved into a shared decision", map >= 0) + assertTrue("the map is filled after the rebuilds have already read it", map < firstRebuild) + assertTrue( + "claimants are not folded to one deterministic pick per identifier", + sql[map].contains("MIN(b.id)") && sql[map].contains("GROUP BY b.ble_id"), + ) + for (match in listOf("INSERT INTO telemetry_frames_new", "INSERT INTO telemetry_minute_buckets_new")) { + assertFalse( + "$match still resolves the identifier against boards, so it can pick its own claimant", + statement(match).contains("FROM boards"), + ) + } + } + + /** A tombstone minted for an unresolved identifier carries no `ble_id`, so it never enters the map. */ + @Test + fun theIdentifierMapIsBuiltAfterTheOrphanMintAndDroppedAfterTheRebuilds() { + val sql = migrationSql() + val mint = sql.indexOfFirst { it.startsWith("INSERT OR IGNORE INTO boards") } + val map = sql.indexOfFirst { it.startsWith("CREATE TEMP TABLE telemetry_device_board_map") } + val dropped = sql.indexOfFirst { it.contains("DROP TABLE IF EXISTS telemetry_device_board_map") } + val lastRebuild = sql.indexOfLast { it.contains("INSERT INTO telemetry_minute_buckets_new") } + + assertTrue("the map is built before the mint, so minted Boards are missing from it", mint < map) + assertTrue("the scratch map outlives the migration", dropped > lastRebuild) + } + + /** A row that never carried an identifier stays unattributed rather than joining a random Board. */ + @Test + fun framesWithNoIdentifierBackfillToNullAndBucketsToTheUnknownSentinel() { + assertTrue( + "frames without a device_id do not backfill to NULL", + statement("INSERT INTO telemetry_frames_new").contains("device_id = '' THEN NULL"), + ) + assertTrue( + "buckets without a device_id do not backfill to the unknown sentinel", + statement("INSERT INTO telemetry_minute_buckets_new").contains("device_id = '' THEN ''"), + ) + } + + // MARK: Orphan minting + + /** + * Telemetry from a Board hard-deleted before tombstones existed, or from a peripheral the Board + * was re-linked away from, resolves to nothing. Without a minted Board it loses both its identity + * and its label — the one case in this migration sequence that creates rows the Rider never made. + */ + @Test + fun unresolvedIdentifiersMintATombstonedBoardNamedFromTheHistoricalDeviceName() { + for (table in listOf("telemetry_frames", "telemetry_minute_buckets", "telemetry_markers", "diagnostic_events")) { + val sql = migrationSql().firstOrNull { + it.startsWith("INSERT OR IGNORE INTO boards") && it.contains("FROM $table t") + } ?: throw AssertionError("no orphan mint sourced from $table") + + assertTrue( + "the mint does not skip identifiers a Board still claims", + sql.contains("NOT EXISTS (SELECT 1 FROM boards b WHERE b.ble_id = t.device_id)"), + ) + assertTrue( + "the minted Board is not named from the telemetry's own device_name", + sql.contains("SELECT n.device_name FROM $table n"), + ) + assertTrue( + "the minted Board id is not derived from the identifier, so re-running duplicates it", + sql.contains("'$ORPHAN_BOARD_ID_PREFIX' || t.device_id"), + ) + } + } + + /** + * A minted Board must never reach the Rider's Board list, and must never capture a future + * re-link: the tombstone stamp keeps it out of `getBoards()`, the null `ble_id` keeps it out of + * every identifier match — including this migration's own backfill on a later upgrade. + */ + @Test + fun aMintedBoardIsTombstonedAndCarriesNoBoardLink() { + val sql = statement("FROM telemetry_frames t") + val columns = sql.substringAfter("(").substringBefore(")").split(",").map { it.trim() } + // Tail of the SELECT list, in column order: ble_id, created_at, deleted_at. A literal NULL for + // the link, a stamped epoch for the tombstone. + val selected = sql.substringBefore("FROM telemetry_frames t").lines() + .map { it.trim().trimEnd(',') } + .filter { it.isNotEmpty() } + .takeLast(3) + + assertEquals( + listOf("id", "name", "ble_id", "created_at", "deleted_at"), + columns, + ) + assertEquals("a minted Board carries a Board Link", "NULL", selected.first()) + assertTrue("a minted Board is not tombstoned", selected.last().toLongOrNull() != null) + assertTrue( + "the Rider's Board list would show minted Boards", + daoSource().contains("SELECT * FROM boards WHERE deleted_at IS NULL ORDER BY created_at ASC"), + ) + } + + // MARK: Table rebuild + + /** The primary key move is a rebuild, not an `ALTER`. */ + @Test + fun theBucketRebuildMovesThePrimaryKey() { + val create = statement("CREATE TABLE telemetry_minute_buckets_new") + val copy = statement("INSERT INTO telemetry_minute_buckets_new") + + assertTrue( + "the bucket primary key is not (bucket_start_ms, board_id)", + create.contains("PRIMARY KEY (bucket_start_ms, board_id)"), + ) + assertFalse("the rebuilt bucket table still carries the BLE identifier", create.contains("device_id")) + assertFalse("the bucket rebuild still copies the Board name", copy.contains("device_name")) + assertTrue( + "the rebuilt table is not swapped in", + migrationSql().contains("ALTER TABLE telemetry_minute_buckets_new RENAME TO telemetry_minute_buckets"), + ) + } + + /** + * The copy is grouped so the rebuild is total: an ungrouped copy would abort the whole migration + * on a `board_id` collision, stranding the database mid-upgrade. + */ + @Test + fun theBucketCopyIsGroupedSoAKeyCollisionCannotAbortTheRebuild() { + val copy = statement("INSERT INTO telemetry_minute_buckets_new") + + assertTrue("colliding buckets are not folded", copy.contains("GROUP BY b.bucket_start_ms, board_id")) + assertTrue("sample counts are not summed on a fold", copy.contains("SUM(b.sample_count)")) + assertTrue("peak speed is not kept on a fold", copy.contains("MAX(b.max_abs_speed_centi_kmh)")) + } + + /** Neither table may keep the columns ADR 0028 retires, on either the schema or the copy. */ + @Test + fun bothRebuiltTablesDropTheBleIdentifierAndTheDenormalizedName() { + for (table in listOf("telemetry_frames", "telemetry_minute_buckets")) { + val create = statement("CREATE TABLE ${table}_new") + assertFalse("$table keeps device_id", create.contains("device_id")) + assertFalse("$table keeps device_name", create.contains("device_name")) + assertTrue("$table has no board_id", create.contains("board_id")) + assertTrue( + "the rebuilt $table is not swapped in", + migrationSql().contains("ALTER TABLE ${table}_new RENAME TO $table"), + ) + } + } + + /** The frame index that meant "this Board" while saying `device_id` follows the column. */ + @Test + fun theFrameLookupIndexMovesOntoBoardId() { + val sql = migrationSql() + + assertTrue( + "the old device_id index survives the rebuild", + sql.contains("DROP INDEX IF EXISTS index_telemetry_frames_device_id_captured_at_ms"), + ) + assertTrue( + "frames have no board_id lookup index", + sql.any { it.contains("index_telemetry_frames_board_id_captured_at_ms") }, + ) + } + + // MARK: Untouched tables + + /** + * ADR 0028 left Markers, Diagnostic Events and Metric Exclusion Ranges on `device_id` because + * that was what crossed the wire for them — circular, and it kept a second copy of the very + * defect the Board move existed to remove: a BLE address can be claimed by two Boards, so rows + * keyed on it cannot say which Board owns them. All three move with the rest. + */ + @Test + fun markersDiagnosticEventsAndExclusionRangesMoveOntoBoardIdToo() { + for (table in listOf("telemetry_markers", "diagnostic_events", "metric_exclusion_ranges")) { + val create = statement("CREATE TABLE ${table}_new") + val copy = statement("INSERT INTO ${table}_new") + + assertFalse("$table keeps device_id", create.contains("device_id")) + assertFalse("$table keeps device_name", create.contains("device_name")) + assertTrue("$table has no board_id", create.contains("board_id")) + assertTrue( + "$table does not resolve its identifier through the shared map", + copy.contains("SELECT m.board_id FROM telemetry_device_board_map m WHERE m.device_id ="), + ) + assertTrue( + "the rebuilt $table is not swapped in", + migrationSql().contains("ALTER TABLE ${table}_new RENAME TO $table"), + ) + } + } + + /** + * A Marker can be written with no Board connected, so its column stays nullable. A Range excludes + * one Board's samples and its column is NOT NULL, so it takes the same sentinel a bucket does. + */ + @Test + fun markersWithoutABoardStayNullAndRangesTakeTheUnknownSentinel() { + assertTrue( + "markers without a device_id do not backfill to NULL", + statement("INSERT INTO telemetry_markers_new").contains("device_id = '' THEN NULL"), + ) + assertTrue( + "ranges without a device_id do not backfill to the unknown sentinel", + statement("INSERT INTO metric_exclusion_ranges_new").contains("device_id = '' THEN ''"), + ) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilderTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilderTest.kt index 9d268afce..9db246898 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilderTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilderTest.kt @@ -11,8 +11,7 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 125_000L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = -1_200, batteryVoltageMv = 77_500, motorCurrentMa = -2_500, @@ -22,8 +21,7 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 130_000L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = 1_600, batteryVoltageMv = 77_100, motorCurrentMa = 3_500, @@ -35,16 +33,14 @@ class TelemetryBucketBuilderTest { locationPoints = listOf( BucketLocationPoint( capturedAtMs = 131_000L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", precise = true, distanceFromPreviousCm = 230L, gpsSpeedCentiMps = 1_250, ), BucketLocationPoint( capturedAtMs = 132_000L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", precise = false, distanceFromPreviousCm = null, gpsSpeedCentiMps = 900, @@ -53,8 +49,7 @@ class TelemetryBucketBuilderTest { ).single() assertEquals(120_000L, buckets.bucketStartMs) - assertEquals("board-1", buckets.deviceId) - assertEquals("ADV2", buckets.deviceName) + assertEquals("board-1", buckets.boardId) assertEquals(2, buckets.sampleCount) assertEquals(2, buckets.gpsPointCount) assertEquals(1, buckets.preciseGpsPointCount) @@ -81,8 +76,7 @@ class TelemetryBucketBuilderTest { locationPoints = listOf( BucketLocationPoint( capturedAtMs = 65_000L, - deviceId = null, - deviceName = null, + boardId = null, precise = true, distanceFromPreviousCm = null, gpsSpeedCentiMps = null, @@ -99,8 +93,7 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 10_000L, - deviceId = "a", - deviceName = "A", + boardId = "a", speedCentiKmh = 100, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -110,8 +103,7 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 70_000L, - deviceId = "a", - deviceName = "A", + boardId = "a", speedCentiKmh = 200, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -121,8 +113,7 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 10_000L, - deviceId = "b", - deviceName = "B", + boardId = "b", speedCentiKmh = 300, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -135,7 +126,7 @@ class TelemetryBucketBuilderTest { ) assertEquals(setOf(0L to "a", 60_000L to "a", 0L to "b"), buckets.map { - it.bucketStartMs to it.deviceId + it.bucketStartMs to it.boardId }.toSet()) } @@ -145,8 +136,7 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 0L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = 499, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -157,8 +147,7 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 1_000L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = -500, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -169,8 +158,7 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 2_000L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = 1_200, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -196,8 +184,7 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 0L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = 100, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -221,8 +208,7 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 0L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = 0, batteryVoltageMv = 50_000, motorCurrentMa = 0, @@ -232,8 +218,7 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 3_600L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = 0, batteryVoltageMv = 50_000, motorCurrentMa = 0, @@ -243,8 +228,7 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 7_200L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = 0, batteryVoltageMv = 50_000, motorCurrentMa = 0, @@ -266,8 +250,7 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 0L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = 5000, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -279,8 +262,7 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 1_000L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", speedCentiKmh = 2000, batteryVoltageMv = 70_000, motorCurrentMa = 0, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryPipelineTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryPipelineTest.kt index e7da882a7..9200f8a39 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryPipelineTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryPipelineTest.kt @@ -341,8 +341,7 @@ class TelemetryPipelineTest { ): TelemetryCapture = TelemetryCapture( capturedAtMs = parsed.lastPacketAt, elapsedRealtimeMs = parsed.lastPacketAt, - deviceId = cfg.deviceId, - deviceName = cfg.deviceName, + boardId = cfg.appBoardId, canId = canId, pitch = parsed.pitch, roll = parsed.roll, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizerTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizerTest.kt index 607a5f61f..f92169d86 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizerTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizerTest.kt @@ -98,8 +98,7 @@ class FreeSpinMetricSanitizerTest { dutyPermille: Int = 0, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, - deviceId = deviceId, - deviceName = "Test", + boardId = deviceId, speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -118,8 +117,7 @@ class FreeSpinMetricSanitizerTest { gpsAccuracyCm: Int = 500, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, - deviceId = deviceId, - deviceName = "Test", + boardId = deviceId, speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizerTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizerTest.kt index b0b61cf68..58cabdc87 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizerTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizerTest.kt @@ -2,7 +2,7 @@ package expo.modules.vescapecore.telemetry.sanitizers import expo.modules.vescapecore.telemetry.BucketTelemetryPoint import expo.modules.vescapecore.telemetry.EXCLUSION_REASON_LOW_SPEED -import expo.modules.vescapecore.telemetry.UNKNOWN_TELEMETRY_DEVICE_ID +import expo.modules.vescapecore.telemetry.UNKNOWN_TELEMETRY_BOARD_ID import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -52,7 +52,7 @@ class LowSpeedAverageSpeedSanitizerTest { ) assertTrue(result.excludedFromAvgSpeed) - assertEquals(UNKNOWN_TELEMETRY_DEVICE_ID, result.exclusions.single().deviceId) + assertEquals(UNKNOWN_TELEMETRY_BOARD_ID, result.exclusions.single().boardId) assertEquals(EXCLUSION_REASON_LOW_SPEED, result.exclusions.single().reason) } @@ -67,8 +67,7 @@ class LowSpeedAverageSpeedSanitizerTest { speedCentiKmh: Int = 0, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, - deviceId = deviceId, - deviceName = "Test", + boardId = deviceId, speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, diff --git a/modules/vescape-core/ios/connection/BoardSessionController.swift b/modules/vescape-core/ios/connection/BoardSessionController.swift index fdc036fee..52c77ffaa 100644 --- a/modules/vescape-core/ios/connection/BoardSessionController.swift +++ b/modules/vescape-core/ios/connection/BoardSessionController.swift @@ -43,6 +43,8 @@ internal struct BoardConnectConfig { recordingEnabled: Bool = false ) -> BoardConnectConfig? { guard let board = appData.getBoard(boardId) else { return nil } + // Reads resolve tombstones so history can name them (ADR 0027); connecting to one is refused. + guard board["deletedAt"] as? Int64 == nil else { return nil } guard let link = board["link"] as? [String: Any?] else { return nil } guard let bleId = link["bleId"] as? String, !bleId.isEmpty else { return nil } let transport = BoardTransport.fromBridge(link["transport"] ?? nil) ?? .direct @@ -1941,7 +1943,8 @@ internal final class BoardSessionController: VescGattListener { /// Persist a connection-lifecycle Local Diagnostic Event with the base session context (device, /// phase, connection seq) so the iOS event log carries the same columns Android does. The store - /// keys `ble_id`/`board_nickname` into the `device_id`/`device_name` columns JS reads. + /// keys `board_id` into the `board_id` column JS reads; `ble_id` and `board_nickname` stay in the + /// opaque properties payload as diagnostic context, never as the row's identity. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/diagnostics/DiagnosticsRecorder.kt `recordLocalDiagnostic` private func recordConnectionDiagnostic( _ eventName: String, @@ -2212,8 +2215,7 @@ internal final class BoardSessionController: VescGattListener { return TelemetryCapture( capturedAtMs: telemetry.lastPacketAt, elapsedRealtimeMs: elapsedMs(), - deviceId: config.bleId, - deviceName: config.name, + boardId: config.appBoardId, canId: canId, telemetry: telemetry, // Recorded frames refuse a stale fix (ADR 0034); live display keeps the last known one. diff --git a/modules/vescape-core/ios/recording/RecordingCoordinator.swift b/modules/vescape-core/ios/recording/RecordingCoordinator.swift index 4721589b8..333b90a0b 100644 --- a/modules/vescape-core/ios/recording/RecordingCoordinator.swift +++ b/modules/vescape-core/ios/recording/RecordingCoordinator.swift @@ -171,7 +171,7 @@ internal final class RecordingCoordinator { } private func recordMarker(_ type: String, config: BoardConnectConfig, message: String? = nil) { - store.recordMarker(type: type, deviceId: config.bleId, deviceName: config.name, message: message) + store.recordMarker(type: type, boardId: config.appBoardId, message: message) } private func nowMs() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000.0) } diff --git a/modules/vescape-core/ios/telemetry/AppDataRepository.swift b/modules/vescape-core/ios/telemetry/AppDataRepository.swift index 525969d66..4905cf4ba 100644 --- a/modules/vescape-core/ios/telemetry/AppDataRepository.swift +++ b/modules/vescape-core/ios/telemetry/AppDataRepository.swift @@ -26,9 +26,20 @@ final class AppDataRepository { /// `CoreForegroundService.emitEvent` static — a module-owned emit the repo funnels through. static var onDataChanged: ((String) -> Void)? - private var pool: DatabasePool? { TelemetryDatabase.pool } + /// Test seam, mirroring `TuneProfileStore(dbWriter:)` / `BoardWarningStore(dbWriter:)`: nil in the + /// app so every access follows the shared pool (including a hot-swap after a restore). + private let dbWriter: (any DatabaseWriter)? - private init() {} + private var writer: (any DatabaseWriter)? { dbWriter ?? TelemetryDatabase.pool } + + private init(dbWriter: (any DatabaseWriter)? = nil) { + self.dbWriter = dbWriter + } + + /// In-memory instance for DB-backed tests. The app always uses `shared`. + static func forTesting(dbWriter: any DatabaseWriter) -> AppDataRepository { + AppDataRepository(dbWriter: dbWriter) + } /// Notify JS that persisted data in [scope] changed, so the matching store reloads and stays in /// sync without an app restart. Every mutating method below funnels through here — new writes get @@ -44,9 +55,9 @@ final class AppDataRepository { /// `boards` was missing a column read on screen exactly like a rider with no boards, so log it: /// a swallowed error still gets to say what it was. private func read(_ fallback: T, _ body: (Database) throws -> T) -> T { - guard let pool else { return fallback } + guard let writer else { return fallback } do { - return try pool.read(body) + return try writer.read(body) } catch { NSLog("[vescape] AppDataRepository read failed: \(error)") return fallback @@ -54,9 +65,9 @@ final class AppDataRepository { } private func write(_ body: @escaping (Database) throws -> Void) { - guard let pool else { return } + guard let writer else { return } do { - try pool.write(body) + try writer.write(body) } catch { NSLog("[vescape] AppDataRepository write failed: \(error)") } @@ -66,11 +77,16 @@ final class AppDataRepository { // MARK: - Boards + /// Live Boards only — a tombstoned Board is gone from every Rider-facing list (ADR 0027). + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `getBoards` func getBoards() -> [[String: Any?]] { read([]) { db in let boards = try Row.fetchAll( db, - sql: "SELECT id, name, ble_id, transport, created_at FROM boards ORDER BY created_at ASC" + sql: """ + SELECT id, name, ble_id, transport, created_at, deleted_at FROM boards + WHERE deleted_at IS NULL ORDER BY created_at ASC + """ ) let settings = try Row.fetchAll(db, sql: "SELECT board_id, key, value_json FROM board_settings") var byBoard: [String: [(String, String)]] = [:] @@ -82,11 +98,17 @@ final class AppDataRepository { } } + /// Resolves tombstones too, deliberately: Ride History still has to name a deleted Board. Callers + /// that act on a Board rather than describe one check `deletedAt` and refuse. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `getBoard` func getBoard(_ id: String) -> [String: Any?]? { read(nil) { db in guard let board = try Row.fetchOne( db, - sql: "SELECT id, name, ble_id, transport, created_at FROM boards WHERE id = ? LIMIT 1", + sql: """ + SELECT id, name, ble_id, transport, created_at, deleted_at FROM boards + WHERE id = ? LIMIT 1 + """, arguments: [id] ) else { return nil } let settings = try Row.fetchAll( @@ -121,9 +143,15 @@ final class AppDataRepository { let updatedAt = nowMs() write { db in + // An existing tombstone survives the write, so an ordinary upsert can never resurrect a + // deleted Board — deletion is terminal (ADR 0027). Only `deleteBoard` stamps a new one. + let deletedAt = try Int64.fetchOne(db, sql: "SELECT deleted_at FROM boards WHERE id = ?", arguments: [id]) try db.execute( - sql: "INSERT OR REPLACE INTO boards (id, name, ble_id, transport, created_at) VALUES (?, ?, ?, ?, ?)", - arguments: [id, name, bleId, transport, createdAt] + sql: """ + INSERT OR REPLACE INTO boards (id, name, ble_id, transport, created_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + arguments: [id, name, bleId, transport, createdAt, deletedAt] ) for (key, value) in settings { guard let value, let json = Self.encodeJson(value) else { @@ -139,12 +167,22 @@ final class AppDataRepository { notifyDataChanged(.boards) } + /// The Rider-facing delete: configuration goes, the Board row stays as a tombstone (ADR 0027). + /// Telemetry and Tune Profiles are untouched — both outlive the Board. + /// + /// A Board that is not there (or already deleted) is left alone. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `deleteBoardWithSettings` func deleteBoard(_ id: String) { + let deletedAt = nowMs() write { db in try db.execute(sql: "DELETE FROM board_settings WHERE board_id = ?", arguments: [id]) + try db.execute(sql: "DELETE FROM board_warnings WHERE board_id = ?", arguments: [id]) // Alert Rules are Board-owned (#254) — drop them with the Board so no orphan rows survive. try db.execute(sql: "DELETE FROM alerts WHERE board_id = ?", arguments: [id]) - try db.execute(sql: "DELETE FROM boards WHERE id = ?", arguments: [id]) + try db.execute( + sql: "UPDATE boards SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL", + arguments: [deletedAt, id] + ) } BoardConfigStore.shared.clear(boardId: id) notifyDataChanged(.boards) @@ -203,6 +241,7 @@ final class AppDataRepository { "matchBoardConfig": values["matchBoardConfig"] ?? nil, "legalMode": values["legalMode"] ?? ["enabled": false], "link": link, + "deletedAt": row["deleted_at"] as Int64?, ] } diff --git a/modules/vescape-core/ios/telemetry/BoardTombstoneTests.swift b/modules/vescape-core/ios/telemetry/BoardTombstoneTests.swift new file mode 100644 index 000000000..5325b17c6 --- /dev/null +++ b/modules/vescape-core/ios/telemetry/BoardTombstoneTests.swift @@ -0,0 +1,141 @@ +import XCTest +import GRDB +@testable import VescapeCore + +/// Board tombstones (ADR 0027): deleting a Board stamps `boards.deleted_at` instead of removing the +/// row, so Ride History outlives the Board that produced it. Configuration still goes; telemetry and +/// Tune Profiles never did and still do not. +/// +/// Runs the real migrator and the real repository against an in-memory database. +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt +final class BoardTombstoneTests: XCTestCase { + private var queue: DatabaseQueue! + private var repo: AppDataRepository! + + override func setUpWithError() throws { + queue = try DatabaseQueue() + try TelemetryDatabase.migrator.migrate(queue) + repo = AppDataRepository.forTesting(dbWriter: queue) + } + + override func tearDownWithError() throws { + repo = nil + queue = nil + } + + private func seedBoard(_ id: String = "board-1") { + repo.upsertBoard([ + "id": id, + "name": "ADV", + "createdAt": Int64(1000), + "link": ["bleId": "AA:BB", "transport": "direct"] as [String: Any?], + ]) + } + + private func deletedAt(_ id: String) throws -> Int64? { + try queue.read { db in + try Int64.fetchOne(db, sql: "SELECT deleted_at FROM boards WHERE id = ?", arguments: [id]) + } + } + + private func rowCount(_ table: String, boardId: String) throws -> Int { + try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(table) WHERE board_id = ?", arguments: [boardId]) ?? 0 + } + } + + // MARK: Migration + + func testMigrationAddsNullableDeletedAtLeavingExistingRowsAlive() throws { + let columns = try queue.read { db in try db.columns(in: "boards") } + let deletedAt = columns.first { $0.name == "deleted_at" } + + XCTAssertNotNil(deletedAt, "boards is missing deleted_at") + XCTAssertFalse(deletedAt?.isNotNull ?? true, "deleted_at must be nullable — null means alive") + + seedBoard() + XCTAssertNil(try self.deletedAt("board-1"), "a fresh Board must start alive") + } + + /// Re-running the whole migrator over a migrated database has to be a no-op, not a duplicate + /// column error. + func testMigrationIsANoOpOnReRun() throws { + XCTAssertNoThrow(try TelemetryDatabase.migrator.migrate(queue)) + } + + // MARK: Delete + + func testDeleteKeepsTheRowAndStampsDeletedAt() throws { + seedBoard() + + repo.deleteBoard("board-1") + + XCTAssertNotNil(try deletedAt("board-1"), "delete removed the row instead of tombstoning it") + } + + func testDeleteStillRemovesBoardConfiguration() throws { + seedBoard() + repo.upsertAlertRule([ + "boardId": "board-1", + "id": "rule-1", + "controlId": "speed", + "threshold": 40.0, + "enabled": true, + "soundType": "beep", + "createdAt": Int64(1000), + ]) + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO board_warnings (board_id, kind, severity, first_detected_at, last_detected_at, payload_json) + VALUES ('board-1', 'test-kind', 'warn', 1, 1, '{}') + """ + ) + } + + repo.deleteBoard("board-1") + + XCTAssertEqual(try rowCount("board_settings", boardId: "board-1"), 0, "board settings survived") + XCTAssertEqual(try rowCount("board_warnings", boardId: "board-1"), 0, "board warnings survived") + XCTAssertEqual(try rowCount("alerts", boardId: "board-1"), 0, "alert rules survived") + } + + /// The reason the tombstone exists: history is what the delete must not take with it. + func testDeleteLeavesTelemetryAndTuneProfilesUntouched() throws { + seedBoard() + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO tune_profiles (id, board_id, name, fields_json, created_at, updated_at) + VALUES ('tune-1', 'board-1', 'Stiff', '{}', 1, 1) + """ + ) + } + + repo.deleteBoard("board-1") + + XCTAssertEqual(try rowCount("tune_profiles", boardId: "board-1"), 1, "tune profiles were deleted") + } + + // MARK: Reads + + func testTombstonedBoardLeavesTheRiderFacingListButStaysResolvableById() throws { + seedBoard() + seedBoard("board-2") + + repo.deleteBoard("board-1") + + XCTAssertEqual(repo.getBoards().compactMap { $0["id"] as? String }, ["board-2"]) + XCTAssertNotNil(repo.getBoard("board-1"), "history can no longer name the deleted Board") + } + + func testUpsertNeverResurrectsATombstonedBoard() throws { + seedBoard() + repo.deleteBoard("board-1") + + seedBoard() + + XCTAssertNotNil(try deletedAt("board-1"), "an upsert cleared the tombstone") + XCTAssertTrue(repo.getBoards().isEmpty, "a resurrected Board came back to the list") + } +} diff --git a/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift b/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift index baf530557..89e0a6260 100644 --- a/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift +++ b/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift @@ -7,7 +7,7 @@ import GRDB /// `TelemetryDatabase.migrator`. /// /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `TELEMETRY_DATABASE_VERSION` -internal let TELEMETRY_SCHEMA_VERSION = 40 +internal let TELEMETRY_SCHEMA_VERSION = 42 private let MANIFEST_ENTRY = "manifest.json" private let DATABASE_ENTRY = "db.sqlite" diff --git a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift index 75eb0cc6e..38ff958be 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift @@ -303,8 +303,7 @@ final class FavoriteStoreTests: XCTestCase { let offset = Int64(index) * intervalMs return BucketTelemetryPoint( capturedAtMs: startMs + offset, - deviceId: "board-1", - deviceName: "VESC Board", + boardId: "board-1", speedCentiKmh: speedCentiKmh, batteryVoltageMv: 50_000, motorCurrentMa: 10_000, diff --git a/modules/vescape-core/ios/telemetry/MetricSanitizer.swift b/modules/vescape-core/ios/telemetry/MetricSanitizer.swift index d3fdbb1c5..104443f51 100644 --- a/modules/vescape-core/ios/telemetry/MetricSanitizer.swift +++ b/modules/vescape-core/ios/telemetry/MetricSanitizer.swift @@ -34,7 +34,7 @@ internal struct SanitizedSample { } internal struct MetricExclusionRange { - let deviceId: String + let boardId: String let reason: String let startMs: Int64 let endMs: Int64 @@ -48,7 +48,7 @@ internal struct SanitizationResult { private struct MetricExclusionSample { let capturedAtMs: Int64 - let deviceId: String + let boardId: String let reason: String } @@ -80,12 +80,12 @@ internal func sanitizeTelemetrySamples( excludedFromMaxDuty: freeSpin ) ) - let deviceId = point.deviceId ?? "" + let boardId = point.boardId ?? "" if lowSpeed { - exclusionSamples.append(MetricExclusionSample(capturedAtMs: point.capturedAtMs, deviceId: deviceId, reason: EXCLUSION_REASON_LOW_SPEED)) + exclusionSamples.append(MetricExclusionSample(capturedAtMs: point.capturedAtMs, boardId: boardId, reason: EXCLUSION_REASON_LOW_SPEED)) } if freeSpin { - exclusionSamples.append(MetricExclusionSample(capturedAtMs: point.capturedAtMs, deviceId: deviceId, reason: EXCLUSION_REASON_FREE_SPIN)) + exclusionSamples.append(MetricExclusionSample(capturedAtMs: point.capturedAtMs, boardId: boardId, reason: EXCLUSION_REASON_FREE_SPIN)) } } return SanitizationResult(samples: sanitized, exclusions: collapseExclusionSamples(exclusionSamples)) @@ -142,7 +142,7 @@ private func nearestPreciseGps( private func collapseExclusionSamples(_ samples: [MetricExclusionSample]) -> [MetricExclusionRange] { guard !samples.isEmpty else { return [] } let sorted = samples.sorted { - if $0.deviceId != $1.deviceId { return $0.deviceId < $1.deviceId } + if $0.boardId != $1.boardId { return $0.boardId < $1.boardId } if $0.reason != $1.reason { return $0.reason < $1.reason } return $0.capturedAtMs < $1.capturedAtMs } @@ -153,11 +153,11 @@ private func collapseExclusionSamples(_ samples: [MetricExclusionSample]) -> [Me var count = 1 func flush() { - ranges.append(MetricExclusionRange(deviceId: current.deviceId, reason: current.reason, startMs: start, endMs: end, sampleCount: count)) + ranges.append(MetricExclusionRange(boardId: current.boardId, reason: current.reason, startMs: start, endMs: end, sampleCount: count)) } for sample in sorted.dropFirst() { - if sample.deviceId == current.deviceId && sample.reason == current.reason && sample.capturedAtMs - end <= EXCLUSION_RANGE_MERGE_GAP_MS { + if sample.boardId == current.boardId && sample.reason == current.reason && sample.capturedAtMs - end <= EXCLUSION_RANGE_MERGE_GAP_MS { end = sample.capturedAtMs count += 1 } else { diff --git a/modules/vescape-core/ios/telemetry/RideHistoryRepository.swift b/modules/vescape-core/ios/telemetry/RideHistoryRepository.swift index f3513dc4f..6237abb3a 100644 --- a/modules/vescape-core/ios/telemetry/RideHistoryRepository.swift +++ b/modules/vescape-core/ios/telemetry/RideHistoryRepository.swift @@ -11,8 +11,8 @@ internal struct RideRoutePoint { } internal struct RideSessionAggregate { - let deviceId: String - var deviceName: String + /// Owning Board (`boards.id`), empty when the buckets match no saved Board (ADR 0028). + let boardId: String var boundaryBefore: String var firstBucketStartMs: Int64 var startAtMs: Int64 @@ -67,6 +67,9 @@ internal final class RideHistoryRepository { var beforeMs = telemetryLong(options["cursorBeforeMs"]) ?? Int64.max let gapMs = rideSplitGapMs() guard let pool else { return ["sessions": [], "hasMore": false, "nextCursorBeforeMs": nil] } + // Names resolve from `boards` on read, never off the bucket row (ADR 0028), so a rename + // relabels the whole Ride History. Read up front: GRDB forbids a nested `read` on the pool. + let boardNames = TelemetryRepository.boardNamesById() return (try? pool.read { db in var buckets: [Row] = [] var complete: [RideSessionAggregate] = [] @@ -98,7 +101,7 @@ internal final class RideHistoryRepository { let page = cutoff.map { value in sorted.filter { $0.firstBucketStartMs >= value } } ?? sorted let hasMore = hasOlderBuckets || cutoff.map { value in sorted.contains { $0.firstBucketStartMs < value } } == true return [ - "sessions": page.map(rideSessionMap), + "sessions": page.map { rideSessionMap($0, boardNames: boardNames) }, "hasMore": hasMore, "nextCursorBeforeMs": hasMore ? page.last?.firstBucketStartMs : nil, ] @@ -130,15 +133,14 @@ internal func groupRideSessions(buckets: [Row], markers: [Row], gapMs: Int64) -> for bucket in buckets.sorted(by: { ($0["first_sample_at_ms"] as Int64) < ($1["first_sample_at_ms"] as Int64) }) { if (bucket["sample_count"] as Int) <= 0 { continue } let boundary = rideBoundaryForBucket(bucket, markers: markers) - let deviceId = bucket["device_id"] as String - let split = current == nil || current?.deviceId != deviceId || + let boardId = bucket["board_id"] as String + let split = current == nil || current?.boardId != boardId || (previous.map { (bucket["first_sample_at_ms"] as Int64) - ($0["last_sample_at_ms"] as Int64) > gapMs } ?? false) || rideBreakBoundaries.contains(boundary) if split { if let current { sessions.append(current) } current = RideSessionAggregate( - deviceId: deviceId, - deviceName: (bucket["device_name"] as String?) ?? "VESC Board", + boardId: boardId, boundaryBefore: boundary, firstBucketStartMs: bucket["bucket_start_ms"] as Int64, startAtMs: bucket["first_sample_at_ms"] as Int64, @@ -157,7 +159,7 @@ private func mergeRideBucket(_ bucket: Row, into session: inout RideSessionAggre session.firstBucketStartMs = min(session.firstBucketStartMs, bucketStart) session.startAtMs = min(session.startAtMs, bucket["first_sample_at_ms"] as Int64) session.endAtMs = max(session.endAtMs, bucket["last_sample_at_ms"] as Int64) - session.blockIds.append("\(session.deviceId):\(bucketStart)") + session.blockIds.append("\(session.boardId):\(bucketStart)") session.blockCount += 1 session.sampleCount += bucket["sample_count"] as Int session.gpsPointCount += bucket["gps_point_count"] as Int @@ -195,7 +197,7 @@ private func rideBoundaryForBucket(_ bucket: Row, markers: [Row]) -> String { let occurred = marker["occurred_at_ms"] as Int64 return occurred >= (bucket["first_sample_at_ms"] as Int64) - 5_000 && occurred <= (bucket["first_sample_at_ms"] as Int64) + 1_000 && - ((marker["device_id"] as String?) ?? "") == (bucket["device_id"] as String) + ((marker["board_id"] as String?) ?? "") == (bucket["board_id"] as String) }.map { $0["type"] as String } ?? "none" } @@ -205,11 +207,12 @@ private func rideDistanceDeltaM(_ bucket: Row) -> Double? { } /// @parity /modules/vescape-core/src/index.ts `RideHistorySession` -internal func rideSessionMap(_ session: RideSessionAggregate) -> [String: Any?] { +internal func rideSessionMap(_ session: RideSessionAggregate, boardNames: [String: String]) -> [String: Any?] { let average = session.avgSpeedSampleCount > 0 ? session.avgSpeedWeightedSum / Double(session.avgSpeedSampleCount) : 0 return [ - "id": "\(session.deviceId.isEmpty ? "unknown" : session.deviceId):\(session.startAtMs):\(session.endAtMs)", - "deviceId": session.deviceId.isEmpty ? nil : session.deviceId, "deviceName": session.deviceName, + "id": "\(session.boardId.isEmpty ? "unknown" : session.boardId):\(session.startAtMs):\(session.endAtMs)", + "boardId": session.boardId.isEmpty ? nil : session.boardId, + "boardName": boardNames[session.boardId] ?? UNKNOWN_TELEMETRY_BOARD_NAME, "startAtMs": session.startAtMs, "endAtMs": session.endAtMs, "movingStartAtMs": session.movingStartAtMs, "movingEndAtMs": session.movingEndAtMs, "blockIds": session.blockIds, "blockCount": session.blockCount, "sampleCount": session.sampleCount, "gpsPointCount": session.gpsPointCount, diff --git a/modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift b/modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift index 9f05248de..ce5a1f8ba 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift @@ -3,8 +3,9 @@ import Foundation /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt internal struct TelemetryBucket { let bucketStartMs: Int64 - let deviceId: String - var deviceName: String? + /// Owning Board (`boards.id`), or `""` when the samples match no saved Board — the column is part + /// of the bucket primary key, so unattributed rows need a value rather than null (ADR 0028). + let boardId: String var sampleCount = 0 var firstSampleAtMs = Int64.max var lastSampleAtMs = Int64.min @@ -33,7 +34,6 @@ internal struct TelemetryBucket { mutating func add(_ point: BucketTelemetryPoint) { sampleCount += 1 - if point.deviceName != nil { deviceName = point.deviceName } firstSampleAtMs = min(firstSampleAtMs, point.capturedAtMs) lastSampleAtMs = max(lastSampleAtMs, point.capturedAtMs) let absSpeed = abs(point.speedCentiKmh) @@ -80,9 +80,9 @@ internal func buildTelemetryBuckets(_ points: [BucketTelemetryPoint]) -> [Teleme var buckets: [String: TelemetryBucket] = [:] for point in points.sorted(by: { $0.capturedAtMs < $1.capturedAtMs }) { let bucketStart = point.capturedAtMs - (point.capturedAtMs % TELEMETRY_BUCKET_SIZE_MS) - let deviceId = point.deviceId ?? "" - let key = "\(deviceId):\(bucketStart)" - var bucket = buckets[key] ?? TelemetryBucket(bucketStartMs: bucketStart, deviceId: deviceId, deviceName: point.deviceName) + let boardId = point.boardId ?? UNKNOWN_TELEMETRY_BOARD_ID + let key = "\(boardId):\(bucketStart)" + var bucket = buckets[key] ?? TelemetryBucket(bucketStartMs: bucketStart, boardId: boardId) bucket.add(point) buckets[key] = bucket } diff --git a/modules/vescape-core/ios/telemetry/TelemetryDao.swift b/modules/vescape-core/ios/telemetry/TelemetryDao.swift index 865ed42b4..f5b5bdcae 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDao.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDao.swift @@ -8,7 +8,7 @@ internal func insertFrame(_ db: Database, _ state: FullTelemetryState) throws { try db.execute( sql: """ INSERT INTO telemetry_frames ( - captured_at_ms, elapsed_realtime_ms, device_id, device_name, can_id, flags, changed_mask_1, changed_mask_2, + captured_at_ms, elapsed_realtime_ms, board_id, can_id, flags, changed_mask_1, changed_mask_2, speed_centi_kmh, battery_voltage_mv, motor_current_ma, battery_current_ma, duty_permille, pitch_centi_deg, roll_centi_deg, balance_pitch_centi_deg, balance_current_ma, erpm, state, switch_state, adc1_milli, adc2_milli, odometer_cm, temp_mosfet_deci_c, temp_motor_deci_c, @@ -17,7 +17,7 @@ internal func insertFrame(_ db: Database, _ state: FullTelemetryState) throws { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ - state.capturedAtMs, state.elapsedRealtimeMs, state.deviceId, state.deviceName, state.capture.canId, + state.capturedAtMs, state.elapsedRealtimeMs, state.boardId, state.capture.canId, TELEMETRY_FLAG_KEYFRAME | (loc == nil ? 0 : TELEMETRY_FLAG_HAS_LOCATION), Int.max, 1, telemetryCenti(t.speed), telemetryMilli(t.batteryVoltage), telemetryMilli(t.motorCurrent), telemetryMilli(t.batteryCurrent), telemetryMilli(t.dutyCycle), @@ -36,7 +36,7 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket) throws { try db.execute( sql: """ INSERT INTO telemetry_minute_buckets ( - bucket_start_ms, device_id, device_name, sample_count, first_sample_at_ms, last_sample_at_ms, + bucket_start_ms, board_id, sample_count, first_sample_at_ms, last_sample_at_ms, sum_abs_speed_centi_kmh, moving_speed_sample_count, sum_moving_abs_speed_centi_kmh, max_abs_speed_centi_kmh, min_battery_voltage_mv, max_motor_current_abs_ma, max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, max_duty_abs_permille, @@ -44,8 +44,7 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket) throws { gps_distance_cm, max_gps_speed_centi_mps, max_temp_mosfet_deci_c, max_temp_motor_deci_c, first_latitude_e7, first_longitude_e7, first_moving_at_ms, last_moving_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(bucket_start_ms, device_id) DO UPDATE SET - device_name=excluded.device_name, + ON CONFLICT(bucket_start_ms, board_id) DO UPDATE SET sample_count=telemetry_minute_buckets.sample_count + excluded.sample_count, last_sample_at_ms=MAX(telemetry_minute_buckets.last_sample_at_ms, excluded.last_sample_at_ms), sum_abs_speed_centi_kmh=telemetry_minute_buckets.sum_abs_speed_centi_kmh + excluded.sum_abs_speed_centi_kmh, @@ -68,7 +67,7 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket) throws { last_moving_at_ms=MAX(telemetry_minute_buckets.last_moving_at_ms, excluded.last_moving_at_ms) """, arguments: [ - b.bucketStartMs, b.deviceId, b.deviceName, b.sampleCount, b.firstSampleAtMs, b.lastSampleAtMs, + b.bucketStartMs, b.boardId, b.sampleCount, b.firstSampleAtMs, b.lastSampleAtMs, b.sumAbsSpeedCentiKmh, b.movingSpeedSampleCount, b.sumMovingAbsSpeedCentiKmh, b.maxAbsSpeedCentiKmh, b.minBatteryVoltageMv, b.maxMotorCurrentAbsMa, b.maxBatteryCurrentAbsMa, b.batteryUsedWhMilli, b.batteryRegenWhMilli, b.maxDutyAbsPermille, b.firstOdometerCm, b.lastOdometerCm, @@ -82,24 +81,24 @@ internal func insertMarker(_ db: Database, _ marker: [String: Any?]) throws { let occurredAtMs = telemetryLong(marker["occurredAtMs"] ?? nil) ?? telemetryNowMs() let elapsedRealtimeMs = telemetryLong(marker["elapsedRealtimeMs"] ?? nil) ?? telemetryElapsedMs() let type = marker["type"] as? String ?? "event" - let deviceId = marker["deviceId"] as? String - let deviceName = marker["deviceName"] as? String + let boardId = marker["boardId"] as? String let message = marker["message"] as? String let gapMs = telemetryLong(marker["gapMs"] ?? nil) try db.execute( - sql: "INSERT INTO telemetry_markers (occurred_at_ms, elapsed_realtime_ms, type, device_id, device_name, message, gap_ms) VALUES (?, ?, ?, ?, ?, ?, ?)", - arguments: [occurredAtMs, elapsedRealtimeMs, type, deviceId, deviceName, message, gapMs] + sql: "INSERT INTO telemetry_markers (occurred_at_ms, elapsed_realtime_ms, type, board_id, message, gap_ms) VALUES (?, ?, ?, ?, ?, ?)", + arguments: [occurredAtMs, elapsedRealtimeMs, type, boardId, message, gapMs] ) } internal func insertExclusion(_ db: Database, _ range: MetricExclusionRange) throws { try db.execute( - sql: "INSERT INTO metric_exclusion_ranges (device_id, reason, start_ms, end_ms, sample_count) VALUES (?, ?, ?, ?, ?)", - arguments: [range.deviceId, range.reason, range.startMs, range.endMs, range.sampleCount] + sql: "INSERT INTO metric_exclusion_ranges (board_id, reason, start_ms, end_ms, sample_count) VALUES (?, ?, ?, ?, ?)", + arguments: [range.boardId, range.reason, range.startMs, range.endMs, range.sampleCount] ) } -internal func historyMap(_ row: Row, markers: [Row]) -> [String: Any?] { +/// [boardNames] resolves `boards.id` -> name on read; the row never carried one (ADR 0028). +internal func historyMap(_ row: Row, markers: [Row], boardNames: [String: String]) -> [String: Any?] { let sampleCount: Int = row["sample_count"] let movingCount: Int? = row["moving_speed_sample_count"] let sumMoving: Int64? = row["sum_moving_abs_speed_centi_kmh"] @@ -107,23 +106,20 @@ internal func historyMap(_ row: Row, markers: [Row]) -> [String: Any?] { ?? (sampleCount > 0 ? Double(row["sum_abs_speed_centi_kmh"] as Int64) / Double(sampleCount) / 100.0 : 0.0) let marker = markers.last { marker in let occurredAtMs = marker["occurred_at_ms"] as Int64 - let markerDevice = marker["device_id"] as String? ?? "" - let bucketDevice = row["device_id"] as String return occurredAtMs >= (row["first_sample_at_ms"] as Int64) - 5_000 && - occurredAtMs <= (row["first_sample_at_ms"] as Int64) + 1_000 && - markerDevice == bucketDevice + occurredAtMs <= (row["first_sample_at_ms"] as Int64) + 1_000 } let distanceDeltaM: Double? = { guard let first = row["first_odometer_cm"] as Int64?, let last = row["last_odometer_cm"] as Int64? else { return nil } return Double(max(0, last - first)) / 100.0 }() return [ - "id": "\(row["device_id"] as String):\(row["bucket_start_ms"] as Int64)", + "id": "\(row["board_id"] as String):\(row["bucket_start_ms"] as Int64)", "startAtMs": row["first_sample_at_ms"] as Int64, "endAtMs": row["last_sample_at_ms"] as Int64, "bucketStartMs": row["bucket_start_ms"] as Int64, - "deviceId": (row["device_id"] as String).isEmpty ? nil : row["device_id"] as String, - "deviceName": row["device_name"] as String? ?? "VESC Board", + "boardId": (row["board_id"] as String).isEmpty ? nil : row["board_id"] as String, + "boardName": boardNames[row["board_id"] as String] ?? UNKNOWN_TELEMETRY_BOARD_NAME, "sampleCount": sampleCount, "gpsPointCount": row["gps_point_count"] as Int, "preciseGpsPointCount": row["precise_gps_point_count"] as Int, @@ -151,12 +147,12 @@ internal func historyMap(_ row: Row, markers: [Row]) -> [String: Any?] { ] } -internal func sampleMap(_ row: Row, batteryPercent: Double?) -> [String: Any?] { +internal func sampleMap(_ row: Row, batteryPercent: Double?, boardNames: [String: String]) -> [String: Any?] { [ "id": row["id"] as Int64, "capturedAtMs": row["captured_at_ms"] as Int64, - "deviceId": row["device_id"] as String?, - "deviceName": row["device_name"] as String? ?? "VESC Board", + "boardId": row["board_id"] as String?, + "boardName": (row["board_id"] as String?).flatMap { boardNames[$0] } ?? UNKNOWN_TELEMETRY_BOARD_NAME, "speedKmh": Double(row["speed_centi_kmh"] as Int? ?? 0) / 100.0, "batteryVoltage": Double(row["battery_voltage_mv"] as Int? ?? 0) / 1000.0, "batteryPercent": batteryPercent, @@ -185,8 +181,7 @@ internal func markerMap(_ row: Row) -> [String: Any?] { "id": row["id"] as Int64, "occurredAtMs": row["occurred_at_ms"] as Int64, "type": row["type"] as String, - "deviceId": row["device_id"] as String?, - "deviceName": row["device_name"] as String?, + "boardId": row["board_id"] as String?, "message": row["message"] as String?, "gapMs": row["gap_ms"] as Int64?, ] @@ -202,7 +197,7 @@ internal func exclusionMap(_ row: Row) -> [String: Any?] { } return [ "id": row["id"] as Int64, - "deviceId": (row["device_id"] as String).isEmpty ? nil : row["device_id"] as String, + "boardId": row["board_id"] as String, "reason": reason, "startMs": row["start_ms"] as Int64, "endMs": row["end_ms"] as Int64, @@ -211,22 +206,22 @@ internal func exclusionMap(_ row: Row) -> [String: Any?] { ] } -internal func gpsMaps(_ rows: [Row]) -> [[String: Any?]] { - var previousByDevice: [String: (lat: Double, lon: Double)] = [:] +internal func gpsMaps(_ rows: [Row], boardNames: [String: String]) -> [[String: Any?]] { + var previousByBoard: [String: (lat: Double, lon: Double)] = [:] return rows.compactMap { row in guard let latitudeE7 = row["latitude_e7"] as Int64?, let longitudeE7 = row["longitude_e7"] as Int64? else { return nil } let latitude = Double(latitudeE7) / 10_000_000.0 let longitude = Double(longitudeE7) / 10_000_000.0 - let deviceId = row["device_id"] as String? ?? "" - let previous = previousByDevice[deviceId] - previousByDevice[deviceId] = (latitude, longitude) + let boardId = row["board_id"] as String? ?? "" + let previous = previousByBoard[boardId] + previousByBoard[boardId] = (latitude, longitude) return [ "id": row["id"] as Int64, "capturedAtMs": row["captured_at_ms"] as Int64, - "deviceId": (row["device_id"] as String?) ?? nil, - "deviceName": row["device_name"] as String? ?? "VESC Board", + "boardId": (row["board_id"] as String?) ?? nil, + "boardName": boardNames[boardId] ?? UNKNOWN_TELEMETRY_BOARD_NAME, "latitude": latitude, "longitude": longitude, "speedMps": (row["gps_speed_centi_mps"] as Int?).map { Double($0) / 100.0 }, @@ -243,8 +238,7 @@ internal func gpsMaps(_ rows: [Row]) -> [[String: Any?]] { internal func bucketPoint(_ row: Row) -> BucketTelemetryPoint? { BucketTelemetryPoint( capturedAtMs: row["captured_at_ms"] as Int64, - deviceId: row["device_id"] as String?, - deviceName: row["device_name"] as String?, + boardId: row["board_id"] as String?, speedCentiKmh: row["speed_centi_kmh"] as Int? ?? 0, batteryVoltageMv: row["battery_voltage_mv"] as Int? ?? 0, motorCurrentMa: row["motor_current_ma"] as Int? ?? 0, diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 1d55e338b..7bb0a17e9 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -548,8 +548,7 @@ enum TelemetryDatabase { migrator.registerMigration("v40_vesc_faults") { db in try VescFaultStore.createTables(db) try VescFaultCaptureStore.createTables(db) - try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_frames_fault") - if try db.columns(in: "telemetry_frames").map(\.name).contains("fault_code") { + if try db.columns(in: "telemetry_frames").map(\.name).contains("fault_code") { try db.execute(sql: """ CREATE TABLE telemetry_frames_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -659,6 +658,421 @@ enum TelemetryDatabase { } } + // Board tombstones (#279). Deleting a Board stops removing its row and stamps `deleted_at` + // instead, so Ride History outlives the Board that produced it (ADR 0027). Additive: existing + // rows stay null, i.e. alive. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_40_41` + migrator.registerMigration("v41_board_deleted_at") { db in + let hasDeletedAt = try db.columns(in: "boards").contains { $0.name == "deleted_at" } + if !hasDeletedAt { + try db.execute(sql: "ALTER TABLE boards ADD COLUMN deleted_at INTEGER") + } + } + + // Telemetry keys on the Board id (#280, ADR 0028). `telemetry_frames` and + // `telemetry_minute_buckets` gain `board_id` and lose `device_id` (the BLE identifier) and + // `device_name` (the Board name denormalized at capture time); Ride History resolves the name + // by looking the Board up instead. Markers, diagnostic events and metric exclusion ranges are + // deliberately untouched — that is what crosses the wire for them. + // + // Both tables are rebuilt rather than altered: the bucket primary key moves to + // `(bucket_start_ms, board_id)`, and the rebuild is what drops the two retired columns. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_41_42` + migrator.registerMigration("v42_telemetry_board_id") { db in + try mintOrphanBoards(db) + try buildDeviceBoardMap(db) + try rebuildFramesOnBoardId(db) + try rebuildBucketsOnBoardId(db) + try rebuildMarkersOnBoardId(db) + try rebuildDiagnosticEventsOnBoardId(db) + try rebuildExclusionRangesOnBoardId(db) + try db.execute(sql: "DROP TABLE IF EXISTS \(DEVICE_BOARD_MAP)") + } + return migrator } } + +/// Stand-in Board id for buckets whose samples match no saved Board. `board_id` is part of the +/// bucket primary key, so unattributed rows need a value rather than null. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt `UNKNOWN_TELEMETRY_BOARD_ID` +internal let UNKNOWN_TELEMETRY_BOARD_ID = "" +internal let UNKNOWN_TELEMETRY_BOARD_NAME = "VESC Board" + +/// Id prefix for the tombstoned Boards the board-id migration mints for telemetry whose BLE +/// identifier resolves to nothing. Derived from the identifier rather than random so the mint is +/// idempotent. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt `ORPHAN_BOARD_ID_PREFIX` +internal let ORPHAN_BOARD_ID_PREFIX = "orphan-" + +/// Telemetry whose `device_id` matches no Board would lose both its identity and its label: either +/// the Board was hard-deleted before tombstones existed (ADR 0027), or it was re-linked to a +/// different peripheral and the old identifier no longer resolves. One tombstoned Board is minted +/// per unresolved identifier, named from that telemetry's own historical `device_name`, so the +/// history stays joinable, keeps a label, and can be backed up. +/// +/// The minted row is a tombstone with no Board Link: `deleted_at` keeps it out of every +/// Rider-facing list, and a null `ble_id` stops it from ever capturing a future re-link. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `mintOrphanBoards` +internal func mintOrphanBoards(_ db: Database) throws { + let now = telemetryNowMs() + for (table, timeColumn) in telemetryTablesKeyedOnDeviceId { + // Metric Exclusion Ranges never carried a `device_name`, so there is nothing to name a Board + // after there — a range on an identifier no other table saw falls back to the generic name. + let historicalName = + table == "metric_exclusion_ranges" + ? "NULL" + : """ + (SELECT n.device_name FROM \(table) n WHERE n.device_id = t.device_id \ + AND n.device_name IS NOT NULL ORDER BY n.\(timeColumn) DESC LIMIT 1) + """ + try db.execute( + sql: """ + INSERT OR IGNORE INTO boards (id, name, ble_id, created_at, deleted_at) + SELECT + ? || t.device_id, + COALESCE(\(historicalName), ?), + NULL, + MIN(t.\(timeColumn)), + ? + FROM \(table) t + WHERE t.device_id IS NOT NULL + AND t.device_id != '' + AND NOT EXISTS (SELECT 1 FROM boards b WHERE b.ble_id = t.device_id) + GROUP BY t.device_id + """, + arguments: [ORPHAN_BOARD_ID_PREFIX, UNKNOWN_TELEMETRY_BOARD_NAME, now] + ) + } +} + +/// Every table the board-id migration moves off the BLE identifier, with the time column its rows +/// are ordered by. All five are minted for and rebuilt together: a Board minted from one table's +/// identifiers has to exist before any other table resolves the same identifier, or the two +/// disagree about who owns the history — the defect this migration exists to remove. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `TELEMETRY_TABLES_KEYED_ON_DEVICE_ID` +private let telemetryTablesKeyedOnDeviceId = [ + ("telemetry_frames", "captured_at_ms"), + ("telemetry_minute_buckets", "bucket_start_ms"), + ("telemetry_markers", "occurred_at_ms"), + ("diagnostic_events", "occurred_at_ms"), + ("metric_exclusion_ranges", "start_ms"), +] + +/// Scratch table holding the board-id migration's one and only BLE identifier → Board decision. +/// Temp, so it belongs to the connection and never reaches the durable schema. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `DEVICE_BOARD_MAP` +private let DEVICE_BOARD_MAP = "telemetry_device_board_map" + +/// One BLE identifier can be claimed by more than one Board — the same peripheral linked twice, +/// which the app supports and a Rider produces by pairing a board they already own a second time. +/// Telemetry predating this migration recorded only the identifier, so for such rows there is no +/// evidence of which of those Boards was connected, and no rule can recover it. +/// +/// What must not happen is the two rebuilds disagreeing. Resolved independently, each +/// `SELECT … LIMIT 1` is free to return a different Board for the same identifier, and then the +/// frames of a ride sit under one Board while its buckets sit under another: History lists the ride +/// from the buckets and finds no frames for it, so stats render over an empty route. +/// +/// So the choice is made exactly once, here, and both rebuilds read it. `MIN(b.id)` is an arbitrary +/// but stable pick among the claimants — arbitrary because the information to do better does not +/// exist, stable because re-running the migration reaches the same answer. Deliberately not left +/// unattributed: an unowned row is never uploaded and is pruned on age, so "unknown" would quietly +/// destroy the history a merely mis-labelled ride keeps intact. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `buildDeviceBoardMap` +internal func buildDeviceBoardMap(_ db: Database) throws { + try db.execute(sql: """ + CREATE TEMP TABLE \(DEVICE_BOARD_MAP) ( + device_id TEXT PRIMARY KEY NOT NULL, + board_id TEXT NOT NULL + ) + """) + try db.execute(sql: """ + INSERT INTO \(DEVICE_BOARD_MAP) (device_id, board_id) + SELECT b.ble_id, MIN(b.id) + FROM boards b + WHERE b.ble_id IS NOT NULL AND b.ble_id != '' + GROUP BY b.ble_id + """) +} + +/// Resolves a telemetry row's `device_id` to a Board id: the Board `buildDeviceBoardMap` chose for +/// the identifier, otherwise the tombstone minted for it. A row that never carried an identifier +/// stays unattributed — `unattributed` is NULL for frames and the sentinel for buckets, whose +/// column is part of the primary key. +/// +/// The lookup hits a primary key holding one row per identifier, so unlike a scan over `boards` it +/// cannot resolve the same identifier two ways in two statements. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `boardIdFromDeviceId` +private func boardIdFromDeviceId(_ alias: String, unattributed: String) -> String { + """ + CASE + WHEN \(alias).device_id IS NULL OR \(alias).device_id = '' THEN \(unattributed) + ELSE COALESCE( + (SELECT m.board_id FROM \(DEVICE_BOARD_MAP) m WHERE m.device_id = \(alias).device_id), + '\(ORPHAN_BOARD_ID_PREFIX)' || \(alias).device_id + ) + END + """ +} + +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `rebuildFramesOnBoardId` +private func rebuildFramesOnBoardId(_ db: Database) throws { + let columns = """ + captured_at_ms, elapsed_realtime_ms, can_id, flags, changed_mask_1, changed_mask_2, \ + speed_centi_kmh, battery_voltage_mv, motor_current_ma, battery_current_ma, duty_permille, \ + pitch_centi_deg, roll_centi_deg, balance_pitch_centi_deg, balance_current_ma, erpm, state, \ + switch_state, adc1_milli, adc2_milli, odometer_cm, temp_mosfet_deci_c, temp_motor_deci_c, \ + latitude_e7, longitude_e7, gps_speed_centi_mps, bearing_centi_deg, accuracy_cm, \ + altitude_cm, location_timestamp_ms + """ + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_frames_captured_at_ms") + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_frames_device_id_captured_at_ms") + try db.execute(sql: """ + CREATE TABLE telemetry_frames_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + captured_at_ms INTEGER NOT NULL, + elapsed_realtime_ms INTEGER NOT NULL, + board_id TEXT, + can_id INTEGER, + flags INTEGER NOT NULL, + changed_mask_1 INTEGER NOT NULL, + changed_mask_2 INTEGER NOT NULL, + speed_centi_kmh INTEGER, + battery_voltage_mv INTEGER, + motor_current_ma INTEGER, + battery_current_ma INTEGER, + duty_permille INTEGER, + pitch_centi_deg INTEGER, + roll_centi_deg INTEGER, + balance_pitch_centi_deg INTEGER, + balance_current_ma INTEGER, + erpm INTEGER, + state INTEGER, + switch_state INTEGER, + adc1_milli INTEGER, + adc2_milli INTEGER, + odometer_cm INTEGER, + temp_mosfet_deci_c INTEGER, + temp_motor_deci_c INTEGER, + latitude_e7 INTEGER, + longitude_e7 INTEGER, + gps_speed_centi_mps INTEGER, + bearing_centi_deg INTEGER, + accuracy_cm INTEGER, + altitude_cm INTEGER, + location_timestamp_ms INTEGER + ) + """) + try db.execute(sql: """ + INSERT INTO telemetry_frames_new (id, board_id, \(columns)) + SELECT f.id, \(boardIdFromDeviceId("f", unattributed: "NULL")), \(columns) + FROM telemetry_frames f + """) + try db.execute(sql: "DROP TABLE telemetry_frames") + try db.execute(sql: "ALTER TABLE telemetry_frames_new RENAME TO telemetry_frames") + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS index_telemetry_frames_captured_at_ms + ON telemetry_frames(captured_at_ms) + """) + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS index_telemetry_frames_board_id_captured_at_ms + ON telemetry_frames(board_id, captured_at_ms) + """) +} + +/// The primary key move from `(bucket_start_ms, device_id)` to `(bucket_start_ms, board_id)` is a +/// table rebuild, not an `ALTER`. It was added to this table earlier in +/// the same release, so the copy has to carry them across explicitly or every bucket silently +/// resets its Sync Cursor position. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `rebuildBucketsOnBoardId` +private func rebuildBucketsOnBoardId(_ db: Database) throws { + let columns = """ + bucket_start_ms, sample_count, first_sample_at_ms, last_sample_at_ms, \ + sum_abs_speed_centi_kmh, moving_speed_sample_count, sum_moving_abs_speed_centi_kmh, \ + max_abs_speed_centi_kmh, min_battery_voltage_mv, max_motor_current_abs_ma, \ + max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, \ + max_duty_abs_permille, first_odometer_cm, last_odometer_cm, gps_point_count, \ + precise_gps_point_count, gps_distance_cm, max_gps_speed_centi_mps, max_temp_mosfet_deci_c, \ + max_temp_motor_deci_c, first_latitude_e7, first_longitude_e7, first_moving_at_ms, \ + last_moving_at_ms + """ + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_minute_buckets_bucket_start_ms") + try db.execute(sql: """ + CREATE TABLE telemetry_minute_buckets_new ( + bucket_start_ms INTEGER NOT NULL, + board_id TEXT NOT NULL, + sample_count INTEGER NOT NULL, + first_sample_at_ms INTEGER NOT NULL, + last_sample_at_ms INTEGER NOT NULL, + sum_abs_speed_centi_kmh INTEGER NOT NULL, + moving_speed_sample_count INTEGER, + sum_moving_abs_speed_centi_kmh INTEGER, + max_abs_speed_centi_kmh INTEGER NOT NULL, + min_battery_voltage_mv INTEGER, + max_motor_current_abs_ma INTEGER NOT NULL, + max_battery_current_abs_ma INTEGER NOT NULL, + battery_used_wh_milli INTEGER NOT NULL, + battery_regen_wh_milli INTEGER NOT NULL, + max_duty_abs_permille INTEGER NOT NULL, + first_odometer_cm INTEGER, + last_odometer_cm INTEGER, + gps_point_count INTEGER NOT NULL, + precise_gps_point_count INTEGER NOT NULL, + gps_distance_cm INTEGER NOT NULL, + max_gps_speed_centi_mps INTEGER, + max_temp_mosfet_deci_c INTEGER, + max_temp_motor_deci_c INTEGER, + first_latitude_e7 INTEGER, + first_longitude_e7 INTEGER, + first_moving_at_ms INTEGER, + last_moving_at_ms INTEGER, + PRIMARY KEY (bucket_start_ms, board_id) + ) + """) + // Grouped rather than copied row-for-row so the rebuild is total. A `board_id` collision on the + // new key needs two identifiers resolving to one Board inside one minute, which the resolver + // cannot produce — the map is keyed on the identifier and a Board carries one — but an ungrouped + // copy would abort the whole migration on a constraint error if it ever did, stranding the + // database mid-upgrade. The fold sums the additive lanes and takes the extreme of the peaks, as + // an upsert merge would. + try db.execute(sql: """ + INSERT INTO telemetry_minute_buckets_new (board_id, \(columns)) + SELECT + \(boardIdFromDeviceId("b", unattributed: "''")) AS board_id, + b.bucket_start_ms, + SUM(b.sample_count), + MIN(b.first_sample_at_ms), + MAX(b.last_sample_at_ms), + SUM(b.sum_abs_speed_centi_kmh), + SUM(b.moving_speed_sample_count), + SUM(b.sum_moving_abs_speed_centi_kmh), + MAX(b.max_abs_speed_centi_kmh), + MIN(b.min_battery_voltage_mv), + MAX(b.max_motor_current_abs_ma), + MAX(b.max_battery_current_abs_ma), + SUM(b.battery_used_wh_milli), + SUM(b.battery_regen_wh_milli), + MAX(b.max_duty_abs_permille), + MIN(b.first_odometer_cm), + MAX(b.last_odometer_cm), + SUM(b.gps_point_count), + SUM(b.precise_gps_point_count), + SUM(b.gps_distance_cm), + MAX(b.max_gps_speed_centi_mps), + MAX(b.max_temp_mosfet_deci_c), + MAX(b.max_temp_motor_deci_c), + MIN(b.first_latitude_e7), + MIN(b.first_longitude_e7), + MIN(b.first_moving_at_ms), + MAX(b.last_moving_at_ms) + FROM telemetry_minute_buckets b + GROUP BY b.bucket_start_ms, board_id + """) + try db.execute(sql: "DROP TABLE telemetry_minute_buckets") + try db.execute(sql: "ALTER TABLE telemetry_minute_buckets_new RENAME TO telemetry_minute_buckets") + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS index_telemetry_minute_buckets_bucket_start_ms + ON telemetry_minute_buckets(bucket_start_ms) + """) +} + +/// A Marker notes something that happened while recording — a gap, a resume. It belongs to the +/// Board it happened on, and `board_id` stays nullable because a Marker can be written with no +/// Board connected. `device_name` goes with the identifier: the Board holds that text once. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `rebuildMarkersOnBoardId` +private func rebuildMarkersOnBoardId(_ db: Database) throws { + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_markers_occurred_at_ms") + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_markers_device_id_occurred_at_ms") + try db.execute(sql: """ + CREATE TABLE telemetry_markers_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + occurred_at_ms INTEGER NOT NULL, + elapsed_realtime_ms INTEGER NOT NULL, + type TEXT NOT NULL, + board_id TEXT, + message TEXT, + gap_ms INTEGER + ) + """) + try db.execute(sql: """ + INSERT INTO telemetry_markers_new + (id, occurred_at_ms, elapsed_realtime_ms, type, board_id, message, gap_ms) + SELECT + m.id, m.occurred_at_ms, m.elapsed_realtime_ms, m.type, + \(boardIdFromDeviceId("m", unattributed: "NULL")), + m.message, m.gap_ms + FROM telemetry_markers m + """) + try db.execute(sql: "DROP TABLE telemetry_markers") + try db.execute(sql: "ALTER TABLE telemetry_markers_new RENAME TO telemetry_markers") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_telemetry_markers_occurred_at_ms ON telemetry_markers(occurred_at_ms)") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_telemetry_markers_board_id_occurred_at_ms ON telemetry_markers(board_id, occurred_at_ms)") +} + +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `rebuildDiagnosticEventsOnBoardId` +private func rebuildDiagnosticEventsOnBoardId(_ db: Database) throws { + try db.execute(sql: "DROP INDEX IF EXISTS index_diagnostic_events_occurred_at_ms") + try db.execute(sql: "DROP INDEX IF EXISTS index_diagnostic_events_event_name") + try db.execute(sql: "DROP INDEX IF EXISTS index_diagnostic_events_device_id_occurred_at_ms") + try db.execute(sql: """ + CREATE TABLE diagnostic_events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + occurred_at_ms INTEGER NOT NULL, + elapsed_realtime_ms INTEGER NOT NULL, + event_name TEXT NOT NULL, + operation TEXT, + phase TEXT, + board_id TEXT, + message TEXT, + properties_json TEXT NOT NULL + ) + """) + try db.execute(sql: """ + INSERT INTO diagnostic_events_new + (id, occurred_at_ms, elapsed_realtime_ms, event_name, operation, phase, board_id, + message, properties_json) + SELECT + e.id, e.occurred_at_ms, e.elapsed_realtime_ms, e.event_name, e.operation, e.phase, + \(boardIdFromDeviceId("e", unattributed: "NULL")), + e.message, e.properties_json + FROM diagnostic_events e + """) + try db.execute(sql: "DROP TABLE diagnostic_events") + try db.execute(sql: "ALTER TABLE diagnostic_events_new RENAME TO diagnostic_events") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_diagnostic_events_occurred_at_ms ON diagnostic_events(occurred_at_ms)") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_diagnostic_events_event_name ON diagnostic_events(event_name)") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_diagnostic_events_board_id_occurred_at_ms ON diagnostic_events(board_id, occurred_at_ms)") +} + +/// A Metric Exclusion Range is a span of *one Board's* samples the app decided not to count, so +/// unlike a Marker it has no meaning without one: `board_id` is NOT NULL, as `device_id` was. A row +/// that never named a device takes the same unattributed sentinel a bucket does — the column is NOT +/// NULL on both, so one sentinel across the two keeps "no Board" a single idea. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `rebuildExclusionRangesOnBoardId` +private func rebuildExclusionRangesOnBoardId(_ db: Database) throws { + try db.execute(sql: "DROP INDEX IF EXISTS index_metric_exclusion_ranges_start_ms_end_ms") + try db.execute(sql: "DROP INDEX IF EXISTS index_metric_exclusion_ranges_device_id_start_ms_end_ms") + try db.execute(sql: """ + CREATE TABLE metric_exclusion_ranges_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + board_id TEXT NOT NULL, + reason TEXT NOT NULL, + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + sample_count INTEGER NOT NULL + ) + """) + try db.execute(sql: """ + INSERT INTO metric_exclusion_ranges_new + (id, board_id, reason, start_ms, end_ms, sample_count) + SELECT + r.id, \(boardIdFromDeviceId("r", unattributed: "''")), r.reason, r.start_ms, r.end_ms, + r.sample_count + FROM metric_exclusion_ranges r + """) + try db.execute(sql: "DROP TABLE metric_exclusion_ranges") + try db.execute(sql: "ALTER TABLE metric_exclusion_ranges_new RENAME TO metric_exclusion_ranges") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_metric_exclusion_ranges_start_ms_end_ms ON metric_exclusion_ranges(start_ms, end_ms)") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_metric_exclusion_ranges_board_id_start_ms_end_ms ON metric_exclusion_ranges(board_id, start_ms, end_ms)") +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift b/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift index a4df0843d..454a1f1fc 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift @@ -262,4 +262,275 @@ final class TelemetryMigrationTests: XCTestCase { XCTAssertEqual(try alertCount(), 1) } + + // MARK: - Telemetry keys on the Board id (#280, ADR 0028) + + /// The last migration before `v42_telemetry_board_id`. Stopping here leaves both telemetry tables + /// in their `device_id` shape. + private static let beforeBoardId = "v41_board_deleted_at" + + private func insertBoard(id: String, name: String, bleId: String?) throws { + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO boards (id, name, ble_id, created_at) + VALUES (?, ?, ?, 1000) + """, + arguments: [id, name, bleId] + ) + } + } + + private func insertLegacyFrame(deviceId: String?, deviceName: String?, capturedAtMs: Int64) throws { + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO telemetry_frames + (captured_at_ms, elapsed_realtime_ms, device_id, device_name, flags, changed_mask_1, changed_mask_2) + VALUES (?, 0, ?, ?, 1, 0, 0) + """, + arguments: [capturedAtMs, deviceId, deviceName] + ) + } + } + + private func insertLegacyBucket( + deviceId: String, + deviceName: String?, + bucketStartMs: Int64, + sampleCount: Int = 1 + ) throws { + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO telemetry_minute_buckets ( + bucket_start_ms, device_id, device_name, sample_count, first_sample_at_ms, + last_sample_at_ms, sum_abs_speed_centi_kmh, max_abs_speed_centi_kmh, + max_motor_current_abs_ma, max_battery_current_abs_ma, battery_used_wh_milli, + battery_regen_wh_milli, max_duty_abs_permille, gps_point_count, + precise_gps_point_count, gps_distance_cm + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + """, + arguments: [ + bucketStartMs, deviceId, deviceName, sampleCount, bucketStartMs, bucketStartMs + 500, + ] + ) + } + } + + private func boardIds(fromFrames: Bool = true) throws -> [String?] { + let table = fromFrames ? "telemetry_frames" : "telemetry_minute_buckets" + return try queue.read { db in + try Row.fetchAll(db, sql: "SELECT board_id FROM \(table) ORDER BY rowid") + .map { $0["board_id"] as String? } + } + } + + private func board(_ id: String) throws -> Row? { + try queue.read { db in + try Row.fetchOne(db, sql: "SELECT * FROM boards WHERE id = ?", arguments: [id]) + } + } + + /// Telemetry that still resolves keeps its Board; the retired columns go with the rebuild. + func testTelemetryBackfillsBoardIdFromTheLinkedBoardAndDropsTheOldColumns() throws { + try migrate(upTo: Self.beforeBoardId) + try insertBoard(id: "board-1", name: "ADV", bleId: "ble-a") + try insertLegacyFrame(deviceId: "ble-a", deviceName: "ADV", capturedAtMs: 60_000) + try insertLegacyBucket(deviceId: "ble-a", deviceName: "ADV", bucketStartMs: 60_000) + + try migrate() + + XCTAssertEqual(try boardIds(), ["board-1"]) + XCTAssertEqual(try boardIds(fromFrames: false), ["board-1"]) + for table in ["telemetry_frames", "telemetry_minute_buckets"] { + let columns = try columnNames(table) + XCTAssertFalse(columns.contains("device_id"), "\(table) kept device_id") + XCTAssertFalse(columns.contains("device_name"), "\(table) kept device_name") + } + } + + /// Two Boards may claim one `ble_id` — the same peripheral linked twice, which the app supports + /// and a Rider produces by pairing a board they already own a second time. Telemetry from before + /// this migration recorded only the identifier, so which of them was connected is unknowable and + /// the pick is arbitrary. What is not arbitrary is that frames and buckets make the *same* pick: + /// split across the two Boards, History lists the ride from its buckets, finds no frames under + /// that Board, and renders stats over an empty route. + func testADuplicatedIdentifierSendsFramesAndBucketsToTheSameBoard() throws { + try migrate(upTo: Self.beforeBoardId) + try insertBoard(id: "board-b", name: "Jeżdżąca Martwica", bleId: "ble-dup") + try insertBoard(id: "board-a", name: "ADV2", bleId: "ble-dup") + try insertLegacyFrame(deviceId: "ble-dup", deviceName: "ADV2", capturedAtMs: 60_000) + try insertLegacyBucket(deviceId: "ble-dup", deviceName: "ADV2", bucketStartMs: 60_000) + + try migrate() + + let frameBoard = try boardIds() + XCTAssertEqual(frameBoard, try boardIds(fromFrames: false), "the ride's frames and buckets split") + XCTAssertEqual(frameBoard, ["board-a"], "the pick is arbitrary but must be stable") + XCTAssertNotNil(try board("board-b"), "the losing claimant is still a Board the Rider owns") + } + + /// ADR-0028 left Markers, Diagnostic Events and Metric Exclusion Ranges on `device_id` because + /// "that is what crosses the wire for them" — circular, and it kept a second copy of the defect + /// the Board move existed to remove. All three resolve through the same one decision, so a + /// duplicated identifier cannot scatter a ride's Markers across the Boards claiming it. + func testMarkersEventsAndRangesMoveOntoTheSameBoardAsTheirTelemetry() throws { + try migrate(upTo: Self.beforeBoardId) + try insertBoard(id: "board-b", name: "Jeżdżąca Martwica", bleId: "ble-dup") + try insertBoard(id: "board-a", name: "ADV2", bleId: "ble-dup") + try insertLegacyBucket(deviceId: "ble-dup", deviceName: "ADV2", bucketStartMs: 60_000) + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO telemetry_markers + (occurred_at_ms, elapsed_realtime_ms, type, device_id, device_name, message, gap_ms) + VALUES (60000, 0, 'gap', 'ble-dup', 'ADV2', NULL, 4000) + """ + ) + try db.execute( + sql: """ + INSERT INTO diagnostic_events + (occurred_at_ms, elapsed_realtime_ms, event_name, operation, phase, device_id, + device_name, message, properties_json) + VALUES (60000, 0, 'ble_connect', 'connect', 'start', 'ble-dup', 'ADV2', NULL, '{}') + """ + ) + try db.execute( + sql: """ + INSERT INTO metric_exclusion_ranges (device_id, reason, start_ms, end_ms, sample_count) + VALUES ('ble-dup', 'free-spin', 60000, 60500, 3) + """ + ) + } + + try migrate() + + let bucketBoard = try boardIds(fromFrames: false).first ?? nil + for table in ["telemetry_markers", "diagnostic_events", "metric_exclusion_ranges"] { + let owners = try queue.read { db in + try Row.fetchAll(db, sql: "SELECT board_id FROM \(table)").map { $0["board_id"] as String? } + } + XCTAssertEqual(owners, [bucketBoard], "\(table) resolved the identifier its own way") + let columns = try columnNames(table) + XCTAssertFalse(columns.contains("device_id"), "\(table) kept device_id") + XCTAssertFalse(columns.contains("device_name"), "\(table) kept device_name") + } + } + + /// A Marker can be written with no Board connected, so its column stays nullable. A Range has no + /// meaning without a Board, so its NOT NULL column takes the sentinel a bucket takes. + func testAMarkerWithNoIdentifierStaysUnattributedAndARangeTakesTheSentinel() throws { + try migrate(upTo: Self.beforeBoardId) + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO telemetry_markers + (occurred_at_ms, elapsed_realtime_ms, type, device_id, device_name, message, gap_ms) + VALUES (60000, 0, 'gap', NULL, NULL, NULL, NULL) + """ + ) + try db.execute( + sql: """ + INSERT INTO metric_exclusion_ranges (device_id, reason, start_ms, end_ms, sample_count) + VALUES ('', 'free-spin', 60000, 60500, 3) + """ + ) + } + + try migrate() + + XCTAssertEqual( + try queue.read { db in try String.fetchOne(db, sql: "SELECT board_id FROM telemetry_markers") }, + nil + ) + XCTAssertEqual( + try queue.read { db in + try String.fetchOne(db, sql: "SELECT board_id FROM metric_exclusion_ranges") + }, + "" + ) + } + + /// A frame that never carried an identifier stays unattributed rather than joining a random + /// Board; the bucket column is part of the primary key, so it takes the sentinel instead. + func testTelemetryWithNoIdentifierStaysUnattributed() throws { + try migrate(upTo: Self.beforeBoardId) + try insertLegacyFrame(deviceId: nil, deviceName: nil, capturedAtMs: 60_000) + try insertLegacyBucket(deviceId: "", deviceName: nil, bucketStartMs: 60_000) + + try migrate() + + XCTAssertEqual(try boardIds(), [nil]) + XCTAssertEqual(try boardIds(fromFrames: false), [""]) + XCTAssertEqual(try queue.read { db in try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM boards") }, 0) + } + + // MARK: Orphan minting + + /// The regression this exists to prevent: telemetry from a Board hard-deleted before tombstones + /// existed resolves to nothing, and without a minted Board it loses both its identity and its + /// label. It must end up pointing at a Board that exists, is tombstoned, and keeps the name the + /// history itself recorded. + func testUnresolvedIdentifierMintsATombstonedBoardCarryingTheHistoricalName() throws { + try migrate(upTo: Self.beforeBoardId) + try insertLegacyFrame(deviceId: "ble-gone", deviceName: "Old Board", capturedAtMs: 60_000) + + try migrate() + + let mintedId = try XCTUnwrap(try boardIds().first ?? nil) + XCTAssertEqual(mintedId, "\(ORPHAN_BOARD_ID_PREFIX)ble-gone") + + let minted = try XCTUnwrap(try board(mintedId)) + XCTAssertEqual(minted["name"] as String, "Old Board") + XCTAssertNotNil(minted["deleted_at"] as Int64?, "a minted Board is not tombstoned") + XCTAssertNil(minted["ble_id"] as String?, "a minted Board carries a Board Link") + } + + /// A minted Board is invisible to the Rider: `getBoards()` filters tombstones (ADR 0027), so the + /// only place it surfaces is the history label it exists to provide. + func testAMintedBoardNeverAppearsInTheRidersBoardList() throws { + try migrate(upTo: Self.beforeBoardId) + try insertBoard(id: "board-1", name: "ADV", bleId: "ble-a") + try insertLegacyFrame(deviceId: "ble-gone", deviceName: "Old Board", capturedAtMs: 60_000) + + try migrate() + + let live = try queue.read { db in + try String.fetchAll(db, sql: "SELECT id FROM boards WHERE deleted_at IS NULL ORDER BY id") + } + XCTAssertEqual(live, ["board-1"]) + } + + /// Minting is derived from the identifier, not random, so a database that somehow reaches the + /// migration twice does not accumulate a second Board per ride. + func testMintingTheSameIdentifierTwiceIsANoOp() throws { + try migrate(upTo: Self.beforeBoardId) + try insertLegacyFrame(deviceId: "ble-gone", deviceName: "Old Board", capturedAtMs: 60_000) + try insertLegacyBucket(deviceId: "ble-gone", deviceName: "Old Board", bucketStartMs: 60_000) + + try migrate() + + XCTAssertEqual(try queue.read { db in try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM boards") }, 1) + } + + // MARK: Bucket rebuild + + /// The primary key move is a table rebuild, and the retired columns go with it. + func testTheBucketRebuildMovesThePrimaryKey() throws { + try migrate(upTo: Self.beforeBoardId) + try insertBoard(id: "board-1", name: "ADV", bleId: "ble-a") + try insertLegacyBucket(deviceId: "ble-a", deviceName: "ADV", bucketStartMs: 60_000) + + try migrate() + + XCTAssertEqual( + try queue.read { db in try db.primaryKey("telemetry_minute_buckets").columns }, + ["bucket_start_ms", "board_id"] + ) + let columns = try columnNames("telemetry_minute_buckets") + XCTAssertFalse(columns.contains("device_id")) + XCTAssertFalse(columns.contains("device_name")) + } + } diff --git a/modules/vescape-core/ios/telemetry/TelemetryPipeline.swift b/modules/vescape-core/ios/telemetry/TelemetryPipeline.swift index 6dfe88ca3..3ed8df349 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryPipeline.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryPipeline.swift @@ -86,8 +86,8 @@ internal func telemetryLocationFreshEnoughToRecord( internal struct TelemetryCapture { let capturedAtMs: Int64 let elapsedRealtimeMs: Int64 - let deviceId: String? - let deviceName: String? + /// Owning Board (`boards.id`) — what every telemetry table is keyed on (ADR 0028). + let boardId: String? let canId: Int? let telemetry: RefloatTelemetry let location: TelemetryLocationCapture? @@ -95,8 +95,8 @@ internal struct TelemetryCapture { internal struct BucketTelemetryPoint { let capturedAtMs: Int64 - let deviceId: String? - let deviceName: String? + /// Owning Board (`boards.id`); the durable identity telemetry is keyed on (ADR 0028). + let boardId: String? let speedCentiKmh: Int let batteryVoltageMv: Int let motorCurrentMa: Int @@ -124,15 +124,13 @@ internal struct FullTelemetryState { var t: RefloatTelemetry { capture.telemetry } var capturedAtMs: Int64 { capture.capturedAtMs } var elapsedRealtimeMs: Int64 { capture.elapsedRealtimeMs } - var deviceId: String? { capture.deviceId } - var deviceName: String? { capture.deviceName } + var boardId: String? { capture.boardId } var location: TelemetryLocationCapture? { capture.location } func toBucketPoint() -> BucketTelemetryPoint { BucketTelemetryPoint( capturedAtMs: capturedAtMs, - deviceId: deviceId, - deviceName: deviceName, + boardId: boardId, speedCentiKmh: telemetryCenti(t.speed), batteryVoltageMv: telemetryMilli(t.batteryVoltage), motorCurrentMa: telemetryMilli(t.motorCurrent), diff --git a/modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift b/modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift index 1a7052bbe..395172ff2 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift @@ -16,44 +16,52 @@ extension TelemetryRepository { let fromMs = telemetryLong(options["fromMs"]) ?? 0 let toMs = telemetryLong(options["toMs"]) ?? telemetryNowMs() let limit = min(MAX_SAMPLE_LIMIT, max(1, telemetryInt(options["limit"]) ?? DEFAULT_SAMPLE_LIMIT)) - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String guard let pool else { return emptyRangePayload() } - // Battery configs and the smoothing window are read up front (each opens its own DB read) so - // the estimate stays a pure computation inside the range read below. - let configs = batteryConfigByDevice() + // Battery configs, board names and the smoothing window are read up front (each opens its own + // DB read) so the estimate stays a pure computation inside the range read below. + let configs = batteryConfigByBoard() + let boardNames = Self.boardNamesById() let windowMs = socWindowMs() return (try? pool.read { db -> [String: Any?] in let sampleRows = try Row.fetchAll( db, sql: """ SELECT * FROM telemetry_frames - WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY captured_at_ms ASC LIMIT ? """, - arguments: [fromMs, toMs, deviceId, deviceId, limit] + arguments: [fromMs, toMs, boardId, boardId, limit] ) let markers = try Row.fetchAll( db, - sql: "SELECT * FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND (? IS NULL OR device_id = ?) ORDER BY occurred_at_ms ASC", - arguments: [fromMs, toMs, deviceId, deviceId] + sql: "SELECT * FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY occurred_at_ms ASC", + arguments: [fromMs, toMs, boardId, boardId] ) let exclusions = try Row.fetchAll( db, - sql: "SELECT * FROM metric_exclusion_ranges WHERE end_ms >= ? AND start_ms <= ? AND (? IS NULL OR device_id = ?) ORDER BY start_ms ASC", - arguments: [fromMs, toMs, deviceId, deviceId] + sql: "SELECT * FROM metric_exclusion_ranges WHERE end_ms >= ? AND start_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY start_ms ASC", + arguments: [fromMs, toMs, boardId, boardId] ).map(exclusionMap) let percents = self.batteryPercents(sampleRows, configs: configs, windowMs: windowMs) let overviewIndices = evenlySpacedIndices(sampleRows.count, limit: HISTORY_CHART_OVERVIEW_SAMPLES) let overviewRows = overviewIndices.map { sampleRows[$0] } let overviewPercents = overviewIndices.map { percents[$0] } - return mergeTelemetryPayload(sampleColumns(sampleRows, batteryPercents: percents), [ - "chartColumns": sampleColumns(overviewRows, batteryPercents: overviewPercents)["boardColumns"], - "chartCount": overviewRows.count, - "gpsSamples": gpsMaps(sampleRows), - "markers": markers.map(markerMap), - "exclusions": exclusions, - ]) + return mergeTelemetryPayload( + sampleColumns(sampleRows, batteryPercents: percents, boardNames: boardNames), + [ + "chartColumns": sampleColumns( + overviewRows, + batteryPercents: overviewPercents, + boardNames: boardNames + )["boardColumns"], + "chartCount": overviewRows.count, + "gpsSamples": gpsMaps(sampleRows, boardNames: boardNames), + "markers": markers.map(markerMap), + "exclusions": exclusions, + ] + ) }) ?? emptyRangePayload() } } @@ -71,20 +79,24 @@ private func evenlySpacedIndices(_ count: Int, limit: Int) -> [Int] { /// that answers JS calls rather than in the DAO. /// /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `smoothedSampleColumns` -internal func sampleColumns(_ rows: [Row], batteryPercents: [Double?]) -> [String: Any?] { +internal func sampleColumns( + _ rows: [Row], + batteryPercents: [Double?], + boardNames: [String: String] +) -> [String: Any?] { var data = Data(capacity: rows.count * SAMPLE_COLUMN_COUNT * MemoryLayout.size) - var deviceIds: [String?] = [] - var deviceNames: [String] = [] - var deviceIndex: [String: Int] = [:] + var boardIds: [String?] = [] + var names: [String] = [] + var boardIndex: [String: Int] = [:] for (i, row) in rows.enumerated() { let id: Int64 = row["id"] - let rawDeviceId = row["device_id"] as String? - let key = rawDeviceId ?? "" - let index = deviceIndex[key] ?? { - deviceIds.append(rawDeviceId) - deviceNames.append(row["device_name"] as String? ?? "VESC Board") - let newIndex = deviceIds.count - 1 - deviceIndex[key] = newIndex + let rawBoardId = row["board_id"] as String? + let key = rawBoardId ?? "" + let index = boardIndex[key] ?? { + boardIds.append(rawBoardId) + names.append(rawBoardId.flatMap { boardNames[$0] } ?? UNKNOWN_TELEMETRY_BOARD_NAME) + let newIndex = boardIds.count - 1 + boardIndex[key] = newIndex return newIndex }() appendDouble(&data, Double(id)) @@ -114,8 +126,8 @@ internal func sampleColumns(_ rows: [Row], batteryPercents: [Double?]) -> [Strin return [ "boardColumns": (try? NativeArrayBuffer.copy(data: data)) ?? NativeArrayBuffer.allocate(size: 0), "boardCount": rows.count, - "boardDevices": deviceIds, - "boardDeviceNames": deviceNames, + "boardIds": boardIds, + "boardNames": names, ] } @@ -123,8 +135,8 @@ internal func emptyRangePayload() -> [String: Any?] { [ "boardColumns": NativeArrayBuffer.allocate(size: 0), "boardCount": 0, - "boardDevices": [] as [String?], - "boardDeviceNames": [] as [String], + "boardIds": [] as [String?], + "boardNames": [] as [String], "chartColumns": NativeArrayBuffer.allocate(size: 0), "chartCount": 0, "gpsSamples": [] as [[String: Any?]], diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index abc6a04ab..8c0bc3c09 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -78,14 +78,13 @@ internal final class TelemetryRepository { } } - func recordMarker(type: String, deviceId: String?, deviceName: String?, message: String? = nil) { + func recordMarker(type: String, boardId: String?, message: String? = nil) { queue.async { self.pendingMarkers.append([ "occurredAtMs": telemetryNowMs(), "elapsedRealtimeMs": telemetryElapsedMs(), "type": type, - "deviceId": deviceId, - "deviceName": deviceName, + "boardId": boardId, "message": message, "gapMs": nil, ]) @@ -125,28 +124,29 @@ internal final class TelemetryRepository { let fromMs = telemetryLong(options["fromMs"]) ?? 0 let beforeMs = telemetryLong(options["cursorBeforeMs"]) ?? toMs let limit = min(500, max(1, telemetryInt(options["limit"]) ?? DEFAULT_HISTORY_LIMIT)) - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String guard let pool else { return [] } + let boardNames = Self.boardNamesById() return (try? pool.read { db in let rows = try Row.fetchAll( db, sql: """ SELECT * FROM telemetry_minute_buckets WHERE bucket_start_ms >= ? AND bucket_start_ms <= ? AND bucket_start_ms < ? - AND (? IS NULL OR device_id = ?) + AND (? IS NULL OR board_id = ?) ORDER BY bucket_start_ms DESC LIMIT ? """, - arguments: [fromMs, toMs, beforeMs, deviceId, deviceId, limit] + arguments: [fromMs, toMs, beforeMs, boardId, boardId, limit] ) let markerFrom = (rows.map { $0["bucket_start_ms"] as Int64 }.min() ?? fromMs) - GAP_BOUNDARY_MS let markerTo = (rows.map { $0["bucket_start_ms"] as Int64 }.max() ?? toMs) + TELEMETRY_BUCKET_SIZE_MS let markers = try Row.fetchAll( db, - sql: "SELECT * FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND (? IS NULL OR device_id = ?) ORDER BY occurred_at_ms ASC", - arguments: [markerFrom, markerTo, deviceId, deviceId] + sql: "SELECT * FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY occurred_at_ms ASC", + arguments: [markerFrom, markerTo, boardId, boardId] ) - return rows.map { historyMap($0, markers: markers) } + return rows.map { historyMap($0, markers: markers, boardNames: boardNames) } }) ?? [] } @@ -155,47 +155,48 @@ internal final class TelemetryRepository { let fromMs = telemetryLong(options["fromMs"]) ?? 0 let toMs = telemetryLong(options["toMs"]) ?? telemetryNowMs() let limit = min(MAX_SAMPLE_LIMIT, max(1, telemetryInt(options["limit"]) ?? DEFAULT_SAMPLE_LIMIT)) - let deviceId = options["deviceId"] as? String - // Battery configs and the smoothing window are read up front (each opens its own DB read) so - // the estimate stays a pure computation inside the frames read below. - let configs = batteryConfigByDevice() + let boardId = options["boardId"] as? String + // Battery configs, board names and the smoothing window are read up front (each opens its own + // DB read) so the estimate stays a pure computation inside the frames read below. + let configs = batteryConfigByBoard() + let boardNames = Self.boardNamesById() let windowMs = socWindowMs() return (try? pool.read { db in let rows = try Row.fetchAll( db, sql: """ SELECT * FROM telemetry_frames - WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY captured_at_ms ASC LIMIT ? """, - arguments: [fromMs, toMs, deviceId, deviceId, limit] + arguments: [fromMs, toMs, boardId, boardId, limit] ) let percents = self.batteryPercents(rows, configs: configs, windowMs: windowMs) - return zip(rows, percents).map { sampleMap($0.0, batteryPercent: $0.1) } + return zip(rows, percents).map { sampleMap($0.0, batteryPercent: $0.1, boardNames: boardNames) } }) ?? [] } // MARK: - Battery SoC on read (ADR-0016) /// Per-sample Battery SoC Estimate for a run of frames (ordered by captured_at_ms): the - /// IR-compensated % from the board's stored battery config, smoothed by a per-device - /// `SocMedianWindow`. Returns one entry per row (nil where no config is known for the device). + /// IR-compensated % from the Board's stored battery config, smoothed by a per-Board + /// `SocMedianWindow`. Returns one entry per row (nil where no config is known for the Board). /// Mirrors how the live path derives % per frame; approximate on read only because Android stores /// delta-encoded frames. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `smoothedSampleMaps` internal func batteryPercents(_ rows: [Row], configs: [String: [String: Any]], windowMs: Int64) -> [Double?] { var windows: [String: SocMedianWindow] = [:] return rows.map { row in - let deviceId = row["device_id"] as String? + let boardId = row["board_id"] as String? let voltageV = Double(row["battery_voltage_mv"] as Int? ?? 0) / 1000.0 let batteryCurrentA = Double(row["battery_current_ma"] as Int? ?? 0) / 1000.0 - guard let deviceId, let raw = deriveBatteryPercent(deviceId: deviceId, voltageV: voltageV, batteryCurrentA: batteryCurrentA, configs: configs) else { + guard let boardId, let raw = deriveBatteryPercent(boardId: boardId, voltageV: voltageV, batteryCurrentA: batteryCurrentA, configs: configs) else { return nil } - let window = windows[deviceId] ?? { + let window = windows[boardId] ?? { let w = SocMedianWindow(windowMs: windowMs) - windows[deviceId] = w + windows[boardId] = w return w }() return window.median(percent: raw, nowMs: row["captured_at_ms"] as Int64) @@ -204,23 +205,24 @@ internal final class TelemetryRepository { /// Derive IR-compensated battery % for one sample, mirroring the live native path. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `deriveBatteryPercent` - private func deriveBatteryPercent(deviceId: String, voltageV: Double, batteryCurrentA: Double, configs: [String: [String: Any]]) -> Double? { - guard let config = configs[deviceId] else { return nil } + private func deriveBatteryPercent(boardId: String, voltageV: Double, batteryCurrentA: Double, configs: [String: [String: Any]]) -> Double? { + guard let config = configs[boardId] else { return nil } return batteryEstimator.estimateBatteryPercent(voltageV: voltageV, config: config, batteryCurrentA: batteryCurrentA) } - /// bleId (telemetry deviceId) -> the board's normalized battery config. - /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `batteryConfigByDevice` - internal func batteryConfigByDevice() -> [String: [String: Any]] { + /// `boards.id` -> the Board's normalized battery config. Keyed on the Board rather than its BLE + /// identifier now that samples carry the Board id (ADR 0028), so a re-linked Board keeps its + /// config across its whole history. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `batteryConfigByBoard` + internal func batteryConfigByBoard() -> [String: [String: Any]] { batteryEstimator.ensureLoaded() var result: [String: [String: Any]] = [:] for board in AppDataRepository.shared.getBoards() { guard - let link = board["link"] as? [String: Any?], - let bleId = link["bleId"] as? String, + let id = board["id"] as? String, let config = board["batteryConfig"] as? [String: Any] else { continue } - result[bleId] = config + result[id] = config } return result } @@ -281,7 +283,7 @@ internal final class TelemetryRepository { guard let range = Self.favoriteRange(options) else { return nil } let startMs = range.startMs let endMs = range.endMs - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String let trimmedName = (options["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let config = queue.sync { metricConfig } let points = (try? pool.read { db in @@ -289,17 +291,17 @@ internal final class TelemetryRepository { db, sql: """ SELECT * FROM telemetry_frames - WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY captured_at_ms ASC """, - arguments: [startMs, endMs, deviceId, deviceId] + arguments: [startMs, endMs, boardId, boardId] ).compactMap(bucketPoint) }) ?? [] let summary = Self.favoriteSummary(points, config: config) let nowMs = telemetryNowMs() let favorite = Favorite( id: UUID().uuidString, - boardId: deviceId.flatMap { Self.boardId(forBleId: $0) }, + boardId: boardId, name: (trimmedName?.isEmpty ?? true) ? nil : trimmedName, startMs: startMs, endMs: endMs, @@ -314,23 +316,16 @@ internal final class TelemetryRepository { ) } - /// The Board that recorded under this BLE peripheral id, resolved once at creation. The ble id is - /// a transport key — it changes on re-link and differs per install — so the durable `boards.id` is - /// what the Favorite keeps. - /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `boardId` - private static func boardId(forBleId bleId: String) -> String? { - AppDataRepository.shared.getBoards().first { board in - (board["link"] as? [String: Any?])?["bleId"] as? String == bleId - }?["id"] as? String - } - - private static func boardNamesById() -> [String: String] { - var names: [String: String] = [:] - for board in AppDataRepository.shared.getBoards() { - guard let id = board["id"] as? String, let name = board["name"] as? String else { continue } - names[id] = name - } - return names + /// `boards.id` -> Board name, tombstones included: Ride History still has to name a Board the + /// Rider deleted (ADR 0027), and resolving on read is what makes a rename retroactive. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `boardNamesById` + internal static func boardNamesById() -> [String: String] { + guard let pool = TelemetryDatabase.pool else { return [:] } + return (try? pool.read { db in + try Row.fetchAll(db, sql: "SELECT id, name FROM boards").reduce(into: [String: String]()) { + $0[$1["id"] as String] = $1["name"] as String + } + }) ?? [:] } /// Favorite ranges are required bridge input. Missing or inverted bounds must fail instead of @@ -355,7 +350,7 @@ internal final class TelemetryRepository { guard let range = Self.favoriteRange(options) else { return nil } let startMs = range.startMs let endMs = range.endMs - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String let trimmedName = (options["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let config = queue.sync { metricConfig } let points = (try? pool.read { db in @@ -363,10 +358,10 @@ internal final class TelemetryRepository { db, sql: """ SELECT * FROM telemetry_frames - WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY captured_at_ms ASC """, - arguments: [startMs, endMs, deviceId, deviceId] + arguments: [startMs, endMs, boardId, boardId] ).compactMap(bucketPoint) }) ?? [] let updated = Favorite( @@ -461,7 +456,7 @@ internal final class TelemetryRepository { guard let pool else { return 0 } let fromMs = telemetryLong(options["fromMs"]) ?? 0 let toMs = telemetryLong(options["toMs"]) ?? 0 - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String guard toMs >= fromMs else { return 0 } let deletable = subtractProtectedTelemetryRanges( deleteRange: TelemetryTimeRange(startMs: fromMs, endMs: toMs), @@ -472,13 +467,13 @@ internal final class TelemetryRepository { for range in deletable { count += try Int.fetchOne( db, - sql: "SELECT COUNT(*) FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", - arguments: [range.startMs, range.endMs, deviceId, deviceId, deviceId] + sql: "SELECT COUNT(*) FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND board_id = ?) OR (? IS NULL AND board_id IS NULL))", + arguments: [range.startMs, range.endMs, boardId, boardId, boardId] ) ?? 0 - try db.execute(sql: "DELETE FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", arguments: [range.startMs, range.endMs, deviceId, deviceId, deviceId]) - try db.execute(sql: "DELETE FROM telemetry_minute_buckets WHERE last_sample_at_ms >= ? AND first_sample_at_ms <= ? AND device_id = ?", arguments: [range.startMs, range.endMs, deviceId ?? ""]) + try db.execute(sql: "DELETE FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND board_id = ?) OR (? IS NULL AND board_id IS NULL))", arguments: [range.startMs, range.endMs, boardId, boardId, boardId]) + try db.execute(sql: "DELETE FROM telemetry_minute_buckets WHERE last_sample_at_ms >= ? AND first_sample_at_ms <= ? AND board_id = ?", arguments: [range.startMs, range.endMs, boardId ?? UNKNOWN_TELEMETRY_BOARD_ID]) try db.execute(sql: "DELETE FROM metric_exclusion_ranges WHERE end_ms >= ? AND start_ms <= ?", arguments: [range.startMs, range.endMs]) - try db.execute(sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", arguments: [range.startMs, range.endMs, deviceId, deviceId, deviceId]) + try db.execute(sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND ((? IS NOT NULL AND board_id = ?) OR (? IS NULL AND board_id IS NULL))", arguments: [range.startMs, range.endMs, boardId, boardId, boardId]) } return count }) ?? 0 @@ -628,8 +623,7 @@ internal final class TelemetryRepository { "occurredAtMs": capture.capturedAtMs, "elapsedRealtimeMs": capture.elapsedRealtimeMs, "type": type, - "deviceId": capture.deviceId, - "deviceName": capture.deviceName, + "boardId": capture.boardId, "message": nil, "gapMs": gapMs, ] @@ -654,8 +648,7 @@ internal final class TelemetryRepository { let elapsed = telemetryElapsedMs() let operation = properties["operation"] as? String let phase = properties["phase"] as? String - let deviceId = properties["ble_id"] as? String - let deviceName = properties["board_nickname"] as? String + let boardId = properties["board_id"] as? String let message = properties["message"] as? String let propertiesJson = Self.encodeDiagnosticProperties(properties) queue.async { @@ -663,10 +656,10 @@ internal final class TelemetryRepository { try db.execute( sql: """ INSERT INTO diagnostic_events - (occurred_at_ms, elapsed_realtime_ms, event_name, operation, phase, device_id, device_name, message, properties_json) + (occurred_at_ms, elapsed_realtime_ms, event_name, operation, phase, board_id, message, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, - arguments: [occurredAtMs, elapsed, eventName, operation, phase, deviceId, deviceName, message, propertiesJson] + arguments: [occurredAtMs, elapsed, eventName, operation, phase, boardId, message, propertiesJson] ) } } @@ -676,18 +669,18 @@ internal final class TelemetryRepository { guard let pool else { return [] } let fromMs = telemetryLong(options["fromMs"]) ?? 0 let toMs = telemetryLong(options["toMs"]) ?? telemetryNowMs() - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String let limit = min(1_000, max(1, telemetryInt(options["limit"]) ?? 200)) return (try? pool.read { db in try Row.fetchAll( db, sql: """ SELECT * FROM diagnostic_events - WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND (? IS NULL OR device_id = ?) + WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY occurred_at_ms DESC LIMIT ? """, - arguments: [fromMs, toMs, deviceId, deviceId, limit] + arguments: [fromMs, toMs, boardId, boardId, limit] ).map { row in [ "id": row["id"] as Int64, @@ -695,8 +688,7 @@ internal final class TelemetryRepository { "eventName": row["event_name"] as String, "operation": row["operation"] as String?, "phase": row["phase"] as String?, - "deviceId": row["device_id"] as String?, - "deviceName": row["device_name"] as String?, + "boardId": row["board_id"] as String?, "message": row["message"] as String?, "propertiesJson": row["properties_json"] as String, ] diff --git a/modules/vescape-core/src/e2eFake.ts b/modules/vescape-core/src/e2eFake.ts index 757b935b9..fb5ff21cb 100644 --- a/modules/vescape-core/src/e2eFake.ts +++ b/modules/vescape-core/src/e2eFake.ts @@ -3,6 +3,7 @@ import type { EventSubscription } from 'expo-modules-core' import type { AppSettings, Board, + BoardInput, BoardCandidate, BoardLink, CompanionPresenceBoard, @@ -304,8 +305,8 @@ function getTelemetryHistory(options: TelemetryHistoryOptions): TelemetryMinuteB if (options.toMs != null) { buckets = buckets.filter((b) => b.startAtMs <= options.toMs!) } - if (options.deviceId != null) { - buckets = buckets.filter((b) => b.deviceId === options.deviceId) + if (options.boardId != null) { + buckets = buckets.filter((b) => b.boardId === options.boardId) } if (options.cursorBeforeMs != null) { buckets = buckets.filter((b) => b.bucketStartMs < options.cursorBeforeMs!) @@ -321,9 +322,9 @@ function getRideHistoryPage(options: { limit?: number; cursorBeforeMs?: number } (a, b) => b.startAtMs - a.startAtMs, ) const sessions: RideHistorySession[] = buckets.map((bucket) => ({ - id: `${bucket.deviceId ?? 'unknown'}:${bucket.startAtMs}:${bucket.endAtMs}`, - deviceId: bucket.deviceId, - deviceName: bucket.deviceName, + id: `${bucket.boardId ?? 'unknown'}:${bucket.startAtMs}:${bucket.endAtMs}`, + boardId: bucket.boardId, + boardName: bucket.boardName, startAtMs: bucket.startAtMs, endAtMs: bucket.endAtMs, movingStartAtMs: bucket.firstMovingAtMs, @@ -367,20 +368,20 @@ function getRideHistoryPage(options: { limit?: number; cursorBeforeMs?: number } function encodeBoardSamples(samples: TelemetrySample[]): { boardColumns: ArrayBuffer boardCount: number - boardDevices: (string | null)[] - boardDeviceNames: string[] + boardIds: (string | null)[] + boardNames: string[] } { const lanes = new Float64Array(samples.length * SAMPLE_COLUMN_COUNT) - const boardDevices: (string | null)[] = [] - const boardDeviceNames: string[] = [] + const boardIds: (string | null)[] = [] + const boardNames: string[] = [] const deviceIndexMap = new Map() - function deviceIndex(deviceId: string | null, deviceName: string): number { - const key = `${deviceId ?? ''}:${deviceName}` + function boardIndex(boardId: string | null, boardName: string): number { + const key = `${boardId ?? ''}:${boardName}` let index = deviceIndexMap.get(key) if (index == null) { - index = boardDevices.length - boardDevices.push(deviceId) - boardDeviceNames.push(deviceName) + index = boardIds.length + boardIds.push(boardId) + boardNames.push(boardName) deviceIndexMap.set(key, index) } return index @@ -391,7 +392,7 @@ function encodeBoardSamples(samples: TelemetrySample[]): { const o = i * SAMPLE_COLUMN_COUNT lanes[o + 0] = s.id lanes[o + 1] = s.capturedAtMs - lanes[o + 2] = deviceIndex(s.deviceId, s.deviceName) + lanes[o + 2] = boardIndex(s.boardId, s.boardName) lanes[o + 3] = s.speedKmh lanes[o + 4] = s.batteryVoltage lanes[o + 5] = s.batteryPercent ?? NaN @@ -417,21 +418,21 @@ function encodeBoardSamples(samples: TelemetrySample[]): { return { boardColumns: lanes.buffer, boardCount: samples.length, - boardDevices, - boardDeviceNames, + boardIds, + boardNames, } } function getHistoryRange(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): { boardColumns: ArrayBuffer boardCount: number - boardDevices: (string | null)[] - boardDeviceNames: string[] + boardIds: (string | null)[] + boardNames: string[] gpsSamples: HistoryGpsSample[] markers: HistoryMarker[] exclusions: MetricExclusion[] @@ -439,8 +440,8 @@ function getHistoryRange(options: { let samples = historySamples.filter( (s) => s.capturedAtMs >= options.fromMs && s.capturedAtMs <= options.toMs, ) - if (options.deviceId != null) { - samples = samples.filter((s) => s.deviceId === options.deviceId) + if (options.boardId != null) { + samples = samples.filter((s) => s.boardId === options.boardId) } if (options.limit != null && options.limit > 0) { samples = samples.slice(0, options.limit) @@ -449,15 +450,17 @@ function getHistoryRange(options: { let gps = historyGps.filter( (g) => g.capturedAtMs >= options.fromMs && g.capturedAtMs <= options.toMs, ) - if (options.deviceId != null) { - gps = gps.filter((g) => g.deviceId === options.deviceId) + if (options.boardId != null) { + gps = gps.filter((g) => g.boardId === options.boardId) } let markers = historyMarkers.filter( (m) => m.occurredAtMs >= options.fromMs && m.occurredAtMs <= options.toMs, ) - if (options.deviceId != null) { - markers = markers.filter((m) => m.deviceId === options.deviceId) + // Markers still key on the BLE identifier (ADR 0028); the fake models one Board per install, so + // the Board-scoped filter maps straight onto it. + if (options.boardId != null) { + markers = markers.filter((m) => m.boardId === options.boardId) } const encoded = encodeBoardSamples(samples) @@ -493,7 +496,7 @@ interface RideSeed { startLongitude: number } -function seedHistoryData(deviceId: string, deviceName: string): void { +function seedHistoryData(boardId: string, boardName: string): void { clearTelemetryHistory() const now = Date.now() @@ -529,7 +532,7 @@ function seedHistoryData(deviceId: string, deviceName: string): void { ] for (const ride of rides) { - addHistoryRide(now + ride.startOffsetMs, ride.durationMs, ride, deviceId, deviceName) + addHistoryRide(now + ride.startOffsetMs, ride.durationMs, ride, boardId, boardName) } } @@ -537,8 +540,8 @@ function addHistoryRide( rideStartMs: number, durationMs: number, ride: RideSeed, - deviceId: string, - deviceName: string, + boardId: string, + boardName: string, ): void { const rideEndMs = rideStartMs + durationMs const sampleCount = 60 @@ -549,8 +552,8 @@ function addHistoryRide( startAtMs: rideStartMs, endAtMs: rideEndMs, bucketStartMs: rideStartMs, - deviceId, - deviceName, + boardId, + boardName, sampleCount, gpsPointCount, preciseGpsPointCount: gpsPointCount, @@ -582,8 +585,8 @@ function addHistoryRide( historySamples.push({ id: nextHistorySampleId++, capturedAtMs: t, - deviceId, - deviceName, + boardId, + boardName, speedKmh: ride.avgSpeedKmh * 0.6 + progress * (ride.maxSpeedKmh - ride.avgSpeedKmh * 0.6), batteryVoltage: 75.6 - progress * 1.6, batteryPercent: 75 - progress * 2, @@ -612,8 +615,8 @@ function addHistoryRide( historyGps.push({ id: nextHistoryGpsId++, capturedAtMs: rideStartMs + progress * durationMs, - deviceId, - deviceName, + boardId, + boardName, latitude: ride.startLatitude + progress * 0.01, longitude: ride.startLongitude + progress * 0.01, speedMps: 5 + progress * 5, @@ -630,8 +633,7 @@ function addHistoryRide( id: nextHistoryMarkerId++, occurredAtMs: rideStartMs, type: 'connected', - deviceId, - deviceName, + boardId, message: null, gapMs: null, }) @@ -639,8 +641,7 @@ function addHistoryRide( id: nextHistoryMarkerId++, occurredAtMs: rideEndMs, type: 'disconnected', - deviceId, - deviceName, + boardId, message: null, gapMs: null, }) @@ -749,12 +750,17 @@ export const e2eFake = { return [...e2eBoards] }, - upsertBoard(board: Board): void { + upsertBoard(board: BoardInput): void { const index = e2eBoards.findIndex((b) => b.id === board.id) + // A tombstone survives an upsert, like native — only a delete stamps one. + const stored: Board = { + ...board, + deletedAt: index >= 0 ? e2eBoards[index].deletedAt : null, + } if (index >= 0) { - e2eBoards[index] = board + e2eBoards[index] = stored } else { - e2eBoards.push(board) + e2eBoards.push(stored) } }, @@ -798,6 +804,7 @@ export const e2eFake = { name: 'E2E Board', description: 'Seeded by Maestro', createdAt: Date.now(), + deletedAt: null, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', @@ -819,6 +826,7 @@ export const e2eFake = { name: 'E2E History Board', description: 'Seeded by Maestro', createdAt: Date.now(), + deletedAt: null, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', @@ -841,6 +849,7 @@ export const e2eFake = { name: 'E2E Privacy Board', description: 'Seeded by Maestro', createdAt: Date.now(), + deletedAt: null, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 14ff3a6c3..6ebd95020 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -188,6 +188,13 @@ export interface Board { name: string description: string | null createdAt: number + /** + * Tombstone stamp: epoch ms of the rider's delete, `null` while the Board is alive. A deleted + * Board keeps its row so Ride History can still name it (ADR 0027) — {@link getBoards} filters + * tombstones, {@link getBoard} deliberately does not. Native-owned: deletion goes through + * {@link deleteBoard}, never through an upsert. + */ + deletedAt: number | null batteryConfig: BatteryConfig | null /** Last Battery SoC Estimate persisted natively; survives full app kill. `undefined` before first session. */ lastBattery?: LastBattery | null @@ -233,6 +240,12 @@ export interface Board { link: BoardLink | null } +/** + * Write shape for {@link upsertBoard}. A tombstone is stamped by {@link deleteBoard} alone, and an + * upsert never clears the one already on the row, so callers never author `deletedAt`. + */ +export type BoardInput = Omit + export interface LastBattery { percent: number voltage: number | null @@ -574,7 +587,8 @@ export interface LiveStateEvent { export interface TelemetryHistoryOptions { fromMs?: number toMs?: number - deviceId?: string + /** Scope to one Board (`boards.id`). Telemetry is keyed on the Board, not the BLE id (ADR 0028). */ + boardId?: string limit?: number cursorBeforeMs?: number } @@ -582,14 +596,14 @@ export interface TelemetryHistoryOptions { export interface DiagnosticEventOptions { fromMs?: number toMs?: number - deviceId?: string + boardId?: string limit?: number } export interface TelemetryDeleteRangeOptions { fromMs: number toMs: number - deviceId?: string | null + boardId?: string | null } export interface TelemetryMinuteBucket { @@ -597,8 +611,10 @@ export interface TelemetryMinuteBucket { startAtMs: number endAtMs: number bucketStartMs: number - deviceId: string | null - deviceName: string + /** Owning Board (`boards.id`), or null when the samples match no saved Board. */ + boardId: string | null + /** Resolved from `boards` on read, never stored on the row — a rename relabels history. */ + boardName: string sampleCount: number gpsPointCount: number preciseGpsPointCount: number @@ -635,8 +651,8 @@ export interface TelemetryMinuteBucket { export interface TelemetrySample { id: number capturedAtMs: number - deviceId: string | null - deviceName: string + boardId: string | null + boardName: string speedKmh: number batteryVoltage: number /** IR-compensated battery %, derived on read from the board's battery config. Null if no config. */ @@ -663,8 +679,8 @@ export interface TelemetrySample { export interface HistoryGpsSample { id: number capturedAtMs: number - deviceId: string | null - deviceName: string + boardId: string | null + boardName: string latitude: number longitude: number speedMps: number | null @@ -687,15 +703,16 @@ export interface HistoryMarker { | 'gap' | 'app_stop' | 'auto_pause' - deviceId: string | null - deviceName: string | null + /** Owning Board (`boards.id`); null when the Marker was written with no Board connected. */ + boardId: string | null message: string | null gapMs: number | null } export interface MetricExclusion { id: number - deviceId: string | null + /** Owning Board (`boards.id`). A range excludes one Board's samples, so it is never absent. */ + boardId: string reason: string startMs: number endMs: number @@ -732,8 +749,8 @@ const SAMPLE_COLUMN_COUNT = 23 interface NativeHistoryRange { boardColumns: ArrayBuffer boardCount: number - boardDevices: (string | null)[] - boardDeviceNames: string[] + boardIds: (string | null)[] + boardNames: string[] chartColumns?: ArrayBuffer chartCount?: number gpsSamples: HistoryGpsSample[] @@ -757,18 +774,18 @@ function decodeBoardSamples( columns: ArrayBuffer = range.boardColumns, count: number = range.boardCount, ): TelemetrySample[] { - const { boardDevices, boardDeviceNames } = range + const { boardIds, boardNames } = range if (!count || !columns) return [] const lanes = new Float64Array(columns) const samples = new Array(count) for (let i = 0; i < count; i++) { const o = i * SAMPLE_COLUMN_COUNT - const deviceIndex = lanes[o + 2] + const boardIndex = lanes[o + 2] samples[i] = { id: lanes[o], capturedAtMs: lanes[o + 1], - deviceId: boardDevices[deviceIndex] ?? null, - deviceName: boardDeviceNames[deviceIndex], + boardId: boardIds[boardIndex] ?? null, + boardName: boardNames[boardIndex], speedKmh: lanes[o + 3], batteryVoltage: lanes[o + 4], batteryPercent: nullableLane(lanes[o + 5]), @@ -851,7 +868,7 @@ export interface Favorite { export interface CreateFavoriteOptions { startMs: number endMs: number - deviceId?: string + boardId?: string name?: string } @@ -862,7 +879,7 @@ export interface CreateFavoriteOptions { export interface UpdateFavoriteOptions { startMs: number endMs: number - deviceId?: string + boardId?: string name: string | null } @@ -981,8 +998,10 @@ export interface RideRoutePoint { */ export interface RideHistorySession { id: string - deviceId: string | null - deviceName: string + /** Owning Board (`boards.id`), or null when the ride matches no saved Board. */ + boardId: string | null + /** Resolved from `boards` on read, never stored on the row — a rename relabels history. */ + boardName: string startAtMs: number endAtMs: number movingStartAtMs: number | null @@ -1176,8 +1195,8 @@ export interface LocalDiagnosticEvent { eventName: string operation: string | null phase: string | null - deviceId: string | null - deviceName: string | null + /** Owning Board (`boards.id`); null when the event was recorded with no Board connected. */ + boardId: string | null message: string | null propertiesJson: string } @@ -2057,13 +2076,13 @@ type VescapeCoreNativeModule = NativeEventEmitter & { getTelemetrySamples(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): Promise getHistoryRange(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): Promise getTelemetrySummary(): Promise @@ -2629,7 +2648,7 @@ export async function getRideHistoryPage( export async function getTelemetrySamples(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): Promise { if (E2E_ENABLED) { @@ -2642,7 +2661,7 @@ export async function getTelemetrySamples(options: { export async function getHistoryRange(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): Promise { const range: NativeHistoryRange = E2E_ENABLED diff --git a/src/app/settings/eventLog.tsx b/src/app/settings/eventLog.tsx index a63547b34..d32ed2faa 100644 --- a/src/app/settings/eventLog.tsx +++ b/src/app/settings/eventLog.tsx @@ -98,7 +98,7 @@ interface EventItemProps { function EventItem({ event, expanded, onToggle }: EventItemProps) { const time = new Date(event.occurredAtMs).toLocaleTimeString() - const meta = [event.operation, event.phase, event.deviceName].filter(Boolean).join(' · ') + const meta = [event.operation, event.phase].filter(Boolean).join(' · ') const dotColor = getEventColor(event.eventName) return ( @@ -122,11 +122,11 @@ function EventItem({ event, expanded, onToggle }: EventItemProps) { {new Date(event.occurredAtMs).toLocaleString()} - {event.deviceId ? ( + {event.boardId ? ( <> - deviceId + boardId - {event.deviceId} + {event.boardId} ) : null} diff --git a/src/modules/alerts/store/alertPresetStore.test.ts b/src/modules/alerts/store/alertPresetStore.test.ts index 1a398d8b8..6919c7b25 100644 --- a/src/modules/alerts/store/alertPresetStore.test.ts +++ b/src/modules/alerts/store/alertPresetStore.test.ts @@ -33,6 +33,7 @@ function makeBoard(overrides?: { name: 'Board', description: null, createdAt: 1, + deletedAt: null, // Honor an explicit `null` (invalid config) — `??` would swallow it back to the valid default. batteryConfig: overrides && 'batteryConfig' in overrides ? (overrides.batteryConfig ?? null) : VALID_BATTERY, diff --git a/src/modules/board/store/boardStore.test.ts b/src/modules/board/store/boardStore.test.ts index 7d45a8a96..048c6f28d 100644 --- a/src/modules/board/store/boardStore.test.ts +++ b/src/modules/board/store/boardStore.test.ts @@ -111,6 +111,7 @@ test('stored Board Link survives a store reload from native boards', async () => name: 'ADV', description: null, createdAt: 1, + deletedAt: null, batteryConfig: null, link: null, } @@ -140,6 +141,7 @@ test('updated battery config survives a store reload from native boards', async name: 'ADV', description: null, createdAt: 1, + deletedAt: null, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', diff --git a/src/modules/board/store/boardStore.ts b/src/modules/board/store/boardStore.ts index 6196ebeb1..f49648844 100644 --- a/src/modules/board/store/boardStore.ts +++ b/src/modules/board/store/boardStore.ts @@ -92,6 +92,7 @@ export const useBoardStore = create((set, get) => ({ name, description: description ?? null, createdAt: Date.now(), + deletedAt: null, batteryConfig: batteryConfig ?? DEFAULT_BATTERY_CONFIG, topSpeedKmh, alertPreset: alertPreset ?? null, diff --git a/src/modules/history/components/HistoryPanelNav.tsx b/src/modules/history/components/HistoryPanelNav.tsx index e80e1a00e..12d250ab7 100644 --- a/src/modules/history/components/HistoryPanelNav.tsx +++ b/src/modules/history/components/HistoryPanelNav.tsx @@ -18,7 +18,7 @@ import { formatRideMeta, formatRideTime } from '@/modules/history/lib/rideFormat interface HistoryPanelNavProps { titleStartMs: number titleEndMs: number - deviceName: string + boardName: string title?: string subtitle?: string canPrevious: boolean @@ -43,7 +43,7 @@ interface HistoryPanelNavProps { export function HistoryPanelNav({ titleStartMs, titleEndMs, - deviceName, + boardName, title, subtitle, canPrevious, @@ -64,7 +64,7 @@ export function HistoryPanelNav({ onOpenCharts, }: HistoryPanelNavProps) { const primaryLabel = title ?? formatRideTime(titleStartMs, titleEndMs) - const secondaryLabel = subtitle ?? formatRideMeta(titleStartMs, titleEndMs, deviceName) + const secondaryLabel = subtitle ?? formatRideMeta(titleStartMs, titleEndMs, boardName) return ( diff --git a/src/modules/history/components/HistorySessionSheet.tsx b/src/modules/history/components/HistorySessionSheet.tsx index a97d0e8bc..0394236e9 100644 --- a/src/modules/history/components/HistorySessionSheet.tsx +++ b/src/modules/history/components/HistorySessionSheet.tsx @@ -69,7 +69,7 @@ export function HistorySessionSheet({ const details = formatRideListDetails( rideWindow.endMs - rideWindow.startMs, session.distanceM, - favorite?.boardName ?? session.deviceName, + favorite?.boardName ?? session.boardName, ) return ( ): TelemetryMinuteBucke startAtMs: 1_100_000, endAtMs: 1_160_000, bucketStartMs: 1_100_000, - deviceId: 'ble-1', - deviceName: 'VESC Board', + boardId: 'ble-1', + boardName: 'VESC Board', sampleCount: 60, gpsPointCount: 10, preciseGpsPointCount: 8, @@ -122,14 +122,14 @@ test('a favorite-backed session reports the pinned range and the pinned summary' expect(detail.blockIds).toEqual(['inside', 'tail']) expect(detail.minLatitude).toBe(52) expect(detail.maxLatitude).toBe(53) - expect(detail.deviceId).toBe('ble-1') + expect(detail.boardId).toBe('ble-1') }) test('a favorite-backed session keeps board identity separate from its name', () => { - expect(favoriteToSession(favorite({ name: 'Dolina single track' }), []).deviceName).toBe( + expect(favoriteToSession(favorite({ name: 'Dolina single track' }), []).boardName).toBe( 'Onewheel', ) - expect(favoriteToSession(favorite({}), []).deviceName).toBe('Onewheel') + expect(favoriteToSession(favorite({}), []).boardName).toBe('Onewheel') }) test('a favorite whose buckets are not loaded still yields a detail session', () => { diff --git a/src/modules/history/lib/favorites.ts b/src/modules/history/lib/favorites.ts index 7c15df2e4..754ace7a9 100644 --- a/src/modules/history/lib/favorites.ts +++ b/src/modules/history/lib/favorites.ts @@ -77,8 +77,8 @@ export function favoriteToSession( const longitudes = routePoints.map((point) => point.longitude).filter(isFinitePoint) return { id: favoriteSessionId(favorite.id), - deviceId: spanned.find((block) => block.deviceId != null)?.deviceId ?? null, - deviceName: favorite.boardName ?? spanned[0]?.deviceName ?? '', + boardId: spanned.find((block) => block.boardId != null)?.boardId ?? null, + boardName: favorite.boardName ?? spanned[0]?.boardName ?? '', startAtMs: favorite.startMs, endAtMs: favorite.endMs, // A Favorite is already a trimmed span: it is its own Moving Window, so the chart and the title diff --git a/src/modules/history/lib/historyMapMarkerInfo.ts b/src/modules/history/lib/historyMapMarkerInfo.ts index 8560187ba..2d6b7b780 100644 --- a/src/modules/history/lib/historyMapMarkerInfo.ts +++ b/src/modules/history/lib/historyMapMarkerInfo.ts @@ -78,7 +78,6 @@ export function buildHistoryMarkerMessage(selection: SelectedHistoryMarker): str ] if (gps.accuracyM != null) lines.push(`GPS accuracy: ${gps.accuracyM.toFixed(1)} m`) - if (marker.deviceName) lines.push(`Board: ${marker.deviceName}`) if (marker.gapMs != null) lines.push(`Gap duration: ${formatDuration(marker.gapMs)}`) if (marker.message) lines.push(`Message: ${marker.message}`) diff --git a/src/modules/history/lib/markerOverlap.test.ts b/src/modules/history/lib/markerOverlap.test.ts index 68bd1b804..146dba90d 100644 --- a/src/modules/history/lib/markerOverlap.test.ts +++ b/src/modules/history/lib/markerOverlap.test.ts @@ -6,8 +6,8 @@ function makeGps(id: number, capturedAtMs: number, lat: number, lng: number): Hi return { id, capturedAtMs, - deviceId: null, - deviceName: 'test', + boardId: null, + boardName: 'test', latitude: lat, longitude: lng, speedMps: null, @@ -21,7 +21,7 @@ function makeGps(id: number, capturedAtMs: number, lat: number, lng: number): Hi } function makeMarker(id: number, occurredAtMs: number, type: HistoryMarker['type']): HistoryMarker { - return { id, occurredAtMs, type, deviceId: null, deviceName: null, message: null, gapMs: null } + return { id, occurredAtMs, type, boardId: null, message: null, gapMs: null } } describe('resolveMarkerRenderData', () => { diff --git a/src/modules/history/lib/mediaHistory.test.ts b/src/modules/history/lib/mediaHistory.test.ts index f96a37660..43d61d659 100644 --- a/src/modules/history/lib/mediaHistory.test.ts +++ b/src/modules/history/lib/mediaHistory.test.ts @@ -15,8 +15,8 @@ function gps(id: number, capturedAtMs: number, latitude = 52, longitude = 21): H return { id, capturedAtMs, - deviceId: 'board', - deviceName: 'Board', + boardId: 'board', + boardName: 'Board', latitude, longitude, speedMps: null, @@ -44,8 +44,7 @@ function marker(occurredAtMs: number, type: HistoryMarker['type']): HistoryMarke id: occurredAtMs, occurredAtMs, type, - deviceId: null, - deviceName: null, + boardId: null, message: null, gapMs: null, } diff --git a/src/modules/history/lib/rideFormat.ts b/src/modules/history/lib/rideFormat.ts index c984d7e60..8e4146af6 100644 --- a/src/modules/history/lib/rideFormat.ts +++ b/src/modules/history/lib/rideFormat.ts @@ -24,9 +24,9 @@ export function formatRideDate(startMs: number, endMs: number): string { return `${s.getDate()} ${MONTHS[s.getMonth()]} – ${e.getDate()} ${MONTHS[e.getMonth()]} ${e.getFullYear()}` } -export function formatRideMeta(startAtMs: number, endAtMs: number, deviceName: string): string { - return deviceName - ? `${formatRideDate(startAtMs, endAtMs)} · ${deviceName}` +export function formatRideMeta(startAtMs: number, endAtMs: number, boardName: string): string { + return boardName + ? `${formatRideDate(startAtMs, endAtMs)} · ${boardName}` : formatRideDate(startAtMs, endAtMs) } @@ -37,12 +37,12 @@ export function formatRideListDateTime(startAtMs: number, endAtMs: number, live export function formatRideListDetails( durationMs: number, distanceM: number | null, - deviceName: string | null, + boardName: string | null, ): string { return [ formatRideListDuration(durationMs), distanceM == null ? null : `${(distanceM / 1000).toFixed(2)} km`, - deviceName?.trim() || null, + boardName?.trim() || null, ] .filter((part): part is string => part != null) .join(' · ') diff --git a/src/modules/history/lib/sessions.ts b/src/modules/history/lib/sessions.ts index a3a4436fe..5c83eeeb5 100644 --- a/src/modules/history/lib/sessions.ts +++ b/src/modules/history/lib/sessions.ts @@ -52,7 +52,7 @@ export function matchRideSession( sessions.find( (session) => session.id === selected.id || - (session.deviceId === selected.deviceId && + (session.boardId === selected.boardId && session.startAtMs <= selected.endAtMs && session.endAtMs >= selected.startAtMs), ) ?? null diff --git a/src/modules/history/screens/HistoryChartsScreen.tsx b/src/modules/history/screens/HistoryChartsScreen.tsx index 39e77872e..f400ba36b 100644 --- a/src/modules/history/screens/HistoryChartsScreen.tsx +++ b/src/modules/history/screens/HistoryChartsScreen.tsx @@ -136,7 +136,7 @@ export function HistoryChartsScreen() { {formatRideTime(session.startAtMs, session.endAtMs)} - {formatRideMeta(session.startAtMs, session.endAtMs, session.deviceName)} + {formatRideMeta(session.startAtMs, session.endAtMs, session.boardName)} ) : null} diff --git a/src/modules/history/store/historySelectionSlice.ts b/src/modules/history/store/historySelectionSlice.ts index 4ddbf6179..d6239d861 100644 --- a/src/modules/history/store/historySelectionSlice.ts +++ b/src/modules/history/store/historySelectionSlice.ts @@ -15,7 +15,7 @@ function getSessionRangeOptions(session: HistorySession) { return { fromMs: session.startAtMs, toMs: session.endAtMs, - ...(session.deviceId ? { deviceId: session.deviceId } : {}), + ...(session.boardId ? { boardId: session.boardId } : {}), } } @@ -59,7 +59,7 @@ export const createHistorySelectionSlice: SliceFactory = (set, get) => ({ const range = await getHistoryRange({ fromMs: block.startAtMs, toMs: block.endAtMs, - ...(block.deviceId ? { deviceId: block.deviceId } : {}), + ...(block.boardId ? { boardId: block.boardId } : {}), limit: 500, }) set({ diff --git a/src/modules/history/store/historyStore.test.ts b/src/modules/history/store/historyStore.test.ts index d765e4130..27f0b3b6f 100644 --- a/src/modules/history/store/historyStore.test.ts +++ b/src/modules/history/store/historyStore.test.ts @@ -38,9 +38,9 @@ function sessionFromBucket(bucket: TelemetryMinuteBucket): RideHistorySession { ? [{ latitude: bucket.firstLatitude, longitude: bucket.firstLongitude }] : [] return { - id: `${bucket.deviceId ?? 'unknown'}:${bucket.startAtMs}:${bucket.endAtMs}`, - deviceId: bucket.deviceId, - deviceName: bucket.deviceName, + id: `${bucket.boardId ?? 'unknown'}:${bucket.startAtMs}:${bucket.endAtMs}`, + boardId: bucket.boardId, + boardName: bucket.boardName, startAtMs: bucket.startAtMs, endAtMs: bucket.endAtMs, movingStartAtMs: bucket.firstMovingAtMs, @@ -191,7 +191,7 @@ test('removes selected session from history and selects next ride', async () => expect(deleteTelemetryRange).toHaveBeenCalledWith({ fromMs: selected.startAtMs, toMs: selected.endAtMs, - deviceId: selected.deviceId, + boardId: selected.boardId, }) expect(useHistoryStore.getState().blocks.map((b) => b.id)).toEqual(['newest', 'oldest']) expect(useHistoryStore.getState().sessions.map((s) => s.id)).toHaveLength(2) @@ -256,7 +256,7 @@ test('selects ride immediately while loading its full route', async () => { expect(getHistoryRange).toHaveBeenLastCalledWith({ fromMs: next.startAtMs, toMs: next.endAtMs, - deviceId: next.deviceId, + boardId: next.boardId, limit: next.sampleCount + 1, }) @@ -267,8 +267,8 @@ test('selects ride immediately while loading its full route', async () => { gpsSamples: Array.from({ length: next.gpsPointCount }, (_, index) => ({ id: index + 1, capturedAtMs: next.startAtMs + index, - deviceId: next.deviceId, - deviceName: next.deviceName, + boardId: next.boardId, + boardName: next.boardName, latitude: 51 + index * 0.001, longitude: 17 + index * 0.001, speedMps: null, @@ -346,8 +346,8 @@ test('loads a small GPS preview when selected ride has no bucket coordinate', as const previewGps: HistoryGpsSample = { id: 1, capturedAtMs: ride.startAtMs, - deviceId: ride.deviceId, - deviceName: ride.deviceName, + boardId: ride.boardId, + boardName: ride.boardName, latitude: 51, longitude: 17, speedMps: null, @@ -376,8 +376,8 @@ test('loads a small GPS preview when selected ride has no bucket coordinate', as const { useHistoryStore } = await import('@/modules/history/store/historyStore') const select = useHistoryStore.getState().selectSession({ - deviceId: ride.deviceId, - deviceName: ride.deviceName, + boardId: ride.boardId, + boardName: ride.boardName, boundaryBefore: ride.boundaryBefore, startAtMs: ride.startAtMs, endAtMs: ride.endAtMs, @@ -405,14 +405,14 @@ test('loads a small GPS preview when selected ride has no bucket coordinate', as minLongitude: null, maxLongitude: null, routePoints: [], - id: `${ride.deviceId}:${ride.startAtMs}:${ride.endAtMs}`, + id: `${ride.boardId}:${ride.startAtMs}:${ride.endAtMs}`, }) await Promise.resolve() expect(getHistoryRange).toHaveBeenNthCalledWith(1, { fromMs: ride.startAtMs, toMs: ride.endAtMs, - deviceId: ride.deviceId, + boardId: ride.boardId, limit: 240, }) expect(getHistoryRange).toHaveBeenCalledTimes(1) diff --git a/src/modules/history/store/historyStore.ts b/src/modules/history/store/historyStore.ts index 71ef704e5..7ae77be73 100644 --- a/src/modules/history/store/historyStore.ts +++ b/src/modules/history/store/historyStore.ts @@ -162,7 +162,7 @@ export const useHistoryStore = create((set, get) => ({ await deleteTelemetryRange({ fromMs: selectedSession.startAtMs, toMs: selectedSession.endAtMs, - deviceId: selectedSession.deviceId, + boardId: selectedSession.boardId, }) const selectedIndex = sessions.findIndex((session) => session.id === selectedSession.id) const [blocks, page] = await Promise.all([ diff --git a/src/screens/main/history/HistoryRideDetail.tsx b/src/screens/main/history/HistoryRideDetail.tsx index 2cccf4a71..d0cd2a366 100644 --- a/src/screens/main/history/HistoryRideDetail.tsx +++ b/src/screens/main/history/HistoryRideDetail.tsx @@ -92,7 +92,7 @@ export function HistoryRideDetail({ endAtMs={session.endAtMs} movingStartAtMs={session.movingStartAtMs} movingEndAtMs={session.movingEndAtMs} - deviceName={session.deviceName} + boardName={session.boardName} navigationTitle={ openFavorite ? formatFavoriteName(openFavorite.name, openFavorite.startMs, openFavorite.endMs) diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index 32f027bcb..80fcea44a 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -54,7 +54,7 @@ interface HistoryTelemetryPanelProps { endAtMs: number movingStartAtMs: number | null movingEndAtMs: number | null - deviceName: string + boardName: string navigationTitle?: string navigationSubtitle?: string /** Full-density samples retained for recording-continuity and GPS-gap detection. */ @@ -101,7 +101,7 @@ export const HistoryTelemetryPanel = memo(function HistoryTelemetryPanel({ endAtMs, movingStartAtMs, movingEndAtMs, - deviceName, + boardName, navigationTitle, navigationSubtitle, gpsGapSamples, @@ -307,7 +307,7 @@ export const HistoryTelemetryPanel = memo(function HistoryTelemetryPanel({ - session.deviceId === selected.deviceId && + session.boardId === selected.boardId && session.startAtMs <= selected.endAtMs && session.endAtMs >= selected.startAtMs, ) diff --git a/src/screens/showcase/mapShowcaseFixtures.ts b/src/screens/showcase/mapShowcaseFixtures.ts index 35f8866a8..487f0fbd2 100644 --- a/src/screens/showcase/mapShowcaseFixtures.ts +++ b/src/screens/showcase/mapShowcaseFixtures.ts @@ -72,8 +72,8 @@ export const FIXTURE_RIDE_GPS_SAMPLES: HistoryGpsSample[] = rideRouteCoordinates return { id: index + 1, capturedAtMs: NOW - (ROUTE_POINT_COUNT - 1 - index) * 5_000, - deviceId: 'fixture-board', - deviceName: 'Fixture Board', + boardId: 'fixture-board', + boardName: 'Fixture Board', latitude, longitude, speedMps: 3 + Math.sin(t * Math.PI * 2) * 2.5, @@ -101,8 +101,8 @@ export const FIXTURE_RIDE_TELEMETRY_SAMPLES: TelemetrySample[] = FIXTURE_RIDE_GP return { id: index + 1, capturedAtMs: gps.capturedAtMs, - deviceId: 'fixture-board', - deviceName: 'Fixture Board', + boardId: 'fixture-board', + boardName: 'Fixture Board', speedKmh, batteryVoltage: 58 - t * 4, batteryPercent: 80 - t * 30, @@ -142,8 +142,7 @@ export const FIXTURE_RIDE_MARKERS: HistoryMarker[] = MARKER_TYPES.map((type, ind occurredAtMs: FIXTURE_RIDE_GPS_SAMPLES[(2 + index * 2) % FIXTURE_RIDE_GPS_SAMPLES.length].capturedAtMs, type, - deviceId: 'fixture-board', - deviceName: 'Fixture Board', + boardId: 'fixture-board', message: type === 'error' ? 'Fixture fault for preview' : null, gapMs: type === 'gap' ? 15_000 : null, })) diff --git a/src/test-utils/factories.ts b/src/test-utils/factories.ts index 81cc350f5..36fd4fe2a 100644 --- a/src/test-utils/factories.ts +++ b/src/test-utils/factories.ts @@ -5,8 +5,8 @@ const BLOCK_DEFAULTS: TelemetryMinuteBucket = { startAtMs: 0, endAtMs: 60_000, bucketStartMs: 0, - deviceId: 'dev-a', - deviceName: 'Board A', + boardId: 'dev-a', + boardName: 'Board A', sampleCount: 10, gpsPointCount: 5, preciseGpsPointCount: 4, @@ -51,8 +51,8 @@ export function makeBlock(overrides: Partial = {}): Telem const SAMPLE_DEFAULTS: TelemetrySample = { id: 1, capturedAtMs: 0, - deviceId: 'dev-a', - deviceName: 'Board A', + boardId: 'dev-a', + boardName: 'Board A', speedKmh: 0, batteryVoltage: 50, batteryPercent: null,