Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**.
Expand Down
20 changes: 20 additions & 0 deletions docs/adr/0027-boards-are-tombstoned-never-deleted.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions docs/adr/0028-telemetry-is-keyed-on-board-id.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 16 additions & 16 deletions docs/native-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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,
Expand All @@ -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?,
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any?>
val bleId = link?.get("bleId") as? String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
}
Expand Down
Loading
Loading