Add persistent car performance analytics

This commit is contained in:
2026-07-31 22:44:51 +02:00
parent 0e4ce931ec
commit 6998e8bdb7
12 changed files with 916 additions and 29 deletions
+40
View File
@@ -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.
+5 -4
View File
@@ -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
strict Android-name validation and fixed `am` or `monkey` argument arrays.
- 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
appropriate, audited, and intended for parked use.
- Do not use ADB or the exported SAIC vehicle service to actuate vehicle hardware in
this project. Vehicle telemetry remains read-only. The SAIC AIDL bridge for battery,
range, and additional signals remains a later research track until semantics, privacy,
and parked-vehicle tests are documented.
this project. Vehicle telemetry remains read-only. Battery, range, voltage, consumption,
and charging state pass through MG Utility's typed content provider; additional SAIC
signals remain a research track until their semantics are documented and tested.
### Web experience
+44 -4
View File
@@ -71,6 +71,9 @@ export type CarTelemetry = {
wheelAngleDegrees: Availability<number>;
batteryPercent: Availability<number>;
rangeKm: Availability<number>;
batteryVoltage: Availability<number>;
totalConsumptionKwh: Availability<number>;
chargingStatus: Availability<number>;
};
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`);
}
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(
config: AdbConfig,
execute: AdbExecutor = defaultExecutor
): 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 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 {
available: true,
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"),
wheelAngleDegrees: numericProperty(properties, "arcsoft.avm.mCurCarWheelAngle", "Wheel angle"),
batteryPercent: unavailable("Traction battery data requires the SAIC vehicle-service bridge"),
rangeKm: unavailable("Range data requires the SAIC vehicle-service bridge")
batteryPercent: bundleNumber(bridgeValues, "soc", "Traction battery", 0, 100),
rangeKm: bundleNumber(bridgeValues, "rangeKm", "Estimated range", 0, 1_500),
batteryVoltage: bundleNumber(bridgeValues, "batteryVolts", "Battery voltage", 0, 1_000),
totalConsumptionKwh: bundleNumber(bridgeValues, "totalConsumptionKwh", "Consumption", 0, 10_000),
chargingStatus: bundleNumber(bridgeValues, "chargingStatus", "Charging status", -1, 100)
};
}
+19 -2
View File
@@ -30,12 +30,12 @@ import {
} from "./network.js";
import {
collectAdbStatus,
collectCarTelemetry,
installAdbApk,
launchAdbTarget,
listInstalledAdbPackages,
MAX_APK_BYTES
} from "./adb.js";
import { CarTelemetryRecorder, type TelemetryPeriod } from "./car-telemetry.js";
import { openShell } from "./shell.js";
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
import "./types.js";
@@ -139,7 +139,9 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
});
const database = openDatabase(config.databasePath);
const jobRunner = new JobRunner(database, undefined, config.adb);
const carTelemetry = new CarTelemetryRecorder(database, config.adb);
if (config.adb.enabled) void collectAdbStatus(config.adb);
carTelemetry.start();
app.decorate("database", database);
app.decorate("jobRunner", jobRunner);
@@ -154,6 +156,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
);
app.addHook("onClose", async () => {
carTelemetry.stop();
jobRunner.close();
database.close();
});
@@ -482,12 +485,26 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
app.get("/api/car/telemetry", async (request, reply) => {
if (!requireUser(request, reply)) return;
try {
return await collectCarTelemetry(config.adb);
return await carTelemetry.readLive();
} catch {
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(
"/api/network/configuration",
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
+384
View File
@@ -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;
}
}
+53
View File
@@ -69,6 +69,59 @@ function migrate(database: CompanionDatabase): void {
created_at TEXT NOT NULL,
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
+9 -7
View File
@@ -170,17 +170,19 @@ describe("ADB dashboard operations", () => {
});
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
const execute: AdbExecutor = async () => ({
stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n",
stderr: ""
});
const execute: AdbExecutor = async (_executable, arguments_) => arguments_.includes("content")
? { stdout: "Result: Bundle[{status=ok, sampledAt=1785540000000, soc=73.5, rangeKm=286, speedKph=18.0, batteryVolts=397.4, totalConsumptionKwh=6.2, chargingStatus=0}]", stderr: "" }
: { stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n", stderr: "" };
await expect(collectCarTelemetry(enabledConfig, execute)).resolves.toMatchObject({
available: true,
speedKph: { available: true, value: 17 },
speedKph: { available: true, value: 18 },
gear: { available: true, value: "4" },
wheelAngleDegrees: { available: true, value: -12.5 },
batteryPercent: { available: false },
rangeKm: { available: false }
batteryPercent: { available: true, value: 73.5 },
rangeKm: { available: true, value: 286 },
batteryVoltage: { available: true, value: 397.4 },
totalConsumptionKwh: { available: true, value: 6.2 },
chargingStatus: { available: true, value: 0 }
});
});
+8
View File
@@ -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/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/history" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/api/car/statistics" })).statusCode).toBe(401);
const token = await csrf(app);
await setup(app, token);
@@ -268,6 +270,12 @@ describe("authentication boundary", () => {
const cookie = `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${session}`;
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({
method: "POST",
url: "/api/adb/shortcuts",
+81
View File
@@ -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
View File
@@ -1,12 +1,45 @@
import { useCallback, useEffect, useState } from "react";
import { BatteryMedium, Gauge, Lightning, RoadHorizon, SteeringWheel, WarningCircle } from "@phosphor-icons/react";
import { getCarTelemetry, type Availability, type CarTelemetry } from "./api";
import {
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() {
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 refresh = useCallback(async () => {
const refreshLive = useCallback(async () => {
try {
setTelemetry(await getCarTelemetry());
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(() => {
void refresh();
const interval = window.setInterval(() => void refresh(), 1_000);
void refreshLive();
const interval = window.setInterval(() => void refreshLive(), 2_000);
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 angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0;
@@ -47,20 +96,124 @@ export function CarPage() {
</div>
</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={<RoadHorizon size={28} />} label="Estimated 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={<RoadHorizon size={28} />} label="OEM range" value={telemetry?.rangeKm} suffix="km" />
<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>
<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>
</footer>
</div>
);
}
function TelemetryValue({ icon, label, value, suffix }: { icon: React.ReactNode; label: string; value: Availability<number> | undefined; suffix: string }) {
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 ChartKey = "batteryPercent" | "rangeKm" | "peakSpeedKph" | "chargingPowerKw";
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`;
}
+66
View File
@@ -122,6 +122,64 @@ export type CarTelemetry = {
wheelAngleDegrees: Availability<number>;
batteryPercent: 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 } };
@@ -216,3 +274,11 @@ export async function deleteAdbShortcut(id: number, csrfToken: string): Promise<
export async function getCarTelemetry(): Promise<CarTelemetry> {
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" }));
}
+42
View File
@@ -349,6 +349,38 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.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-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) {
.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; }
.energy-readout { grid-template-columns: 1fr 1fr; }
.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) {
@@ -377,4 +411,12 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.energy-readout { grid-template-columns: 1fr; }
.telemetry-source { grid-column: auto; }
.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; }
}