diff --git a/.env.example b/.env.example index 3ca70af..5181984 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,8 @@ COOKIE_SECURE=false ALLOWED_ORIGINS=http://mg4pi.local:8787 UPDATE_STATUS_PATH=/var/lib/pi-car-companion/update-status.json 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 diff --git a/PLAN.md b/PLAN.md index 1960ce7..e13471c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -297,8 +297,8 @@ The repository contains a working MVP and tracks remaining delivery work here. S ### Milestone 3 — ADB transport and Android client -- [ ] 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] Implement one-device ADB configuration, connection/authorization state, timeouts, output bounds, and test fixtures. +- [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. - [ ] Implement URL setup/storage, trusted-origin navigation, and narrow cleartext policy. - [ ] Implement WebView session behavior, soft keyboard, refresh, loading, and offline/error screens. diff --git a/README.md b/README.md index 49757c5..8c4ab98 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The current MVP slice includes: - field-level unavailable states when host data cannot be collected; - authenticated Server-Sent Events for live status updates; - 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; - an idempotent Raspberry Pi installer with systemd start-on-boot support; - 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 - `UPDATE_STATUS_PATH`: update state shared with the systemd update unit - `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= +``` + +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 diff --git a/install.sh b/install.sh index bb19f78..85583aa 100755 --- a/install.sh +++ b/install.sh @@ -38,7 +38,7 @@ fi echo "[1/7] Installing operating-system packages" export DEBIAN_FRONTEND=noninteractive 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="" 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 useradd --system --home-dir "${DATA_DIR}" --shell /usr/sbin/nologin --user-group "${SERVICE_USER}" 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 0750 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${DATA_DIR}" install -d -m 0750 -o root -g "${SERVICE_USER}" "${CONFIG_DIR}" diff --git a/server/src/adb.ts b/server/src/adb.ts new file mode 100644 index 0000000..6c130b2 --- /dev/null +++ b/server/src/adb.ts @@ -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; + model: Availability; + androidVersion: Availability; + apiLevel: Availability; + buildId: Availability; + abi: Availability; + }; + display: { + size: Availability<{ widthPixels: number; heightPixels: number }>; + densityDpi: Availability; + }; +}; + +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(reason: string): Availability { + 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 { + 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 { + 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 { + 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> => { + 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(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) + } + } + }; +} diff --git a/server/src/app.ts b/server/src/app.ts index cc9a23a..b146ee9 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -97,7 +97,7 @@ export async function buildApp(config: AppConfig): Promise { } }); 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 { 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 { 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`); diff --git a/server/src/config.ts b/server/src/config.ts index a0243c5..1370f31 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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; 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 + } }; } diff --git a/server/src/jobs.ts b/server/src/jobs.ts index 41ede0b..6f5410e 100644 --- a/server/src/jobs.ts +++ b/server/src/jobs.ts @@ -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 => ({ ...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[] { diff --git a/server/src/status.ts b/server/src/status.ts index e0e5d6c..f6ee721 100644 --- a/server/src/status.ts +++ b/server/src/status.ts @@ -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> { } } -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("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 }; } diff --git a/server/tests/adb.test.ts b/server/tests/adb.test.ts new file mode 100644 index 0000000..7edc57f --- /dev/null +++ b/server/tests/adb.test.ts @@ -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(); + 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([ + ["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" } } } + }); + }); +}); diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index 4c2385a..2605aab 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -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", diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts new file mode 100644 index 0000000..08ef0af --- /dev/null +++ b/server/tests/config.test.ts @@ -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" + }); + }); +}); diff --git a/server/tests/update.test.ts b/server/tests/update.test.ts index f0140e5..ad608bb 100644 --- a/server/tests/update.test.ts +++ b/server/tests/update.test.ts @@ -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 } }; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 9a1d38e..39add77 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -322,6 +322,17 @@ function StatusContent({ status }: { status: SystemStatus }) { const temperature = status.cpu.temperatureCelsius.available ? `${status.cpu.temperatureCelsius.value.toFixed(1)}°C` : "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 (
@@ -348,6 +359,7 @@ function StatusContent({ status }: { status: SystemStatus }) {
Operating system{status.operatingSystem}
Processor{status.cpu.model}
Wi-Fi detail{status.network.wifi.available ? status.network.wifi.value : status.network.wifi.reason}
+
MG4 head unit{headUnitLabel}
); diff --git a/web/src/api.ts b/web/src/api.ts index af633d5..0d749bc 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -10,6 +10,31 @@ export type Availability = | { available: true; value: T } | { available: false; reason: string }; +export type HeadUnitStatus = + | { + available: true; + value: { + state: "connected"; + identity: { + manufacturer: Availability; + model: Availability; + androidVersion: Availability; + apiLevel: Availability; + buildId: Availability; + abi: Availability; + }; + display: { + size: Availability<{ widthPixels: number; heightPixels: number }>; + densityDpi: Availability; + }; + }; + } + | { + available: false; + state: "disabled" | "unavailable" | "offline" | "unauthorized"; + reason: string; + }; + export type SystemStatus = { collectedAt: string; hostname: string; @@ -28,6 +53,7 @@ export type SystemStatus = { wifi: Availability; }; service: { state: "healthy"; processUptimeSeconds: number }; + headUnit: HeadUnitStatus; }; export type UpdateStatus = { diff --git a/web/src/styles.css b/web/src/styles.css index cd7490e..63b73a1 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; } .meter { height: 3px; margin-top: 17px; background: var(--line); } .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 strong { overflow: hidden; color: #d9dfd3; font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }