feat: add direct car telemetry management
This commit is contained in:
+54
-23
@@ -2,12 +2,16 @@ import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import type { Availability } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_ADB_OUTPUT_BYTES = 16 * 1024;
|
||||
const MAX_ADB_LIST_OUTPUT_BYTES = 512 * 1024;
|
||||
const CAR_PROBE_LOCAL_PATH = fileURLToPath(new URL("../assets/car-telemetry-probe.dex", import.meta.url));
|
||||
const CAR_PROBE_REMOTE_PATH = "/data/local/tmp/pi-car-telemetry-probe.dex";
|
||||
const carProbeReady = new WeakMap<AdbExecutor, Set<string>>();
|
||||
export const MAX_APK_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
export type AdbConfig = {
|
||||
@@ -239,12 +243,16 @@ function numericProperty(properties: Map<string, string>, key: string, label: st
|
||||
return Number.isFinite(value) ? { available: true, value } : unavailable(`${label} is not exposed by the head unit`);
|
||||
}
|
||||
|
||||
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());
|
||||
function parseProbeOutput(output: string): Map<string, string> | null {
|
||||
const line = output.split(/\r?\n/).find((candidate) => candidate.trim().startsWith("{"));
|
||||
if (!line) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
if (parsed.status !== "ok") return null;
|
||||
return new Map(Object.entries(parsed).map(([key, value]) => [key, String(value)]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function bundleNumber(
|
||||
@@ -257,40 +265,63 @@ function bundleNumber(
|
||||
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`);
|
||||
: unavailable(`${label} is unavailable from the factory vehicle service over ADB`);
|
||||
}
|
||||
|
||||
async function sampleVehicleService(config: AdbConfig, execute: AdbExecutor): Promise<Map<string, string> | null> {
|
||||
const serial = await resolveSerial(config, execute);
|
||||
let readySerials = carProbeReady.get(execute);
|
||||
if (!readySerials) {
|
||||
readySerials = new Set<string>();
|
||||
carProbeReady.set(execute, readySerials);
|
||||
}
|
||||
if (!readySerials.has(serial)) {
|
||||
await execute(config.executable, ["-s", serial, "push", CAR_PROBE_LOCAL_PATH, CAR_PROBE_REMOTE_PATH], {
|
||||
timeout: Math.max(config.timeoutMs, 10_000),
|
||||
maxBuffer: MAX_ADB_OUTPUT_BYTES
|
||||
});
|
||||
readySerials.add(serial);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
config.executable,
|
||||
["-s", serial, "shell", `CLASSPATH=${CAR_PROBE_REMOTE_PATH}`, "app_process", "/system/bin", "cloud.molberg.picarprobe.CarTelemetryProbe"],
|
||||
{ timeout: Math.max(config.timeoutMs, 7_000), maxBuffer: MAX_ADB_OUTPUT_BYTES }
|
||||
);
|
||||
return parseProbeOutput(result.stdout);
|
||||
} catch (error) {
|
||||
const text = errorText(error);
|
||||
if (text.includes("classnotfound") || text.includes("class not found") || text.includes("no such file") || text.includes("dex")) {
|
||||
readySerials.delete(serial);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectCarTelemetry(
|
||||
config: AdbConfig,
|
||||
execute: AdbExecutor = defaultExecutor
|
||||
): Promise<CarTelemetry> {
|
||||
const [propertiesResult, bridgeResult] = await Promise.all([
|
||||
const [propertiesResult, vehicleValues] = await Promise.all([
|
||||
executeForDevice(config, ["shell", "getprop"], execute, MAX_ADB_LIST_OUTPUT_BYTES),
|
||||
executeForDevice(
|
||||
config,
|
||||
["shell", "content", "call", "--uri", "content://cloud.molberg.mgutility.vehicle", "--method", "sample"],
|
||||
execute,
|
||||
MAX_ADB_OUTPUT_BYTES
|
||||
).catch(() => null)
|
||||
sampleVehicleService(config, execute).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);
|
||||
const serviceSpeed = bundleNumber(vehicleValues, "speedKph", "Vehicle speed", 0, 400);
|
||||
return {
|
||||
available: true,
|
||||
collectedAt: new Date().toISOString(),
|
||||
speedKph: bridgeSpeed.available ? bridgeSpeed : numericProperty(properties, "arcsoft.avm.mCurCarSpeed", "Vehicle speed"),
|
||||
speedKph: serviceSpeed.available ? serviceSpeed : numericProperty(properties, "arcsoft.avm.mCurCarSpeed", "Vehicle speed"),
|
||||
gear: rawGear ? { available: true, value: rawGear } : unavailable("Gear is not exposed by the head unit"),
|
||||
wheelAngleDegrees: numericProperty(properties, "arcsoft.avm.mCurCarWheelAngle", "Wheel angle"),
|
||||
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)
|
||||
batteryPercent: bundleNumber(vehicleValues, "soc", "Traction battery", 0, 100),
|
||||
rangeKm: bundleNumber(vehicleValues, "rangeKm", "Estimated range", 0, 1_500),
|
||||
batteryVoltage: bundleNumber(vehicleValues, "batteryVolts", "Battery voltage", 0, 1_000),
|
||||
totalConsumptionKwh: bundleNumber(vehicleValues, "totalConsumptionKwh", "Consumption", 0, 10_000),
|
||||
chargingStatus: bundleNumber(vehicleValues, "chargingStatus", "Charging status", -1, 100)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ const adbShortcutSchema = z.object({ name: z.string().trim().min(1).max(48), typ
|
||||
}
|
||||
return { name: value.name, ...target.data };
|
||||
});
|
||||
const clearTelemetrySchema = z.object({ mode: z.enum(["history", "all"]) });
|
||||
|
||||
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
|
||||
type SessionUserRow = { id: number; username: string; role: "admin" };
|
||||
@@ -505,6 +506,30 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
return carTelemetry.store.overview();
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/car/history/clear",
|
||||
{ config: { rateLimit: { max: 4, timeWindow: "10 minutes" } } },
|
||||
async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const parsed = clearTelemetrySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({
|
||||
error: { code: "INVALID_CLEAR_TELEMETRY_MODE", message: "Choose which car statistics to clear" }
|
||||
});
|
||||
}
|
||||
|
||||
const result = carTelemetry.store.clear(parsed.data.mode);
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "car.telemetry-clear",
|
||||
result: "success",
|
||||
ipAddress: clientAddress(request),
|
||||
details: result
|
||||
});
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/network/configuration",
|
||||
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
|
||||
|
||||
@@ -21,6 +21,7 @@ type StoredSample = {
|
||||
};
|
||||
|
||||
export type TelemetryPeriod = "24h" | "7d" | "30d" | "1y" | "all";
|
||||
export type ClearTelemetryMode = "history" | "all";
|
||||
|
||||
export type TelemetryHistoryPoint = {
|
||||
timestamp: number;
|
||||
@@ -252,6 +253,39 @@ export class CarTelemetryStore {
|
||||
transaction();
|
||||
}
|
||||
|
||||
clear(mode: ClearTelemetryMode): { mode: ClearTelemetryMode; deletedSamples: number; deletedRollups: number } {
|
||||
const transaction = this.database.transaction(() => {
|
||||
const deletedSamples = this.database.prepare("DELETE FROM car_telemetry_samples").run().changes;
|
||||
const deletedRollups = this.database.prepare("DELETE FROM car_telemetry_hourly").run().changes;
|
||||
|
||||
if (mode === "all") {
|
||||
this.database.prepare(
|
||||
`UPDATE car_telemetry_stats SET
|
||||
first_sample_at = NULL,
|
||||
last_sample_at = NULL,
|
||||
sample_count = 0,
|
||||
peak_speed_kph = NULL,
|
||||
peak_speed_at = NULL,
|
||||
peak_charging_power_kw = NULL,
|
||||
peak_charging_power_at = NULL,
|
||||
max_observed_range_km = NULL,
|
||||
max_observed_range_at = NULL,
|
||||
tracked_distance_km = 0,
|
||||
driving_seconds = 0,
|
||||
charging_seconds = 0,
|
||||
charge_sessions = 0,
|
||||
soc_used_percent = 0
|
||||
WHERE id = 1`
|
||||
).run();
|
||||
}
|
||||
|
||||
return { mode, deletedSamples, deletedRollups };
|
||||
});
|
||||
|
||||
this.#samplesSincePrune = 0;
|
||||
return transaction();
|
||||
}
|
||||
|
||||
overview() {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user