Automatically reconnect single ADB head unit

This commit is contained in:
2026-07-31 19:25:32 +02:00
parent 3e3e0010d6
commit 0a0dae144c
12 changed files with 103 additions and 29 deletions
+34 -7
View File
@@ -26,7 +26,7 @@ export type AdbExecutor = (
options: ExecuteOptions
) => Promise<{ stdout: string; stderr: string }>;
export type AdbConnectionState = "disabled" | "unavailable" | "offline" | "unauthorized";
export type AdbConnectionState = "disabled" | "unavailable" | "offline" | "unauthorized" | "ambiguous";
export type AdbDeviceSnapshot = {
state: "connected";
@@ -111,15 +111,42 @@ export async function collectAdbStatus(
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 options = { timeout: config.timeoutMs, maxBuffer: MAX_ADB_OUTPUT_BYTES };
let serial = config.serial;
if (!serial) {
try {
const output = (await execute(config.executable, ["devices"], options)).stdout;
const devices = output
.split(/\r?\n/)
.slice(1)
.map((line) => line.trim().split(/\s+/, 3))
.filter((parts): parts is [string, string, ...string[]] => Boolean(parts[0] && parts[1]));
if (devices.length === 0) {
return { available: false, state: "offline", reason: "No ADB infotainment device is connected" };
}
if (devices.length > 1) {
return {
available: false,
state: "ambiguous",
reason: "Multiple ADB devices are connected; configure ADB_SERIAL to select the infotainment unit"
};
}
const [discoveredSerial, discoveredState] = devices[0]!;
if (discoveredState === "unauthorized") {
return { available: false, state: "unauthorized", reason: "Authorize this Pi on the infotainment screen" };
}
if (discoveredState !== "device") {
return { available: false, state: "offline", reason: "The discovered infotainment device is offline" };
}
serial = discoveredSerial;
} catch (error) {
return connectionFailure(error);
}
}
const run = async (arguments_: readonly string[]) =>
execute(config.executable, ["-s", config.serial!, ...arguments_], {
timeout: config.timeoutMs,
maxBuffer: MAX_ADB_OUTPUT_BYTES
});
execute(config.executable, ["-s", serial, ...arguments_], options);
try {
const state = (await run(["get-state"])).stdout.trim().toLowerCase();
+2
View File
@@ -19,6 +19,7 @@ import {
} from "./security.js";
import { collectSystemStatus } from "./status.js";
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
import { collectAdbStatus } from "./adb.js";
import "./types.js";
const usernameSchema = z
@@ -98,6 +99,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
});
const database = openDatabase(config.databasePath);
const jobRunner = new JobRunner(database, undefined, config.adb);
if (config.adb.enabled) void collectAdbStatus(config.adb);
app.decorate("database", database);
app.decorate("jobRunner", jobRunner);
-4
View File
@@ -50,10 +50,6 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
}
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,
+36
View File
@@ -80,6 +80,42 @@ describe("read-only ADB collector", () => {
});
});
it("automatically discovers exactly one device and refuses ambiguous connections", async () => {
const calls: string[][] = [];
const execute: AdbExecutor = async (_executable, arguments_) => {
calls.push([...arguments_]);
if (arguments_[0] === "devices") {
return { stdout: "List of devices attached\nAUTO-MG4\tdevice product:saic model:autus\n", stderr: "" };
}
if (arguments_.at(-1) === "get-state") return { stdout: "device\n", stderr: "" };
return { stdout: "value\n", stderr: "" };
};
const status = await collectAdbStatus({ ...enabledConfig, serial: null }, execute);
expect(status).toMatchObject({ available: true, value: { state: "connected" } });
expect(calls[0]).toEqual(["devices"]);
for (const arguments_ of calls.slice(1)) expect(arguments_.slice(0, 2)).toEqual(["-s", "AUTO-MG4"]);
const multiple: AdbExecutor = async () => ({
stdout: "List of devices attached\nMG4\tdevice\nPHONE\tdevice\n",
stderr: ""
});
await expect(collectAdbStatus({ ...enabledConfig, serial: null }, multiple)).resolves.toMatchObject({
available: false,
state: "ambiguous"
});
});
it("reports an automatically discovered device awaiting authorization", async () => {
const execute: AdbExecutor = async () => ({
stdout: "List of devices attached\nAUTO-MG4\tunauthorized\n",
stderr: ""
});
await expect(collectAdbStatus({ ...enabledConfig, serial: null }, execute)).resolves.toMatchObject({
available: false,
state: "unauthorized"
});
});
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: "" };
+2 -2
View File
@@ -11,8 +11,8 @@ describe("ADB configuration", () => {
});
});
it("requires a constrained serial when enabled", () => {
expect(() => loadConfig({ NODE_ENV: "test", ADB_ENABLED: "true" })).toThrow("ADB_SERIAL is required");
it("supports automatic discovery or a constrained pinned serial when enabled", () => {
expect(loadConfig({ NODE_ENV: "test", ADB_ENABLED: "true" }).adb).toMatchObject({ enabled: true, serial: null });
expect(() =>
loadConfig({ NODE_ENV: "test", ADB_ENABLED: "true", ADB_SERIAL: "serial; shell command" })
).toThrow();