feat: add direct car telemetry management
This commit is contained in:
+11
-11
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" } } },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user