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
+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();
});
});