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
+1 -1
View File
@@ -8,7 +8,7 @@ COOKIE_SECURE=false
ALLOWED_ORIGINS=http://mg4pi.local:8787 ALLOWED_ORIGINS=http://mg4pi.local:8787
UPDATE_STATUS_PATH=/var/lib/pi-car-companion/update-status.json UPDATE_STATUS_PATH=/var/lib/pi-car-companion/update-status.json
VERSION_FILE=/opt/pi-car-companion/current/REVISION VERSION_FILE=/opt/pi-car-companion/current/REVISION
# Disabled by default. When enabled, ADB_SERIAL must identify exactly one authorized head unit. # Disabled by default. Leave ADB_SERIAL empty to auto-select only when exactly one device is attached.
ADB_ENABLED=false ADB_ENABLED=false
ADB_PATH=adb ADB_PATH=adb
ADB_SERIAL= ADB_SERIAL=
+3 -2
View File
@@ -227,8 +227,9 @@ reference, not proof for other model years, trims, regions, or software builds.
- Car UX restrictions are enforced while moving. Version 1 is parked-use software - Car UX restrictions are enforced while moving. Version 1 is parked-use software
unless a later native activity is explicitly designed, declared, and validated as unless a later native activity is explicitly designed, declared, and validated as
distraction optimized. distraction optimized.
- The Pi is the only intended ADB host. A future bridge must target one configured - The Pi is the only intended ADB host. The bridge targets either one configured
device, use argument-array process execution, enforce timeouts and output bounds, serial or exactly one automatically discovered device, refuses ambiguous multi-device
connections, uses argument-array process execution, enforces timeouts and output bounds,
expose unavailable/unauthorized/offline states, and register every operation in a expose unavailable/unauthorized/offline states, and register every operation in a
typed server-side allowlist. No API accepts a shell string, arbitrary ADB arguments, typed server-side allowlist. No API accepts a shell string, arbitrary ADB arguments,
a package/component supplied by a client, or an unrestricted file path. a package/component supplied by a client, or an unrestricted file path.
+11 -6
View File
@@ -151,15 +151,19 @@ Important values:
- `UPDATE_STATUS_PATH`: update state shared with the systemd update unit - `UPDATE_STATUS_PATH`: update state shared with the systemd update unit
- `VERSION_FILE`: revision metadata for the active atomic release - `VERSION_FILE`: revision metadata for the active atomic release
- `ADB_ENABLED`: enables the read-only head-unit collector; defaults to `false` - `ADB_ENABLED`: enables the read-only head-unit collector; defaults to `false`
- `ADB_SERIAL`: exact device serial used for every ADB command when enabled - `ADB_SERIAL`: optional exact serial; when empty, discovery requires exactly one attached device
- `ADB_PATH` and `ADB_TIMEOUT_MS`: executable path and per-command timeout - `ADB_PATH` and `ADB_TIMEOUT_MS`: executable path and per-command timeout
### Read-only ADB setup ### Read-only ADB setup
ADB integration is optional and disabled by default. Every command is pinned to one ADB integration is optional and disabled by default. It starts the ADB server when the
configured serial, uses a fixed server-side argument list, and has a timeout and 16 KiB companion service boots. With no configured serial it automatically selects exactly one
output limit. It currently reads connection state, selected `getprop` identity fields, attached device and refuses ambiguous multi-device connections; after selection every
and `wm size`/`wm density` only. command is serial-pinned. Commands use fixed server-side argument lists with a timeout
and 16 KiB output limit. The ADB authorization key persists in the companion data
directory, so an already-authorized car reconnects after Pi reboot or USB reattachment.
The collector currently reads connection state, selected `getprop` identity fields, and
`wm size`/`wm density` only.
On a deployed Pi, rerun `install.sh` once so `android-tools-adb` is present and the On a deployed Pi, rerun `install.sh` once so `android-tools-adb` is present and the
service account receives the standard `plugdev` device-access group. Connect the head service account receives the standard `plugdev` device-access group. Connect the head
@@ -174,7 +178,8 @@ Accept the authorization prompt on the parked infotainment screen. Set these val
```text ```text
ADB_ENABLED=true ADB_ENABLED=true
ADB_SERIAL=<exact serial shown by adb devices> # Optional when only the MG4 is attached:
ADB_SERIAL=
``` ```
If the device is disconnected, offline, unauthorized, partially readable, or ADB is If the device is disconnected, offline, unauthorized, partially readable, or ADB is
+4
View File
@@ -129,6 +129,10 @@ COOKIE_SECURE=false
ALLOWED_ORIGINS=http://mg4pi.local:8787 ALLOWED_ORIGINS=http://mg4pi.local:8787
UPDATE_STATUS_PATH=${DATA_DIR}/update-status.json UPDATE_STATUS_PATH=${DATA_DIR}/update-status.json
VERSION_FILE=${CURRENT}/REVISION VERSION_FILE=${CURRENT}/REVISION
ADB_ENABLED=false
ADB_PATH=adb
ADB_SERIAL=
ADB_TIMEOUT_MS=3000
EOF EOF
fi fi
chown root:"${SERVICE_USER}" "${CONFIG_DIR}/companion.env" chown root:"${SERVICE_USER}" "${CONFIG_DIR}/companion.env"
+34 -7
View File
@@ -26,7 +26,7 @@ export type AdbExecutor = (
options: ExecuteOptions options: ExecuteOptions
) => Promise<{ stdout: string; stderr: string }>; ) => Promise<{ stdout: string; stderr: string }>;
export type AdbConnectionState = "disabled" | "unavailable" | "offline" | "unauthorized"; export type AdbConnectionState = "disabled" | "unavailable" | "offline" | "unauthorized" | "ambiguous";
export type AdbDeviceSnapshot = { export type AdbDeviceSnapshot = {
state: "connected"; state: "connected";
@@ -111,15 +111,42 @@ export async function collectAdbStatus(
if (!config.enabled) { if (!config.enabled) {
return { available: false, state: "disabled", reason: "ADB integration is not configured" }; 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[]) => const run = async (arguments_: readonly string[]) =>
execute(config.executable, ["-s", config.serial!, ...arguments_], { execute(config.executable, ["-s", serial, ...arguments_], options);
timeout: config.timeoutMs,
maxBuffer: MAX_ADB_OUTPUT_BYTES
});
try { try {
const state = (await run(["get-state"])).stdout.trim().toLowerCase(); const state = (await run(["get-state"])).stdout.trim().toLowerCase();
+2
View File
@@ -19,6 +19,7 @@ import {
} from "./security.js"; } from "./security.js";
import { collectSystemStatus } from "./status.js"; import { collectSystemStatus } from "./status.js";
import { readUpdateStatus, triggerSystemUpdate } from "./update.js"; import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
import { collectAdbStatus } from "./adb.js";
import "./types.js"; import "./types.js";
const usernameSchema = z const usernameSchema = z
@@ -98,6 +99,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
}); });
const database = openDatabase(config.databasePath); const database = openDatabase(config.databasePath);
const jobRunner = new JobRunner(database, undefined, config.adb); const jobRunner = new JobRunner(database, undefined, config.adb);
if (config.adb.enabled) void collectAdbStatus(config.adb);
app.decorate("database", database); app.decorate("database", database);
app.decorate("jobRunner", jobRunner); 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; const adbSerial = parsed.ADB_SERIAL.trim() || null;
if (parsed.ADB_ENABLED && !adbSerial) {
throw new Error("ADB_SERIAL is required when ADB_ENABLED=true");
}
return { return {
nodeEnv: parsed.NODE_ENV, nodeEnv: parsed.NODE_ENV,
host: parsed.HOST, 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 () => { it("keeps individual fields unavailable when a connected device only partially responds", async () => {
const execute: AdbExecutor = async (_executable, arguments_) => { const execute: AdbExecutor = async (_executable, arguments_) => {
if (arguments_.at(-1) === "get-state") return { stdout: "device\n", stderr: "" }; 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", () => { it("supports automatic discovery or a constrained pinned 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).toMatchObject({ enabled: true, serial: null });
expect(() => expect(() =>
loadConfig({ NODE_ENV: "test", ADB_ENABLED: "true", ADB_SERIAL: "serial; shell command" }) loadConfig({ NODE_ENV: "test", ADB_ENABLED: "true", ADB_SERIAL: "serial; shell command" })
).toThrow(); ).toThrow();
+1
View File
@@ -9,6 +9,7 @@ User=pi-companion
Group=pi-companion Group=pi-companion
WorkingDirectory=/opt/pi-car-companion/current WorkingDirectory=/opt/pi-car-companion/current
EnvironmentFile=/etc/pi-car-companion/companion.env EnvironmentFile=/etc/pi-car-companion/companion.env
Environment=HOME=/var/lib/pi-car-companion
ExecStart=/usr/bin/node /opt/pi-car-companion/current/server/dist/index.js ExecStart=/usr/bin/node /opt/pi-car-companion/current/server/dist/index.js
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
+8 -6
View File
@@ -328,11 +328,13 @@ function StatusContent({ status }: { status: SystemStatus }) {
: "Connected" : "Connected"
: status.headUnit.state === "unauthorized" : status.headUnit.state === "unauthorized"
? "Authorization required" ? "Authorization required"
: status.headUnit.state === "disabled" : status.headUnit.state === "ambiguous"
? "Not configured" ? "Multiple devices"
: status.headUnit.state === "offline" : status.headUnit.state === "disabled"
? "Offline" ? "Not configured"
: "Unavailable"; : status.headUnit.state === "offline"
? "Offline"
: "Unavailable";
return ( return (
<div className="status-layout"> <div className="status-layout">
@@ -359,7 +361,7 @@ function StatusContent({ status }: { status: SystemStatus }) {
<div><span>Operating system</span><strong>{status.operatingSystem}</strong></div> <div><span>Operating system</span><strong>{status.operatingSystem}</strong></div>
<div><span>Processor</span><strong>{status.cpu.model}</strong></div> <div><span>Processor</span><strong>{status.cpu.model}</strong></div>
<div><span>Wi-Fi detail</span><strong>{status.network.wifi.available ? status.network.wifi.value : status.network.wifi.reason}</strong></div> <div><span>Wi-Fi detail</span><strong>{status.network.wifi.available ? status.network.wifi.value : status.network.wifi.reason}</strong></div>
<div><span>MG4 head unit</span><strong title={status.headUnit.available ? "ADB connected" : status.headUnit.reason}>{headUnitLabel}</strong></div> <div><span>ADB connection</span><strong title={status.headUnit.available ? "MG4 head unit connected" : status.headUnit.reason}>{headUnitLabel}</strong></div>
</section> </section>
</div> </div>
); );
+1 -1
View File
@@ -31,7 +31,7 @@ export type HeadUnitStatus =
} }
| { | {
available: false; available: false;
state: "disabled" | "unavailable" | "offline" | "unauthorized"; state: "disabled" | "unavailable" | "offline" | "unauthorized" | "ambiguous";
reason: string; reason: string;
}; };