import type { CompanionDatabase } from "./database.js"; import { collectCarTelemetry, type AdbConfig, type CarTelemetry } from "./adb.js"; const SAMPLE_INTERVAL_MS = 10_000; const RAW_RETENTION_MS = 90 * 24 * 60 * 60 * 1_000; const ROLLUP_RETENTION_MS = 10 * 365 * 24 * 60 * 60 * 1_000; const MAX_RAW_SAMPLES = 800_000; const MAX_HOURLY_ROLLUPS = 88_000; const BATTERY_USABLE_KWH = 61.7; type StoredSample = { recordedAt: number; speedKph: number | null; wheelAngleDegrees: number | null; batteryPercent: number | null; rangeKm: number | null; batteryVoltage: number | null; totalConsumptionKwh: number | null; chargingStatus: number | null; chargingPowerKw: number | null; }; export type TelemetryPeriod = "24h" | "7d" | "30d" | "1y" | "all"; export type ClearTelemetryMode = "history" | "all"; export type TelemetryHistoryPoint = { timestamp: number; speedKph: number | null; peakSpeedKph: number | null; batteryPercent: number | null; rangeKm: number | null; chargingPowerKw: number | null; }; function value(field: { available: true; value: T } | { available: false; reason: string }): T | null { return field.available ? field.value : null; } function finite(value_: unknown): number | null { return typeof value_ === "number" && Number.isFinite(value_) ? value_ : null; } function rowToStored(row: Record | undefined): StoredSample | null { if (!row) return null; return { recordedAt: Number(row.recorded_at), speedKph: finite(row.speed_kph), wheelAngleDegrees: finite(row.wheel_angle_degrees), batteryPercent: finite(row.battery_percent), rangeKm: finite(row.range_km), batteryVoltage: finite(row.battery_voltage), totalConsumptionKwh: finite(row.total_consumption_kwh), chargingStatus: finite(row.charging_status), chargingPowerKw: finite(row.charging_power_kw) }; } export class CarTelemetryStore { #samplesSincePrune = 0; constructor(private readonly database: CompanionDatabase) {} record(telemetry: CarTelemetry): StoredSample { const recordedAt = Date.parse(telemetry.collectedAt); const timestamp = Number.isFinite(recordedAt) ? recordedAt : Date.now(); const previous = rowToStored(this.database .prepare("SELECT * FROM car_telemetry_samples ORDER BY recorded_at DESC LIMIT 1") .get() as Record | undefined); const speedKph = value(telemetry.speedKph); const batteryPercent = value(telemetry.batteryPercent); const chargingStatus = value(telemetry.chargingStatus); const chargingPowerKw = this.#estimateChargingPower(timestamp, batteryPercent, chargingStatus); const sample: StoredSample = { recordedAt: timestamp, speedKph, wheelAngleDegrees: value(telemetry.wheelAngleDegrees), batteryPercent, rangeKm: value(telemetry.rangeKm), batteryVoltage: value(telemetry.batteryVoltage), totalConsumptionKwh: value(telemetry.totalConsumptionKwh), chargingStatus, chargingPowerKw }; const deltaSeconds = previous ? Math.max(0, (timestamp - previous.recordedAt) / 1_000) : 0; const contiguous = deltaSeconds > 0 && deltaSeconds <= 60; const averageSpeed = contiguous && speedKph !== null && previous !== null && previous.speedKph !== null ? (speedKph + previous.speedKph) / 2 : speedKph ?? 0; const distanceKm = contiguous && averageSpeed > 1 ? averageSpeed * deltaSeconds / 3_600 : 0; const drivingSeconds = distanceKm > 0 ? deltaSeconds : 0; const charging = chargingStatus !== null && chargingStatus > 0; const wasCharging = previous !== null && previous.chargingStatus !== null && previous.chargingStatus > 0; const chargingSeconds = contiguous && charging ? deltaSeconds : 0; const chargeSession = charging && !wasCharging ? 1 : 0; const socDrop = contiguous && !charging && batteryPercent !== null && previous !== null && previous.batteryPercent !== null ? Math.min(Math.max(previous.batteryPercent - batteryPercent, 0), 2) : 0; const transaction = this.database.transaction(() => { this.database.prepare( `INSERT OR REPLACE INTO car_telemetry_samples (recorded_at, speed_kph, wheel_angle_degrees, battery_percent, range_km, battery_voltage, total_consumption_kwh, charging_status, charging_power_kw) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( sample.recordedAt, sample.speedKph, sample.wheelAngleDegrees, sample.batteryPercent, sample.rangeKm, sample.batteryVoltage, sample.totalConsumptionKwh, sample.chargingStatus, sample.chargingPowerKw ); this.#updateRollup(sample, distanceKm, drivingSeconds, chargingSeconds); this.#updateStats(sample, distanceKm, drivingSeconds, chargingSeconds, chargeSession, socDrop); }); transaction(); this.#samplesSincePrune += 1; if (this.#samplesSincePrune >= 360) { this.prune(timestamp); this.#samplesSincePrune = 0; } return sample; } #estimateChargingPower(recordedAt: number, batteryPercent: number | null, chargingStatus: number | null): number | null { if (batteryPercent === null || chargingStatus === null || chargingStatus <= 0) return null; const baseline = this.database.prepare( `SELECT recorded_at, battery_percent FROM car_telemetry_samples WHERE recorded_at BETWEEN ? AND ? AND battery_percent IS NOT NULL ORDER BY recorded_at DESC LIMIT 1` ).get(recordedAt - 10 * 60_000, recordedAt - 2 * 60_000) as { recorded_at: number; battery_percent: number } | undefined; if (!baseline) return null; const elapsedHours = (recordedAt - baseline.recorded_at) / 3_600_000; const socGain = batteryPercent - baseline.battery_percent; if (elapsedHours <= 0 || socGain < 0.2) return null; const estimate = (socGain / 100) * BATTERY_USABLE_KWH / elapsedHours; return estimate > 0 && estimate <= 350 ? Math.round(estimate * 10) / 10 : null; } #updateRollup(sample: StoredSample, distanceKm: number, drivingSeconds: number, chargingSeconds: number): void { const bucketStart = Math.floor(sample.recordedAt / 3_600_000) * 3_600_000; const speedCount = sample.speedKph === null ? 0 : 1; const socCount = sample.batteryPercent === null ? 0 : 1; const rangeCount = sample.rangeKm === null ? 0 : 1; const powerCount = sample.chargingPowerKw === null ? 0 : 1; this.database.prepare( `INSERT INTO car_telemetry_hourly (bucket_start, sample_count, speed_sum, speed_count, speed_max, soc_sum, soc_count, soc_min, soc_max, range_sum, range_count, range_max, charge_power_sum, charge_power_count, charge_power_max, distance_km, driving_seconds, charging_seconds) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(bucket_start) DO UPDATE SET sample_count = sample_count + 1, speed_sum = speed_sum + excluded.speed_sum, speed_count = speed_count + excluded.speed_count, speed_max = CASE WHEN excluded.speed_max IS NULL THEN speed_max WHEN speed_max IS NULL OR excluded.speed_max > speed_max THEN excluded.speed_max ELSE speed_max END, soc_sum = soc_sum + excluded.soc_sum, soc_count = soc_count + excluded.soc_count, soc_min = CASE WHEN excluded.soc_min IS NULL THEN soc_min WHEN soc_min IS NULL OR excluded.soc_min < soc_min THEN excluded.soc_min ELSE soc_min END, soc_max = CASE WHEN excluded.soc_max IS NULL THEN soc_max WHEN soc_max IS NULL OR excluded.soc_max > soc_max THEN excluded.soc_max ELSE soc_max END, range_sum = range_sum + excluded.range_sum, range_count = range_count + excluded.range_count, range_max = CASE WHEN excluded.range_max IS NULL THEN range_max WHEN range_max IS NULL OR excluded.range_max > range_max THEN excluded.range_max ELSE range_max END, charge_power_sum = charge_power_sum + excluded.charge_power_sum, charge_power_count = charge_power_count + excluded.charge_power_count, charge_power_max = CASE WHEN excluded.charge_power_max IS NULL THEN charge_power_max WHEN charge_power_max IS NULL OR excluded.charge_power_max > charge_power_max THEN excluded.charge_power_max ELSE charge_power_max END, distance_km = distance_km + excluded.distance_km, driving_seconds = driving_seconds + excluded.driving_seconds, charging_seconds = charging_seconds + excluded.charging_seconds` ).run( bucketStart, sample.speedKph ?? 0, speedCount, sample.speedKph, sample.batteryPercent ?? 0, socCount, sample.batteryPercent, sample.batteryPercent, sample.rangeKm ?? 0, rangeCount, sample.rangeKm, sample.chargingPowerKw ?? 0, powerCount, sample.chargingPowerKw, distanceKm, drivingSeconds, chargingSeconds ); } #updateStats( sample: StoredSample, distanceKm: number, drivingSeconds: number, chargingSeconds: number, chargeSession: number, socDrop: number ): void { this.database.prepare( `UPDATE car_telemetry_stats SET first_sample_at = COALESCE(first_sample_at, ?), last_sample_at = ?, sample_count = sample_count + 1, peak_speed_at = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_at END, peak_speed_kph = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_kph END, peak_charging_power_at = CASE WHEN ? IS NOT NULL AND (peak_charging_power_kw IS NULL OR ? > peak_charging_power_kw) THEN ? ELSE peak_charging_power_at END, peak_charging_power_kw = CASE WHEN ? IS NOT NULL AND (peak_charging_power_kw IS NULL OR ? > peak_charging_power_kw) THEN ? ELSE peak_charging_power_kw END, max_observed_range_at = CASE WHEN ? IS NOT NULL AND (max_observed_range_km IS NULL OR ? > max_observed_range_km) THEN ? ELSE max_observed_range_at END, max_observed_range_km = CASE WHEN ? IS NOT NULL AND (max_observed_range_km IS NULL OR ? > max_observed_range_km) THEN ? ELSE max_observed_range_km END, tracked_distance_km = tracked_distance_km + ?, driving_seconds = driving_seconds + ?, charging_seconds = charging_seconds + ?, charge_sessions = charge_sessions + ?, soc_used_percent = soc_used_percent + ? WHERE id = 1` ).run( sample.recordedAt, sample.recordedAt, sample.speedKph, sample.speedKph, sample.recordedAt, sample.speedKph, sample.speedKph, sample.speedKph, sample.chargingPowerKw, sample.chargingPowerKw, sample.recordedAt, sample.chargingPowerKw, sample.chargingPowerKw, sample.chargingPowerKw, sample.rangeKm, sample.rangeKm, sample.recordedAt, sample.rangeKm, sample.rangeKm, sample.rangeKm, distanceKm, drivingSeconds, chargingSeconds, chargeSession, socDrop ); } prune(now = Date.now()): void { const transaction = this.database.transaction(() => { this.database.prepare("DELETE FROM car_telemetry_samples WHERE recorded_at < ?").run(now - RAW_RETENTION_MS); this.database.prepare( `DELETE FROM car_telemetry_samples WHERE recorded_at IN ( SELECT recorded_at FROM car_telemetry_samples ORDER BY recorded_at DESC LIMIT -1 OFFSET ? )` ).run(MAX_RAW_SAMPLES); this.database.prepare("DELETE FROM car_telemetry_hourly WHERE bucket_start < ?").run(now - ROLLUP_RETENTION_MS); this.database.prepare( `DELETE FROM car_telemetry_hourly WHERE bucket_start IN ( SELECT bucket_start FROM car_telemetry_hourly ORDER BY bucket_start DESC LIMIT -1 OFFSET ? )` ).run(MAX_HOURLY_ROLLUPS); }); transaction(); } clear(mode: ClearTelemetryMode): { mode: ClearTelemetryMode; deletedSamples: number; deletedRollups: number } { const transaction = this.database.transaction(() => { const deletedSamples = this.database.prepare("DELETE FROM car_telemetry_samples").run().changes; const deletedRollups = this.database.prepare("DELETE FROM car_telemetry_hourly").run().changes; if (mode === "all") { this.database.prepare( `UPDATE car_telemetry_stats SET first_sample_at = NULL, last_sample_at = NULL, sample_count = 0, peak_speed_kph = NULL, peak_speed_at = NULL, peak_charging_power_kw = NULL, peak_charging_power_at = NULL, max_observed_range_km = NULL, max_observed_range_at = NULL, tracked_distance_km = 0, driving_seconds = 0, charging_seconds = 0, charge_sessions = 0, soc_used_percent = 0 WHERE id = 1` ).run(); } return { mode, deletedSamples, deletedRollups }; }); this.#samplesSincePrune = 0; return transaction(); } overview() { const stats = this.database.prepare("SELECT * FROM car_telemetry_stats WHERE id = 1").get() as Record; const latest = rowToStored(this.database.prepare("SELECT * FROM car_telemetry_samples ORDER BY recorded_at DESC LIMIT 1").get() as Record | undefined); const storage = this.database.prepare( `SELECT (SELECT COUNT(*) FROM car_telemetry_samples) AS raw_samples, (SELECT MIN(recorded_at) FROM car_telemetry_samples) AS oldest_raw_at, (SELECT COUNT(*) FROM car_telemetry_hourly) AS hourly_rollups, (SELECT MIN(bucket_start) FROM car_telemetry_hourly) AS oldest_rollup_at` ).get() as Record; const trackedDistance = Number(stats.tracked_distance_km ?? 0); const drivingSeconds = Number(stats.driving_seconds ?? 0); const socUsed = Number(stats.soc_used_percent ?? 0); const ownRangeEstimate = trackedDistance >= 10 && socUsed >= 5 ? trackedDistance / socUsed * 100 : null; return { latest, records: { peakSpeedKph: finite(stats.peak_speed_kph), peakSpeedAt: finite(stats.peak_speed_at), peakChargingPowerKw: finite(stats.peak_charging_power_kw), peakChargingPowerAt: finite(stats.peak_charging_power_at), maxObservedRangeKm: finite(stats.max_observed_range_km), maxObservedRangeAt: finite(stats.max_observed_range_at) }, lifetime: { firstSampleAt: finite(stats.first_sample_at), lastSampleAt: finite(stats.last_sample_at), sampleCount: Number(stats.sample_count ?? 0), trackedDistanceKm: trackedDistance, drivingSeconds, chargingSeconds: Number(stats.charging_seconds ?? 0), chargeSessions: Number(stats.charge_sessions ?? 0), socUsedPercent: socUsed, averageMovingSpeedKph: drivingSeconds > 0 ? trackedDistance / (drivingSeconds / 3_600) : null, ownRangeEstimateKm: ownRangeEstimate }, storage: { rawSamples: Number(storage.raw_samples ?? 0), hourlyRollups: Number(storage.hourly_rollups ?? 0), oldestRawAt: finite(storage.oldest_raw_at), oldestRollupAt: finite(storage.oldest_rollup_at), estimatedBytes: Number(storage.raw_samples ?? 0) * 112 + Number(storage.hourly_rollups ?? 0) * 176, rawRetentionDays: 90, rollupRetentionYears: 10, rawSampleCap: MAX_RAW_SAMPLES } }; } history(period: TelemetryPeriod): { period: TelemetryPeriod; points: TelemetryHistoryPoint[] } { const now = Date.now(); const settings: Record = { "24h": { duration: 24 * 60 * 60_000, bucket: 5 * 60_000, rollup: false }, "7d": { duration: 7 * 24 * 60 * 60_000, bucket: 30 * 60_000, rollup: false }, "30d": { duration: 30 * 24 * 60 * 60_000, bucket: 2 * 60 * 60_000, rollup: false }, "1y": { duration: 365 * 24 * 60 * 60_000, bucket: 24 * 60 * 60_000, rollup: true }, all: { duration: ROLLUP_RETENTION_MS, bucket: 7 * 24 * 60 * 60_000, rollup: true } }; const setting = settings[period]; const from = now - setting.duration; const rows = setting.rollup ? this.database.prepare( `SELECT CAST(bucket_start / ? AS INTEGER) * ? AS timestamp, CASE WHEN SUM(speed_count) > 0 THEN SUM(speed_sum) / SUM(speed_count) END AS speed_kph, MAX(speed_max) AS peak_speed_kph, CASE WHEN SUM(soc_count) > 0 THEN SUM(soc_sum) / SUM(soc_count) END AS battery_percent, CASE WHEN SUM(range_count) > 0 THEN SUM(range_sum) / SUM(range_count) END AS range_km, MAX(charge_power_max) AS charging_power_kw FROM car_telemetry_hourly WHERE bucket_start >= ? GROUP BY timestamp ORDER BY timestamp` ).all(setting.bucket, setting.bucket, from) : this.database.prepare( `SELECT CAST(recorded_at / ? AS INTEGER) * ? AS timestamp, AVG(speed_kph) AS speed_kph, MAX(speed_kph) AS peak_speed_kph, AVG(battery_percent) AS battery_percent, AVG(range_km) AS range_km, MAX(charging_power_kw) AS charging_power_kw FROM car_telemetry_samples WHERE recorded_at >= ? GROUP BY timestamp ORDER BY timestamp` ).all(setting.bucket, setting.bucket, from); return { period, points: (rows as Record[]).map((row) => ({ timestamp: Number(row.timestamp), speedKph: finite(row.speed_kph), peakSpeedKph: finite(row.peak_speed_kph), batteryPercent: finite(row.battery_percent), rangeKm: finite(row.range_km), chargingPowerKw: finite(row.charging_power_kw) })) }; } } export class CarTelemetryRecorder { readonly store: CarTelemetryStore; #timer: NodeJS.Timeout | null = null; #pending: Promise | null = null; #latest: CarTelemetry | null = null; #lastStoredAt = 0; constructor(database: CompanionDatabase, private readonly config: AdbConfig) { this.store = new CarTelemetryStore(database); } start(): void { if (!this.config.enabled || this.#timer) return; void this.readLive().catch(() => undefined); this.#timer = setInterval(() => void this.readLive().catch(() => undefined), SAMPLE_INTERVAL_MS); this.#timer.unref(); } stop(): void { if (this.#timer) clearInterval(this.#timer); this.#timer = null; } async readLive(): Promise { const latestAt = this.#latest ? Date.parse(this.#latest.collectedAt) : 0; if (this.#latest && Date.now() - latestAt < 1_500) return this.#latest; if (this.#pending) return this.#pending; this.#pending = collectCarTelemetry(this.config).then((telemetry) => { this.#latest = telemetry; const sampleAt = Date.parse(telemetry.collectedAt); if (sampleAt - this.#lastStoredAt >= SAMPLE_INTERVAL_MS - 1_000) { this.store.record(telemetry); this.#lastStoredAt = sampleAt; } return telemetry; }).finally(() => { this.#pending = null; }); return this.#pending; } }