feat: add direct car telemetry management

This commit is contained in:
2026-07-31 23:14:40 +02:00
parent 530162cbf7
commit e347a581e1
16 changed files with 447 additions and 57 deletions
Binary file not shown.
+54 -23
View File
@@ -2,12 +2,16 @@ import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { Availability } from "./types.js";
const execFileAsync = promisify(execFile);
const MAX_ADB_OUTPUT_BYTES = 16 * 1024;
const MAX_ADB_LIST_OUTPUT_BYTES = 512 * 1024;
const CAR_PROBE_LOCAL_PATH = fileURLToPath(new URL("../assets/car-telemetry-probe.dex", import.meta.url));
const CAR_PROBE_REMOTE_PATH = "/data/local/tmp/pi-car-telemetry-probe.dex";
const carProbeReady = new WeakMap<AdbExecutor, Set<string>>();
export const MAX_APK_BYTES = 256 * 1024 * 1024;
export type AdbConfig = {
@@ -239,12 +243,16 @@ 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());
function parseProbeOutput(output: string): Map<string, string> | null {
const line = output.split(/\r?\n/).find((candidate) => candidate.trim().startsWith("{"));
if (!line) return null;
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
if (parsed.status !== "ok") return null;
return new Map(Object.entries(parsed).map(([key, value]) => [key, String(value)]));
} catch {
return null;
}
return values;
}
function bundleNumber(
@@ -257,40 +265,63 @@ function bundleNumber(
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`);
: unavailable(`${label} is unavailable from the factory vehicle service over ADB`);
}
async function sampleVehicleService(config: AdbConfig, execute: AdbExecutor): Promise<Map<string, string> | null> {
const serial = await resolveSerial(config, execute);
let readySerials = carProbeReady.get(execute);
if (!readySerials) {
readySerials = new Set<string>();
carProbeReady.set(execute, readySerials);
}
if (!readySerials.has(serial)) {
await execute(config.executable, ["-s", serial, "push", CAR_PROBE_LOCAL_PATH, CAR_PROBE_REMOTE_PATH], {
timeout: Math.max(config.timeoutMs, 10_000),
maxBuffer: MAX_ADB_OUTPUT_BYTES
});
readySerials.add(serial);
}
try {
const result = await execute(
config.executable,
["-s", serial, "shell", `CLASSPATH=${CAR_PROBE_REMOTE_PATH}`, "app_process", "/system/bin", "cloud.molberg.picarprobe.CarTelemetryProbe"],
{ timeout: Math.max(config.timeoutMs, 7_000), maxBuffer: MAX_ADB_OUTPUT_BYTES }
);
return parseProbeOutput(result.stdout);
} catch (error) {
const text = errorText(error);
if (text.includes("classnotfound") || text.includes("class not found") || text.includes("no such file") || text.includes("dex")) {
readySerials.delete(serial);
}
throw error;
}
}
export async function collectCarTelemetry(
config: AdbConfig,
execute: AdbExecutor = defaultExecutor
): Promise<CarTelemetry> {
const [propertiesResult, bridgeResult] = await Promise.all([
const [propertiesResult, vehicleValues] = 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)
sampleVehicleService(config, execute).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);
const serviceSpeed = bundleNumber(vehicleValues, "speedKph", "Vehicle speed", 0, 400);
return {
available: true,
collectedAt: new Date().toISOString(),
speedKph: bridgeSpeed.available ? bridgeSpeed : numericProperty(properties, "arcsoft.avm.mCurCarSpeed", "Vehicle speed"),
speedKph: serviceSpeed.available ? serviceSpeed : 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: 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)
batteryPercent: bundleNumber(vehicleValues, "soc", "Traction battery", 0, 100),
rangeKm: bundleNumber(vehicleValues, "rangeKm", "Estimated range", 0, 1_500),
batteryVoltage: bundleNumber(vehicleValues, "batteryVolts", "Battery voltage", 0, 1_000),
totalConsumptionKwh: bundleNumber(vehicleValues, "totalConsumptionKwh", "Consumption", 0, 10_000),
chargingStatus: bundleNumber(vehicleValues, "chargingStatus", "Charging status", -1, 100)
};
}
+25
View File
@@ -82,6 +82,7 @@ const adbShortcutSchema = z.object({ name: z.string().trim().min(1).max(48), typ
}
return { name: value.name, ...target.data };
});
const clearTelemetrySchema = z.object({ mode: z.enum(["history", "all"]) });
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
type SessionUserRow = { id: number; username: string; role: "admin" };
@@ -505,6 +506,30 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
return carTelemetry.store.overview();
});
app.post(
"/api/car/history/clear",
{ config: { rateLimit: { max: 4, timeWindow: "10 minutes" } } },
async (request, reply) => {
if (!requireUser(request, reply)) return;
const parsed = clearTelemetrySchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
error: { code: "INVALID_CLEAR_TELEMETRY_MODE", message: "Choose which car statistics to clear" }
});
}
const result = carTelemetry.store.clear(parsed.data.mode);
audit(database, {
actorUserId: request.authUser!.id,
action: "car.telemetry-clear",
result: "success",
ipAddress: clientAddress(request),
details: result
});
return result;
}
);
app.post(
"/api/network/configuration",
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
+34
View File
@@ -21,6 +21,7 @@ type StoredSample = {
};
export type TelemetryPeriod = "24h" | "7d" | "30d" | "1y" | "all";
export type ClearTelemetryMode = "history" | "all";
export type TelemetryHistoryPoint = {
timestamp: number;
@@ -252,6 +253,39 @@ export class CarTelemetryStore {
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<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);
+26 -4
View File
@@ -169,10 +169,15 @@ describe("ADB dashboard operations", () => {
await expect(launchAdbTarget(enabledConfig, { type: "component", value: "com.example/.Main;reboot" }, execute)).rejects.toThrow("INVALID_ADB_COMPONENT");
});
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
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: "" };
it("reads vehicle data through a pushed ADB probe without an installed companion app", async () => {
const calls: string[][] = [];
const execute: AdbExecutor = async (_executable, arguments_) => {
calls.push([...arguments_]);
if (arguments_.includes("app_process")) {
return { stdout: '{"status":"ok","soc":73.5,"rangeKm":286,"speedKph":18,"batteryVolts":397.4,"totalConsumptionKwh":6.2,"chargingStatus":0}\n', stderr: "" };
}
return { 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: 18 },
@@ -184,6 +189,23 @@ describe("ADB dashboard operations", () => {
totalConsumptionKwh: { available: true, value: 6.2 },
chargingStatus: { available: true, value: 0 }
});
expect(calls.some((call) => call[2] === "push" && call.at(-1) === "/data/local/tmp/pi-car-telemetry-probe.dex")).toBe(true);
expect(calls.some((call) => call.includes("app_process"))).toBe(true);
expect(calls.flat()).not.toContain("content://cloud.molberg.mgutility.vehicle");
await collectCarTelemetry(enabledConfig, execute);
expect(calls.filter((call) => call[2] === "push")).toHaveLength(1);
});
it("keeps direct properties available when the factory vehicle service cannot be sampled", async () => {
const execute: AdbExecutor = async (_executable, arguments_) => {
if (arguments_.includes("app_process")) throw new Error("service unavailable");
return { 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({
speedKph: { available: true, value: 17 },
batteryPercent: { available: false, reason: expect.stringContaining("factory vehicle service over ADB") }
});
});
it("accepts only ZIP-formatted APK payloads and removes the temporary file", async () => {
+33
View File
@@ -259,6 +259,12 @@ describe("authentication boundary", () => {
expect((await app.inject({ method: "GET", url: "/api/car/statistics" })).statusCode).toBe(401);
const token = await csrf(app);
expect((await app.inject({
method: "POST",
url: "/api/car/history/clear",
headers: mutationHeaders(token),
payload: { mode: "history" }
})).statusCode).toBe(401);
await setup(app, token);
const login = await app.inject({
method: "POST",
@@ -276,6 +282,33 @@ describe("authentication boundary", () => {
const invalidPeriod = await app.inject({ method: "GET", url: "/api/car/history?period=forever", headers: { cookie } });
expect(invalidPeriod.statusCode).toBe(400);
const invalidClear = await app.inject({
method: "POST",
url: "/api/car/history/clear",
headers,
payload: { mode: "recent" }
});
expect(invalidClear.statusCode).toBe(400);
expect(invalidClear.json()).toMatchObject({ error: { code: "INVALID_CLEAR_TELEMETRY_MODE" } });
const clearHistory = await app.inject({
method: "POST",
url: "/api/car/history/clear",
headers,
payload: { mode: "history" }
});
expect(clearHistory.statusCode).toBe(200);
expect(clearHistory.json()).toEqual({ mode: "history", deletedSamples: 0, deletedRollups: 0 });
const clearAll = await app.inject({
method: "POST",
url: "/api/car/history/clear",
headers,
payload: { mode: "all" }
});
expect(clearAll.statusCode).toBe(200);
expect(clearAll.json()).toEqual({ mode: "all", deletedSamples: 0, deletedRollups: 0 });
const invalid = await app.inject({
method: "POST",
url: "/api/adb/shortcuts",
+54
View File
@@ -78,4 +78,58 @@ describe("car telemetry history", () => {
expect(store.history("24h").points).toHaveLength(1);
database.close();
});
it("clears history independently from all-time statistics", () => {
const database = openDatabase(":memory:");
const store = new CarTelemetryStore(database);
const startedAt = Date.now() - 10 * 60_000;
store.record(sample(startedAt, { speed: 20, soc: 80, range: 320, charging: 0 }));
for (let index = 1; index <= 40; index += 1) {
store.record(sample(startedAt + index * 10_000, {
speed: 110,
soc: 80 - index * 0.15,
range: 320 - index,
charging: 0
}));
}
const before = store.overview();
expect(before.lifetime.trackedDistanceKm).toBeGreaterThan(10);
expect(before.lifetime.ownRangeEstimateKm).not.toBeNull();
expect(store.clear("history")).toMatchObject({ mode: "history", deletedSamples: 41 });
const kept = store.overview();
expect(store.history("24h").points).toEqual([]);
expect(kept.storage.rawSamples).toBe(0);
expect(kept.storage.hourlyRollups).toBe(0);
expect(kept.records).toEqual(before.records);
expect(kept.lifetime).toEqual(before.lifetime);
store.record(sample(Date.now(), { speed: 30, soc: 70, range: 260, charging: 0 }));
expect(store.clear("all")).toMatchObject({ mode: "all", deletedSamples: 1 });
const reset = store.overview();
expect(store.history("all").points).toEqual([]);
expect(reset.records).toEqual({
peakSpeedKph: null,
peakSpeedAt: null,
peakChargingPowerKw: null,
peakChargingPowerAt: null,
maxObservedRangeKm: null,
maxObservedRangeAt: null
});
expect(reset.lifetime).toMatchObject({
firstSampleAt: null,
lastSampleAt: null,
sampleCount: 0,
trackedDistanceKm: 0,
drivingSeconds: 0,
chargingSeconds: 0,
chargeSessions: 0,
socUsedPercent: 0,
averageMovingSpeedKph: null,
ownRangeEstimateKm: null
});
database.close();
});
});