Add read-only MG4 ADB status collector
This commit is contained in:
@@ -8,3 +8,8 @@ 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.
|
||||||
|
ADB_ENABLED=false
|
||||||
|
ADB_PATH=adb
|
||||||
|
ADB_SERIAL=
|
||||||
|
ADB_TIMEOUT_MS=3000
|
||||||
|
|||||||
@@ -297,8 +297,8 @@ The repository contains a working MVP and tracks remaining delivery work here. S
|
|||||||
|
|
||||||
### Milestone 3 — ADB transport and Android client
|
### Milestone 3 — ADB transport and Android client
|
||||||
|
|
||||||
- [ ] Implement one-device ADB configuration, connection/authorization state, timeouts, output bounds, and test fixtures.
|
- [x] Implement one-device ADB configuration, connection/authorization state, timeouts, output bounds, and test fixtures.
|
||||||
- [ ] Add allowlisted, read-only device identity and display collectors with per-field availability.
|
- [x] Add allowlisted, read-only device identity and display collectors with per-field availability.
|
||||||
- [ ] Create the Kotlin project with minimum SDK 28 and landscape support.
|
- [ ] Create the Kotlin project with minimum SDK 28 and landscape support.
|
||||||
- [ ] Implement URL setup/storage, trusted-origin navigation, and narrow cleartext policy.
|
- [ ] Implement URL setup/storage, trusted-origin navigation, and narrow cleartext policy.
|
||||||
- [ ] Implement WebView session behavior, soft keyboard, refresh, loading, and offline/error screens.
|
- [ ] Implement WebView session behavior, soft keyboard, refresh, loading, and offline/error screens.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ The current MVP slice includes:
|
|||||||
- field-level unavailable states when host data cannot be collected;
|
- field-level unavailable states when host data cannot be collected;
|
||||||
- authenticated Server-Sent Events for live status updates;
|
- authenticated Server-Sent Events for live status updates;
|
||||||
- allowlisted refresh-health, offline network-diagnostics, and sanitized support-bundle jobs;
|
- allowlisted refresh-health, offline network-diagnostics, and sanitized support-bundle jobs;
|
||||||
|
- disabled-by-default, serial-pinned ADB status with read-only head-unit identity and display collection;
|
||||||
- a responsive 1920×720-oriented setup, login, and dashboard UI;
|
- a responsive 1920×720-oriented setup, login, and dashboard UI;
|
||||||
- an idempotent Raspberry Pi installer with systemd start-on-boot support;
|
- an idempotent Raspberry Pi installer with systemd start-on-boot support;
|
||||||
- atomic, dashboard-triggered Git updates with automatic verification and rollback safety;
|
- atomic, dashboard-triggered Git updates with automatic verification and rollback safety;
|
||||||
@@ -149,6 +150,36 @@ Important values:
|
|||||||
- `ALLOWED_ORIGINS`: comma-separated exact browser origins accepted for state changes
|
- `ALLOWED_ORIGINS`: comma-separated exact browser origins accepted for state changes
|
||||||
- `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_SERIAL`: exact device serial used for every ADB command when enabled
|
||||||
|
- `ADB_PATH` and `ADB_TIMEOUT_MS`: executable path and per-command timeout
|
||||||
|
|
||||||
|
### Read-only ADB setup
|
||||||
|
|
||||||
|
ADB integration is optional and disabled by default. Every command is pinned to one
|
||||||
|
configured serial, uses a fixed server-side argument list, and has a timeout and 16 KiB
|
||||||
|
output limit. It 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
|
||||||
|
service account receives the standard `plugdev` device-access group. Connect the head
|
||||||
|
unit, then establish authorization as the service account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u pi-companion -H adb devices
|
||||||
|
```
|
||||||
|
|
||||||
|
Accept the authorization prompt on the parked infotainment screen. Set these values in
|
||||||
|
`/etc/pi-car-companion/companion.env` and restart the service:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ADB_ENABLED=true
|
||||||
|
ADB_SERIAL=<exact serial shown by adb devices>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the device is disconnected, offline, unauthorized, partially readable, or ADB is
|
||||||
|
missing, the dashboard reports that state without substituting generated data. Do not
|
||||||
|
add shell strings or state-changing commands to this collector.
|
||||||
|
|
||||||
## Safety boundary
|
## Safety boundary
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -38,7 +38,7 @@ fi
|
|||||||
echo "[1/7] Installing operating-system packages"
|
echo "[1/7] Installing operating-system packages"
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y ca-certificates curl gnupg git rsync sudo util-linux avahi-daemon sqlite3 build-essential
|
apt-get install -y ca-certificates curl gnupg git rsync sudo util-linux avahi-daemon sqlite3 android-tools-adb build-essential
|
||||||
|
|
||||||
node_major=""
|
node_major=""
|
||||||
if command -v node >/dev/null 2>&1; then
|
if command -v node >/dev/null 2>&1; then
|
||||||
@@ -63,6 +63,9 @@ echo "[3/7] Creating the service account and directories"
|
|||||||
if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
|
if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||||
useradd --system --home-dir "${DATA_DIR}" --shell /usr/sbin/nologin --user-group "${SERVICE_USER}"
|
useradd --system --home-dir "${DATA_DIR}" --shell /usr/sbin/nologin --user-group "${SERVICE_USER}"
|
||||||
fi
|
fi
|
||||||
|
if getent group plugdev >/dev/null 2>&1; then
|
||||||
|
usermod --append --groups plugdev "${SERVICE_USER}"
|
||||||
|
fi
|
||||||
install -d -m 0755 -o root -g root "${INSTALL_ROOT}" "${REPOSITORY}" "${RELEASES}"
|
install -d -m 0755 -o root -g root "${INSTALL_ROOT}" "${REPOSITORY}" "${RELEASES}"
|
||||||
install -d -m 0750 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${DATA_DIR}"
|
install -d -m 0750 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${DATA_DIR}"
|
||||||
install -d -m 0750 -o root -g "${SERVICE_USER}" "${CONFIG_DIR}"
|
install -d -m 0750 -o root -g "${SERVICE_USER}" "${CONFIG_DIR}"
|
||||||
|
|||||||
@@ -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 database = openDatabase(config.databasePath);
|
||||||
const jobRunner = new JobRunner(database);
|
const jobRunner = new JobRunner(database, undefined, config.adb);
|
||||||
|
|
||||||
app.decorate("database", database);
|
app.decorate("database", database);
|
||||||
app.decorate("jobRunner", jobRunner);
|
app.decorate("jobRunner", jobRunner);
|
||||||
@@ -275,7 +275,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
|
|
||||||
app.get("/api/status", async (request, reply) => {
|
app.get("/api/status", async (request, reply) => {
|
||||||
if (!requireUser(request, reply)) return;
|
if (!requireUser(request, reply)) return;
|
||||||
return collectSystemStatus();
|
return collectSystemStatus(config.adb);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/jobs", async (request, reply) => {
|
app.get("/api/jobs", async (request, reply) => {
|
||||||
@@ -369,7 +369,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
const sendStatus = async () => {
|
const sendStatus = async () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
try {
|
try {
|
||||||
const status = await collectSystemStatus();
|
const status = await collectSystemStatus(config.adb);
|
||||||
reply.raw.write(`event: status\ndata: ${JSON.stringify(status)}\n\n`);
|
reply.raw.write(`event: status\ndata: ${JSON.stringify(status)}\n\n`);
|
||||||
} catch {
|
} catch {
|
||||||
reply.raw.write(`event: collection-error\ndata: {"code":"STATUS_COLLECTION_FAILED"}\n\n`);
|
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 { resolve } from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { AdbConfig } from "./adb.js";
|
||||||
|
|
||||||
const booleanFromString = z
|
const booleanFromString = z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
@@ -15,7 +16,11 @@ const schema = z.object({
|
|||||||
COOKIE_SECURE: booleanFromString,
|
COOKIE_SECURE: booleanFromString,
|
||||||
ALLOWED_ORIGINS: z.string().default("http://mg4pi.local:8787"),
|
ALLOWED_ORIGINS: z.string().default("http://mg4pi.local:8787"),
|
||||||
UPDATE_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/update-status.json"),
|
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 = {
|
export type AppConfig = {
|
||||||
@@ -28,6 +33,7 @@ export type AppConfig = {
|
|||||||
allowedOrigins: ReadonlySet<string>;
|
allowedOrigins: ReadonlySet<string>;
|
||||||
updateStatusPath: string;
|
updateStatusPath: string;
|
||||||
versionFile: string;
|
versionFile: string;
|
||||||
|
adb: AdbConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppConfig {
|
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");
|
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 {
|
return {
|
||||||
nodeEnv: parsed.NODE_ENV,
|
nodeEnv: parsed.NODE_ENV,
|
||||||
host: parsed.HOST,
|
host: parsed.HOST,
|
||||||
@@ -52,6 +63,12 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
|
|||||||
cookieSecure: parsed.COOKIE_SECURE,
|
cookieSecure: parsed.COOKIE_SECURE,
|
||||||
allowedOrigins,
|
allowedOrigins,
|
||||||
updateStatusPath: parsed.UPDATE_STATUS_PATH,
|
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 { hostname, networkInterfaces } from "node:os";
|
||||||
import type { CompanionDatabase } from "./database.js";
|
import type { CompanionDatabase } from "./database.js";
|
||||||
import { collectSystemStatus } from "./status.js";
|
import { collectSystemStatus } from "./status.js";
|
||||||
|
import { DISABLED_ADB_CONFIG, type AdbConfig } from "./adb.js";
|
||||||
|
|
||||||
const MAX_OUTPUT_BYTES = 32 * 1024;
|
const MAX_OUTPUT_BYTES = 32 * 1024;
|
||||||
const SECRET_KEY = /authorization|cookie|csrf|password|secret|token|credential/i;
|
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 => ({
|
const safeJob = (definition: Omit<JobDefinition, "riskLevel" | "confirmationRequired">): JobDefinition => ({
|
||||||
...definition,
|
...definition,
|
||||||
riskLevel: "low",
|
riskLevel: "low",
|
||||||
@@ -110,7 +111,7 @@ function defaultDefinitions(database: CompanionDatabase): readonly JobDefinition
|
|||||||
title: "Refresh system health",
|
title: "Refresh system health",
|
||||||
description: "Collect a fresh system health snapshot.",
|
description: "Collect a fresh system health snapshot.",
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
run: async () => collectSystemStatus()
|
run: async () => collectSystemStatus(adbConfig)
|
||||||
}),
|
}),
|
||||||
safeJob({
|
safeJob({
|
||||||
id: "network-diagnostics",
|
id: "network-diagnostics",
|
||||||
@@ -126,7 +127,7 @@ function defaultDefinitions(database: CompanionDatabase): readonly JobDefinition
|
|||||||
timeoutMs: 20_000,
|
timeoutMs: 20_000,
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const generatedAt = new Date().toISOString();
|
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(
|
constructor(
|
||||||
private readonly database: CompanionDatabase,
|
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[] {
|
listJobs(): JobDescriptor[] {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import type { Availability } from "./types.js";
|
import type { Availability } from "./types.js";
|
||||||
|
import { collectAdbStatus, DISABLED_ADB_CONFIG, type AdbConfig } from "./adb.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
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 interfaces: Array<{ name: string; address: string; family: string }> = [];
|
||||||
let interfaceReason: string | null = null;
|
let interfaceReason: string | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +58,7 @@ export async function collectSystemStatus() {
|
|||||||
const totalMemory = os.totalmem();
|
const totalMemory = os.totalmem();
|
||||||
const freeMemory = os.freemem();
|
const freeMemory = os.freemem();
|
||||||
|
|
||||||
const [temperature, disk] = await Promise.all([cpuTemperature(), diskUsage()]);
|
const [temperature, disk, headUnit] = await Promise.all([cpuTemperature(), diskUsage(), collectAdbStatus(adbConfig)]);
|
||||||
return {
|
return {
|
||||||
collectedAt: new Date().toISOString(),
|
collectedAt: new Date().toISOString(),
|
||||||
hostname: os.hostname(),
|
hostname: os.hostname(),
|
||||||
@@ -75,6 +76,7 @@ export async function collectSystemStatus() {
|
|||||||
interfaceReason,
|
interfaceReason,
|
||||||
wifi: unavailable<string>("Wi-Fi capability detection is scheduled for the next milestone")
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" } } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,8 @@ function testConfig(): AppConfig {
|
|||||||
cookieSecure: false,
|
cookieSecure: false,
|
||||||
allowedOrigins: new Set(["http://mg4pi.local:8787"]),
|
allowedOrigins: new Set(["http://mg4pi.local:8787"]),
|
||||||
updateStatusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-update.json`,
|
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 ?? ""}` }
|
headers: { cookie: `${SESSION_COOKIE}=${sessionCookie?.value ?? ""}` }
|
||||||
});
|
});
|
||||||
expect(status.statusCode, status.body).toBe(200);
|
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({
|
const update = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,8 @@ function config(updateStatusPath: string, versionFile: string): AppConfig {
|
|||||||
cookieSecure: false,
|
cookieSecure: false,
|
||||||
allowedOrigins: new Set(),
|
allowedOrigins: new Set(),
|
||||||
updateStatusPath,
|
updateStatusPath,
|
||||||
versionFile
|
versionFile,
|
||||||
|
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,17 @@ function StatusContent({ status }: { status: SystemStatus }) {
|
|||||||
const temperature = status.cpu.temperatureCelsius.available
|
const temperature = status.cpu.temperatureCelsius.available
|
||||||
? `${status.cpu.temperatureCelsius.value.toFixed(1)}°C`
|
? `${status.cpu.temperatureCelsius.value.toFixed(1)}°C`
|
||||||
: "Unavailable";
|
: "Unavailable";
|
||||||
|
const headUnitLabel = status.headUnit.available
|
||||||
|
? status.headUnit.value.identity.model.available
|
||||||
|
? status.headUnit.value.identity.model.value
|
||||||
|
: "Connected"
|
||||||
|
: status.headUnit.state === "unauthorized"
|
||||||
|
? "Authorization required"
|
||||||
|
: status.headUnit.state === "disabled"
|
||||||
|
? "Not configured"
|
||||||
|
: status.headUnit.state === "offline"
|
||||||
|
? "Offline"
|
||||||
|
: "Unavailable";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="status-layout">
|
<div className="status-layout">
|
||||||
@@ -348,6 +359,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>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,31 @@ export type Availability<T> =
|
|||||||
| { available: true; value: T }
|
| { available: true; value: T }
|
||||||
| { available: false; reason: string };
|
| { available: false; reason: string };
|
||||||
|
|
||||||
|
export type HeadUnitStatus =
|
||||||
|
| {
|
||||||
|
available: true;
|
||||||
|
value: {
|
||||||
|
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>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
available: false;
|
||||||
|
state: "disabled" | "unavailable" | "offline" | "unauthorized";
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SystemStatus = {
|
export type SystemStatus = {
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
@@ -28,6 +53,7 @@ export type SystemStatus = {
|
|||||||
wifi: Availability<string>;
|
wifi: Availability<string>;
|
||||||
};
|
};
|
||||||
service: { state: "healthy"; processUptimeSeconds: number };
|
service: { state: "healthy"; processUptimeSeconds: number };
|
||||||
|
headUnit: HeadUnitStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateStatus = {
|
export type UpdateStatus = {
|
||||||
|
|||||||
+1
-1
@@ -99,7 +99,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
.metric p { min-height: 2.6em; margin: 0; color: var(--muted); font-size: 0.83rem; line-height: 1.35; }
|
.metric p { min-height: 2.6em; margin: 0; color: var(--muted); font-size: 0.83rem; line-height: 1.35; }
|
||||||
.meter { height: 3px; margin-top: 17px; background: var(--line); }
|
.meter { height: 3px; margin-top: 17px; background: var(--line); }
|
||||||
.meter span { display: block; height: 100%; background: var(--accent); }
|
.meter span { display: block; height: 100%; background: var(--accent); }
|
||||||
.system-strip { display: grid; grid-template-columns: 1fr 1.4fr 1.2fr; gap: 32px; padding: 22px 28px; border: 1px solid var(--line); border-radius: var(--radius); }
|
.system-strip { display: grid; grid-template-columns: 1fr 1.4fr 1.2fr 1fr; gap: 28px; padding: 22px 28px; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||||
.system-strip div { min-width: 0; display: grid; }
|
.system-strip div { min-width: 0; display: grid; }
|
||||||
.system-strip strong { overflow: hidden; color: #d9dfd3; font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }
|
.system-strip strong { overflow: hidden; color: #d9dfd3; font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user