diff --git a/CAR_TELEMETRY.md b/CAR_TELEMETRY.md new file mode 100644 index 0000000..8bbd688 --- /dev/null +++ b/CAR_TELEMETRY.md @@ -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. diff --git a/PLAN.md b/PLAN.md index 99b58e6..15db8b4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/server/src/adb.ts b/server/src/adb.ts index 740e574..8bbc1f6 100644 --- a/server/src/adb.ts +++ b/server/src/adb.ts @@ -71,6 +71,9 @@ export type CarTelemetry = { wheelAngleDegrees: Availability; batteryPercent: Availability; rangeKm: Availability; + batteryVoltage: Availability; + totalConsumptionKwh: Availability; + chargingStatus: Availability; }; const defaultExecutor: AdbExecutor = async (executable, arguments_, options) => { @@ -236,21 +239,58 @@ function numericProperty(properties: Map, 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 { + const values = new Map(); + for (const match of output.matchAll(/(?:^|[{,\s])(\w+)=([^,}\]]+)/g)) { + values.set(match[1]!, match[2]!.trim()); + } + return values; +} + +function bundleNumber( + values: Map | null, + key: string, + label: string, + minimum: number, + maximum: number +): Availability { + 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 { - 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) }; } diff --git a/server/src/app.ts b/server/src/app.ts index 512589b..d31aba2 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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 { }); 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 { ); app.addHook("onClose", async () => { + carTelemetry.stop(); jobRunner.close(); database.close(); }); @@ -482,12 +485,26 @@ export async function buildApp(config: AppConfig): Promise { 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" } } }, diff --git a/server/src/car-telemetry.ts b/server/src/car-telemetry.ts new file mode 100644 index 0000000..4caf802 --- /dev/null +++ b/server/src/car-telemetry.ts @@ -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(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 | 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 | 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; + const latest = rowToStored(this.database.prepare("SELECT * FROM car_telemetry_samples ORDER BY recorded_at DESC LIMIT 1").get() as Record | 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; + 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 = { + "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[]).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 | 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 { + 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; + } +} diff --git a/server/src/database.ts b/server/src/database.ts index 46ee866..90c9c8f 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -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 diff --git a/server/tests/adb.test.ts b/server/tests/adb.test.ts index ea56bd8..fda4bc0 100644 --- a/server/tests/adb.test.ts +++ b/server/tests/adb.test.ts @@ -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 } }); }); diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index 3bb3cc3..3ce67cf 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -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", diff --git a/server/tests/car-telemetry.test.ts b/server/tests/car-telemetry.test.ts new file mode 100644 index 0000000..7fb102f --- /dev/null +++ b/server/tests/car-telemetry.test.ts @@ -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 = (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(); + }); +}); diff --git a/web/src/CarPage.tsx b/web/src/CarPage.tsx index 1e9ced9..ca3cd02 100644 --- a/web/src/CarPage.tsx +++ b/web/src/CarPage.tsx @@ -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(null); + const [statistics, setStatistics] = useState(null); + const [history, setHistory] = useState([]); + const [period, setPeriod] = useState("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() { -
+
} label="Traction battery" value={telemetry?.batteryPercent} suffix="%" /> - } label="Estimated range" value={telemetry?.rangeKm} suffix="km" /> -
SAIC bridge needed

Battery and range are not exposed in the captured Android property set. The verified AIDL service is the next data source.

+ } label="OEM range" value={telemetry?.rangeKm} suffix="km" /> + } label="Battery voltage" value={telemetry?.batteryVoltage} suffix="V" decimals={1} /> + } label="Use since charge" value={telemetry?.totalConsumptionKwh} suffix="kWh" decimals={2} /> +
+ +
+ } label="Peak speed" value={formatMetric(statistics?.records.peakSpeedKph, "km/h")} note={recordedDate(statistics?.records.peakSpeedAt)} /> + } label="Peak charge estimate" value={formatMetric(statistics?.records.peakChargingPowerKw, "kW", 1)} note="Estimated from SOC gain over time" /> + } 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"} /> + } label="Best OEM range" value={formatMetric(statistics?.records.maxObservedRangeKm, "km")} note={recordedDate(statistics?.records.maxObservedRangeAt)} /> + } label="Tracked distance" value={formatMetric(statistics?.lifetime.trackedDistanceKm, "km", 1)} note={`${formatMetric(statistics?.lifetime.averageMovingSpeedKph, "km/h")} moving average`} /> + } label="Observed drive time" value={formatDuration(statistics?.lifetime.drivingSeconds ?? 0)} note={`${statistics?.lifetime.chargeSessions ?? 0} charging sessions detected`} /> +
+ +
+
+

Battery and performance history

Indexed samples with long-term hourly rollups.

+
+ {periods.map((item) => )} +
+
+
+ + +
+
+ +
+ +
Bounded telemetry archive

Raw samples: {statistics?.storage.rawSamples.toLocaleString() ?? "0"} of {statistics?.storage.rawSampleCap.toLocaleString() ?? "800,000"}. Hourly rollups: {statistics?.storage.hourlyRollups.toLocaleString() ?? "0"}.

+
{formatBytes(statistics?.storage.estimatedBytes ?? 0)}{statistics?.storage.rawRetentionDays ?? 90} days raw, {statistics?.storage.rollupRetentionYears ?? 10} years rolled up
- Source: live ADB system properties + Sources: SAIC vehicle service through MG Utility and live ADB properties {telemetry ? `Updated ${new Date(telemetry.collectedAt).toLocaleTimeString()}` : "Waiting for telemetry"}
); } -function TelemetryValue({ icon, label, value, suffix }: { icon: React.ReactNode; label: string; value: Availability | undefined; suffix: string }) { - return
{icon}{label}{value?.available ? `${Math.round(value.value)} ${suffix}` : "Unavailable"}{value && !value.available ? value.reason : "Live vehicle value"}
; +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
+

{title}

{first.label}{second.label}
+ {points.length < 2 ?
Collecting enough samples to draw this period.
: <> + + {[0.25, 0.5, 0.75].map((ratio) => )} + + + +
{firstLine.minimum.toFixed(0)}-{firstLine.maximum.toFixed(0)} {first.unit}{secondLine.minimum.toFixed(0)}-{secondLine.maximum.toFixed(0)} {second.unit}
+ } +
; +} + +function TelemetryValue({ icon, label, value: availability, suffix, decimals = 0 }: { icon: React.ReactNode; label: string; value: Availability | undefined; suffix: string; decimals?: number }) { + return
{icon}{label}{availability?.available ? `${availability.value.toFixed(decimals)} ${suffix}` : "Unavailable"}{availability && !availability.available ? availability.reason : "Live vehicle value"}
; +} + +function RecordCard({ icon, label, value, note }: { icon: React.ReactNode; label: string; value: string; note: string }) { + return
{icon}{label}{value}{note}
; +} + +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`; } diff --git a/web/src/api.ts b/web/src/api.ts index d166b89..6f7712e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -122,6 +122,64 @@ export type CarTelemetry = { wheelAngleDegrees: Availability; batteryPercent: Availability; rangeKm: Availability; + batteryVoltage: Availability; + totalConsumptionKwh: Availability; + chargingStatus: Availability; +}; + +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 { return parseResponse(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 { + return parseResponse(await fetch("/api/car/statistics", { credentials: "same-origin" })); +} diff --git a/web/src/styles.css b/web/src/styles.css index 3168507..a3040d3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; } }