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 # Car telemetry and retention
The Pi samples the MG4 every 10 seconds while ADB is enabled. Speed, gear, and steering The Pi samples the MG4 every 10 seconds while ADB is enabled. Gear and steering angle
angle come from the verified ArcSoft properties. Battery values come from the read-only come from the verified ArcSoft properties. Battery values, range, and speed are read
content provider in `cloud.molberg.mgutility`: directly from the factory SAIC vehicle Binder service by a bundled read-only DEX probe:
```bash ```bash
adb shell content call \ adb push server/assets/car-telemetry-probe.dex /data/local/tmp/pi-car-telemetry-probe.dex
--uri content://cloud.molberg.mgutility.vehicle \ adb shell CLASSPATH=/data/local/tmp/pi-car-telemetry-probe.dex \
--method sample app_process /system/bin cloud.molberg.picarprobe.CarTelemetryProbe
``` ```
The provider must be present in the installed MG Utility APK. Until it connects to the The helper is not installed and has no dependency on MG Utility. It performs only known
SAIC vehicle service, battery fields remain unavailable while the ArcSoft signals and getter transactions. If the factory service is unavailable, battery fields remain
their history continue working. unavailable while the ArcSoft signals and their history continue working.
## Storage limits ## 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 - Peak values mean the highest value observed at a 10-second sampling point. They are
not guaranteed to capture a shorter event between samples. not guaranteed to capture a shorter event between samples.
The collector sends no vehicle-control calls. Installing MG Utility and enabling ADB The collector sends no vehicle-control calls. Enabling and authorizing ADB remains a
remain parked-vehicle administration tasks. 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 - 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 unless a later native activity is explicitly designed, declared, and validated as
distraction optimized. 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 serial or exactly one automatically discovered device, refuses ambiguous multi-device
connections, uses argument-array process execution, enforces timeouts and output bounds, connections, uses argument-array process execution, enforces timeouts and output bounds,
exposes unavailable/unauthorized/offline states, and registers every operation in a 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 or an unrestricted file path. Package and component targets are accepted only through
strict Android-name validation and fixed `am` or `monkey` argument arrays. strict Android-name validation and fixed `am` or `monkey` argument arrays.
- Device identity, connection health, display, package inventory, bounded APK install, - Device identity, connection health, display, package inventory, bounded APK install,
validated app launch, the three confirmed ArcSoft vehicle properties, and the typed validated app launch, the three confirmed ArcSoft vehicle properties, and direct
read-only MG Utility battery bridge are in scope. read-only factory vehicle-service telemetry are in scope.
State-changing ADB actions are authenticated, CSRF-protected, rate-limited where State-changing ADB actions are authenticated, CSRF-protected, rate-limited where
appropriate, audited, and intended for parked use. appropriate, audited, and intended for parked use.
- Do not use ADB or the exported SAIC vehicle service to actuate vehicle hardware in - 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, this project. Vehicle telemetry remains read-only. Battery, range, voltage, consumption,
and charging state pass through MG Utility's typed content provider; additional SAIC and charging state come from fixed getter transactions made by the non-installed ADB
signals remain a research track until their semantics are documented and tested. probe; additional SAIC signals remain a research track until documented and tested.
### Web experience ### 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 missing, the dashboard reports that state without substituting generated data. APK
installation and app launches are administrator actions intended for a parked vehicle. 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: The Car page refreshes the verified live properties `arcsoft.avm.mCurCarSpeed`,
`arcsoft.avm.mCurCarSpeed`, `arcsoft.avm.mCurCarGear`, and `arcsoft.avm.mCurCarGear`, and `arcsoft.avm.mCurCarWheelAngle`. Traction-battery and
`arcsoft.avm.mCurCarWheelAngle`. The property dump does not expose traction-battery or range values are queried directly from the factory SAIC vehicle service using a small
range values. Those fields remain unavailable until a read-only Android bridge binds to read-only DEX probe run through ADB. The probe is pushed to `/data/local/tmp` and run
SAIC's exported vehicle service; head-unit battery data is deliberately not presented as with `app_process`; it is not installed as an Android app and does not depend on MG
the car battery. Utility. Android's head-unit battery status is deliberately not presented as the car
battery.
### System controls and shell ### 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 { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util"; import { promisify } from "node:util";
import type { Availability } from "./types.js"; import type { Availability } from "./types.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const MAX_ADB_OUTPUT_BYTES = 16 * 1024; const MAX_ADB_OUTPUT_BYTES = 16 * 1024;
const MAX_ADB_LIST_OUTPUT_BYTES = 512 * 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 const MAX_APK_BYTES = 256 * 1024 * 1024;
export type AdbConfig = { 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`); return Number.isFinite(value) ? { available: true, value } : unavailable(`${label} is not exposed by the head unit`);
} }
function parseContentBundle(output: string): Map<string, string> { function parseProbeOutput(output: string): Map<string, string> | null {
const values = new Map<string, string>(); const line = output.split(/\r?\n/).find((candidate) => candidate.trim().startsWith("{"));
for (const match of output.matchAll(/(?:^|[{,\s])(\w+)=([^,}\]]+)/g)) { if (!line) return null;
values.set(match[1]!, match[2]!.trim()); 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( function bundleNumber(
@@ -257,40 +265,63 @@ function bundleNumber(
const value = Number(values?.get(key)); const value = Number(values?.get(key));
return Number.isFinite(value) && value >= minimum && value <= maximum return Number.isFinite(value) && value >= minimum && value <= maximum
? { available: true, value } ? { 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( export async function collectCarTelemetry(
config: AdbConfig, config: AdbConfig,
execute: AdbExecutor = defaultExecutor execute: AdbExecutor = defaultExecutor
): Promise<CarTelemetry> { ): 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", "getprop"], execute, MAX_ADB_LIST_OUTPUT_BYTES),
executeForDevice( sampleVehicleService(config, execute).catch(() => null)
config,
["shell", "content", "call", "--uri", "content://cloud.molberg.mgutility.vehicle", "--method", "sample"],
execute,
MAX_ADB_OUTPUT_BYTES
).catch(() => null)
]); ]);
const output = propertiesResult.stdout; const output = propertiesResult.stdout;
const properties = parseGetprop(output); const properties = parseGetprop(output);
const rawGear = properties.get("arcsoft.avm.mCurCarGear"); const rawGear = properties.get("arcsoft.avm.mCurCarGear");
const bridge = bridgeResult ? parseContentBundle(bridgeResult.stdout) : null; const serviceSpeed = bundleNumber(vehicleValues, "speedKph", "Vehicle speed", 0, 400);
const bridgeReady = bridge?.get("status") === "ok";
const bridgeValues = bridgeReady ? bridge : null;
const bridgeSpeed = bundleNumber(bridgeValues, "speedKph", "Vehicle speed", 0, 400);
return { return {
available: true, available: true,
collectedAt: new Date().toISOString(), 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"), gear: rawGear ? { available: true, value: rawGear } : unavailable("Gear is not exposed by the head unit"),
wheelAngleDegrees: numericProperty(properties, "arcsoft.avm.mCurCarWheelAngle", "Wheel angle"), wheelAngleDegrees: numericProperty(properties, "arcsoft.avm.mCurCarWheelAngle", "Wheel angle"),
batteryPercent: bundleNumber(bridgeValues, "soc", "Traction battery", 0, 100), batteryPercent: bundleNumber(vehicleValues, "soc", "Traction battery", 0, 100),
rangeKm: bundleNumber(bridgeValues, "rangeKm", "Estimated range", 0, 1_500), rangeKm: bundleNumber(vehicleValues, "rangeKm", "Estimated range", 0, 1_500),
batteryVoltage: bundleNumber(bridgeValues, "batteryVolts", "Battery voltage", 0, 1_000), batteryVoltage: bundleNumber(vehicleValues, "batteryVolts", "Battery voltage", 0, 1_000),
totalConsumptionKwh: bundleNumber(bridgeValues, "totalConsumptionKwh", "Consumption", 0, 10_000), totalConsumptionKwh: bundleNumber(vehicleValues, "totalConsumptionKwh", "Consumption", 0, 10_000),
chargingStatus: bundleNumber(bridgeValues, "chargingStatus", "Charging status", -1, 100) 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 }; 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 UserRow = { id: number; username: string; role: "admin"; password_hash: string };
type SessionUserRow = { id: number; username: string; role: "admin" }; type SessionUserRow = { id: number; username: string; role: "admin" };
@@ -505,6 +506,30 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
return carTelemetry.store.overview(); 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( app.post(
"/api/network/configuration", "/api/network/configuration",
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } }, { 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 TelemetryPeriod = "24h" | "7d" | "30d" | "1y" | "all";
export type ClearTelemetryMode = "history" | "all";
export type TelemetryHistoryPoint = { export type TelemetryHistoryPoint = {
timestamp: number; timestamp: number;
@@ -252,6 +253,39 @@ export class CarTelemetryStore {
transaction(); 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() { overview() {
const stats = this.database.prepare("SELECT * FROM car_telemetry_stats WHERE id = 1").get() as Record<string, number | null>; 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 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"); 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 () => { it("reads vehicle data through a pushed ADB probe without an installed companion app", async () => {
const execute: AdbExecutor = async (_executable, arguments_) => arguments_.includes("content") const calls: string[][] = [];
? { stdout: "Result: Bundle[{status=ok, sampledAt=1785540000000, soc=73.5, rangeKm=286, speedKph=18.0, batteryVolts=397.4, totalConsumptionKwh=6.2, chargingStatus=0}]", stderr: "" } const execute: AdbExecutor = async (_executable, arguments_) => {
: { stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n", stderr: "" }; 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({ await expect(collectCarTelemetry(enabledConfig, execute)).resolves.toMatchObject({
available: true, available: true,
speedKph: { available: true, value: 18 }, speedKph: { available: true, value: 18 },
@@ -184,6 +189,23 @@ describe("ADB dashboard operations", () => {
totalConsumptionKwh: { available: true, value: 6.2 }, totalConsumptionKwh: { available: true, value: 6.2 },
chargingStatus: { available: true, value: 0 } 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 () => { 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); expect((await app.inject({ method: "GET", url: "/api/car/statistics" })).statusCode).toBe(401);
const token = await csrf(app); 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); await setup(app, token);
const login = await app.inject({ const login = await app.inject({
method: "POST", method: "POST",
@@ -276,6 +282,33 @@ describe("authentication boundary", () => {
const invalidPeriod = await app.inject({ method: "GET", url: "/api/car/history?period=forever", headers: { cookie } }); const invalidPeriod = await app.inject({ method: "GET", url: "/api/car/history?period=forever", headers: { cookie } });
expect(invalidPeriod.statusCode).toBe(400); 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({ const invalid = await app.inject({
method: "POST", method: "POST",
url: "/api/adb/shortcuts", url: "/api/adb/shortcuts",
+54
View File
@@ -78,4 +78,58 @@ describe("car telemetry history", () => {
expect(store.history("24h").points).toHaveLength(1); expect(store.history("24h").points).toHaveLength(1);
database.close(); 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" ? <> {page === "home" ? <>
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>} {error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />} {!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> </main>
</div> </div>
); );
+46 -3
View File
@@ -10,16 +10,19 @@ import {
Speedometer, Speedometer,
SteeringWheel, SteeringWheel,
Timer, Timer,
Trash,
Trophy, Trophy,
WarningCircle WarningCircle
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { import {
clearCarStatistics,
getCarHistory, getCarHistory,
getCarStatistics, getCarStatistics,
getCarTelemetry, getCarTelemetry,
type Availability, type Availability,
type CarStatistics, type CarStatistics,
type CarTelemetry, type CarTelemetry,
type ClearTelemetryMode,
type TelemetryHistoryPoint, type TelemetryHistoryPoint,
type TelemetryPeriod type TelemetryPeriod
} from "./api"; } from "./api";
@@ -32,12 +35,15 @@ const periods: Array<{ value: TelemetryPeriod; label: string }> = [
{ value: "all", label: "All" } { value: "all", label: "All" }
]; ];
export function CarPage() { export function CarPage({ csrfToken }: { csrfToken: string }) {
const [telemetry, setTelemetry] = useState<CarTelemetry | null>(null); const [telemetry, setTelemetry] = useState<CarTelemetry | null>(null);
const [statistics, setStatistics] = useState<CarStatistics | null>(null); const [statistics, setStatistics] = useState<CarStatistics | null>(null);
const [history, setHistory] = useState<TelemetryHistoryPoint[]>([]); const [history, setHistory] = useState<TelemetryHistoryPoint[]>([]);
const [period, setPeriod] = useState<TelemetryPeriod>("24h"); const [period, setPeriod] = useState<TelemetryPeriod>("24h");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [clearDialog, setClearDialog] = useState(false);
const [clearing, setClearing] = useState<ClearTelemetryMode | null>(null);
const refreshLive = useCallback(async () => { const refreshLive = useCallback(async () => {
try { try {
@@ -74,9 +80,27 @@ export function CarPage() {
const angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0; const angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0;
const speedArc = Math.min(Math.max((speed ?? 0) / 180, 0), 1); 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 ( return (
<div className="car-page"> <div className="car-page">
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}</div>} {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"> <section className="drive-stage">
<div className="speed-cluster"> <div className="speed-cluster">
<div className="speed-arc" style={{ "--speed": speedArc } as React.CSSProperties} /> <div className="speed-arc" style={{ "--speed": speedArc } as React.CSSProperties} />
@@ -138,13 +162,32 @@ export function CarPage() {
<section className="telemetry-storage"> <section className="telemetry-storage">
<Database size={26} /> <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>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> </section>
<footer className="telemetry-footer"> <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> <span>{telemetry ? `Updated ${new Date(telemetry.collectedAt).toLocaleTimeString()}` : "Waiting for telemetry"}</span>
</footer> </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> </div>
); );
} }
+6
View File
@@ -282,3 +282,9 @@ export async function getCarHistory(period: TelemetryPeriod): Promise<{ period:
export async function getCarStatistics(): Promise<CarStatistics> { export async function getCarStatistics(): Promise<CarStatistics> {
return parseResponse<CarStatistics>(await fetch("/api/car/statistics", { credentials: "same-origin" })); 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-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-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; } .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 > svg { color: var(--accent); }
.telemetry-storage > div { min-width: 0; } .telemetry-storage > div { min-width: 0; }
.telemetry-storage strong { display: block; margin-bottom: 5px; } .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 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-summary { text-align: right; }
.telemetry-storage > div:last-child strong { font: 750 1.15rem/1 ui-monospace, "Cascadia Code", monospace; } .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) { @media (max-width: 1200px) {
.adb-command-grid { grid-template-columns: 1fr; } .adb-command-grid { grid-template-columns: 1fr; }
@@ -418,5 +424,6 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.history-panel { padding: 19px; } .history-panel { padding: 19px; }
.history-chart svg { height: 180px; } .history-chart svg { height: 180px; }
.telemetry-storage { grid-template-columns: auto minmax(0, 1fr); } .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; }
} }