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