Add persistent car performance analytics

This commit is contained in:
2026-07-31 22:44:51 +02:00
parent 0e4ce931ec
commit 6998e8bdb7
12 changed files with 916 additions and 29 deletions
+44 -4
View File
@@ -71,6 +71,9 @@ export type CarTelemetry = {
wheelAngleDegrees: Availability<number>;
batteryPercent: Availability<number>;
rangeKm: Availability<number>;
batteryVoltage: Availability<number>;
totalConsumptionKwh: Availability<number>;
chargingStatus: Availability<number>;
};
const defaultExecutor: AdbExecutor = async (executable, arguments_, options) => {
@@ -236,21 +239,58 @@ function numericProperty(properties: Map<string, string>, key: string, label: st
return Number.isFinite(value) ? { available: true, value } : unavailable(`${label} is not exposed by the head unit`);
}
function parseContentBundle(output: string): Map<string, string> {
const values = new Map<string, string>();
for (const match of output.matchAll(/(?:^|[{,\s])(\w+)=([^,}\]]+)/g)) {
values.set(match[1]!, match[2]!.trim());
}
return values;
}
function bundleNumber(
values: Map<string, string> | null,
key: string,
label: string,
minimum: number,
maximum: number
): Availability<number> {
const value = Number(values?.get(key));
return Number.isFinite(value) && value >= minimum && value <= maximum
? { available: true, value }
: unavailable(`${label} is unavailable from the MG Utility bridge`);
}
export async function collectCarTelemetry(
config: AdbConfig,
execute: AdbExecutor = defaultExecutor
): Promise<CarTelemetry> {
const output = (await executeForDevice(config, ["shell", "getprop"], execute, MAX_ADB_LIST_OUTPUT_BYTES)).stdout;
const [propertiesResult, bridgeResult] = await Promise.all([
executeForDevice(config, ["shell", "getprop"], execute, MAX_ADB_LIST_OUTPUT_BYTES),
executeForDevice(
config,
["shell", "content", "call", "--uri", "content://cloud.molberg.mgutility.vehicle", "--method", "sample"],
execute,
MAX_ADB_OUTPUT_BYTES
).catch(() => null)
]);
const output = propertiesResult.stdout;
const properties = parseGetprop(output);
const rawGear = properties.get("arcsoft.avm.mCurCarGear");
const bridge = bridgeResult ? parseContentBundle(bridgeResult.stdout) : null;
const bridgeReady = bridge?.get("status") === "ok";
const bridgeValues = bridgeReady ? bridge : null;
const bridgeSpeed = bundleNumber(bridgeValues, "speedKph", "Vehicle speed", 0, 400);
return {
available: true,
collectedAt: new Date().toISOString(),
speedKph: numericProperty(properties, "arcsoft.avm.mCurCarSpeed", "Vehicle speed"),
speedKph: bridgeSpeed.available ? bridgeSpeed : numericProperty(properties, "arcsoft.avm.mCurCarSpeed", "Vehicle speed"),
gear: rawGear ? { available: true, value: rawGear } : unavailable("Gear is not exposed by the head unit"),
wheelAngleDegrees: numericProperty(properties, "arcsoft.avm.mCurCarWheelAngle", "Wheel angle"),
batteryPercent: unavailable("Traction battery data requires the SAIC vehicle-service bridge"),
rangeKm: unavailable("Range data requires the SAIC vehicle-service bridge")
batteryPercent: bundleNumber(bridgeValues, "soc", "Traction battery", 0, 100),
rangeKm: bundleNumber(bridgeValues, "rangeKm", "Estimated range", 0, 1_500),
batteryVoltage: bundleNumber(bridgeValues, "batteryVolts", "Battery voltage", 0, 1_000),
totalConsumptionKwh: bundleNumber(bridgeValues, "totalConsumptionKwh", "Consumption", 0, 10_000),
chargingStatus: bundleNumber(bridgeValues, "chargingStatus", "Charging status", -1, 100)
};
}
+19 -2
View File
@@ -30,12 +30,12 @@ import {
} from "./network.js";
import {
collectAdbStatus,
collectCarTelemetry,
installAdbApk,
launchAdbTarget,
listInstalledAdbPackages,
MAX_APK_BYTES
} from "./adb.js";
import { CarTelemetryRecorder, type TelemetryPeriod } from "./car-telemetry.js";
import { openShell } from "./shell.js";
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
import "./types.js";
@@ -139,7 +139,9 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
});
const database = openDatabase(config.databasePath);
const jobRunner = new JobRunner(database, undefined, config.adb);
const carTelemetry = new CarTelemetryRecorder(database, config.adb);
if (config.adb.enabled) void collectAdbStatus(config.adb);
carTelemetry.start();
app.decorate("database", database);
app.decorate("jobRunner", jobRunner);
@@ -154,6 +156,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
);
app.addHook("onClose", async () => {
carTelemetry.stop();
jobRunner.close();
database.close();
});
@@ -482,12 +485,26 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
app.get("/api/car/telemetry", async (request, reply) => {
if (!requireUser(request, reply)) return;
try {
return await collectCarTelemetry(config.adb);
return await carTelemetry.readLive();
} catch {
return reply.code(503).send({ error: { code: "CAR_TELEMETRY_UNAVAILABLE", message: "Live car telemetry is unavailable over ADB" } });
}
});
app.get<{ Querystring: { period?: string } }>("/api/car/history", async (request, reply) => {
if (!requireUser(request, reply)) return;
const parsed = z.enum(["24h", "7d", "30d", "1y", "all"]).safeParse(request.query.period ?? "24h");
if (!parsed.success) {
return reply.code(400).send({ error: { code: "INVALID_HISTORY_PERIOD", message: "That history period is not available" } });
}
return carTelemetry.store.history(parsed.data as TelemetryPeriod);
});
app.get("/api/car/statistics", async (request, reply) => {
if (!requireUser(request, reply)) return;
return carTelemetry.store.overview();
});
app.post(
"/api/network/configuration",
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
+384
View File
@@ -0,0 +1,384 @@
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 TelemetryHistoryPoint = {
timestamp: number;
speedKph: number | null;
peakSpeedKph: number | null;
batteryPercent: number | null;
rangeKm: number | null;
chargingPowerKw: number | null;
};
function value<T>(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<string, unknown> | 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<string, unknown> | 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();
}
overview() {
const stats = this.database.prepare("SELECT * FROM car_telemetry_stats WHERE id = 1").get() as Record<string, number | null>;
const latest = rowToStored(this.database.prepare("SELECT * FROM car_telemetry_samples ORDER BY recorded_at DESC LIMIT 1").get() as Record<string, unknown> | 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<string, number | null>;
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<TelemetryPeriod, { duration: number; bucket: number; rollup: boolean }> = {
"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<string, unknown>[]).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<CarTelemetry> | 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<CarTelemetry> {
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;
}
}
+53
View File
@@ -69,6 +69,59 @@ function migrate(database: CompanionDatabase): void {
created_at TEXT NOT NULL,
UNIQUE(target_type, target_value)
);
CREATE TABLE IF NOT EXISTS car_telemetry_samples (
recorded_at INTEGER PRIMARY KEY,
speed_kph REAL,
wheel_angle_degrees REAL,
battery_percent REAL,
range_km REAL,
battery_voltage REAL,
total_consumption_kwh REAL,
charging_status INTEGER,
charging_power_kw REAL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS car_telemetry_hourly (
bucket_start INTEGER PRIMARY KEY,
sample_count INTEGER NOT NULL,
speed_sum REAL NOT NULL,
speed_count INTEGER NOT NULL,
speed_max REAL,
soc_sum REAL NOT NULL,
soc_count INTEGER NOT NULL,
soc_min REAL,
soc_max REAL,
range_sum REAL NOT NULL,
range_count INTEGER NOT NULL,
range_max REAL,
charge_power_sum REAL NOT NULL,
charge_power_count INTEGER NOT NULL,
charge_power_max REAL,
distance_km REAL NOT NULL,
driving_seconds REAL NOT NULL,
charging_seconds REAL NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS car_telemetry_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
first_sample_at INTEGER,
last_sample_at INTEGER,
sample_count INTEGER NOT NULL DEFAULT 0,
peak_speed_kph REAL,
peak_speed_at INTEGER,
peak_charging_power_kw REAL,
peak_charging_power_at INTEGER,
max_observed_range_km REAL,
max_observed_range_at INTEGER,
tracked_distance_km REAL NOT NULL DEFAULT 0,
driving_seconds REAL NOT NULL DEFAULT 0,
charging_seconds REAL NOT NULL DEFAULT 0,
charge_sessions INTEGER NOT NULL DEFAULT 0,
soc_used_percent REAL NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO car_telemetry_stats (id) VALUES (1);
`);
database
+9 -7
View File
@@ -170,17 +170,19 @@ describe("ADB dashboard operations", () => {
});
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
const execute: AdbExecutor = async () => ({
stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n",
stderr: ""
});
const execute: AdbExecutor = async (_executable, arguments_) => arguments_.includes("content")
? { stdout: "Result: Bundle[{status=ok, sampledAt=1785540000000, soc=73.5, rangeKm=286, speedKph=18.0, batteryVolts=397.4, totalConsumptionKwh=6.2, chargingStatus=0}]", stderr: "" }
: { stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n", stderr: "" };
await expect(collectCarTelemetry(enabledConfig, execute)).resolves.toMatchObject({
available: true,
speedKph: { available: true, value: 17 },
speedKph: { available: true, value: 18 },
gear: { available: true, value: "4" },
wheelAngleDegrees: { available: true, value: -12.5 },
batteryPercent: { available: false },
rangeKm: { available: false }
batteryPercent: { available: true, value: 73.5 },
rangeKm: { available: true, value: 286 },
batteryVoltage: { available: true, value: 397.4 },
totalConsumptionKwh: { available: true, value: 6.2 },
chargingStatus: { available: true, value: 0 }
});
});
+8
View File
@@ -255,6 +255,8 @@ describe("authentication boundary", () => {
expect((await app.inject({ method: "GET", url: "/api/adb/packages" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/api/adb/shortcuts" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/api/car/telemetry" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/api/car/history" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/api/car/statistics" })).statusCode).toBe(401);
const token = await csrf(app);
await setup(app, token);
@@ -268,6 +270,12 @@ describe("authentication boundary", () => {
const cookie = `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${session}`;
const headers = { ...mutationHeaders(token), cookie };
const history = await app.inject({ method: "GET", url: "/api/car/history?period=24h", headers: { cookie } });
expect(history.statusCode).toBe(200);
expect(history.json()).toMatchObject({ period: "24h", points: [] });
const invalidPeriod = await app.inject({ method: "GET", url: "/api/car/history?period=forever", headers: { cookie } });
expect(invalidPeriod.statusCode).toBe(400);
const invalid = await app.inject({
method: "POST",
url: "/api/adb/shortcuts",
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { CarTelemetryStore } from "../src/car-telemetry.js";
import { openDatabase } from "../src/database.js";
import type { CarTelemetry } from "../src/adb.js";
function sample(
recordedAt: number,
values: { speed?: number; soc?: number; range?: number; charging?: number; volts?: number } = {}
): CarTelemetry {
const available = <T>(value: T) => ({ available: true as const, value });
const missing = { available: false as const, reason: "Unavailable in test" };
return {
available: true,
collectedAt: new Date(recordedAt).toISOString(),
speedKph: values.speed === undefined ? missing : available(values.speed),
gear: available("4"),
wheelAngleDegrees: available(0),
batteryPercent: values.soc === undefined ? missing : available(values.soc),
rangeKm: values.range === undefined ? missing : available(values.range),
batteryVoltage: values.volts === undefined ? missing : available(values.volts),
totalConsumptionKwh: available(4.2),
chargingStatus: values.charging === undefined ? missing : available(values.charging)
};
}
describe("car telemetry history", () => {
it("keeps bounded raw samples, hourly rollups, lifetime records, and an observed range estimate", () => {
const database = openDatabase(":memory:");
const store = new CarTelemetryStore(database);
const startedAt = Date.now() - 2 * 60 * 60_000;
store.record(sample(startedAt, { speed: 0, soc: 80, range: 320, charging: 0, volts: 398 }));
for (let index = 1; index <= 400; index += 1) {
store.record(sample(startedAt + index * 10_000, {
speed: 100,
soc: 80 - index * 0.02,
range: 320 - index * 0.3,
charging: 0,
volts: 390
}));
}
const chargingAt = startedAt + 401 * 10_000;
for (let index = 0; index <= 13; index += 1) {
store.record(sample(chargingAt + index * 10_000, {
speed: 0,
soc: 60 + index * 0.2,
range: 240 + index,
charging: 1,
volts: 405
}));
}
const overview = store.overview();
expect(overview.records.peakSpeedKph).toBe(100);
expect(overview.records.peakChargingPowerKw).toBeGreaterThan(30);
expect(overview.records.maxObservedRangeKm).toBe(320);
expect(overview.lifetime.trackedDistanceKm).toBeGreaterThan(10);
expect(overview.lifetime.ownRangeEstimateKm).toBeGreaterThan(100);
expect(overview.lifetime.chargeSessions).toBe(1);
expect(overview.storage.rawSamples).toBe(415);
expect(store.history("24h").points.length).toBeGreaterThan(1);
expect(store.history("all").points.length).toBeGreaterThan(0);
store.prune(chargingAt + 91 * 24 * 60 * 60_000);
const pruned = store.overview();
expect(pruned.storage.rawSamples).toBe(0);
expect(pruned.records.peakSpeedKph).toBe(100);
expect(pruned.lifetime.trackedDistanceKm).toBeGreaterThan(10);
database.close();
});
it("leaves estimates empty until enough real observations exist", () => {
const database = openDatabase(":memory:");
const store = new CarTelemetryStore(database);
store.record(sample(Date.now(), { speed: 0, soc: 75, range: 280, charging: 0 }));
expect(store.overview().lifetime.ownRangeEstimateKm).toBeNull();
expect(store.history("24h").points).toHaveLength(1);
database.close();
});
});