Add ADB management and live car telemetry

This commit is contained in:
2026-07-31 22:23:12 +02:00
parent 34b31c4caf
commit 0e4ce931ec
12 changed files with 911 additions and 23 deletions
+166
View File
@@ -1,9 +1,14 @@
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 { 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;
export const MAX_APK_BYTES = 256 * 1024 * 1024;
export type AdbConfig = {
enabled: boolean;
@@ -48,6 +53,26 @@ export type AdbStatus =
| { available: true; value: AdbDeviceSnapshot }
| { available: false; state: AdbConnectionState; reason: string };
export type InstalledAdbPackage = {
packageName: string;
apkPath: string;
system: boolean;
};
export type AdbShortcutTarget =
| { type: "component"; value: string }
| { type: "package"; value: string };
export type CarTelemetry = {
available: true;
collectedAt: string;
speedKph: Availability<number>;
gear: Availability<string>;
wheelAngleDegrees: Availability<number>;
batteryPercent: Availability<number>;
rangeKm: Availability<number>;
};
const defaultExecutor: AdbExecutor = async (executable, arguments_, options) => {
const result = await execFileAsync(executable, [...arguments_], {
timeout: options.timeout,
@@ -88,6 +113,147 @@ function connectionFailure(error: unknown): Exclude<AdbStatus, { available: true
return { available: false, state: "unavailable", reason: "ADB device state could not be collected" };
}
async function resolveSerial(config: AdbConfig, execute: AdbExecutor): Promise<string> {
if (!config.enabled) throw new Error("ADB_DISABLED");
if (config.serial) return config.serial;
const output = (await execute(config.executable, ["devices"], {
timeout: config.timeoutMs,
maxBuffer: MAX_ADB_OUTPUT_BYTES
})).stdout;
const devices = output
.split(/\r?\n/)
.slice(1)
.map((line) => line.trim().split(/\s+/, 3))
.filter((parts) => parts[0] && parts[1]);
if (devices.length !== 1 || devices[0]?.[1] !== "device") throw new Error("ADB_DEVICE_UNAVAILABLE");
return devices[0][0]!;
}
async function executeForDevice(
config: AdbConfig,
arguments_: readonly string[],
execute: AdbExecutor,
maxBuffer = MAX_ADB_OUTPUT_BYTES,
timeout = config.timeoutMs
): Promise<{ stdout: string; stderr: string }> {
const serial = await resolveSerial(config, execute);
return execute(config.executable, ["-s", serial, ...arguments_], { timeout, maxBuffer });
}
function validatePackageName(value: string): boolean {
return /^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*$/.test(value);
}
function validateComponent(value: string): boolean {
const [packageName, activity, extra] = value.split("/");
return !extra && Boolean(packageName && activity) && validatePackageName(packageName!) &&
/^(?:\.|[a-zA-Z])[a-zA-Z0-9_.$]*$/.test(activity!);
}
export async function listInstalledAdbPackages(
config: AdbConfig,
execute: AdbExecutor = defaultExecutor
): Promise<InstalledAdbPackage[]> {
const output = (await executeForDevice(
config,
["shell", "pm", "list", "packages", "-f"],
execute,
MAX_ADB_LIST_OUTPUT_BYTES
)).stdout;
return output
.split(/\r?\n/)
.map((line) => /^package:(.+)=([a-zA-Z0-9_.]+)$/.exec(line.trim()))
.filter((match): match is RegExpExecArray => Boolean(match))
.map((match) => ({
apkPath: match[1]!,
packageName: match[2]!,
system: match[1]!.startsWith("/system/") || match[1]!.startsWith("/vendor/") || match[1]!.startsWith("/product/")
}))
.sort((left, right) => left.packageName.localeCompare(right.packageName));
}
export async function launchAdbTarget(
config: AdbConfig,
target: AdbShortcutTarget,
execute: AdbExecutor = defaultExecutor
): Promise<void> {
if (target.type === "component") {
if (!validateComponent(target.value)) throw new Error("INVALID_ADB_COMPONENT");
const result = await executeForDevice(config, ["shell", "am", "start", "-n", target.value], execute);
if (/\b(?:error|exception|does not exist)\b/i.test(`${result.stdout}\n${result.stderr}`)) {
throw new Error("ADB_LAUNCH_FAILED");
}
return;
}
if (!validatePackageName(target.value)) throw new Error("INVALID_ADB_PACKAGE");
const result = await executeForDevice(
config,
["shell", "monkey", "-p", target.value, "-c", "android.intent.category.LAUNCHER", "1"],
execute
);
if (/no activities found|monkey aborted|\berror\b/i.test(`${result.stdout}\n${result.stderr}`)) {
throw new Error("ADB_LAUNCH_FAILED");
}
}
export async function installAdbApk(
config: AdbConfig,
apk: Buffer,
execute: AdbExecutor = defaultExecutor
): Promise<void> {
if (apk.byteLength < 4 || apk.byteLength > MAX_APK_BYTES || apk.readUInt32BE(0) !== 0x504b0304) {
throw new Error("INVALID_APK");
}
const directory = await mkdtemp(join(tmpdir(), "pi-car-apk-"));
const apkPath = join(directory, "upload.apk");
try {
await writeFile(apkPath, apk, { mode: 0o600 });
const serial = await resolveSerial(config, execute);
const result = await execute(config.executable, ["-s", serial, "install", "-r", apkPath], {
timeout: Math.max(config.timeoutMs, 120_000),
maxBuffer: MAX_ADB_OUTPUT_BYTES
});
if (!/\bSuccess\b/i.test(result.stdout) || /\bFailure\b/i.test(`${result.stdout}\n${result.stderr}`)) {
throw new Error("APK_INSTALL_FAILED");
}
} finally {
await rm(directory, { recursive: true, force: true });
}
}
function parseGetprop(output: string): Map<string, string> {
const properties = new Map<string, string>();
for (const line of output.split(/\r?\n/)) {
const match = /^\[([^\]]+)\]: \[(.*)\]$/.exec(line.trim());
if (match) properties.set(match[1]!, match[2]!);
}
return properties;
}
function numericProperty(properties: Map<string, string>, key: string, label: string): Availability<number> {
const raw = properties.get(key);
const value = raw === undefined || raw === "" ? Number.NaN : Number(raw);
return Number.isFinite(value) ? { available: true, value } : unavailable(`${label} is not exposed by the head unit`);
}
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 properties = parseGetprop(output);
const rawGear = properties.get("arcsoft.avm.mCurCarGear");
return {
available: true,
collectedAt: new Date().toISOString(),
speedKph: 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")
};
}
function parseSize(value: string): Availability<{ widthPixels: number; heightPixels: number }> {
const matches = [...value.matchAll(/(?:physical|override) size:\s*(\d+)x(\d+)/gi)];
const match = matches.at(-1);
+154 -1
View File
@@ -28,7 +28,14 @@ import {
requestHotspotConfiguration,
requestNetworkAction
} from "./network.js";
import { collectAdbStatus } from "./adb.js";
import {
collectAdbStatus,
collectCarTelemetry,
installAdbApk,
launchAdbTarget,
listInstalledAdbPackages,
MAX_APK_BYTES
} from "./adb.js";
import { openShell } from "./shell.js";
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
import "./types.js";
@@ -54,6 +61,28 @@ const setupCredentialsSchema = loginCredentialsSchema.extend({
.regex(/[0-9]/, "Password must contain at least one number")
});
const adbPackageNameSchema = z.string().trim().max(240).regex(
/^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*$/,
"Enter a valid Android package name"
);
const adbComponentSchema = z.string().trim().max(240).regex(
/^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*\/(?:\.|[a-zA-Z])[a-zA-Z0-9_.$]*$/,
"Enter a valid Android component"
);
const adbTargetSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("component"), value: adbComponentSchema }),
z.object({ type: z.literal("package"), value: adbPackageNameSchema })
]);
const adbShortcutSchema = z.object({ name: z.string().trim().min(1).max(48), type: z.enum(["component", "package"]), value: z.string() })
.transform((value, context) => {
const target = adbTargetSchema.safeParse({ type: value.type, value: value.value });
if (!target.success) {
context.addIssue({ code: "custom", message: target.error.issues[0]?.message ?? "Enter a valid Android target" });
return z.NEVER;
}
return { name: value.name, ...target.data };
});
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
type SessionUserRow = { id: number; username: string; role: "admin" };
@@ -118,6 +147,11 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
await app.register(cookie);
await app.register(rateLimit, { global: false });
await app.register(websocket);
app.addContentTypeParser(
["application/vnd.android.package-archive", "application/octet-stream"],
{ parseAs: "buffer" },
(_request, body, done) => done(null, body)
);
app.addHook("onClose", async () => {
jobRunner.close();
@@ -335,6 +369,125 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
return readNetworkActivity(config.network.activityPath);
});
app.get("/api/adb/packages", async (request, reply) => {
if (!requireUser(request, reply)) return;
try {
return { packages: await listInstalledAdbPackages(config.adb) };
} catch {
return reply.code(503).send({ error: { code: "ADB_UNAVAILABLE", message: "The infotainment unit is not available over ADB" } });
}
});
app.post(
"/api/adb/install",
{ config: { rateLimit: { max: 4, timeWindow: "10 minutes" } }, bodyLimit: MAX_APK_BYTES },
async (request, reply) => {
if (!requireUser(request, reply)) return;
if (!Buffer.isBuffer(request.body)) {
return reply.code(400).send({ error: { code: "INVALID_APK", message: "Choose a valid APK file" } });
}
try {
await installAdbApk(config.adb, request.body);
audit(database, {
actorUserId: request.authUser!.id,
action: "adb.install-apk",
result: "success",
ipAddress: clientAddress(request),
details: { bytes: request.body.byteLength }
});
return reply.code(201).send({ installed: true });
} catch (error) {
const invalid = error instanceof Error && error.message === "INVALID_APK";
audit(database, {
actorUserId: request.authUser!.id,
action: "adb.install-apk",
result: "failure",
ipAddress: clientAddress(request),
details: { reason: invalid ? "invalid_apk" : "install_failed" }
});
return reply.code(invalid ? 400 : 503).send({
error: {
code: invalid ? "INVALID_APK" : "APK_INSTALL_FAILED",
message: invalid ? "Choose a valid APK file" : "The APK could not be installed on the infotainment unit"
}
});
}
}
);
app.post("/api/adb/launch", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (request, reply) => {
if (!requireUser(request, reply)) return;
const parsed = adbTargetSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: { code: "INVALID_ADB_TARGET", message: "Enter a valid package or activity" } });
}
try {
await launchAdbTarget(config.adb, parsed.data);
audit(database, {
actorUserId: request.authUser!.id,
action: "adb.launch",
result: "success",
ipAddress: clientAddress(request),
details: { targetType: parsed.data.type, target: parsed.data.value }
});
return { launched: true };
} catch {
audit(database, {
actorUserId: request.authUser!.id,
action: "adb.launch",
result: "failure",
ipAddress: clientAddress(request),
details: { targetType: parsed.data.type, target: parsed.data.value }
});
return reply.code(503).send({ error: { code: "ADB_LAUNCH_FAILED", message: "The target could not be opened on the infotainment unit" } });
}
});
app.get("/api/adb/shortcuts", async (request, reply) => {
if (!requireUser(request, reply)) return;
const shortcuts = database
.prepare("SELECT id, name, target_type AS type, target_value AS value, created_at AS createdAt FROM adb_shortcuts ORDER BY created_at, id")
.all();
return { shortcuts };
});
app.post("/api/adb/shortcuts", async (request, reply) => {
if (!requireUser(request, reply)) return;
const parsed = adbShortcutSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: { code: "INVALID_ADB_SHORTCUT", message: "Enter a name and a valid target" } });
}
try {
const createdAt = new Date().toISOString();
const result = database
.prepare("INSERT INTO adb_shortcuts (name, target_type, target_value, created_at) VALUES (?, ?, ?, ?)")
.run(parsed.data.name, parsed.data.type, parsed.data.value, createdAt);
return reply.code(201).send({ id: Number(result.lastInsertRowid), ...parsed.data, createdAt });
} catch {
return reply.code(409).send({ error: { code: "ADB_SHORTCUT_EXISTS", message: "A shortcut for that target already exists" } });
}
});
app.delete<{ Params: { id: string } }>("/api/adb/shortcuts/:id", async (request, reply) => {
if (!requireUser(request, reply)) return;
const id = Number(request.params.id);
if (!Number.isInteger(id) || id < 1) {
return reply.code(400).send({ error: { code: "INVALID_ADB_SHORTCUT", message: "That shortcut is invalid" } });
}
const result = database.prepare("DELETE FROM adb_shortcuts WHERE id = ?").run(id);
if (result.changes === 0) return reply.code(404).send({ error: { code: "ADB_SHORTCUT_NOT_FOUND", message: "Shortcut not found" } });
return reply.code(204).send();
});
app.get("/api/car/telemetry", async (request, reply) => {
if (!requireUser(request, reply)) return;
try {
return await collectCarTelemetry(config.adb);
} catch {
return reply.code(503).send({ error: { code: "CAR_TELEMETRY_UNAVAILABLE", message: "Live car telemetry is unavailable over ADB" } });
}
});
app.post(
"/api/network/configuration",
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
+9
View File
@@ -60,6 +60,15 @@ function migrate(database: CompanionDatabase): void {
);
CREATE INDEX IF NOT EXISTS job_runs_created_idx ON job_runs(created_at DESC);
CREATE TABLE IF NOT EXISTS adb_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
target_type TEXT NOT NULL CHECK (target_type IN ('component', 'package')),
target_value TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(target_type, target_value)
);
`);
database
+70 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { collectAdbStatus, type AdbConfig, type AdbExecutor } from "../src/adb.js";
import { existsSync } from "node:fs";
import {
collectAdbStatus,
collectCarTelemetry,
installAdbApk,
launchAdbTarget,
listInstalledAdbPackages,
type AdbConfig,
type AdbExecutor
} from "../src/adb.js";
const enabledConfig: AdbConfig = {
enabled: true,
@@ -129,3 +138,63 @@ describe("read-only ADB collector", () => {
});
});
});
describe("ADB dashboard operations", () => {
it("lists installed packages and identifies system paths", async () => {
const execute: AdbExecutor = async (_executable, arguments_) => {
expect(arguments_).toEqual(["-s", "MG4-HEAD-UNIT", "shell", "pm", "list", "packages", "-f"]);
return {
stdout: "package:/data/app/com.example/base.apk=com.example\npackage:/system/app/Settings/Settings.apk=com.android.settings\n",
stderr: ""
};
};
await expect(listInstalledAdbPackages(enabledConfig, execute)).resolves.toEqual([
{ packageName: "com.android.settings", apkPath: "/system/app/Settings/Settings.apk", system: true },
{ packageName: "com.example", apkPath: "/data/app/com.example/base.apk", system: false }
]);
});
it("launches validated packages and components without a shell", async () => {
const calls: string[][] = [];
const execute: AdbExecutor = async (_executable, arguments_) => {
calls.push([...arguments_]);
return { stdout: "", stderr: "" };
};
await launchAdbTarget(enabledConfig, { type: "component", value: "com.android.settings/.Settings" }, execute);
await launchAdbTarget(enabledConfig, { type: "package", value: "com.example.app" }, execute);
expect(calls).toEqual([
["-s", "MG4-HEAD-UNIT", "shell", "am", "start", "-n", "com.android.settings/.Settings"],
["-s", "MG4-HEAD-UNIT", "shell", "monkey", "-p", "com.example.app", "-c", "android.intent.category.LAUNCHER", "1"]
]);
await expect(launchAdbTarget(enabledConfig, { type: "component", value: "com.example/.Main;reboot" }, execute)).rejects.toThrow("INVALID_ADB_COMPONENT");
});
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
const execute: AdbExecutor = async () => ({
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 },
gear: { available: true, value: "4" },
wheelAngleDegrees: { available: true, value: -12.5 },
batteryPercent: { available: false },
rangeKm: { available: false }
});
});
it("accepts only ZIP-formatted APK payloads and removes the temporary file", async () => {
let temporaryPath = "";
const execute: AdbExecutor = async (_executable, arguments_, options) => {
temporaryPath = arguments_.at(-1) ?? "";
expect(arguments_.slice(0, 4)).toEqual(["-s", "MG4-HEAD-UNIT", "install", "-r"]);
expect(existsSync(temporaryPath)).toBe(true);
expect(options.timeout).toBe(120_000);
return { stdout: "Success\n", stderr: "" };
};
await installAdbApk(enabledConfig, Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00]), execute);
expect(existsSync(temporaryPath)).toBe(false);
await expect(installAdbApk(enabledConfig, Buffer.from("not an apk"), execute)).rejects.toThrow("INVALID_APK");
});
});
+50
View File
@@ -250,6 +250,56 @@ describe("authentication boundary", () => {
expect(invalidConfiguration.json()).toMatchObject({ error: { code: "INVALID_HOTSPOT_CONFIGURATION" } });
});
it("protects ADB operations and persists only validated shortcuts", async () => {
const app = await createApp();
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);
const token = await csrf(app);
await setup(app, token);
const login = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: mutationHeaders(token),
payload: { username: "owner", password: "Correct-horse1" }
});
const session = login.cookies.find((item) => item.name === SESSION_COOKIE)?.value ?? "";
const cookie = `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${session}`;
const headers = { ...mutationHeaders(token), cookie };
const invalid = await app.inject({
method: "POST",
url: "/api/adb/shortcuts",
headers,
payload: { name: "Unsafe", type: "component", value: "com.example/.Main;reboot" }
});
expect(invalid.statusCode).toBe(400);
const created = await app.inject({
method: "POST",
url: "/api/adb/shortcuts",
headers,
payload: { name: "Settings", type: "component", value: "com.android.settings/.Settings" }
});
expect(created.statusCode).toBe(201);
const shortcutId = created.json<{ id: number }>().id;
const shortcuts = await app.inject({ method: "GET", url: "/api/adb/shortcuts", headers: { cookie } });
expect(shortcuts.json()).toMatchObject({ shortcuts: [{ id: shortcutId, name: "Settings" }] });
const badApk = await app.inject({
method: "POST",
url: "/api/adb/install",
headers: { ...headers, "content-type": "application/vnd.android.package-archive" },
payload: Buffer.from("plain text")
});
expect(badApk.statusCode).toBe(400);
expect(badApk.json()).toMatchObject({ error: { code: "INVALID_APK" } });
const removed = await app.inject({ method: "DELETE", url: `/api/adb/shortcuts/${shortcutId}`, headers });
expect(removed.statusCode).toBe(204);
});
it("exposes only allowlisted jobs and authenticated run history", async () => {
const app = await createApp();
const token = await csrf(app);