Add persistent car performance analytics
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
# 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`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb shell content call \
|
||||||
|
--uri content://cloud.molberg.mgutility.vehicle \
|
||||||
|
--method sample
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Storage limits
|
||||||
|
|
||||||
|
- Raw 10-second samples are retained for 90 days and capped at 800,000 rows.
|
||||||
|
- Hourly rollups are retained for 10 years and capped at 88,000 rows.
|
||||||
|
- Lifetime counters and observed records survive raw-data pruning.
|
||||||
|
- Graph endpoints use indexed time buckets and return compact aggregates rather than
|
||||||
|
every raw sample.
|
||||||
|
|
||||||
|
At the configured interval the raw cap is approximately 90 days. Expected storage is
|
||||||
|
well below 150 MB, although SQLite page allocation and filesystem details can vary.
|
||||||
|
|
||||||
|
## Derived statistics
|
||||||
|
|
||||||
|
- Tracked distance integrates consecutive speed samples no more than 60 seconds apart.
|
||||||
|
- The dashboard's range estimate uses tracked distance per observed SOC consumed. It
|
||||||
|
waits for at least 10 km and 5 percentage points of discharge before showing a value.
|
||||||
|
- Charging power is an estimate based on SOC gain over a 2-10 minute window and the
|
||||||
|
MG4 Luxury 2022 usable-capacity assumption of 61.7 kWh. It is deliberately labeled as
|
||||||
|
estimated and is capped at 350 kW to reject corrupt samples.
|
||||||
|
- 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.
|
||||||
@@ -237,13 +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, and the three confirmed ArcSoft vehicle properties are in scope.
|
validated app launch, the three confirmed ArcSoft vehicle properties, and the typed
|
||||||
|
read-only MG Utility battery bridge 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. The SAIC AIDL bridge for battery,
|
this project. Vehicle telemetry remains read-only. Battery, range, voltage, consumption,
|
||||||
range, and additional signals remains a later research track until semantics, privacy,
|
and charging state pass through MG Utility's typed content provider; additional SAIC
|
||||||
and parked-vehicle tests are documented.
|
signals remain a research track until their semantics are documented and tested.
|
||||||
|
|
||||||
### Web experience
|
### Web experience
|
||||||
|
|
||||||
|
|||||||
+44
-4
@@ -71,6 +71,9 @@ export type CarTelemetry = {
|
|||||||
wheelAngleDegrees: Availability<number>;
|
wheelAngleDegrees: Availability<number>;
|
||||||
batteryPercent: Availability<number>;
|
batteryPercent: Availability<number>;
|
||||||
rangeKm: Availability<number>;
|
rangeKm: Availability<number>;
|
||||||
|
batteryVoltage: Availability<number>;
|
||||||
|
totalConsumptionKwh: Availability<number>;
|
||||||
|
chargingStatus: Availability<number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultExecutor: AdbExecutor = async (executable, arguments_, options) => {
|
const defaultExecutor: AdbExecutor = async (executable, arguments_, options) => {
|
||||||
@@ -236,21 +239,58 @@ 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> {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
for (const match of output.matchAll(/(?:^|[{,\s])(\w+)=([^,}\]]+)/g)) {
|
||||||
|
values.set(match[1]!, match[2]!.trim());
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundleNumber(
|
||||||
|
values: Map<string, string> | null,
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number
|
||||||
|
): Availability<number> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function collectCarTelemetry(
|
export async function collectCarTelemetry(
|
||||||
config: AdbConfig,
|
config: AdbConfig,
|
||||||
execute: AdbExecutor = defaultExecutor
|
execute: AdbExecutor = defaultExecutor
|
||||||
): Promise<CarTelemetry> {
|
): Promise<CarTelemetry> {
|
||||||
const output = (await executeForDevice(config, ["shell", "getprop"], execute, MAX_ADB_LIST_OUTPUT_BYTES)).stdout;
|
const [propertiesResult, bridgeResult] = 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)
|
||||||
|
]);
|
||||||
|
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 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: numericProperty(properties, "arcsoft.avm.mCurCarSpeed", "Vehicle speed"),
|
speedKph: bridgeSpeed.available ? bridgeSpeed : 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: unavailable("Traction battery data requires the SAIC vehicle-service bridge"),
|
batteryPercent: bundleNumber(bridgeValues, "soc", "Traction battery", 0, 100),
|
||||||
rangeKm: unavailable("Range data requires the SAIC vehicle-service bridge")
|
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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -30,12 +30,12 @@ import {
|
|||||||
} from "./network.js";
|
} from "./network.js";
|
||||||
import {
|
import {
|
||||||
collectAdbStatus,
|
collectAdbStatus,
|
||||||
collectCarTelemetry,
|
|
||||||
installAdbApk,
|
installAdbApk,
|
||||||
launchAdbTarget,
|
launchAdbTarget,
|
||||||
listInstalledAdbPackages,
|
listInstalledAdbPackages,
|
||||||
MAX_APK_BYTES
|
MAX_APK_BYTES
|
||||||
} from "./adb.js";
|
} from "./adb.js";
|
||||||
|
import { CarTelemetryRecorder, type TelemetryPeriod } from "./car-telemetry.js";
|
||||||
import { openShell } from "./shell.js";
|
import { openShell } from "./shell.js";
|
||||||
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
|
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
|
||||||
import "./types.js";
|
import "./types.js";
|
||||||
@@ -139,7 +139,9 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
});
|
});
|
||||||
const database = openDatabase(config.databasePath);
|
const database = openDatabase(config.databasePath);
|
||||||
const jobRunner = new JobRunner(database, undefined, config.adb);
|
const jobRunner = new JobRunner(database, undefined, config.adb);
|
||||||
|
const carTelemetry = new CarTelemetryRecorder(database, config.adb);
|
||||||
if (config.adb.enabled) void collectAdbStatus(config.adb);
|
if (config.adb.enabled) void collectAdbStatus(config.adb);
|
||||||
|
carTelemetry.start();
|
||||||
|
|
||||||
app.decorate("database", database);
|
app.decorate("database", database);
|
||||||
app.decorate("jobRunner", jobRunner);
|
app.decorate("jobRunner", jobRunner);
|
||||||
@@ -154,6 +156,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
|
carTelemetry.stop();
|
||||||
jobRunner.close();
|
jobRunner.close();
|
||||||
database.close();
|
database.close();
|
||||||
});
|
});
|
||||||
@@ -482,12 +485,26 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
app.get("/api/car/telemetry", async (request, reply) => {
|
app.get("/api/car/telemetry", async (request, reply) => {
|
||||||
if (!requireUser(request, reply)) return;
|
if (!requireUser(request, reply)) return;
|
||||||
try {
|
try {
|
||||||
return await collectCarTelemetry(config.adb);
|
return await carTelemetry.readLive();
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(503).send({ error: { code: "CAR_TELEMETRY_UNAVAILABLE", message: "Live car telemetry is unavailable over ADB" } });
|
return reply.code(503).send({ error: { code: "CAR_TELEMETRY_UNAVAILABLE", message: "Live car telemetry is unavailable over ADB" } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { period?: string } }>("/api/car/history", async (request, reply) => {
|
||||||
|
if (!requireUser(request, reply)) return;
|
||||||
|
const parsed = z.enum(["24h", "7d", "30d", "1y", "all"]).safeParse(request.query.period ?? "24h");
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: { code: "INVALID_HISTORY_PERIOD", message: "That history period is not available" } });
|
||||||
|
}
|
||||||
|
return carTelemetry.store.history(parsed.data as TelemetryPeriod);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/car/statistics", async (request, reply) => {
|
||||||
|
if (!requireUser(request, reply)) return;
|
||||||
|
return carTelemetry.store.overview();
|
||||||
|
});
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
"/api/network/configuration",
|
"/api/network/configuration",
|
||||||
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
|
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import type { CompanionDatabase } from "./database.js";
|
||||||
|
import { collectCarTelemetry, type AdbConfig, type CarTelemetry } from "./adb.js";
|
||||||
|
|
||||||
|
const SAMPLE_INTERVAL_MS = 10_000;
|
||||||
|
const RAW_RETENTION_MS = 90 * 24 * 60 * 60 * 1_000;
|
||||||
|
const ROLLUP_RETENTION_MS = 10 * 365 * 24 * 60 * 60 * 1_000;
|
||||||
|
const MAX_RAW_SAMPLES = 800_000;
|
||||||
|
const MAX_HOURLY_ROLLUPS = 88_000;
|
||||||
|
const BATTERY_USABLE_KWH = 61.7;
|
||||||
|
|
||||||
|
type StoredSample = {
|
||||||
|
recordedAt: number;
|
||||||
|
speedKph: number | null;
|
||||||
|
wheelAngleDegrees: number | null;
|
||||||
|
batteryPercent: number | null;
|
||||||
|
rangeKm: number | null;
|
||||||
|
batteryVoltage: number | null;
|
||||||
|
totalConsumptionKwh: number | null;
|
||||||
|
chargingStatus: number | null;
|
||||||
|
chargingPowerKw: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelemetryPeriod = "24h" | "7d" | "30d" | "1y" | "all";
|
||||||
|
|
||||||
|
export type TelemetryHistoryPoint = {
|
||||||
|
timestamp: number;
|
||||||
|
speedKph: number | null;
|
||||||
|
peakSpeedKph: number | null;
|
||||||
|
batteryPercent: number | null;
|
||||||
|
rangeKm: number | null;
|
||||||
|
chargingPowerKw: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function value<T>(field: { available: true; value: T } | { available: false; reason: string }): T | null {
|
||||||
|
return field.available ? field.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finite(value_: unknown): number | null {
|
||||||
|
return typeof value_ === "number" && Number.isFinite(value_) ? value_ : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToStored(row: Record<string, unknown> | undefined): StoredSample | null {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
recordedAt: Number(row.recorded_at),
|
||||||
|
speedKph: finite(row.speed_kph),
|
||||||
|
wheelAngleDegrees: finite(row.wheel_angle_degrees),
|
||||||
|
batteryPercent: finite(row.battery_percent),
|
||||||
|
rangeKm: finite(row.range_km),
|
||||||
|
batteryVoltage: finite(row.battery_voltage),
|
||||||
|
totalConsumptionKwh: finite(row.total_consumption_kwh),
|
||||||
|
chargingStatus: finite(row.charging_status),
|
||||||
|
chargingPowerKw: finite(row.charging_power_kw)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CarTelemetryStore {
|
||||||
|
#samplesSincePrune = 0;
|
||||||
|
|
||||||
|
constructor(private readonly database: CompanionDatabase) {}
|
||||||
|
|
||||||
|
record(telemetry: CarTelemetry): StoredSample {
|
||||||
|
const recordedAt = Date.parse(telemetry.collectedAt);
|
||||||
|
const timestamp = Number.isFinite(recordedAt) ? recordedAt : Date.now();
|
||||||
|
const previous = rowToStored(this.database
|
||||||
|
.prepare("SELECT * FROM car_telemetry_samples ORDER BY recorded_at DESC LIMIT 1")
|
||||||
|
.get() as Record<string, unknown> | undefined);
|
||||||
|
const speedKph = value(telemetry.speedKph);
|
||||||
|
const batteryPercent = value(telemetry.batteryPercent);
|
||||||
|
const chargingStatus = value(telemetry.chargingStatus);
|
||||||
|
const chargingPowerKw = this.#estimateChargingPower(timestamp, batteryPercent, chargingStatus);
|
||||||
|
const sample: StoredSample = {
|
||||||
|
recordedAt: timestamp,
|
||||||
|
speedKph,
|
||||||
|
wheelAngleDegrees: value(telemetry.wheelAngleDegrees),
|
||||||
|
batteryPercent,
|
||||||
|
rangeKm: value(telemetry.rangeKm),
|
||||||
|
batteryVoltage: value(telemetry.batteryVoltage),
|
||||||
|
totalConsumptionKwh: value(telemetry.totalConsumptionKwh),
|
||||||
|
chargingStatus,
|
||||||
|
chargingPowerKw
|
||||||
|
};
|
||||||
|
|
||||||
|
const deltaSeconds = previous ? Math.max(0, (timestamp - previous.recordedAt) / 1_000) : 0;
|
||||||
|
const contiguous = deltaSeconds > 0 && deltaSeconds <= 60;
|
||||||
|
const averageSpeed = contiguous && speedKph !== null && previous !== null && previous.speedKph !== null
|
||||||
|
? (speedKph + previous.speedKph) / 2
|
||||||
|
: speedKph ?? 0;
|
||||||
|
const distanceKm = contiguous && averageSpeed > 1 ? averageSpeed * deltaSeconds / 3_600 : 0;
|
||||||
|
const drivingSeconds = distanceKm > 0 ? deltaSeconds : 0;
|
||||||
|
const charging = chargingStatus !== null && chargingStatus > 0;
|
||||||
|
const wasCharging = previous !== null && previous.chargingStatus !== null && previous.chargingStatus > 0;
|
||||||
|
const chargingSeconds = contiguous && charging ? deltaSeconds : 0;
|
||||||
|
const chargeSession = charging && !wasCharging ? 1 : 0;
|
||||||
|
const socDrop = contiguous && !charging && batteryPercent !== null && previous !== null && previous.batteryPercent !== null
|
||||||
|
? Math.min(Math.max(previous.batteryPercent - batteryPercent, 0), 2)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
this.database.prepare(
|
||||||
|
`INSERT OR REPLACE INTO car_telemetry_samples
|
||||||
|
(recorded_at, speed_kph, wheel_angle_degrees, battery_percent, range_km,
|
||||||
|
battery_voltage, total_consumption_kwh, charging_status, charging_power_kw)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
sample.recordedAt,
|
||||||
|
sample.speedKph,
|
||||||
|
sample.wheelAngleDegrees,
|
||||||
|
sample.batteryPercent,
|
||||||
|
sample.rangeKm,
|
||||||
|
sample.batteryVoltage,
|
||||||
|
sample.totalConsumptionKwh,
|
||||||
|
sample.chargingStatus,
|
||||||
|
sample.chargingPowerKw
|
||||||
|
);
|
||||||
|
this.#updateRollup(sample, distanceKm, drivingSeconds, chargingSeconds);
|
||||||
|
this.#updateStats(sample, distanceKm, drivingSeconds, chargingSeconds, chargeSession, socDrop);
|
||||||
|
});
|
||||||
|
transaction();
|
||||||
|
|
||||||
|
this.#samplesSincePrune += 1;
|
||||||
|
if (this.#samplesSincePrune >= 360) {
|
||||||
|
this.prune(timestamp);
|
||||||
|
this.#samplesSincePrune = 0;
|
||||||
|
}
|
||||||
|
return sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
#estimateChargingPower(recordedAt: number, batteryPercent: number | null, chargingStatus: number | null): number | null {
|
||||||
|
if (batteryPercent === null || chargingStatus === null || chargingStatus <= 0) return null;
|
||||||
|
const baseline = this.database.prepare(
|
||||||
|
`SELECT recorded_at, battery_percent FROM car_telemetry_samples
|
||||||
|
WHERE recorded_at BETWEEN ? AND ? AND battery_percent IS NOT NULL
|
||||||
|
ORDER BY recorded_at DESC LIMIT 1`
|
||||||
|
).get(recordedAt - 10 * 60_000, recordedAt - 2 * 60_000) as { recorded_at: number; battery_percent: number } | undefined;
|
||||||
|
if (!baseline) return null;
|
||||||
|
const elapsedHours = (recordedAt - baseline.recorded_at) / 3_600_000;
|
||||||
|
const socGain = batteryPercent - baseline.battery_percent;
|
||||||
|
if (elapsedHours <= 0 || socGain < 0.2) return null;
|
||||||
|
const estimate = (socGain / 100) * BATTERY_USABLE_KWH / elapsedHours;
|
||||||
|
return estimate > 0 && estimate <= 350 ? Math.round(estimate * 10) / 10 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateRollup(sample: StoredSample, distanceKm: number, drivingSeconds: number, chargingSeconds: number): void {
|
||||||
|
const bucketStart = Math.floor(sample.recordedAt / 3_600_000) * 3_600_000;
|
||||||
|
const speedCount = sample.speedKph === null ? 0 : 1;
|
||||||
|
const socCount = sample.batteryPercent === null ? 0 : 1;
|
||||||
|
const rangeCount = sample.rangeKm === null ? 0 : 1;
|
||||||
|
const powerCount = sample.chargingPowerKw === null ? 0 : 1;
|
||||||
|
this.database.prepare(
|
||||||
|
`INSERT INTO car_telemetry_hourly
|
||||||
|
(bucket_start, sample_count, speed_sum, speed_count, speed_max, soc_sum, soc_count, soc_min, soc_max,
|
||||||
|
range_sum, range_count, range_max, charge_power_sum, charge_power_count, charge_power_max,
|
||||||
|
distance_km, driving_seconds, charging_seconds)
|
||||||
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(bucket_start) DO UPDATE SET
|
||||||
|
sample_count = sample_count + 1,
|
||||||
|
speed_sum = speed_sum + excluded.speed_sum,
|
||||||
|
speed_count = speed_count + excluded.speed_count,
|
||||||
|
speed_max = CASE WHEN excluded.speed_max IS NULL THEN speed_max WHEN speed_max IS NULL OR excluded.speed_max > speed_max THEN excluded.speed_max ELSE speed_max END,
|
||||||
|
soc_sum = soc_sum + excluded.soc_sum,
|
||||||
|
soc_count = soc_count + excluded.soc_count,
|
||||||
|
soc_min = CASE WHEN excluded.soc_min IS NULL THEN soc_min WHEN soc_min IS NULL OR excluded.soc_min < soc_min THEN excluded.soc_min ELSE soc_min END,
|
||||||
|
soc_max = CASE WHEN excluded.soc_max IS NULL THEN soc_max WHEN soc_max IS NULL OR excluded.soc_max > soc_max THEN excluded.soc_max ELSE soc_max END,
|
||||||
|
range_sum = range_sum + excluded.range_sum,
|
||||||
|
range_count = range_count + excluded.range_count,
|
||||||
|
range_max = CASE WHEN excluded.range_max IS NULL THEN range_max WHEN range_max IS NULL OR excluded.range_max > range_max THEN excluded.range_max ELSE range_max END,
|
||||||
|
charge_power_sum = charge_power_sum + excluded.charge_power_sum,
|
||||||
|
charge_power_count = charge_power_count + excluded.charge_power_count,
|
||||||
|
charge_power_max = CASE WHEN excluded.charge_power_max IS NULL THEN charge_power_max WHEN charge_power_max IS NULL OR excluded.charge_power_max > charge_power_max THEN excluded.charge_power_max ELSE charge_power_max END,
|
||||||
|
distance_km = distance_km + excluded.distance_km,
|
||||||
|
driving_seconds = driving_seconds + excluded.driving_seconds,
|
||||||
|
charging_seconds = charging_seconds + excluded.charging_seconds`
|
||||||
|
).run(
|
||||||
|
bucketStart,
|
||||||
|
sample.speedKph ?? 0,
|
||||||
|
speedCount,
|
||||||
|
sample.speedKph,
|
||||||
|
sample.batteryPercent ?? 0,
|
||||||
|
socCount,
|
||||||
|
sample.batteryPercent,
|
||||||
|
sample.batteryPercent,
|
||||||
|
sample.rangeKm ?? 0,
|
||||||
|
rangeCount,
|
||||||
|
sample.rangeKm,
|
||||||
|
sample.chargingPowerKw ?? 0,
|
||||||
|
powerCount,
|
||||||
|
sample.chargingPowerKw,
|
||||||
|
distanceKm,
|
||||||
|
drivingSeconds,
|
||||||
|
chargingSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateStats(
|
||||||
|
sample: StoredSample,
|
||||||
|
distanceKm: number,
|
||||||
|
drivingSeconds: number,
|
||||||
|
chargingSeconds: number,
|
||||||
|
chargeSession: number,
|
||||||
|
socDrop: number
|
||||||
|
): void {
|
||||||
|
this.database.prepare(
|
||||||
|
`UPDATE car_telemetry_stats SET
|
||||||
|
first_sample_at = COALESCE(first_sample_at, ?),
|
||||||
|
last_sample_at = ?,
|
||||||
|
sample_count = sample_count + 1,
|
||||||
|
peak_speed_at = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_at END,
|
||||||
|
peak_speed_kph = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_kph END,
|
||||||
|
peak_charging_power_at = CASE WHEN ? IS NOT NULL AND (peak_charging_power_kw IS NULL OR ? > peak_charging_power_kw) THEN ? ELSE peak_charging_power_at END,
|
||||||
|
peak_charging_power_kw = CASE WHEN ? IS NOT NULL AND (peak_charging_power_kw IS NULL OR ? > peak_charging_power_kw) THEN ? ELSE peak_charging_power_kw END,
|
||||||
|
max_observed_range_at = CASE WHEN ? IS NOT NULL AND (max_observed_range_km IS NULL OR ? > max_observed_range_km) THEN ? ELSE max_observed_range_at END,
|
||||||
|
max_observed_range_km = CASE WHEN ? IS NOT NULL AND (max_observed_range_km IS NULL OR ? > max_observed_range_km) THEN ? ELSE max_observed_range_km END,
|
||||||
|
tracked_distance_km = tracked_distance_km + ?,
|
||||||
|
driving_seconds = driving_seconds + ?,
|
||||||
|
charging_seconds = charging_seconds + ?,
|
||||||
|
charge_sessions = charge_sessions + ?,
|
||||||
|
soc_used_percent = soc_used_percent + ?
|
||||||
|
WHERE id = 1`
|
||||||
|
).run(
|
||||||
|
sample.recordedAt,
|
||||||
|
sample.recordedAt,
|
||||||
|
sample.speedKph, sample.speedKph, sample.recordedAt,
|
||||||
|
sample.speedKph, sample.speedKph, sample.speedKph,
|
||||||
|
sample.chargingPowerKw, sample.chargingPowerKw, sample.recordedAt,
|
||||||
|
sample.chargingPowerKw, sample.chargingPowerKw, sample.chargingPowerKw,
|
||||||
|
sample.rangeKm, sample.rangeKm, sample.recordedAt,
|
||||||
|
sample.rangeKm, sample.rangeKm, sample.rangeKm,
|
||||||
|
distanceKm,
|
||||||
|
drivingSeconds,
|
||||||
|
chargingSeconds,
|
||||||
|
chargeSession,
|
||||||
|
socDrop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
prune(now = Date.now()): void {
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
this.database.prepare("DELETE FROM car_telemetry_samples WHERE recorded_at < ?").run(now - RAW_RETENTION_MS);
|
||||||
|
this.database.prepare(
|
||||||
|
`DELETE FROM car_telemetry_samples WHERE recorded_at IN (
|
||||||
|
SELECT recorded_at FROM car_telemetry_samples ORDER BY recorded_at DESC LIMIT -1 OFFSET ?
|
||||||
|
)`
|
||||||
|
).run(MAX_RAW_SAMPLES);
|
||||||
|
this.database.prepare("DELETE FROM car_telemetry_hourly WHERE bucket_start < ?").run(now - ROLLUP_RETENTION_MS);
|
||||||
|
this.database.prepare(
|
||||||
|
`DELETE FROM car_telemetry_hourly WHERE bucket_start IN (
|
||||||
|
SELECT bucket_start FROM car_telemetry_hourly ORDER BY bucket_start DESC LIMIT -1 OFFSET ?
|
||||||
|
)`
|
||||||
|
).run(MAX_HOURLY_ROLLUPS);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
const storage = this.database.prepare(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*) FROM car_telemetry_samples) AS raw_samples,
|
||||||
|
(SELECT MIN(recorded_at) FROM car_telemetry_samples) AS oldest_raw_at,
|
||||||
|
(SELECT COUNT(*) FROM car_telemetry_hourly) AS hourly_rollups,
|
||||||
|
(SELECT MIN(bucket_start) FROM car_telemetry_hourly) AS oldest_rollup_at`
|
||||||
|
).get() as Record<string, number | null>;
|
||||||
|
const trackedDistance = Number(stats.tracked_distance_km ?? 0);
|
||||||
|
const drivingSeconds = Number(stats.driving_seconds ?? 0);
|
||||||
|
const socUsed = Number(stats.soc_used_percent ?? 0);
|
||||||
|
const ownRangeEstimate = trackedDistance >= 10 && socUsed >= 5 ? trackedDistance / socUsed * 100 : null;
|
||||||
|
return {
|
||||||
|
latest,
|
||||||
|
records: {
|
||||||
|
peakSpeedKph: finite(stats.peak_speed_kph),
|
||||||
|
peakSpeedAt: finite(stats.peak_speed_at),
|
||||||
|
peakChargingPowerKw: finite(stats.peak_charging_power_kw),
|
||||||
|
peakChargingPowerAt: finite(stats.peak_charging_power_at),
|
||||||
|
maxObservedRangeKm: finite(stats.max_observed_range_km),
|
||||||
|
maxObservedRangeAt: finite(stats.max_observed_range_at)
|
||||||
|
},
|
||||||
|
lifetime: {
|
||||||
|
firstSampleAt: finite(stats.first_sample_at),
|
||||||
|
lastSampleAt: finite(stats.last_sample_at),
|
||||||
|
sampleCount: Number(stats.sample_count ?? 0),
|
||||||
|
trackedDistanceKm: trackedDistance,
|
||||||
|
drivingSeconds,
|
||||||
|
chargingSeconds: Number(stats.charging_seconds ?? 0),
|
||||||
|
chargeSessions: Number(stats.charge_sessions ?? 0),
|
||||||
|
socUsedPercent: socUsed,
|
||||||
|
averageMovingSpeedKph: drivingSeconds > 0 ? trackedDistance / (drivingSeconds / 3_600) : null,
|
||||||
|
ownRangeEstimateKm: ownRangeEstimate
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
rawSamples: Number(storage.raw_samples ?? 0),
|
||||||
|
hourlyRollups: Number(storage.hourly_rollups ?? 0),
|
||||||
|
oldestRawAt: finite(storage.oldest_raw_at),
|
||||||
|
oldestRollupAt: finite(storage.oldest_rollup_at),
|
||||||
|
estimatedBytes: Number(storage.raw_samples ?? 0) * 112 + Number(storage.hourly_rollups ?? 0) * 176,
|
||||||
|
rawRetentionDays: 90,
|
||||||
|
rollupRetentionYears: 10,
|
||||||
|
rawSampleCap: MAX_RAW_SAMPLES
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
history(period: TelemetryPeriod): { period: TelemetryPeriod; points: TelemetryHistoryPoint[] } {
|
||||||
|
const now = Date.now();
|
||||||
|
const settings: Record<TelemetryPeriod, { duration: number; bucket: number; rollup: boolean }> = {
|
||||||
|
"24h": { duration: 24 * 60 * 60_000, bucket: 5 * 60_000, rollup: false },
|
||||||
|
"7d": { duration: 7 * 24 * 60 * 60_000, bucket: 30 * 60_000, rollup: false },
|
||||||
|
"30d": { duration: 30 * 24 * 60 * 60_000, bucket: 2 * 60 * 60_000, rollup: false },
|
||||||
|
"1y": { duration: 365 * 24 * 60 * 60_000, bucket: 24 * 60 * 60_000, rollup: true },
|
||||||
|
all: { duration: ROLLUP_RETENTION_MS, bucket: 7 * 24 * 60 * 60_000, rollup: true }
|
||||||
|
};
|
||||||
|
const setting = settings[period];
|
||||||
|
const from = now - setting.duration;
|
||||||
|
const rows = setting.rollup
|
||||||
|
? this.database.prepare(
|
||||||
|
`SELECT CAST(bucket_start / ? AS INTEGER) * ? AS timestamp,
|
||||||
|
CASE WHEN SUM(speed_count) > 0 THEN SUM(speed_sum) / SUM(speed_count) END AS speed_kph,
|
||||||
|
MAX(speed_max) AS peak_speed_kph,
|
||||||
|
CASE WHEN SUM(soc_count) > 0 THEN SUM(soc_sum) / SUM(soc_count) END AS battery_percent,
|
||||||
|
CASE WHEN SUM(range_count) > 0 THEN SUM(range_sum) / SUM(range_count) END AS range_km,
|
||||||
|
MAX(charge_power_max) AS charging_power_kw
|
||||||
|
FROM car_telemetry_hourly WHERE bucket_start >= ? GROUP BY timestamp ORDER BY timestamp`
|
||||||
|
).all(setting.bucket, setting.bucket, from)
|
||||||
|
: this.database.prepare(
|
||||||
|
`SELECT CAST(recorded_at / ? AS INTEGER) * ? AS timestamp,
|
||||||
|
AVG(speed_kph) AS speed_kph, MAX(speed_kph) AS peak_speed_kph,
|
||||||
|
AVG(battery_percent) AS battery_percent, AVG(range_km) AS range_km,
|
||||||
|
MAX(charging_power_kw) AS charging_power_kw
|
||||||
|
FROM car_telemetry_samples WHERE recorded_at >= ? GROUP BY timestamp ORDER BY timestamp`
|
||||||
|
).all(setting.bucket, setting.bucket, from);
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
points: (rows as Record<string, unknown>[]).map((row) => ({
|
||||||
|
timestamp: Number(row.timestamp),
|
||||||
|
speedKph: finite(row.speed_kph),
|
||||||
|
peakSpeedKph: finite(row.peak_speed_kph),
|
||||||
|
batteryPercent: finite(row.battery_percent),
|
||||||
|
rangeKm: finite(row.range_km),
|
||||||
|
chargingPowerKw: finite(row.charging_power_kw)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CarTelemetryRecorder {
|
||||||
|
readonly store: CarTelemetryStore;
|
||||||
|
#timer: NodeJS.Timeout | null = null;
|
||||||
|
#pending: Promise<CarTelemetry> | null = null;
|
||||||
|
#latest: CarTelemetry | null = null;
|
||||||
|
#lastStoredAt = 0;
|
||||||
|
|
||||||
|
constructor(database: CompanionDatabase, private readonly config: AdbConfig) {
|
||||||
|
this.store = new CarTelemetryStore(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (!this.config.enabled || this.#timer) return;
|
||||||
|
void this.readLive().catch(() => undefined);
|
||||||
|
this.#timer = setInterval(() => void this.readLive().catch(() => undefined), SAMPLE_INTERVAL_MS);
|
||||||
|
this.#timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.#timer) clearInterval(this.#timer);
|
||||||
|
this.#timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readLive(): Promise<CarTelemetry> {
|
||||||
|
const latestAt = this.#latest ? Date.parse(this.#latest.collectedAt) : 0;
|
||||||
|
if (this.#latest && Date.now() - latestAt < 1_500) return this.#latest;
|
||||||
|
if (this.#pending) return this.#pending;
|
||||||
|
this.#pending = collectCarTelemetry(this.config).then((telemetry) => {
|
||||||
|
this.#latest = telemetry;
|
||||||
|
const sampleAt = Date.parse(telemetry.collectedAt);
|
||||||
|
if (sampleAt - this.#lastStoredAt >= SAMPLE_INTERVAL_MS - 1_000) {
|
||||||
|
this.store.record(telemetry);
|
||||||
|
this.#lastStoredAt = sampleAt;
|
||||||
|
}
|
||||||
|
return telemetry;
|
||||||
|
}).finally(() => { this.#pending = null; });
|
||||||
|
return this.#pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,59 @@ function migrate(database: CompanionDatabase): void {
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
UNIQUE(target_type, target_value)
|
UNIQUE(target_type, target_value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS car_telemetry_samples (
|
||||||
|
recorded_at INTEGER PRIMARY KEY,
|
||||||
|
speed_kph REAL,
|
||||||
|
wheel_angle_degrees REAL,
|
||||||
|
battery_percent REAL,
|
||||||
|
range_km REAL,
|
||||||
|
battery_voltage REAL,
|
||||||
|
total_consumption_kwh REAL,
|
||||||
|
charging_status INTEGER,
|
||||||
|
charging_power_kw REAL
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS car_telemetry_hourly (
|
||||||
|
bucket_start INTEGER PRIMARY KEY,
|
||||||
|
sample_count INTEGER NOT NULL,
|
||||||
|
speed_sum REAL NOT NULL,
|
||||||
|
speed_count INTEGER NOT NULL,
|
||||||
|
speed_max REAL,
|
||||||
|
soc_sum REAL NOT NULL,
|
||||||
|
soc_count INTEGER NOT NULL,
|
||||||
|
soc_min REAL,
|
||||||
|
soc_max REAL,
|
||||||
|
range_sum REAL NOT NULL,
|
||||||
|
range_count INTEGER NOT NULL,
|
||||||
|
range_max REAL,
|
||||||
|
charge_power_sum REAL NOT NULL,
|
||||||
|
charge_power_count INTEGER NOT NULL,
|
||||||
|
charge_power_max REAL,
|
||||||
|
distance_km REAL NOT NULL,
|
||||||
|
driving_seconds REAL NOT NULL,
|
||||||
|
charging_seconds REAL NOT NULL
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS car_telemetry_stats (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
first_sample_at INTEGER,
|
||||||
|
last_sample_at INTEGER,
|
||||||
|
sample_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
peak_speed_kph REAL,
|
||||||
|
peak_speed_at INTEGER,
|
||||||
|
peak_charging_power_kw REAL,
|
||||||
|
peak_charging_power_at INTEGER,
|
||||||
|
max_observed_range_km REAL,
|
||||||
|
max_observed_range_at INTEGER,
|
||||||
|
tracked_distance_km REAL NOT NULL DEFAULT 0,
|
||||||
|
driving_seconds REAL NOT NULL DEFAULT 0,
|
||||||
|
charging_seconds REAL NOT NULL DEFAULT 0,
|
||||||
|
charge_sessions INTEGER NOT NULL DEFAULT 0,
|
||||||
|
soc_used_percent REAL NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO car_telemetry_stats (id) VALUES (1);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
database
|
database
|
||||||
|
|||||||
@@ -170,17 +170,19 @@ describe("ADB dashboard operations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
|
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
|
||||||
const execute: AdbExecutor = async () => ({
|
const execute: AdbExecutor = async (_executable, arguments_) => arguments_.includes("content")
|
||||||
stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n",
|
? { stdout: "Result: Bundle[{status=ok, sampledAt=1785540000000, soc=73.5, rangeKm=286, speedKph=18.0, batteryVolts=397.4, totalConsumptionKwh=6.2, chargingStatus=0}]", stderr: "" }
|
||||||
stderr: ""
|
: { 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: 17 },
|
speedKph: { available: true, value: 18 },
|
||||||
gear: { available: true, value: "4" },
|
gear: { available: true, value: "4" },
|
||||||
wheelAngleDegrees: { available: true, value: -12.5 },
|
wheelAngleDegrees: { available: true, value: -12.5 },
|
||||||
batteryPercent: { available: false },
|
batteryPercent: { available: true, value: 73.5 },
|
||||||
rangeKm: { available: false }
|
rangeKm: { available: true, value: 286 },
|
||||||
|
batteryVoltage: { available: true, value: 397.4 },
|
||||||
|
totalConsumptionKwh: { available: true, value: 6.2 },
|
||||||
|
chargingStatus: { available: true, value: 0 }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ describe("authentication boundary", () => {
|
|||||||
expect((await app.inject({ method: "GET", url: "/api/adb/packages" })).statusCode).toBe(401);
|
expect((await app.inject({ method: "GET", url: "/api/adb/packages" })).statusCode).toBe(401);
|
||||||
expect((await app.inject({ method: "GET", url: "/api/adb/shortcuts" })).statusCode).toBe(401);
|
expect((await app.inject({ method: "GET", url: "/api/adb/shortcuts" })).statusCode).toBe(401);
|
||||||
expect((await app.inject({ method: "GET", url: "/api/car/telemetry" })).statusCode).toBe(401);
|
expect((await app.inject({ method: "GET", url: "/api/car/telemetry" })).statusCode).toBe(401);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/car/history" })).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);
|
||||||
await setup(app, token);
|
await setup(app, token);
|
||||||
@@ -268,6 +270,12 @@ describe("authentication boundary", () => {
|
|||||||
const cookie = `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${session}`;
|
const cookie = `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${session}`;
|
||||||
const headers = { ...mutationHeaders(token), cookie };
|
const headers = { ...mutationHeaders(token), cookie };
|
||||||
|
|
||||||
|
const history = await app.inject({ method: "GET", url: "/api/car/history?period=24h", headers: { cookie } });
|
||||||
|
expect(history.statusCode).toBe(200);
|
||||||
|
expect(history.json()).toMatchObject({ period: "24h", points: [] });
|
||||||
|
const invalidPeriod = await app.inject({ method: "GET", url: "/api/car/history?period=forever", headers: { cookie } });
|
||||||
|
expect(invalidPeriod.statusCode).toBe(400);
|
||||||
|
|
||||||
const invalid = await app.inject({
|
const invalid = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/adb/shortcuts",
|
url: "/api/adb/shortcuts",
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CarTelemetryStore } from "../src/car-telemetry.js";
|
||||||
|
import { openDatabase } from "../src/database.js";
|
||||||
|
import type { CarTelemetry } from "../src/adb.js";
|
||||||
|
|
||||||
|
function sample(
|
||||||
|
recordedAt: number,
|
||||||
|
values: { speed?: number; soc?: number; range?: number; charging?: number; volts?: number } = {}
|
||||||
|
): CarTelemetry {
|
||||||
|
const available = <T>(value: T) => ({ available: true as const, value });
|
||||||
|
const missing = { available: false as const, reason: "Unavailable in test" };
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
collectedAt: new Date(recordedAt).toISOString(),
|
||||||
|
speedKph: values.speed === undefined ? missing : available(values.speed),
|
||||||
|
gear: available("4"),
|
||||||
|
wheelAngleDegrees: available(0),
|
||||||
|
batteryPercent: values.soc === undefined ? missing : available(values.soc),
|
||||||
|
rangeKm: values.range === undefined ? missing : available(values.range),
|
||||||
|
batteryVoltage: values.volts === undefined ? missing : available(values.volts),
|
||||||
|
totalConsumptionKwh: available(4.2),
|
||||||
|
chargingStatus: values.charging === undefined ? missing : available(values.charging)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("car telemetry history", () => {
|
||||||
|
it("keeps bounded raw samples, hourly rollups, lifetime records, and an observed range estimate", () => {
|
||||||
|
const database = openDatabase(":memory:");
|
||||||
|
const store = new CarTelemetryStore(database);
|
||||||
|
const startedAt = Date.now() - 2 * 60 * 60_000;
|
||||||
|
|
||||||
|
store.record(sample(startedAt, { speed: 0, soc: 80, range: 320, charging: 0, volts: 398 }));
|
||||||
|
for (let index = 1; index <= 400; index += 1) {
|
||||||
|
store.record(sample(startedAt + index * 10_000, {
|
||||||
|
speed: 100,
|
||||||
|
soc: 80 - index * 0.02,
|
||||||
|
range: 320 - index * 0.3,
|
||||||
|
charging: 0,
|
||||||
|
volts: 390
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const chargingAt = startedAt + 401 * 10_000;
|
||||||
|
for (let index = 0; index <= 13; index += 1) {
|
||||||
|
store.record(sample(chargingAt + index * 10_000, {
|
||||||
|
speed: 0,
|
||||||
|
soc: 60 + index * 0.2,
|
||||||
|
range: 240 + index,
|
||||||
|
charging: 1,
|
||||||
|
volts: 405
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const overview = store.overview();
|
||||||
|
expect(overview.records.peakSpeedKph).toBe(100);
|
||||||
|
expect(overview.records.peakChargingPowerKw).toBeGreaterThan(30);
|
||||||
|
expect(overview.records.maxObservedRangeKm).toBe(320);
|
||||||
|
expect(overview.lifetime.trackedDistanceKm).toBeGreaterThan(10);
|
||||||
|
expect(overview.lifetime.ownRangeEstimateKm).toBeGreaterThan(100);
|
||||||
|
expect(overview.lifetime.chargeSessions).toBe(1);
|
||||||
|
expect(overview.storage.rawSamples).toBe(415);
|
||||||
|
expect(store.history("24h").points.length).toBeGreaterThan(1);
|
||||||
|
expect(store.history("all").points.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
store.prune(chargingAt + 91 * 24 * 60 * 60_000);
|
||||||
|
const pruned = store.overview();
|
||||||
|
expect(pruned.storage.rawSamples).toBe(0);
|
||||||
|
expect(pruned.records.peakSpeedKph).toBe(100);
|
||||||
|
expect(pruned.lifetime.trackedDistanceKm).toBeGreaterThan(10);
|
||||||
|
database.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves estimates empty until enough real observations exist", () => {
|
||||||
|
const database = openDatabase(":memory:");
|
||||||
|
const store = new CarTelemetryStore(database);
|
||||||
|
store.record(sample(Date.now(), { speed: 0, soc: 75, range: 280, charging: 0 }));
|
||||||
|
expect(store.overview().lifetime.ownRangeEstimateKm).toBeNull();
|
||||||
|
expect(store.history("24h").points).toHaveLength(1);
|
||||||
|
database.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
+165
-12
@@ -1,12 +1,45 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { BatteryMedium, Gauge, Lightning, RoadHorizon, SteeringWheel, WarningCircle } from "@phosphor-icons/react";
|
import {
|
||||||
import { getCarTelemetry, type Availability, type CarTelemetry } from "./api";
|
BatteryMedium,
|
||||||
|
ChargingStation,
|
||||||
|
ChartLineUp,
|
||||||
|
Database,
|
||||||
|
Gauge,
|
||||||
|
MapTrifold,
|
||||||
|
RoadHorizon,
|
||||||
|
Speedometer,
|
||||||
|
SteeringWheel,
|
||||||
|
Timer,
|
||||||
|
Trophy,
|
||||||
|
WarningCircle
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import {
|
||||||
|
getCarHistory,
|
||||||
|
getCarStatistics,
|
||||||
|
getCarTelemetry,
|
||||||
|
type Availability,
|
||||||
|
type CarStatistics,
|
||||||
|
type CarTelemetry,
|
||||||
|
type TelemetryHistoryPoint,
|
||||||
|
type TelemetryPeriod
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
const periods: Array<{ value: TelemetryPeriod; label: string }> = [
|
||||||
|
{ value: "24h", label: "24H" },
|
||||||
|
{ value: "7d", label: "7D" },
|
||||||
|
{ value: "30d", label: "30D" },
|
||||||
|
{ value: "1y", label: "1Y" },
|
||||||
|
{ value: "all", label: "All" }
|
||||||
|
];
|
||||||
|
|
||||||
export function CarPage() {
|
export function CarPage() {
|
||||||
const [telemetry, setTelemetry] = useState<CarTelemetry | null>(null);
|
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 [error, setError] = useState("");
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refreshLive = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setTelemetry(await getCarTelemetry());
|
setTelemetry(await getCarTelemetry());
|
||||||
setError("");
|
setError("");
|
||||||
@@ -15,11 +48,27 @@ export function CarPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshHistory = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [nextStatistics, nextHistory] = await Promise.all([getCarStatistics(), getCarHistory(period)]);
|
||||||
|
setStatistics(nextStatistics);
|
||||||
|
setHistory(nextHistory.points);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : "Car history could not be loaded");
|
||||||
|
}
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refreshLive();
|
||||||
const interval = window.setInterval(() => void refresh(), 1_000);
|
const interval = window.setInterval(() => void refreshLive(), 2_000);
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [refresh]);
|
}, [refreshLive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshHistory();
|
||||||
|
const interval = window.setInterval(() => void refreshHistory(), 15_000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [refreshHistory]);
|
||||||
|
|
||||||
const speed = telemetry?.speedKph.available ? telemetry.speedKph.value : null;
|
const speed = telemetry?.speedKph.available ? telemetry.speedKph.value : null;
|
||||||
const angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0;
|
const angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0;
|
||||||
@@ -47,20 +96,124 @@ export function CarPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="energy-readout">
|
<section className="energy-readout live-energy">
|
||||||
<TelemetryValue icon={<BatteryMedium size={28} />} label="Traction battery" value={telemetry?.batteryPercent} suffix="%" />
|
<TelemetryValue icon={<BatteryMedium size={28} />} label="Traction battery" value={telemetry?.batteryPercent} suffix="%" />
|
||||||
<TelemetryValue icon={<RoadHorizon size={28} />} label="Estimated range" value={telemetry?.rangeKm} suffix="km" />
|
<TelemetryValue icon={<RoadHorizon size={28} />} label="OEM range" value={telemetry?.rangeKm} suffix="km" />
|
||||||
<article className="telemetry-source"><Lightning size={28} /><div><strong>SAIC bridge needed</strong><p>Battery and range are not exposed in the captured Android property set. The verified AIDL service is the next data source.</p></div></article>
|
<TelemetryValue icon={<ChargingStation size={28} />} label="Battery voltage" value={telemetry?.batteryVoltage} suffix="V" decimals={1} />
|
||||||
|
<TelemetryValue icon={<ChartLineUp size={28} />} label="Use since charge" value={telemetry?.totalConsumptionKwh} suffix="kWh" decimals={2} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="record-grid" aria-label="All-time vehicle statistics">
|
||||||
|
<RecordCard icon={<Trophy size={25} />} label="Peak speed" value={formatMetric(statistics?.records.peakSpeedKph, "km/h")} note={recordedDate(statistics?.records.peakSpeedAt)} />
|
||||||
|
<RecordCard icon={<ChargingStation size={25} />} label="Peak charge estimate" value={formatMetric(statistics?.records.peakChargingPowerKw, "kW", 1)} note="Estimated from SOC gain over time" />
|
||||||
|
<RecordCard icon={<MapTrifold size={25} />} label="Our range estimate" value={formatMetric(statistics?.lifetime.ownRangeEstimateKm, "km")} note={statistics?.lifetime.ownRangeEstimateKm ? "Based on observed distance per SOC" : "Learning after 10 km and 5% SOC"} />
|
||||||
|
<RecordCard icon={<RoadHorizon size={25} />} label="Best OEM range" value={formatMetric(statistics?.records.maxObservedRangeKm, "km")} note={recordedDate(statistics?.records.maxObservedRangeAt)} />
|
||||||
|
<RecordCard icon={<Speedometer size={25} />} label="Tracked distance" value={formatMetric(statistics?.lifetime.trackedDistanceKm, "km", 1)} note={`${formatMetric(statistics?.lifetime.averageMovingSpeedKph, "km/h")} moving average`} />
|
||||||
|
<RecordCard icon={<Timer size={25} />} label="Observed drive time" value={formatDuration(statistics?.lifetime.drivingSeconds ?? 0)} note={`${statistics?.lifetime.chargeSessions ?? 0} charging sessions detected`} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="history-panel">
|
||||||
|
<div className="history-heading">
|
||||||
|
<div><h2>Battery and performance history</h2><p>Indexed samples with long-term hourly rollups.</p></div>
|
||||||
|
<div className="period-tabs" aria-label="History period">
|
||||||
|
{periods.map((item) => <button key={item.value} className={period === item.value ? "active" : ""} onClick={() => setPeriod(item.value)}>{item.label}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chart-grid">
|
||||||
|
<HistoryChart
|
||||||
|
title="Energy position"
|
||||||
|
points={history}
|
||||||
|
first={{ key: "batteryPercent", label: "SOC", unit: "%", color: "var(--accent)" }}
|
||||||
|
second={{ key: "rangeKm", label: "OEM range", unit: "km", color: "#64b8e8" }}
|
||||||
|
/>
|
||||||
|
<HistoryChart
|
||||||
|
title="Observed peaks"
|
||||||
|
points={history}
|
||||||
|
first={{ key: "peakSpeedKph", label: "Speed", unit: "km/h", color: "var(--accent)" }}
|
||||||
|
second={{ key: "chargingPowerKw", label: "Charge estimate", unit: "kW", color: "#64b8e8" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="telemetry-footer">
|
<footer className="telemetry-footer">
|
||||||
<span>Source: live ADB system properties</span>
|
<span>Sources: SAIC vehicle service through MG Utility and live ADB properties</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TelemetryValue({ icon, label, value, suffix }: { icon: React.ReactNode; label: string; value: Availability<number> | undefined; suffix: string }) {
|
type ChartKey = "batteryPercent" | "rangeKm" | "peakSpeedKph" | "chargingPowerKw";
|
||||||
return <article className="energy-value">{icon}<span>{label}</span><strong>{value?.available ? `${Math.round(value.value)} ${suffix}` : "Unavailable"}</strong><small>{value && !value.available ? value.reason : "Live vehicle value"}</small></article>;
|
type ChartSeries = { key: ChartKey; label: string; unit: string; color: string };
|
||||||
|
|
||||||
|
function HistoryChart({ title, points, first, second }: { title: string; points: TelemetryHistoryPoint[]; first: ChartSeries; second: ChartSeries }) {
|
||||||
|
const width = 900;
|
||||||
|
const height = 230;
|
||||||
|
const padding = 28;
|
||||||
|
const timestamps = points.map((point) => point.timestamp);
|
||||||
|
const start = Math.min(...timestamps);
|
||||||
|
const end = Math.max(...timestamps);
|
||||||
|
const coordinates = (series: ChartSeries) => {
|
||||||
|
const values = points.map((point) => point[series.key]).filter((item): item is number => item !== null);
|
||||||
|
if (values.length === 0) return { path: "", minimum: 0, maximum: 0 };
|
||||||
|
const minimum = Math.min(...values);
|
||||||
|
const maximum = Math.max(...values);
|
||||||
|
const spread = Math.max(maximum - minimum, 1);
|
||||||
|
const path = points
|
||||||
|
.filter((point) => point[series.key] !== null)
|
||||||
|
.map((point) => {
|
||||||
|
const x = padding + ((point.timestamp - start) / Math.max(end - start, 1)) * (width - padding * 2);
|
||||||
|
const y = height - padding - (((point[series.key] as number) - minimum) / spread) * (height - padding * 2);
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
return { path, minimum, maximum };
|
||||||
|
};
|
||||||
|
const firstLine = coordinates(first);
|
||||||
|
const secondLine = coordinates(second);
|
||||||
|
|
||||||
|
return <article className="history-chart">
|
||||||
|
<div><h3>{title}</h3><div className="chart-legend"><span style={{ color: first.color }}>{first.label}</span><span style={{ color: second.color }}>{second.label}</span></div></div>
|
||||||
|
{points.length < 2 ? <div className="chart-empty">Collecting enough samples to draw this period.</div> : <>
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${first.label} and ${second.label} history`}>
|
||||||
|
{[0.25, 0.5, 0.75].map((ratio) => <line key={ratio} x1={padding} y1={height * ratio} x2={width - padding} y2={height * ratio} className="chart-guide" />)}
|
||||||
|
<polyline points={firstLine.path} fill="none" stroke={first.color} strokeWidth="4" vectorEffect="non-scaling-stroke" />
|
||||||
|
<polyline points={secondLine.path} fill="none" stroke={second.color} strokeWidth="3" vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
<div className="chart-scale"><span>{firstLine.minimum.toFixed(0)}-{firstLine.maximum.toFixed(0)} {first.unit}</span><span>{secondLine.minimum.toFixed(0)}-{secondLine.maximum.toFixed(0)} {second.unit}</span></div>
|
||||||
|
</>}
|
||||||
|
</article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TelemetryValue({ icon, label, value: availability, suffix, decimals = 0 }: { icon: React.ReactNode; label: string; value: Availability<number> | undefined; suffix: string; decimals?: number }) {
|
||||||
|
return <article className="energy-value">{icon}<span>{label}</span><strong>{availability?.available ? `${availability.value.toFixed(decimals)} ${suffix}` : "Unavailable"}</strong><small>{availability && !availability.available ? availability.reason : "Live vehicle value"}</small></article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordCard({ icon, label, value, note }: { icon: React.ReactNode; label: string; value: string; note: string }) {
|
||||||
|
return <article>{icon}<span>{label}</span><strong>{value}</strong><small>{note}</small></article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetric(value: number | null | undefined, unit: string, decimals = 0): string {
|
||||||
|
return value === null || value === undefined ? "Learning" : `${value.toFixed(decimals)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordedDate(timestamp: number | null | undefined): string {
|
||||||
|
return timestamp ? `Recorded ${new Date(timestamp).toLocaleDateString()}` : "No record yet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3_600);
|
||||||
|
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||||
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.max(bytes / 1024, 0).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB estimated`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,64 @@ export type CarTelemetry = {
|
|||||||
wheelAngleDegrees: Availability<number>;
|
wheelAngleDegrees: Availability<number>;
|
||||||
batteryPercent: Availability<number>;
|
batteryPercent: Availability<number>;
|
||||||
rangeKm: Availability<number>;
|
rangeKm: Availability<number>;
|
||||||
|
batteryVoltage: Availability<number>;
|
||||||
|
totalConsumptionKwh: Availability<number>;
|
||||||
|
chargingStatus: Availability<number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelemetryPeriod = "24h" | "7d" | "30d" | "1y" | "all";
|
||||||
|
|
||||||
|
export type TelemetryHistoryPoint = {
|
||||||
|
timestamp: number;
|
||||||
|
speedKph: number | null;
|
||||||
|
peakSpeedKph: number | null;
|
||||||
|
batteryPercent: number | null;
|
||||||
|
rangeKm: number | null;
|
||||||
|
chargingPowerKw: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CarStatistics = {
|
||||||
|
latest: {
|
||||||
|
recordedAt: number;
|
||||||
|
speedKph: number | null;
|
||||||
|
wheelAngleDegrees: number | null;
|
||||||
|
batteryPercent: number | null;
|
||||||
|
rangeKm: number | null;
|
||||||
|
batteryVoltage: number | null;
|
||||||
|
totalConsumptionKwh: number | null;
|
||||||
|
chargingStatus: number | null;
|
||||||
|
chargingPowerKw: number | null;
|
||||||
|
} | null;
|
||||||
|
records: {
|
||||||
|
peakSpeedKph: number | null;
|
||||||
|
peakSpeedAt: number | null;
|
||||||
|
peakChargingPowerKw: number | null;
|
||||||
|
peakChargingPowerAt: number | null;
|
||||||
|
maxObservedRangeKm: number | null;
|
||||||
|
maxObservedRangeAt: number | null;
|
||||||
|
};
|
||||||
|
lifetime: {
|
||||||
|
firstSampleAt: number | null;
|
||||||
|
lastSampleAt: number | null;
|
||||||
|
sampleCount: number;
|
||||||
|
trackedDistanceKm: number;
|
||||||
|
drivingSeconds: number;
|
||||||
|
chargingSeconds: number;
|
||||||
|
chargeSessions: number;
|
||||||
|
socUsedPercent: number;
|
||||||
|
averageMovingSpeedKph: number | null;
|
||||||
|
ownRangeEstimateKm: number | null;
|
||||||
|
};
|
||||||
|
storage: {
|
||||||
|
rawSamples: number;
|
||||||
|
hourlyRollups: number;
|
||||||
|
oldestRawAt: number | null;
|
||||||
|
oldestRollupAt: number | null;
|
||||||
|
estimatedBytes: number;
|
||||||
|
rawRetentionDays: number;
|
||||||
|
rollupRetentionYears: number;
|
||||||
|
rawSampleCap: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type ErrorResponse = { error?: { code?: string; message?: string } };
|
type ErrorResponse = { error?: { code?: string; message?: string } };
|
||||||
@@ -216,3 +274,11 @@ export async function deleteAdbShortcut(id: number, csrfToken: string): Promise<
|
|||||||
export async function getCarTelemetry(): Promise<CarTelemetry> {
|
export async function getCarTelemetry(): Promise<CarTelemetry> {
|
||||||
return parseResponse<CarTelemetry>(await fetch("/api/car/telemetry", { credentials: "same-origin" }));
|
return parseResponse<CarTelemetry>(await fetch("/api/car/telemetry", { credentials: "same-origin" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCarHistory(period: TelemetryPeriod): Promise<{ period: TelemetryPeriod; points: TelemetryHistoryPoint[] }> {
|
||||||
|
return parseResponse(await fetch(`/api/car/history?period=${period}`, { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCarStatistics(): Promise<CarStatistics> {
|
||||||
|
return parseResponse<CarStatistics>(await fetch("/api/car/statistics", { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -349,6 +349,38 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
.telemetry-source strong { display: block; margin-bottom: 8px; }
|
.telemetry-source strong { display: block; margin-bottom: 8px; }
|
||||||
.telemetry-source p { max-width: 55ch; margin: 0; color: var(--muted); font-size: 0.8rem; line-height: 1.5; }
|
.telemetry-source p { max-width: 55ch; margin: 0; color: var(--muted); font-size: 0.8rem; line-height: 1.5; }
|
||||||
.telemetry-footer { display: flex; justify-content: space-between; gap: 18px; color: var(--muted); font: 0.72rem/1.4 ui-monospace, "Cascadia Code", monospace; }
|
.telemetry-footer { display: flex; justify-content: space-between; gap: 18px; color: var(--muted); font: 0.72rem/1.4 ui-monospace, "Cascadia Code", monospace; }
|
||||||
|
.live-energy { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.record-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||||
|
.record-grid article { min-width: 0; min-height: 145px; display: grid; grid-template-columns: auto minmax(0, 1fr); align-content: center; gap: 7px 11px; padding: 23px; background: var(--surface); }
|
||||||
|
.record-grid svg { color: var(--accent); }
|
||||||
|
.record-grid span { align-self: center; color: var(--muted); font-size: 0.78rem; }
|
||||||
|
.record-grid strong { grid-column: 1 / -1; margin-top: 10px; font: 750 clamp(1.35rem, 2vw, 2rem)/1 ui-monospace, "Cascadia Code", monospace; letter-spacing: -0.035em; }
|
||||||
|
.record-grid small { grid-column: 1 / -1; overflow: hidden; color: var(--muted); font-size: 0.72rem; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.history-panel { min-width: 0; padding: 24px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||||
|
.history-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
|
||||||
|
.history-heading h2 { margin: 0 0 5px; font-size: 1.2rem; }
|
||||||
|
.history-heading p { margin: 0; color: var(--muted); font-size: 0.78rem; }
|
||||||
|
.period-tabs { display: flex; gap: 4px; padding: 4px; background: #10140f; border: 1px solid var(--line); border-radius: 9px; }
|
||||||
|
.period-tabs button { min-width: 48px; min-height: 38px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 6px; font-size: 0.72rem; font-weight: 750; cursor: pointer; }
|
||||||
|
.period-tabs button:hover { color: var(--text); }
|
||||||
|
.period-tabs button.active { color: var(--accent-ink); background: var(--accent); }
|
||||||
|
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.history-chart { min-width: 0; padding: 16px 17px 12px; background: #121610; border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.history-chart > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 15px; }
|
||||||
|
.history-chart h3 { margin: 0; font-size: 0.88rem; }
|
||||||
|
.chart-legend { display: flex; gap: 13px; font: 0.68rem/1.2 ui-monospace, "Cascadia Code", monospace; }
|
||||||
|
.chart-legend span::before { content: ""; width: 13px; height: 2px; display: inline-block; margin: 0 5px 3px 0; background: currentColor; }
|
||||||
|
.history-chart svg { width: 100%; height: 220px; display: block; margin-top: 12px; overflow: visible; }
|
||||||
|
.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 > 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; }
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.adb-command-grid { grid-template-columns: 1fr; }
|
.adb-command-grid { grid-template-columns: 1fr; }
|
||||||
@@ -357,6 +389,8 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
.gear-cluster strong { font-size: 4rem; }
|
.gear-cluster strong { font-size: 4rem; }
|
||||||
.energy-readout { grid-template-columns: 1fr 1fr; }
|
.energy-readout { grid-template-columns: 1fr 1fr; }
|
||||||
.telemetry-source { grid-column: 1 / -1; }
|
.telemetry-source { grid-column: 1 / -1; }
|
||||||
|
.record-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.chart-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
@@ -377,4 +411,12 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
.energy-readout { grid-template-columns: 1fr; }
|
.energy-readout { grid-template-columns: 1fr; }
|
||||||
.telemetry-source { grid-column: auto; }
|
.telemetry-source { grid-column: auto; }
|
||||||
.telemetry-footer { flex-direction: column; }
|
.telemetry-footer { flex-direction: column; }
|
||||||
|
.record-grid { grid-template-columns: 1fr; }
|
||||||
|
.history-heading { align-items: stretch; flex-direction: column; }
|
||||||
|
.period-tabs { width: 100%; }
|
||||||
|
.period-tabs button { min-width: 0; flex: 1; padding: 0 5px; }
|
||||||
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user