diff --git a/CAR_TELEMETRY.md b/CAR_TELEMETRY.md index 8bbd688..a5b3222 100644 --- a/CAR_TELEMETRY.md +++ b/CAR_TELEMETRY.md @@ -1,18 +1,18 @@ # Car telemetry and retention -The Pi samples the MG4 every 10 seconds while ADB is enabled. Speed, gear, and steering -angle come from the verified ArcSoft properties. Battery values come from the read-only -content provider in `cloud.molberg.mgutility`: +The Pi samples the MG4 every 10 seconds while ADB is enabled. Gear and steering angle +come from the verified ArcSoft properties. Battery values, range, and speed are read +directly from the factory SAIC vehicle Binder service by a bundled read-only DEX probe: ```bash -adb shell content call \ - --uri content://cloud.molberg.mgutility.vehicle \ - --method sample +adb push server/assets/car-telemetry-probe.dex /data/local/tmp/pi-car-telemetry-probe.dex +adb shell CLASSPATH=/data/local/tmp/pi-car-telemetry-probe.dex \ + app_process /system/bin cloud.molberg.picarprobe.CarTelemetryProbe ``` -The provider must be present in the installed MG Utility APK. Until it connects to the -SAIC vehicle service, battery fields remain unavailable while the ArcSoft signals and -their history continue working. +The helper is not installed and has no dependency on MG Utility. It performs only known +getter transactions. If the factory service is unavailable, battery fields remain +unavailable while the ArcSoft signals and their history continue working. ## Storage limits @@ -36,5 +36,5 @@ well below 150 MB, although SQLite page allocation and filesystem details can va - Peak values mean the highest value observed at a 10-second sampling point. They are not guaranteed to capture a shorter event between samples. -The collector sends no vehicle-control calls. Installing MG Utility and enabling ADB -remain parked-vehicle administration tasks. +The collector sends no vehicle-control calls. Enabling and authorizing ADB remains a +parked-vehicle administration task. diff --git a/PLAN.md b/PLAN.md index 15db8b4..8549ea0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -229,7 +229,7 @@ reference, not proof for other model years, trims, regions, or software builds. - Car UX restrictions are enforced while moving. Version 1 is parked-use software unless a later native activity is explicitly designed, declared, and validated as distraction optimized. -- The Pi is the only intended ADB host. The bridge targets either one configured +- The Pi is the only intended ADB host. The integration targets either one configured serial or exactly one automatically discovered device, refuses ambiguous multi-device connections, uses argument-array process execution, enforces timeouts and output bounds, exposes unavailable/unauthorized/offline states, and registers every operation in a @@ -237,14 +237,14 @@ reference, not proof for other model years, trims, regions, or software builds. or an unrestricted file path. Package and component targets are accepted only through strict Android-name validation and fixed `am` or `monkey` argument arrays. - Device identity, connection health, display, package inventory, bounded APK install, - validated app launch, the three confirmed ArcSoft vehicle properties, and the typed - read-only MG Utility battery bridge are in scope. + validated app launch, the three confirmed ArcSoft vehicle properties, and direct + read-only factory vehicle-service telemetry are in scope. State-changing ADB actions are authenticated, CSRF-protected, rate-limited where appropriate, audited, and intended for parked use. - Do not use ADB or the exported SAIC vehicle service to actuate vehicle hardware in this project. Vehicle telemetry remains read-only. Battery, range, voltage, consumption, - and charging state pass through MG Utility's typed content provider; additional SAIC - signals remain a research track until their semantics are documented and tested. + and charging state come from fixed getter transactions made by the non-installed ADB + probe; additional SAIC signals remain a research track until documented and tested. ### Web experience diff --git a/README.md b/README.md index 27151b6..dc81140 100644 --- a/README.md +++ b/README.md @@ -239,12 +239,13 @@ If the device is disconnected, offline, unauthorized, partially readable, or ADB missing, the dashboard reports that state without substituting generated data. APK installation and app launches are administrator actions intended for a parked vehicle. -The Car page refreshes the three verified live properties from the captured MG4 unit: -`arcsoft.avm.mCurCarSpeed`, `arcsoft.avm.mCurCarGear`, and -`arcsoft.avm.mCurCarWheelAngle`. The property dump does not expose traction-battery or -range values. Those fields remain unavailable until a read-only Android bridge binds to -SAIC's exported vehicle service; head-unit battery data is deliberately not presented as -the car battery. +The Car page refreshes the verified live properties `arcsoft.avm.mCurCarSpeed`, +`arcsoft.avm.mCurCarGear`, and `arcsoft.avm.mCurCarWheelAngle`. Traction-battery and +range values are queried directly from the factory SAIC vehicle service using a small +read-only DEX probe run through ADB. The probe is pushed to `/data/local/tmp` and run +with `app_process`; it is not installed as an Android app and does not depend on MG +Utility. Android's head-unit battery status is deliberately not presented as the car +battery. ### System controls and shell diff --git a/android-helper/README.md b/android-helper/README.md new file mode 100644 index 0000000..5f15323 --- /dev/null +++ b/android-helper/README.md @@ -0,0 +1,10 @@ +# Direct ADB car telemetry helper + +`CarTelemetryProbe` is a small, read-only DEX program run by Android's `app_process` +through ADB. It is pushed to `/data/local/tmp` and is never installed as an Android +application. It binds directly to the factory SAIC vehicle service and performs only +the known getter transactions for SOC, range, speed, voltage, consumption, and charging +status. + +The checked-in `server/assets/car-telemetry-probe.dex` is built from this source for +Android API 28. It has no MG Utility dependency and sends no vehicle-control calls. diff --git a/android-helper/src/cloud/molberg/picarprobe/CarTelemetryProbe.java b/android-helper/src/cloud/molberg/picarprobe/CarTelemetryProbe.java new file mode 100644 index 0000000..94fb16c --- /dev/null +++ b/android-helper/src/cloud/molberg/picarprobe/CarTelemetryProbe.java @@ -0,0 +1,124 @@ +package cloud.molberg.picarprobe; + +import android.app.ActivityThread; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.os.Looper; +import android.os.Parcel; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Read-only shell process that samples the factory SAIC vehicle Binder service. */ +public final class CarTelemetryProbe { + private static final String HUB_TOKEN = "com.saicmotor.sdk.vehiclesettings.IHubService"; + private static final String CHARGING_TOKEN = "com.saicmotor.sdk.vehiclesettings.IVehicleChargingService"; + private static final String CONDITION_TOKEN = "com.saicmotor.sdk.vehiclesettings.IVehicleConditionService"; + private static final AtomicBoolean finished = new AtomicBoolean(false); + + private CarTelemetryProbe() {} + + public static void main(String[] arguments) { + try { + Looper.prepareMainLooper(); + Context context = ActivityThread.systemMain().getSystemContext(); + Intent intent = new Intent("com.saicmotor.service.vehicle.VehicleService") + .setPackage("com.saicmotor.service.vehicle"); + boolean binding = context.bindService(intent, new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder hub) { + try { + IBinder charging = service(hub, "vehiclecharging"); + IBinder condition = service(hub, "vehiclecondition"); + float soc = readFloat(charging, CHARGING_TOKEN, 3); + int rangeKm = readInt(charging, CHARGING_TOKEN, 4); + float speedKph = readFloat(condition, CONDITION_TOKEN, 3); + float batteryVolts = readFloat(charging, CHARGING_TOKEN, 64); + float totalConsumptionKwh = readFloat(charging, CHARGING_TOKEN, 75); + int chargingStatus = readInt(charging, CHARGING_TOKEN, 9); + succeed(soc, rangeKm, speedKph, batteryVolts, totalConsumptionKwh, chargingStatus); + } catch (Throwable error) { + fail("binder_read_failed"); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + fail("service_disconnected"); + } + }, Context.BIND_AUTO_CREATE); + if (!binding) fail("service_unavailable"); + + Thread timeout = new Thread(() -> { + try { Thread.sleep(5000); } catch (InterruptedException ignored) { return; } + fail("service_timeout"); + }, "car-telemetry-timeout"); + timeout.setDaemon(true); + timeout.start(); + Looper.loop(); + } catch (Throwable error) { + fail("probe_failed"); + } + } + + private static IBinder service(IBinder hub, String name) throws Exception { + Parcel data = Parcel.obtain(); + Parcel reply = Parcel.obtain(); + try { + data.writeInterfaceToken(HUB_TOKEN); + data.writeString(name); + hub.transact(1, data, reply, 0); + reply.readException(); + IBinder binder = reply.readStrongBinder(); + if (binder == null) throw new IllegalStateException("Missing " + name); + return binder; + } finally { + data.recycle(); + reply.recycle(); + } + } + + private static float readFloat(IBinder binder, String token, int code) throws Exception { + Parcel data = Parcel.obtain(); + Parcel reply = Parcel.obtain(); + try { + data.writeInterfaceToken(token); + binder.transact(code, data, reply, 0); + reply.readException(); + return reply.readFloat(); + } finally { + data.recycle(); + reply.recycle(); + } + } + + private static int readInt(IBinder binder, String token, int code) throws Exception { + Parcel data = Parcel.obtain(); + Parcel reply = Parcel.obtain(); + try { + data.writeInterfaceToken(token); + binder.transact(code, data, reply, 0); + reply.readException(); + return reply.readInt(); + } finally { + data.recycle(); + reply.recycle(); + } + } + + private static void succeed(float soc, int rangeKm, float speedKph, float batteryVolts, float consumption, int charging) { + if (!finished.compareAndSet(false, true)) return; + System.out.printf(Locale.US, + "{\"status\":\"ok\",\"soc\":%.3f,\"rangeKm\":%d,\"speedKph\":%.3f,\"batteryVolts\":%.3f,\"totalConsumptionKwh\":%.3f,\"chargingStatus\":%d}%n", + soc, rangeKm, speedKph, batteryVolts, consumption, charging); + System.exit(0); + } + + private static void fail(String reason) { + if (!finished.compareAndSet(false, true)) return; + System.out.println("{\"status\":\"error\",\"reason\":\"" + reason + "\"}"); + System.exit(0); + } +} diff --git a/server/assets/car-telemetry-probe.dex b/server/assets/car-telemetry-probe.dex new file mode 100644 index 0000000..8512b94 Binary files /dev/null and b/server/assets/car-telemetry-probe.dex differ diff --git a/server/src/adb.ts b/server/src/adb.ts index 8bbc1f6..3283829 100644 --- a/server/src/adb.ts +++ b/server/src/adb.ts @@ -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>(); export const MAX_APK_BYTES = 256 * 1024 * 1024; export type AdbConfig = { @@ -239,12 +243,16 @@ function numericProperty(properties: Map, 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 { - const values = new Map(); - for (const match of output.matchAll(/(?:^|[{,\s])(\w+)=([^,}\]]+)/g)) { - values.set(match[1]!, match[2]!.trim()); +function parseProbeOutput(output: string): Map | null { + const line = output.split(/\r?\n/).find((candidate) => candidate.trim().startsWith("{")); + if (!line) return null; + try { + const parsed = JSON.parse(line) as Record; + 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 | null> { + const serial = await resolveSerial(config, execute); + let readySerials = carProbeReady.get(execute); + if (!readySerials) { + readySerials = new Set(); + 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 { - 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) }; } diff --git a/server/src/app.ts b/server/src/app.ts index 63b8941..af3971c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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 { 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" } } }, diff --git a/server/src/car-telemetry.ts b/server/src/car-telemetry.ts index 4caf802..70dd163 100644 --- a/server/src/car-telemetry.ts +++ b/server/src/car-telemetry.ts @@ -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; const latest = rowToStored(this.database.prepare("SELECT * FROM car_telemetry_samples ORDER BY recorded_at DESC LIMIT 1").get() as Record | undefined); diff --git a/server/tests/adb.test.ts b/server/tests/adb.test.ts index fda4bc0..5b4dc48 100644 --- a/server/tests/adb.test.ts +++ b/server/tests/adb.test.ts @@ -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 () => { diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index 3ce67cf..2a17ab2 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -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", diff --git a/server/tests/car-telemetry.test.ts b/server/tests/car-telemetry.test.ts index 7fb102f..26a8e02 100644 --- a/server/tests/car-telemetry.test.ts +++ b/server/tests/car-telemetry.test.ts @@ -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(); + }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 089e32d..6b6fe2a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -232,7 +232,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => {page === "home" ? <> {error &&
{error}
} {!status ? : } - : page === "car" ? : page === "adb" ? : page === "network" ? : } + : page === "car" ? : page === "adb" ? : page === "network" ? : } ); diff --git a/web/src/CarPage.tsx b/web/src/CarPage.tsx index ca3cd02..1012da4 100644 --- a/web/src/CarPage.tsx +++ b/web/src/CarPage.tsx @@ -10,16 +10,19 @@ import { Speedometer, SteeringWheel, Timer, + Trash, Trophy, WarningCircle } from "@phosphor-icons/react"; import { + clearCarStatistics, getCarHistory, getCarStatistics, getCarTelemetry, type Availability, type CarStatistics, type CarTelemetry, + type ClearTelemetryMode, type TelemetryHistoryPoint, type TelemetryPeriod } from "./api"; @@ -32,12 +35,15 @@ const periods: Array<{ value: TelemetryPeriod; label: string }> = [ { value: "all", label: "All" } ]; -export function CarPage() { +export function CarPage({ csrfToken }: { csrfToken: string }) { const [telemetry, setTelemetry] = useState(null); const [statistics, setStatistics] = useState(null); const [history, setHistory] = useState([]); const [period, setPeriod] = useState("24h"); const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [clearDialog, setClearDialog] = useState(false); + const [clearing, setClearing] = useState(null); const refreshLive = useCallback(async () => { try { @@ -74,9 +80,27 @@ export function CarPage() { const angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0; const speedArc = Math.min(Math.max((speed ?? 0) / 180, 0), 1); + const clearStatistics = async (mode: ClearTelemetryMode) => { + setClearing(mode); + setError(""); + setNotice(""); + try { + await clearCarStatistics(mode, csrfToken); + setHistory([]); + await refreshHistory(); + setClearDialog(false); + setNotice(mode === "all" ? "All car statistics were cleared." : "History cleared. All-time records were kept."); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Car statistics could not be cleared"); + } finally { + setClearing(null); + } + }; + return (
{error &&
{error}
} + {notice &&
{notice}
}
@@ -138,13 +162,32 @@ export function CarPage() {
Bounded telemetry archive

Raw samples: {statistics?.storage.rawSamples.toLocaleString() ?? "0"} of {statistics?.storage.rawSampleCap.toLocaleString() ?? "800,000"}. Hourly rollups: {statistics?.storage.hourlyRollups.toLocaleString() ?? "0"}.

-
{formatBytes(statistics?.storage.estimatedBytes ?? 0)}{statistics?.storage.rawRetentionDays ?? 90} days raw, {statistics?.storage.rollupRetentionYears ?? 10} years rolled up
+
{formatBytes(statistics?.storage.estimatedBytes ?? 0)}{statistics?.storage.rawRetentionDays ?? 90} days raw, {statistics?.storage.rollupRetentionYears ?? 10} years rolled up
+
- Sources: SAIC vehicle service through MG Utility and live ADB properties + Sources: factory SAIC vehicle service and live properties, queried directly over ADB {telemetry ? `Updated ${new Date(telemetry.collectedAt).toLocaleTimeString()}` : "Waiting for telemetry"}
+ + {clearDialog &&
!clearing && setClearDialog(false)}> +
event.stopPropagation()}> +

Clear car statistics

+

Choose whether to keep your long-term records. This cannot be undone.

+
+ + +
+
+
+
}
); } diff --git a/web/src/api.ts b/web/src/api.ts index 6f7712e..77b22e3 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -282,3 +282,9 @@ export async function getCarHistory(period: TelemetryPeriod): Promise<{ period: export async function getCarStatistics(): Promise { return parseResponse(await fetch("/api/car/statistics", { credentials: "same-origin" })); } + +export type ClearTelemetryMode = "history" | "all"; + +export async function clearCarStatistics(mode: ClearTelemetryMode, csrfToken: string): Promise { + await post("/api/car/history/clear", { mode }, csrfToken); +} diff --git a/web/src/styles.css b/web/src/styles.css index a3040d3..7b5e25b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -374,13 +374,19 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .chart-guide { stroke: #30372d; stroke-width: 1; vector-effect: non-scaling-stroke; } .chart-scale { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font: 0.66rem/1.3 ui-monospace, "Cascadia Code", monospace; } .chart-empty { min-height: 220px; display: grid !important; place-items: center; color: var(--muted); font-size: 0.78rem; text-align: center; } -.telemetry-storage { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 15px; padding: 20px 23px; border: 1px solid var(--line); border-radius: var(--radius); } +.telemetry-storage { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 15px; padding: 20px 23px; border: 1px solid var(--line); border-radius: var(--radius); } .telemetry-storage > svg { color: var(--accent); } .telemetry-storage > div { min-width: 0; } .telemetry-storage strong { display: block; margin-bottom: 5px; } .telemetry-storage p, .telemetry-storage span { margin: 0; color: var(--muted); font-size: 0.75rem; line-height: 1.4; } -.telemetry-storage > div:last-child { text-align: right; } -.telemetry-storage > div:last-child strong { font: 750 1.15rem/1 ui-monospace, "Cascadia Code", monospace; } +.telemetry-storage-summary { text-align: right; } +.telemetry-storage-summary strong { font: 750 1.15rem/1 ui-monospace, "Cascadia Code", monospace; } +.telemetry-clear-button { min-height: 46px; padding: 0 17px; } +.clear-statistics-options { display: grid; gap: 10px; margin-top: 22px; } +.clear-statistics-options button { width: 100%; min-height: 82px; height: auto; display: grid; justify-items: start; gap: 5px; padding: 15px 18px; font-size: 0.82rem; text-align: left; white-space: normal; } +.clear-statistics-options button small { color: var(--muted); font-size: 0.72rem; font-weight: 500; line-height: 1.4; } +.clear-statistics-options .danger-button small { color: #e7b8b1; } +.status-success { display: flex; align-items: center; min-height: 52px; padding: 12px 16px; color: var(--accent); background: rgba(185, 231, 105, 0.08); border: 1px solid rgba(185, 231, 105, 0.24); border-radius: 10px; } @media (max-width: 1200px) { .adb-command-grid { grid-template-columns: 1fr; } @@ -418,5 +424,6 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .history-panel { padding: 19px; } .history-chart svg { height: 180px; } .telemetry-storage { grid-template-columns: auto minmax(0, 1fr); } - .telemetry-storage > div:last-child { grid-column: 1 / -1; text-align: left; } + .telemetry-storage-summary { grid-column: 1 / -1; text-align: left; } + .telemetry-clear-button { grid-column: 1 / -1; width: 100%; padding: 0 17px; font-size: inherit; } }