Add read-only MG4 ADB status collector

This commit is contained in:
2026-07-31 19:17:43 +02:00
parent 4970fda1b5
commit 3e3e0010d6
16 changed files with 411 additions and 20 deletions
+169
View File
@@ -0,0 +1,169 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { Availability } from "./types.js";
const execFileAsync = promisify(execFile);
const MAX_ADB_OUTPUT_BYTES = 16 * 1024;
export type AdbConfig = {
enabled: boolean;
executable: string;
serial: string | null;
timeoutMs: number;
};
export const DISABLED_ADB_CONFIG: AdbConfig = {
enabled: false,
executable: "adb",
serial: null,
timeoutMs: 3_000
};
type ExecuteOptions = { timeout: number; maxBuffer: number };
export type AdbExecutor = (
executable: string,
arguments_: readonly string[],
options: ExecuteOptions
) => Promise<{ stdout: string; stderr: string }>;
export type AdbConnectionState = "disabled" | "unavailable" | "offline" | "unauthorized";
export type AdbDeviceSnapshot = {
state: "connected";
identity: {
manufacturer: Availability<string>;
model: Availability<string>;
androidVersion: Availability<string>;
apiLevel: Availability<number>;
buildId: Availability<string>;
abi: Availability<string>;
};
display: {
size: Availability<{ widthPixels: number; heightPixels: number }>;
densityDpi: Availability<number>;
};
};
export type AdbStatus =
| { available: true; value: AdbDeviceSnapshot }
| { available: false; state: AdbConnectionState; reason: string };
const defaultExecutor: AdbExecutor = async (executable, arguments_, options) => {
const result = await execFileAsync(executable, [...arguments_], {
timeout: options.timeout,
maxBuffer: options.maxBuffer,
encoding: "utf8",
windowsHide: true
});
return { stdout: result.stdout, stderr: result.stderr };
};
function unavailable<T>(reason: string): Availability<T> {
return { available: false, reason };
}
function errorText(error: unknown): string {
if (!error || typeof error !== "object") return "";
const candidate = error as { message?: unknown; stderr?: unknown; code?: unknown; killed?: unknown };
return [candidate.message, candidate.stderr, candidate.code, candidate.killed ? "timed out" : ""]
.filter((value): value is string => typeof value === "string")
.join(" ")
.toLowerCase();
}
function connectionFailure(error: unknown): Exclude<AdbStatus, { available: true }> {
const text = errorText(error);
if (text.includes("unauthorized")) {
return { available: false, state: "unauthorized", reason: "Authorize this Pi on the infotainment screen" };
}
if (text.includes("offline") || text.includes("no devices") || text.includes("device not found")) {
return { available: false, state: "offline", reason: "The configured infotainment device is offline" };
}
if (text.includes("enoent") || text.includes("not found")) {
return { available: false, state: "unavailable", reason: "The configured ADB executable is unavailable" };
}
if (text.includes("timed out") || text.includes("etimedout")) {
return { available: false, state: "offline", reason: "The configured infotainment device did not respond in time" };
}
return { available: false, state: "unavailable", reason: "ADB device state could not be collected" };
}
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);
const widthPixels = Number(match?.[1]);
const heightPixels = Number(match?.[2]);
return widthPixels > 0 && heightPixels > 0
? { available: true, value: { widthPixels, heightPixels } }
: unavailable("ADB returned an unrecognized display size");
}
function parseDensity(value: string): Availability<number> {
const matches = [...value.matchAll(/(?:physical|override) density:\s*(\d+)/gi)];
const density = Number(matches.at(-1)?.[1]);
return density > 0 ? { available: true, value: density } : unavailable("ADB returned an unrecognized display density");
}
export async function collectAdbStatus(
config: AdbConfig,
execute: AdbExecutor = defaultExecutor
): Promise<AdbStatus> {
if (!config.enabled) {
return { available: false, state: "disabled", reason: "ADB integration is not configured" };
}
if (!config.serial) {
return { available: false, state: "unavailable", reason: "ADB is enabled without a configured device serial" };
}
const run = async (arguments_: readonly string[]) =>
execute(config.executable, ["-s", config.serial!, ...arguments_], {
timeout: config.timeoutMs,
maxBuffer: MAX_ADB_OUTPUT_BYTES
});
try {
const state = (await run(["get-state"])).stdout.trim().toLowerCase();
if (state !== "device") {
return state.includes("unauthorized")
? { available: false, state: "unauthorized", reason: "Authorize this Pi on the infotainment screen" }
: { available: false, state: "offline", reason: "The configured infotainment device is offline" };
}
} catch (error) {
return connectionFailure(error);
}
const read = async (arguments_: readonly string[], label: string): Promise<Availability<string>> => {
try {
const value = (await run(arguments_)).stdout.trim();
return value ? { available: true, value } : unavailable(`ADB returned no ${label}`);
} catch {
return unavailable(`ADB could not read ${label}`);
}
};
const [manufacturer, model, androidVersion, apiLevelRaw, buildId, abi, sizeRaw, densityRaw] = await Promise.all([
read(["shell", "getprop", "ro.product.manufacturer"], "manufacturer"),
read(["shell", "getprop", "ro.product.model"], "model"),
read(["shell", "getprop", "ro.build.version.release"], "Android version"),
read(["shell", "getprop", "ro.build.version.sdk"], "API level"),
read(["shell", "getprop", "ro.build.display.id"], "build ID"),
read(["shell", "getprop", "ro.product.cpu.abi"], "CPU ABI"),
read(["shell", "wm", "size"], "display size"),
read(["shell", "wm", "density"], "display density")
]);
const apiLevel = apiLevelRaw.available && /^\d+$/.test(apiLevelRaw.value)
? { available: true as const, value: Number(apiLevelRaw.value) }
: unavailable<number>(apiLevelRaw.available ? "ADB returned an invalid API level" : apiLevelRaw.reason);
return {
available: true,
value: {
state: "connected",
identity: { manufacturer, model, androidVersion, apiLevel, buildId, abi },
display: {
size: sizeRaw.available ? parseSize(sizeRaw.value) : unavailable(sizeRaw.reason),
densityDpi: densityRaw.available ? parseDensity(densityRaw.value) : unavailable(densityRaw.reason)
}
}
};
}
+3 -3
View File
@@ -97,7 +97,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
}
});
const database = openDatabase(config.databasePath);
const jobRunner = new JobRunner(database);
const jobRunner = new JobRunner(database, undefined, config.adb);
app.decorate("database", database);
app.decorate("jobRunner", jobRunner);
@@ -275,7 +275,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
app.get("/api/status", async (request, reply) => {
if (!requireUser(request, reply)) return;
return collectSystemStatus();
return collectSystemStatus(config.adb);
});
app.get("/api/jobs", async (request, reply) => {
@@ -369,7 +369,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
const sendStatus = async () => {
if (closed) return;
try {
const status = await collectSystemStatus();
const status = await collectSystemStatus(config.adb);
reply.raw.write(`event: status\ndata: ${JSON.stringify(status)}\n\n`);
} catch {
reply.raw.write(`event: collection-error\ndata: {"code":"STATUS_COLLECTION_FAILED"}\n\n`);
+19 -2
View File
@@ -1,5 +1,6 @@
import { resolve } from "node:path";
import { z } from "zod";
import type { AdbConfig } from "./adb.js";
const booleanFromString = z
.enum(["true", "false"])
@@ -15,7 +16,11 @@ const schema = z.object({
COOKIE_SECURE: booleanFromString,
ALLOWED_ORIGINS: z.string().default("http://mg4pi.local:8787"),
UPDATE_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/update-status.json"),
VERSION_FILE: z.string().default(resolve("REVISION"))
VERSION_FILE: z.string().default(resolve("REVISION")),
ADB_ENABLED: booleanFromString,
ADB_PATH: z.string().min(1).default("adb"),
ADB_SERIAL: z.string().max(128).regex(/^[a-zA-Z0-9._:-]*$/).default(""),
ADB_TIMEOUT_MS: z.coerce.number().int().min(500).max(15_000).default(3_000)
});
export type AppConfig = {
@@ -28,6 +33,7 @@ export type AppConfig = {
allowedOrigins: ReadonlySet<string>;
updateStatusPath: string;
versionFile: string;
adb: AdbConfig;
};
export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppConfig {
@@ -43,6 +49,11 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
allowedOrigins.add("http://127.0.0.1:5173");
}
const adbSerial = parsed.ADB_SERIAL.trim() || null;
if (parsed.ADB_ENABLED && !adbSerial) {
throw new Error("ADB_SERIAL is required when ADB_ENABLED=true");
}
return {
nodeEnv: parsed.NODE_ENV,
host: parsed.HOST,
@@ -52,6 +63,12 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
cookieSecure: parsed.COOKIE_SECURE,
allowedOrigins,
updateStatusPath: parsed.UPDATE_STATUS_PATH,
versionFile: parsed.VERSION_FILE
versionFile: parsed.VERSION_FILE,
adb: {
enabled: parsed.ADB_ENABLED,
executable: parsed.ADB_PATH,
serial: adbSerial,
timeoutMs: parsed.ADB_TIMEOUT_MS
}
};
}
+7 -5
View File
@@ -3,6 +3,7 @@ import { getServers } from "node:dns";
import { hostname, networkInterfaces } from "node:os";
import type { CompanionDatabase } from "./database.js";
import { collectSystemStatus } from "./status.js";
import { DISABLED_ADB_CONFIG, type AdbConfig } from "./adb.js";
const MAX_OUTPUT_BYTES = 32 * 1024;
const SECRET_KEY = /authorization|cookie|csrf|password|secret|token|credential/i;
@@ -98,7 +99,7 @@ function supportBundle(database: CompanionDatabase, generatedAt: string, status:
};
}
function defaultDefinitions(database: CompanionDatabase): readonly JobDefinition[] {
function defaultDefinitions(database: CompanionDatabase, adbConfig: AdbConfig): readonly JobDefinition[] {
const safeJob = (definition: Omit<JobDefinition, "riskLevel" | "confirmationRequired">): JobDefinition => ({
...definition,
riskLevel: "low",
@@ -110,7 +111,7 @@ function defaultDefinitions(database: CompanionDatabase): readonly JobDefinition
title: "Refresh system health",
description: "Collect a fresh system health snapshot.",
timeoutMs: 15_000,
run: async () => collectSystemStatus()
run: async () => collectSystemStatus(adbConfig)
}),
safeJob({
id: "network-diagnostics",
@@ -126,7 +127,7 @@ function defaultDefinitions(database: CompanionDatabase): readonly JobDefinition
timeoutMs: 20_000,
run: async () => {
const generatedAt = new Date().toISOString();
return supportBundle(database, generatedAt, await collectSystemStatus());
return supportBundle(database, generatedAt, await collectSystemStatus(adbConfig));
}
})
];
@@ -169,9 +170,10 @@ export class JobRunner {
constructor(
private readonly database: CompanionDatabase,
jobs: readonly JobDefinition[] = defaultDefinitions(database)
jobs?: readonly JobDefinition[],
adbConfig: AdbConfig = DISABLED_ADB_CONFIG
) {
this.#jobs = new Map(jobs.map((job) => [job.id, job]));
this.#jobs = new Map((jobs ?? defaultDefinitions(database, adbConfig)).map((job) => [job.id, job]));
}
listJobs(): JobDescriptor[] {
+5 -3
View File
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import os from "node:os";
import type { Availability } from "./types.js";
import { collectAdbStatus, DISABLED_ADB_CONFIG, type AdbConfig } from "./adb.js";
const execFileAsync = promisify(execFile);
@@ -41,7 +42,7 @@ async function diskUsage(): Promise<Availability<DiskStatus>> {
}
}
export async function collectSystemStatus() {
export async function collectSystemStatus(adbConfig: AdbConfig = DISABLED_ADB_CONFIG) {
let interfaces: Array<{ name: string; address: string; family: string }> = [];
let interfaceReason: string | null = null;
try {
@@ -57,7 +58,7 @@ export async function collectSystemStatus() {
const totalMemory = os.totalmem();
const freeMemory = os.freemem();
const [temperature, disk] = await Promise.all([cpuTemperature(), diskUsage()]);
const [temperature, disk, headUnit] = await Promise.all([cpuTemperature(), diskUsage(), collectAdbStatus(adbConfig)]);
return {
collectedAt: new Date().toISOString(),
hostname: os.hostname(),
@@ -75,6 +76,7 @@ export async function collectSystemStatus() {
interfaceReason,
wifi: unavailable<string>("Wi-Fi capability detection is scheduled for the next milestone")
},
service: { state: "healthy" as const, processUptimeSeconds: process.uptime() }
service: { state: "healthy" as const, processUptimeSeconds: process.uptime() },
headUnit
};
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from "vitest";
import { collectAdbStatus, type AdbConfig, type AdbExecutor } from "../src/adb.js";
const enabledConfig: AdbConfig = {
enabled: true,
executable: "/usr/bin/adb",
serial: "MG4-HEAD-UNIT",
timeoutMs: 2_500
};
describe("read-only ADB collector", () => {
it("does not execute anything while integration is disabled", async () => {
const execute = vi.fn<AdbExecutor>();
await expect(collectAdbStatus({ ...enabledConfig, enabled: false }, execute)).resolves.toEqual({
available: false,
state: "disabled",
reason: "ADB integration is not configured"
});
expect(execute).not.toHaveBeenCalled();
});
it("uses only fixed argument arrays for one serial and parses identity and display values", async () => {
const values = new Map<string, string>([
["get-state", "device\n"],
["shell getprop ro.product.manufacturer", "SAIC\n"],
["shell getprop ro.product.model", "AUTUS\n"],
["shell getprop ro.build.version.release", "9\n"],
["shell getprop ro.build.version.sdk", "28\n"],
["shell getprop ro.build.display.id", "PPR1.181005.003\n"],
["shell getprop ro.product.cpu.abi", "arm64-v8a\n"],
["shell wm size", "Physical size: 1920x720\n"],
["shell wm density", "Physical density: 160\n"]
]);
const calls: string[][] = [];
const execute: AdbExecutor = async (executable, arguments_, options) => {
expect(executable).toBe("/usr/bin/adb");
expect(options).toEqual({ timeout: 2_500, maxBuffer: 16 * 1024 });
calls.push([...arguments_]);
return { stdout: values.get(arguments_.slice(2).join(" ")) ?? "", stderr: "" };
};
await expect(collectAdbStatus(enabledConfig, execute)).resolves.toMatchObject({
available: true,
value: {
state: "connected",
identity: {
manufacturer: { available: true, value: "SAIC" },
androidVersion: { available: true, value: "9" },
apiLevel: { available: true, value: 28 },
abi: { available: true, value: "arm64-v8a" }
},
display: {
size: { available: true, value: { widthPixels: 1920, heightPixels: 720 } },
densityDpi: { available: true, value: 160 }
}
}
});
expect(calls).toHaveLength(9);
for (const arguments_ of calls) {
expect(arguments_.slice(0, 2)).toEqual(["-s", "MG4-HEAD-UNIT"]);
expect(arguments_).not.toContain("sh");
expect(arguments_).not.toContain("-c");
}
});
it("reports authorization and offline failures without leaking command output", async () => {
const unauthorized: AdbExecutor = async () => {
throw { stderr: "error: device unauthorized. private-debug-details" };
};
await expect(collectAdbStatus(enabledConfig, unauthorized)).resolves.toEqual({
available: false,
state: "unauthorized",
reason: "Authorize this Pi on the infotainment screen"
});
const offline: AdbExecutor = async () => ({ stdout: "offline\n", stderr: "" });
await expect(collectAdbStatus(enabledConfig, offline)).resolves.toMatchObject({
available: false,
state: "offline"
});
});
it("keeps individual fields unavailable when a connected device only partially responds", async () => {
const execute: AdbExecutor = async (_executable, arguments_) => {
if (arguments_.at(-1) === "get-state") return { stdout: "device\n", stderr: "" };
if (arguments_.at(-1) === "ro.product.model") throw new Error("read failed");
return { stdout: "unexpected\n", stderr: "" };
};
const status = await collectAdbStatus(enabledConfig, execute);
expect(status).toMatchObject({
available: true,
value: { identity: { model: { available: false, reason: "ADB could not read model" } } }
});
});
});
+6 -2
View File
@@ -16,7 +16,8 @@ function testConfig(): AppConfig {
cookieSecure: false,
allowedOrigins: new Set(["http://mg4pi.local:8787"]),
updateStatusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-update.json`,
versionFile: `/tmp/pi-car-companion-test-${process.pid}-missing-revision`
versionFile: `/tmp/pi-car-companion-test-${process.pid}-missing-revision`,
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
};
}
@@ -156,7 +157,10 @@ describe("authentication boundary", () => {
headers: { cookie: `${SESSION_COOKIE}=${sessionCookie?.value ?? ""}` }
});
expect(status.statusCode, status.body).toBe(200);
expect(status.json()).toMatchObject({ service: { state: "healthy" } });
expect(status.json()).toMatchObject({
service: { state: "healthy" },
headUnit: { available: false, state: "disabled" }
});
const update = await app.inject({
method: "POST",
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { loadConfig } from "../src/config.js";
describe("ADB configuration", () => {
it("is disabled by default", () => {
expect(loadConfig({ NODE_ENV: "test" }).adb).toEqual({
enabled: false,
executable: "adb",
serial: null,
timeoutMs: 3_000
});
});
it("requires a constrained serial when enabled", () => {
expect(() => loadConfig({ NODE_ENV: "test", ADB_ENABLED: "true" })).toThrow("ADB_SERIAL is required");
expect(() =>
loadConfig({ NODE_ENV: "test", ADB_ENABLED: "true", ADB_SERIAL: "serial; shell command" })
).toThrow();
expect(loadConfig({ NODE_ENV: "test", ADB_ENABLED: "true", ADB_SERIAL: "10.0.0.10:5555" }).adb).toMatchObject({
enabled: true,
serial: "10.0.0.10:5555"
});
});
});
+2 -1
View File
@@ -17,7 +17,8 @@ function config(updateStatusPath: string, versionFile: string): AppConfig {
cookieSecure: false,
allowedOrigins: new Set(),
updateStatusPath,
versionFile
versionFile,
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
};
}