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
+11 -11
View File
@@ -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.
+5 -5
View File
@@ -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
+7 -6
View File
@@ -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
+10
View File
@@ -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.
@@ -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);
}
}
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();
});
});
+1 -1
View File
@@ -232,7 +232,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
{page === "home" ? <>
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
</> : page === "car" ? <CarPage /> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
</> : page === "car" ? <CarPage csrfToken={auth.csrfToken} /> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
</main>
</div>
);
+46 -3
View File
@@ -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<CarTelemetry | null>(null);
const [statistics, setStatistics] = useState<CarStatistics | null>(null);
const [history, setHistory] = useState<TelemetryHistoryPoint[]>([]);
const [period, setPeriod] = useState<TelemetryPeriod>("24h");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [clearDialog, setClearDialog] = useState(false);
const [clearing, setClearing] = useState<ClearTelemetryMode | null>(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 (
<div className="car-page">
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}</div>}
{notice && <div className="status-success" role="status">{notice}</div>}
<section className="drive-stage">
<div className="speed-cluster">
<div className="speed-arc" style={{ "--speed": speedArc } as React.CSSProperties} />
@@ -138,13 +162,32 @@ export function CarPage() {
<section className="telemetry-storage">
<Database size={26} />
<div><strong>Bounded telemetry archive</strong><p>Raw samples: {statistics?.storage.rawSamples.toLocaleString() ?? "0"} of {statistics?.storage.rawSampleCap.toLocaleString() ?? "800,000"}. Hourly rollups: {statistics?.storage.hourlyRollups.toLocaleString() ?? "0"}.</p></div>
<div><strong>{formatBytes(statistics?.storage.estimatedBytes ?? 0)}</strong><span>{statistics?.storage.rawRetentionDays ?? 90} days raw, {statistics?.storage.rollupRetentionYears ?? 10} years rolled up</span></div>
<div className="telemetry-storage-summary"><strong>{formatBytes(statistics?.storage.estimatedBytes ?? 0)}</strong><span>{statistics?.storage.rawRetentionDays ?? 90} days raw, {statistics?.storage.rollupRetentionYears ?? 10} years rolled up</span></div>
<button className="secondary-button telemetry-clear-button" onClick={() => setClearDialog(true)}><Trash size={20} />Clear statistics</button>
</section>
<footer className="telemetry-footer">
<span>Sources: SAIC vehicle service through MG Utility and live ADB properties</span>
<span>Sources: factory SAIC vehicle service and live properties, queried directly over ADB</span>
<span>{telemetry ? `Updated ${new Date(telemetry.collectedAt).toLocaleTimeString()}` : "Waiting for telemetry"}</span>
</footer>
{clearDialog && <div className="modal-backdrop" onMouseDown={() => !clearing && setClearDialog(false)}>
<div className="confirm-dialog telemetry-clear-dialog" role="dialog" aria-modal="true" aria-labelledby="clear-statistics-title" onMouseDown={(event) => event.stopPropagation()}>
<h2 id="clear-statistics-title">Clear car statistics</h2>
<p>Choose whether to keep your long-term records. This cannot be undone.</p>
<div className="clear-statistics-options">
<button className="secondary-button" onClick={() => void clearStatistics("history")} disabled={Boolean(clearing)}>
<span>{clearing === "history" ? "Clearing..." : "Clear data, keep all-time info"}</span>
<small>Deletes graphs and stored history. Keeps peaks, tracked totals, charging sessions, and range learning.</small>
</button>
<button className="danger-button" onClick={() => void clearStatistics("all")} disabled={Boolean(clearing)}>
<span>{clearing === "all" ? "Clearing..." : "Clear ALL data"}</span>
<small>Deletes history and resets every peak, total, counter, and learned estimate.</small>
</button>
</div>
<div className="dialog-actions"><button className="secondary-button" onClick={() => setClearDialog(false)} disabled={Boolean(clearing)}>Cancel</button></div>
</div>
</div>}
</div>
);
}
+6
View File
@@ -282,3 +282,9 @@ export async function getCarHistory(period: TelemetryPeriod): Promise<{ period:
export async function getCarStatistics(): Promise<CarStatistics> {
return parseResponse<CarStatistics>(await fetch("/api/car/statistics", { credentials: "same-origin" }));
}
export type ClearTelemetryMode = "history" | "all";
export async function clearCarStatistics(mode: ClearTelemetryMode, csrfToken: string): Promise<void> {
await post("/api/car/history/clear", { mode }, csrfToken);
}
+11 -4
View File
@@ -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; }
}