Add read-only MG4 ADB status collector
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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[] {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user