Add ADB management and live car telemetry
This commit is contained in:
@@ -232,16 +232,18 @@ reference, not proof for other model years, trims, regions, or software builds.
|
||||
- The Pi is the only intended ADB host. The bridge targets either one configured
|
||||
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
|
||||
exposes unavailable/unauthorized/offline states, and registers every operation in a
|
||||
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.
|
||||
- Begin with read-only device identity, connection health, display, package/version,
|
||||
and carefully selected `dumpsys` collectors. Any app launch, input injection, file
|
||||
transfer, or settings change is a separate state-changing capability requiring an
|
||||
explicit threat model, parked-state policy, confirmation policy, and audit trail.
|
||||
or an unrestricted file path. Package and component targets are accepted only through
|
||||
strict Android-name validation and fixed `am` or `monkey` argument arrays.
|
||||
- Device identity, connection health, display, package inventory, bounded APK install,
|
||||
validated app launch, and the three confirmed ArcSoft vehicle properties are in scope.
|
||||
State-changing ADB actions are authenticated, CSRF-protected, rate-limited where
|
||||
appropriate, audited, and intended for parked use.
|
||||
- Do not use ADB or the exported SAIC vehicle service to actuate vehicle hardware in
|
||||
this project. Vehicle telemetry and vendor APIs remain a later, read-only research
|
||||
track until permissions, semantics, privacy, and parked-vehicle tests are documented.
|
||||
this project. Vehicle telemetry remains read-only. The SAIC AIDL bridge for battery,
|
||||
range, and additional signals remains a later research track until semantics, privacy,
|
||||
and parked-vehicle tests are documented.
|
||||
|
||||
### Web experience
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ 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;
|
||||
- disabled-by-default, serial-pinned ADB status, APK installation, package inventory, and validated app shortcuts;
|
||||
- live MG4 speed, gear, and steering-angle telemetry with a dedicated visual Car dashboard;
|
||||
- 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;
|
||||
@@ -202,16 +203,17 @@ The authenticated **Network** page refreshes every two seconds and reports:
|
||||
|
||||
The observer runs read-only as a separate root service and publishes a validated snapshot to the unprivileged application. It does not capture packet payloads, passwords, cookies, HTTP bodies, or HTTPS paths. DNS metadata comes from the system journal and follows its configured retention; the dashboard snapshot is overwritten rather than appended.
|
||||
|
||||
### Read-only ADB setup
|
||||
### ADB setup
|
||||
|
||||
ADB integration is optional and disabled by default. It starts the ADB server when the
|
||||
companion service boots. With no configured serial it automatically selects exactly one
|
||||
attached device and refuses ambiguous multi-device connections; after selection every
|
||||
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
|
||||
command is serial-pinned. Commands use fixed server-side argument lists with timeouts
|
||||
and bounded output. 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.
|
||||
The dashboard can read identity and display information, list installed packages,
|
||||
install a selected APK of at most 256 MB, and launch validated package or component
|
||||
targets. It never accepts shell text or arbitrary ADB arguments.
|
||||
|
||||
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
|
||||
@@ -231,8 +233,15 @@ 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.
|
||||
missing, the dashboard reports that state without substituting generated data. APK
|
||||
installation and app launches are administrator actions intended for a parked vehicle.
|
||||
|
||||
The Car page refreshes the three verified live properties from the captured MG4 unit:
|
||||
`arcsoft.avm.mCurCarSpeed`, `arcsoft.avm.mCurCarGear`, and
|
||||
`arcsoft.avm.mCurCarWheelAngle`. The property dump does not expose traction-battery or
|
||||
range values. Those fields remain unavailable until a read-only Android bridge binds to
|
||||
SAIC's exported vehicle service; head-unit battery data is deliberately not presented as
|
||||
the car battery.
|
||||
|
||||
### System controls and shell
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Availability } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_ADB_OUTPUT_BYTES = 16 * 1024;
|
||||
const MAX_ADB_LIST_OUTPUT_BYTES = 512 * 1024;
|
||||
export const MAX_APK_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
export type AdbConfig = {
|
||||
enabled: boolean;
|
||||
@@ -48,6 +53,26 @@ export type AdbStatus =
|
||||
| { available: true; value: AdbDeviceSnapshot }
|
||||
| { available: false; state: AdbConnectionState; reason: string };
|
||||
|
||||
export type InstalledAdbPackage = {
|
||||
packageName: string;
|
||||
apkPath: string;
|
||||
system: boolean;
|
||||
};
|
||||
|
||||
export type AdbShortcutTarget =
|
||||
| { type: "component"; value: string }
|
||||
| { type: "package"; value: string };
|
||||
|
||||
export type CarTelemetry = {
|
||||
available: true;
|
||||
collectedAt: string;
|
||||
speedKph: Availability<number>;
|
||||
gear: Availability<string>;
|
||||
wheelAngleDegrees: Availability<number>;
|
||||
batteryPercent: Availability<number>;
|
||||
rangeKm: Availability<number>;
|
||||
};
|
||||
|
||||
const defaultExecutor: AdbExecutor = async (executable, arguments_, options) => {
|
||||
const result = await execFileAsync(executable, [...arguments_], {
|
||||
timeout: options.timeout,
|
||||
@@ -88,6 +113,147 @@ function connectionFailure(error: unknown): Exclude<AdbStatus, { available: true
|
||||
return { available: false, state: "unavailable", reason: "ADB device state could not be collected" };
|
||||
}
|
||||
|
||||
async function resolveSerial(config: AdbConfig, execute: AdbExecutor): Promise<string> {
|
||||
if (!config.enabled) throw new Error("ADB_DISABLED");
|
||||
if (config.serial) return config.serial;
|
||||
const output = (await execute(config.executable, ["devices"], {
|
||||
timeout: config.timeoutMs,
|
||||
maxBuffer: MAX_ADB_OUTPUT_BYTES
|
||||
})).stdout;
|
||||
const devices = output
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.map((line) => line.trim().split(/\s+/, 3))
|
||||
.filter((parts) => parts[0] && parts[1]);
|
||||
if (devices.length !== 1 || devices[0]?.[1] !== "device") throw new Error("ADB_DEVICE_UNAVAILABLE");
|
||||
return devices[0][0]!;
|
||||
}
|
||||
|
||||
async function executeForDevice(
|
||||
config: AdbConfig,
|
||||
arguments_: readonly string[],
|
||||
execute: AdbExecutor,
|
||||
maxBuffer = MAX_ADB_OUTPUT_BYTES,
|
||||
timeout = config.timeoutMs
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const serial = await resolveSerial(config, execute);
|
||||
return execute(config.executable, ["-s", serial, ...arguments_], { timeout, maxBuffer });
|
||||
}
|
||||
|
||||
function validatePackageName(value: string): boolean {
|
||||
return /^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
function validateComponent(value: string): boolean {
|
||||
const [packageName, activity, extra] = value.split("/");
|
||||
return !extra && Boolean(packageName && activity) && validatePackageName(packageName!) &&
|
||||
/^(?:\.|[a-zA-Z])[a-zA-Z0-9_.$]*$/.test(activity!);
|
||||
}
|
||||
|
||||
export async function listInstalledAdbPackages(
|
||||
config: AdbConfig,
|
||||
execute: AdbExecutor = defaultExecutor
|
||||
): Promise<InstalledAdbPackage[]> {
|
||||
const output = (await executeForDevice(
|
||||
config,
|
||||
["shell", "pm", "list", "packages", "-f"],
|
||||
execute,
|
||||
MAX_ADB_LIST_OUTPUT_BYTES
|
||||
)).stdout;
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => /^package:(.+)=([a-zA-Z0-9_.]+)$/.exec(line.trim()))
|
||||
.filter((match): match is RegExpExecArray => Boolean(match))
|
||||
.map((match) => ({
|
||||
apkPath: match[1]!,
|
||||
packageName: match[2]!,
|
||||
system: match[1]!.startsWith("/system/") || match[1]!.startsWith("/vendor/") || match[1]!.startsWith("/product/")
|
||||
}))
|
||||
.sort((left, right) => left.packageName.localeCompare(right.packageName));
|
||||
}
|
||||
|
||||
export async function launchAdbTarget(
|
||||
config: AdbConfig,
|
||||
target: AdbShortcutTarget,
|
||||
execute: AdbExecutor = defaultExecutor
|
||||
): Promise<void> {
|
||||
if (target.type === "component") {
|
||||
if (!validateComponent(target.value)) throw new Error("INVALID_ADB_COMPONENT");
|
||||
const result = await executeForDevice(config, ["shell", "am", "start", "-n", target.value], execute);
|
||||
if (/\b(?:error|exception|does not exist)\b/i.test(`${result.stdout}\n${result.stderr}`)) {
|
||||
throw new Error("ADB_LAUNCH_FAILED");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!validatePackageName(target.value)) throw new Error("INVALID_ADB_PACKAGE");
|
||||
const result = await executeForDevice(
|
||||
config,
|
||||
["shell", "monkey", "-p", target.value, "-c", "android.intent.category.LAUNCHER", "1"],
|
||||
execute
|
||||
);
|
||||
if (/no activities found|monkey aborted|\berror\b/i.test(`${result.stdout}\n${result.stderr}`)) {
|
||||
throw new Error("ADB_LAUNCH_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
export async function installAdbApk(
|
||||
config: AdbConfig,
|
||||
apk: Buffer,
|
||||
execute: AdbExecutor = defaultExecutor
|
||||
): Promise<void> {
|
||||
if (apk.byteLength < 4 || apk.byteLength > MAX_APK_BYTES || apk.readUInt32BE(0) !== 0x504b0304) {
|
||||
throw new Error("INVALID_APK");
|
||||
}
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-apk-"));
|
||||
const apkPath = join(directory, "upload.apk");
|
||||
try {
|
||||
await writeFile(apkPath, apk, { mode: 0o600 });
|
||||
const serial = await resolveSerial(config, execute);
|
||||
const result = await execute(config.executable, ["-s", serial, "install", "-r", apkPath], {
|
||||
timeout: Math.max(config.timeoutMs, 120_000),
|
||||
maxBuffer: MAX_ADB_OUTPUT_BYTES
|
||||
});
|
||||
if (!/\bSuccess\b/i.test(result.stdout) || /\bFailure\b/i.test(`${result.stdout}\n${result.stderr}`)) {
|
||||
throw new Error("APK_INSTALL_FAILED");
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function parseGetprop(output: string): Map<string, string> {
|
||||
const properties = new Map<string, string>();
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const match = /^\[([^\]]+)\]: \[(.*)\]$/.exec(line.trim());
|
||||
if (match) properties.set(match[1]!, match[2]!);
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
function numericProperty(properties: Map<string, string>, key: string, label: string): Availability<number> {
|
||||
const raw = properties.get(key);
|
||||
const value = raw === undefined || raw === "" ? Number.NaN : Number(raw);
|
||||
return Number.isFinite(value) ? { available: true, value } : unavailable(`${label} is not exposed by the head unit`);
|
||||
}
|
||||
|
||||
export async function collectCarTelemetry(
|
||||
config: AdbConfig,
|
||||
execute: AdbExecutor = defaultExecutor
|
||||
): Promise<CarTelemetry> {
|
||||
const output = (await executeForDevice(config, ["shell", "getprop"], execute, MAX_ADB_LIST_OUTPUT_BYTES)).stdout;
|
||||
const properties = parseGetprop(output);
|
||||
const rawGear = properties.get("arcsoft.avm.mCurCarGear");
|
||||
return {
|
||||
available: true,
|
||||
collectedAt: new Date().toISOString(),
|
||||
speedKph: numericProperty(properties, "arcsoft.avm.mCurCarSpeed", "Vehicle speed"),
|
||||
gear: rawGear ? { available: true, value: rawGear } : unavailable("Gear is not exposed by the head unit"),
|
||||
wheelAngleDegrees: numericProperty(properties, "arcsoft.avm.mCurCarWheelAngle", "Wheel angle"),
|
||||
batteryPercent: unavailable("Traction battery data requires the SAIC vehicle-service bridge"),
|
||||
rangeKm: unavailable("Range data requires the SAIC vehicle-service bridge")
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
+154
-1
@@ -28,7 +28,14 @@ import {
|
||||
requestHotspotConfiguration,
|
||||
requestNetworkAction
|
||||
} from "./network.js";
|
||||
import { collectAdbStatus } from "./adb.js";
|
||||
import {
|
||||
collectAdbStatus,
|
||||
collectCarTelemetry,
|
||||
installAdbApk,
|
||||
launchAdbTarget,
|
||||
listInstalledAdbPackages,
|
||||
MAX_APK_BYTES
|
||||
} from "./adb.js";
|
||||
import { openShell } from "./shell.js";
|
||||
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
|
||||
import "./types.js";
|
||||
@@ -54,6 +61,28 @@ const setupCredentialsSchema = loginCredentialsSchema.extend({
|
||||
.regex(/[0-9]/, "Password must contain at least one number")
|
||||
});
|
||||
|
||||
const adbPackageNameSchema = z.string().trim().max(240).regex(
|
||||
/^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*$/,
|
||||
"Enter a valid Android package name"
|
||||
);
|
||||
const adbComponentSchema = z.string().trim().max(240).regex(
|
||||
/^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*\/(?:\.|[a-zA-Z])[a-zA-Z0-9_.$]*$/,
|
||||
"Enter a valid Android component"
|
||||
);
|
||||
const adbTargetSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("component"), value: adbComponentSchema }),
|
||||
z.object({ type: z.literal("package"), value: adbPackageNameSchema })
|
||||
]);
|
||||
const adbShortcutSchema = z.object({ name: z.string().trim().min(1).max(48), type: z.enum(["component", "package"]), value: z.string() })
|
||||
.transform((value, context) => {
|
||||
const target = adbTargetSchema.safeParse({ type: value.type, value: value.value });
|
||||
if (!target.success) {
|
||||
context.addIssue({ code: "custom", message: target.error.issues[0]?.message ?? "Enter a valid Android target" });
|
||||
return z.NEVER;
|
||||
}
|
||||
return { name: value.name, ...target.data };
|
||||
});
|
||||
|
||||
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
|
||||
type SessionUserRow = { id: number; username: string; role: "admin" };
|
||||
|
||||
@@ -118,6 +147,11 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { global: false });
|
||||
await app.register(websocket);
|
||||
app.addContentTypeParser(
|
||||
["application/vnd.android.package-archive", "application/octet-stream"],
|
||||
{ parseAs: "buffer" },
|
||||
(_request, body, done) => done(null, body)
|
||||
);
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
jobRunner.close();
|
||||
@@ -335,6 +369,125 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
return readNetworkActivity(config.network.activityPath);
|
||||
});
|
||||
|
||||
app.get("/api/adb/packages", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
try {
|
||||
return { packages: await listInstalledAdbPackages(config.adb) };
|
||||
} catch {
|
||||
return reply.code(503).send({ error: { code: "ADB_UNAVAILABLE", message: "The infotainment unit is not available over ADB" } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/adb/install",
|
||||
{ config: { rateLimit: { max: 4, timeWindow: "10 minutes" } }, bodyLimit: MAX_APK_BYTES },
|
||||
async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
if (!Buffer.isBuffer(request.body)) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_APK", message: "Choose a valid APK file" } });
|
||||
}
|
||||
try {
|
||||
await installAdbApk(config.adb, request.body);
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "adb.install-apk",
|
||||
result: "success",
|
||||
ipAddress: clientAddress(request),
|
||||
details: { bytes: request.body.byteLength }
|
||||
});
|
||||
return reply.code(201).send({ installed: true });
|
||||
} catch (error) {
|
||||
const invalid = error instanceof Error && error.message === "INVALID_APK";
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "adb.install-apk",
|
||||
result: "failure",
|
||||
ipAddress: clientAddress(request),
|
||||
details: { reason: invalid ? "invalid_apk" : "install_failed" }
|
||||
});
|
||||
return reply.code(invalid ? 400 : 503).send({
|
||||
error: {
|
||||
code: invalid ? "INVALID_APK" : "APK_INSTALL_FAILED",
|
||||
message: invalid ? "Choose a valid APK file" : "The APK could not be installed on the infotainment unit"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post("/api/adb/launch", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const parsed = adbTargetSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_ADB_TARGET", message: "Enter a valid package or activity" } });
|
||||
}
|
||||
try {
|
||||
await launchAdbTarget(config.adb, parsed.data);
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "adb.launch",
|
||||
result: "success",
|
||||
ipAddress: clientAddress(request),
|
||||
details: { targetType: parsed.data.type, target: parsed.data.value }
|
||||
});
|
||||
return { launched: true };
|
||||
} catch {
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "adb.launch",
|
||||
result: "failure",
|
||||
ipAddress: clientAddress(request),
|
||||
details: { targetType: parsed.data.type, target: parsed.data.value }
|
||||
});
|
||||
return reply.code(503).send({ error: { code: "ADB_LAUNCH_FAILED", message: "The target could not be opened on the infotainment unit" } });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/adb/shortcuts", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const shortcuts = database
|
||||
.prepare("SELECT id, name, target_type AS type, target_value AS value, created_at AS createdAt FROM adb_shortcuts ORDER BY created_at, id")
|
||||
.all();
|
||||
return { shortcuts };
|
||||
});
|
||||
|
||||
app.post("/api/adb/shortcuts", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const parsed = adbShortcutSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_ADB_SHORTCUT", message: "Enter a name and a valid target" } });
|
||||
}
|
||||
try {
|
||||
const createdAt = new Date().toISOString();
|
||||
const result = database
|
||||
.prepare("INSERT INTO adb_shortcuts (name, target_type, target_value, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run(parsed.data.name, parsed.data.type, parsed.data.value, createdAt);
|
||||
return reply.code(201).send({ id: Number(result.lastInsertRowid), ...parsed.data, createdAt });
|
||||
} catch {
|
||||
return reply.code(409).send({ error: { code: "ADB_SHORTCUT_EXISTS", message: "A shortcut for that target already exists" } });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/adb/shortcuts/:id", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const id = Number(request.params.id);
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_ADB_SHORTCUT", message: "That shortcut is invalid" } });
|
||||
}
|
||||
const result = database.prepare("DELETE FROM adb_shortcuts WHERE id = ?").run(id);
|
||||
if (result.changes === 0) return reply.code(404).send({ error: { code: "ADB_SHORTCUT_NOT_FOUND", message: "Shortcut not found" } });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get("/api/car/telemetry", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
try {
|
||||
return await collectCarTelemetry(config.adb);
|
||||
} catch {
|
||||
return reply.code(503).send({ error: { code: "CAR_TELEMETRY_UNAVAILABLE", message: "Live car telemetry is unavailable over ADB" } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/network/configuration",
|
||||
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
|
||||
|
||||
@@ -60,6 +60,15 @@ function migrate(database: CompanionDatabase): void {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS job_runs_created_idx ON job_runs(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adb_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK (target_type IN ('component', 'package')),
|
||||
target_value TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(target_type, target_value)
|
||||
);
|
||||
`);
|
||||
|
||||
database
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectAdbStatus, type AdbConfig, type AdbExecutor } from "../src/adb.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
collectAdbStatus,
|
||||
collectCarTelemetry,
|
||||
installAdbApk,
|
||||
launchAdbTarget,
|
||||
listInstalledAdbPackages,
|
||||
type AdbConfig,
|
||||
type AdbExecutor
|
||||
} from "../src/adb.js";
|
||||
|
||||
const enabledConfig: AdbConfig = {
|
||||
enabled: true,
|
||||
@@ -129,3 +138,63 @@ describe("read-only ADB collector", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADB dashboard operations", () => {
|
||||
it("lists installed packages and identifies system paths", async () => {
|
||||
const execute: AdbExecutor = async (_executable, arguments_) => {
|
||||
expect(arguments_).toEqual(["-s", "MG4-HEAD-UNIT", "shell", "pm", "list", "packages", "-f"]);
|
||||
return {
|
||||
stdout: "package:/data/app/com.example/base.apk=com.example\npackage:/system/app/Settings/Settings.apk=com.android.settings\n",
|
||||
stderr: ""
|
||||
};
|
||||
};
|
||||
await expect(listInstalledAdbPackages(enabledConfig, execute)).resolves.toEqual([
|
||||
{ packageName: "com.android.settings", apkPath: "/system/app/Settings/Settings.apk", system: true },
|
||||
{ packageName: "com.example", apkPath: "/data/app/com.example/base.apk", system: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it("launches validated packages and components without a shell", async () => {
|
||||
const calls: string[][] = [];
|
||||
const execute: AdbExecutor = async (_executable, arguments_) => {
|
||||
calls.push([...arguments_]);
|
||||
return { stdout: "", stderr: "" };
|
||||
};
|
||||
await launchAdbTarget(enabledConfig, { type: "component", value: "com.android.settings/.Settings" }, execute);
|
||||
await launchAdbTarget(enabledConfig, { type: "package", value: "com.example.app" }, execute);
|
||||
expect(calls).toEqual([
|
||||
["-s", "MG4-HEAD-UNIT", "shell", "am", "start", "-n", "com.android.settings/.Settings"],
|
||||
["-s", "MG4-HEAD-UNIT", "shell", "monkey", "-p", "com.example.app", "-c", "android.intent.category.LAUNCHER", "1"]
|
||||
]);
|
||||
await expect(launchAdbTarget(enabledConfig, { type: "component", value: "com.example/.Main;reboot" }, execute)).rejects.toThrow("INVALID_ADB_COMPONENT");
|
||||
});
|
||||
|
||||
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
|
||||
const execute: AdbExecutor = async () => ({
|
||||
stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n",
|
||||
stderr: ""
|
||||
});
|
||||
await expect(collectCarTelemetry(enabledConfig, execute)).resolves.toMatchObject({
|
||||
available: true,
|
||||
speedKph: { available: true, value: 17 },
|
||||
gear: { available: true, value: "4" },
|
||||
wheelAngleDegrees: { available: true, value: -12.5 },
|
||||
batteryPercent: { available: false },
|
||||
rangeKm: { available: false }
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts only ZIP-formatted APK payloads and removes the temporary file", async () => {
|
||||
let temporaryPath = "";
|
||||
const execute: AdbExecutor = async (_executable, arguments_, options) => {
|
||||
temporaryPath = arguments_.at(-1) ?? "";
|
||||
expect(arguments_.slice(0, 4)).toEqual(["-s", "MG4-HEAD-UNIT", "install", "-r"]);
|
||||
expect(existsSync(temporaryPath)).toBe(true);
|
||||
expect(options.timeout).toBe(120_000);
|
||||
return { stdout: "Success\n", stderr: "" };
|
||||
};
|
||||
await installAdbApk(enabledConfig, Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00]), execute);
|
||||
expect(existsSync(temporaryPath)).toBe(false);
|
||||
await expect(installAdbApk(enabledConfig, Buffer.from("not an apk"), execute)).rejects.toThrow("INVALID_APK");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +250,56 @@ describe("authentication boundary", () => {
|
||||
expect(invalidConfiguration.json()).toMatchObject({ error: { code: "INVALID_HOTSPOT_CONFIGURATION" } });
|
||||
});
|
||||
|
||||
it("protects ADB operations and persists only validated shortcuts", async () => {
|
||||
const app = await createApp();
|
||||
expect((await app.inject({ method: "GET", url: "/api/adb/packages" })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "GET", url: "/api/adb/shortcuts" })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "GET", url: "/api/car/telemetry" })).statusCode).toBe(401);
|
||||
|
||||
const token = await csrf(app);
|
||||
await setup(app, token);
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
headers: mutationHeaders(token),
|
||||
payload: { username: "owner", password: "Correct-horse1" }
|
||||
});
|
||||
const session = login.cookies.find((item) => item.name === SESSION_COOKIE)?.value ?? "";
|
||||
const cookie = `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${session}`;
|
||||
const headers = { ...mutationHeaders(token), cookie };
|
||||
|
||||
const invalid = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/adb/shortcuts",
|
||||
headers,
|
||||
payload: { name: "Unsafe", type: "component", value: "com.example/.Main;reboot" }
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
|
||||
const created = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/adb/shortcuts",
|
||||
headers,
|
||||
payload: { name: "Settings", type: "component", value: "com.android.settings/.Settings" }
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const shortcutId = created.json<{ id: number }>().id;
|
||||
const shortcuts = await app.inject({ method: "GET", url: "/api/adb/shortcuts", headers: { cookie } });
|
||||
expect(shortcuts.json()).toMatchObject({ shortcuts: [{ id: shortcutId, name: "Settings" }] });
|
||||
|
||||
const badApk = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/adb/install",
|
||||
headers: { ...headers, "content-type": "application/vnd.android.package-archive" },
|
||||
payload: Buffer.from("plain text")
|
||||
});
|
||||
expect(badApk.statusCode).toBe(400);
|
||||
expect(badApk.json()).toMatchObject({ error: { code: "INVALID_APK" } });
|
||||
|
||||
const removed = await app.inject({ method: "DELETE", url: `/api/adb/shortcuts/${shortcutId}`, headers });
|
||||
expect(removed.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it("exposes only allowlisted jobs and authenticated run history", async () => {
|
||||
const app = await createApp();
|
||||
const token = await csrf(app);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
AndroidLogo,
|
||||
ArrowClockwise,
|
||||
MagnifyingGlass,
|
||||
Package,
|
||||
Play,
|
||||
Plus,
|
||||
Trash,
|
||||
UploadSimple,
|
||||
WarningCircle
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
deleteAdbShortcut,
|
||||
getAdbPackages,
|
||||
getAdbShortcuts,
|
||||
post,
|
||||
uploadApk,
|
||||
type AdbShortcut,
|
||||
type AdbTarget,
|
||||
type InstalledAdbPackage
|
||||
} from "./api";
|
||||
|
||||
const suggestions = [
|
||||
{ name: "Developer options", value: "com.android.settings/.Settings$DevelopmentSettingsDashboardActivity" },
|
||||
{ name: "System settings", value: "com.saicmotor.hmi.systemsettings/.SettingsActivity" },
|
||||
{ name: "Engineering mode", value: "com.saicmotor.hmi.engmode/.home.ui.EngineeringModeActivity" }
|
||||
] as const;
|
||||
|
||||
export function AdbPage({ csrfToken }: { csrfToken: string }) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [packages, setPackages] = useState<InstalledAdbPackage[]>([]);
|
||||
const [shortcuts, setShortcuts] = useState<AdbShortcut[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [target, setTarget] = useState("");
|
||||
const [targetType, setTargetType] = useState<AdbTarget["type"]>("component");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [packageResult, shortcutResult] = await Promise.allSettled([getAdbPackages(), getAdbShortcuts()]);
|
||||
if (packageResult.status === "fulfilled") setPackages(packageResult.value);
|
||||
if (shortcutResult.status === "fulfilled") setShortcuts(shortcutResult.value);
|
||||
const failure = packageResult.status === "rejected" ? packageResult.reason : shortcutResult.status === "rejected" ? shortcutResult.reason : null;
|
||||
setError(failure instanceof Error ? failure.message : failure ? "ADB data could not be loaded" : "");
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
const visiblePackages = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return normalized ? packages.filter((item) => item.packageName.toLowerCase().includes(normalized)) : packages;
|
||||
}, [packages, query]);
|
||||
|
||||
async function install(file: File | undefined) {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".apk")) {
|
||||
setError("Choose a file ending in .apk");
|
||||
return;
|
||||
}
|
||||
setBusy("install");
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
await uploadApk(file, csrfToken);
|
||||
setMessage(`${file.name} was installed`);
|
||||
await refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "The APK could not be installed");
|
||||
} finally {
|
||||
setBusy("");
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function launch(nextTarget: AdbTarget, busyKey: string) {
|
||||
setBusy(busyKey);
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
await post("/api/adb/launch", nextTarget, csrfToken);
|
||||
setMessage("Opened on the infotainment screen");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "The target could not be opened");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveShortcut(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy("save");
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
const shortcut = await post<AdbShortcut>("/api/adb/shortcuts", { name, type: targetType, value: target }, csrfToken);
|
||||
setShortcuts((current) => [...current, shortcut]);
|
||||
setName("");
|
||||
setTarget("");
|
||||
setMessage("Shortcut saved");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "The shortcut could not be saved");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeShortcut(id: number) {
|
||||
setBusy(`delete-${id}`);
|
||||
try {
|
||||
await deleteAdbShortcut(id, csrfToken);
|
||||
setShortcuts((current) => current.filter((item) => item.id !== id));
|
||||
setMessage("Shortcut removed");
|
||||
setError("");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "The shortcut could not be removed");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="adb-page">
|
||||
{(error || message) && <div className={error ? "status-error" : "adb-success"} role="status">
|
||||
{error ? <WarningCircle size={21} /> : <AndroidLogo size={21} />}{error || message}
|
||||
</div>}
|
||||
|
||||
<section className="adb-command-grid">
|
||||
<div className="adb-install-panel">
|
||||
<div className="panel-heading"><div><h2>Install an APK</h2><p>Upload and replace an app directly on the connected head unit.</p></div><UploadSimple size={30} /></div>
|
||||
<input ref={fileInput} className="visually-hidden" type="file" accept=".apk,application/vnd.android.package-archive" onChange={(event) => void install(event.target.files?.[0])} />
|
||||
<button className="primary-button" onClick={() => fileInput.current?.click()} disabled={Boolean(busy)}>
|
||||
<UploadSimple size={21} />{busy === "install" ? "Installing..." : "Choose APK"}
|
||||
</button>
|
||||
<small>Maximum file size: 256 MB. Existing apps are updated in place.</small>
|
||||
</div>
|
||||
|
||||
<form className="shortcut-form" onSubmit={(event) => void saveShortcut(event)}>
|
||||
<div className="panel-heading"><div><h2>Save a shortcut</h2><p>Use a package name or an explicit Android activity.</p></div><Plus size={28} /></div>
|
||||
<div className="shortcut-fields">
|
||||
<label><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} placeholder="Developer options" required maxLength={48} /></label>
|
||||
<label><span>Target type</span><select value={targetType} onChange={(event) => setTargetType(event.target.value as AdbTarget["type"])}><option value="component">Activity</option><option value="package">App package</option></select></label>
|
||||
<label className="target-field"><span>{targetType === "component" ? "Package/activity" : "Package name"}</span><input value={target} onChange={(event) => setTarget(event.target.value)} placeholder={targetType === "component" ? "com.example/.MainActivity" : "com.example.app"} required maxLength={240} /></label>
|
||||
</div>
|
||||
<div className="shortcut-form-footer">
|
||||
<div className="suggestion-list" aria-label="Suggested targets">
|
||||
{suggestions.map((item) => <button type="button" key={item.name} onClick={() => { setName(item.name); setTargetType("component"); setTarget(item.value); }}>{item.name}</button>)}
|
||||
</div>
|
||||
<button className="secondary-button" type="submit" disabled={Boolean(busy)}><Plus size={20} />{busy === "save" ? "Saving..." : "Save"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{shortcuts.length > 0 && <section className="shortcut-strip" aria-label="Saved shortcuts">
|
||||
{shortcuts.map((shortcut) => <article key={shortcut.id}>
|
||||
<button className="shortcut-launch" onClick={() => void launch(shortcut, `shortcut-${shortcut.id}`)} disabled={Boolean(busy)}>
|
||||
<Play size={20} weight="fill" /><span><strong>{shortcut.name}</strong><small>{shortcut.value}</small></span>
|
||||
</button>
|
||||
<button className="icon-button" aria-label={`Remove ${shortcut.name}`} onClick={() => void removeShortcut(shortcut.id)} disabled={Boolean(busy)}><Trash size={19} /></button>
|
||||
</article>)}
|
||||
</section>}
|
||||
|
||||
<section className="packages-panel">
|
||||
<div className="packages-toolbar">
|
||||
<div><h2>Installed apps</h2><p>{loading ? "Reading package manager..." : `${packages.length} packages found`}</p></div>
|
||||
<label className="package-search"><MagnifyingGlass size={20} /><span className="visually-hidden">Filter installed apps</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter packages" /></label>
|
||||
<button className="icon-button" aria-label="Refresh installed apps" onClick={() => void refresh()} disabled={loading}><ArrowClockwise size={21} className={loading ? "spin" : ""} /></button>
|
||||
</div>
|
||||
<div className="package-list">
|
||||
{!loading && visiblePackages.length === 0 && <div className="adb-empty">No installed packages match this filter.</div>}
|
||||
{visiblePackages.map((item) => <article key={item.packageName}>
|
||||
<Package size={22} />
|
||||
<div><strong>{item.packageName}</strong><small>{item.apkPath}</small></div>
|
||||
<span className="package-kind">{item.system ? "System" : "User"}</span>
|
||||
<button className="icon-button" aria-label={`Launch ${item.packageName}`} title="Launch app" onClick={() => void launch({ type: "package", value: item.packageName }, `package-${item.packageName}`)} disabled={Boolean(busy)}><Play size={18} weight="fill" /></button>
|
||||
</article>)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+10
-5
@@ -1,6 +1,8 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
AndroidLogo,
|
||||
CarProfile,
|
||||
CheckCircle,
|
||||
Cpu,
|
||||
Gauge,
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
WarningCircle,
|
||||
WifiHigh
|
||||
} from "@phosphor-icons/react";
|
||||
import { AdbPage } from "./AdbPage";
|
||||
import { CarPage } from "./CarPage";
|
||||
import {
|
||||
ApiError,
|
||||
getAuthState,
|
||||
@@ -160,7 +164,7 @@ function AccountScreen({
|
||||
}
|
||||
|
||||
function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => Promise<void> }) {
|
||||
const [page, setPage] = useState<"home" | "network" | "settings">("home");
|
||||
const [page, setPage] = useState<"home" | "car" | "adb" | "network" | "settings">("home");
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting");
|
||||
@@ -202,8 +206,9 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
<div className="brand brand-compact"><Gauge size={30} weight="duotone" /><span>Pi Companion</span></div>
|
||||
<nav aria-label="Primary navigation">
|
||||
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => setPage("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
|
||||
<button className={`nav-item ${page === "car" ? "active" : ""}`} onClick={() => setPage("car")}><CarProfile size={24} weight={page === "car" ? "fill" : "regular"} /><span>Car</span></button>
|
||||
<button className={`nav-item ${page === "adb" ? "active" : ""}`} onClick={() => setPage("adb")}><AndroidLogo size={24} weight={page === "adb" ? "fill" : "regular"} /><span>ADB</span></button>
|
||||
<button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => setPage("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button>
|
||||
<button className="nav-item" disabled><TerminalWindow size={24} /><span>Jobs</span><small>Soon</small></button>
|
||||
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => setPage("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
|
||||
</nav>
|
||||
<button className="nav-item logout" onClick={() => void logout()}><SignOut size={24} /><span>Sign out</span></button>
|
||||
@@ -211,8 +216,8 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<div>
|
||||
<p className="eyebrow">{page === "home" ? "System overview" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
|
||||
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "network" ? "Network activity" : "Settings"}</h1>
|
||||
<p className="eyebrow">{page === "home" ? "System overview" : page === "car" ? "Live vehicle signals" : page === "adb" ? "Android device bridge" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
|
||||
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "car" ? "Car" : page === "adb" ? "ADB" : page === "network" ? "Network activity" : "Settings"}</h1>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className={`connection-state ${connection}`}>
|
||||
@@ -227,7 +232,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
{page === "home" ? <>
|
||||
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
|
||||
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
|
||||
</> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||
</> : page === "car" ? <CarPage /> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BatteryMedium, Gauge, Lightning, RoadHorizon, SteeringWheel, WarningCircle } from "@phosphor-icons/react";
|
||||
import { getCarTelemetry, type Availability, type CarTelemetry } from "./api";
|
||||
|
||||
export function CarPage() {
|
||||
const [telemetry, setTelemetry] = useState<CarTelemetry | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setTelemetry(await getCarTelemetry());
|
||||
setError("");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Live car telemetry could not be loaded");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const interval = window.setInterval(() => void refresh(), 1_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [refresh]);
|
||||
|
||||
const speed = telemetry?.speedKph.available ? telemetry.speedKph.value : null;
|
||||
const angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0;
|
||||
const speedArc = Math.min(Math.max((speed ?? 0) / 180, 0), 1);
|
||||
|
||||
return (
|
||||
<div className="car-page">
|
||||
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}</div>}
|
||||
<section className="drive-stage">
|
||||
<div className="speed-cluster">
|
||||
<div className="speed-arc" style={{ "--speed": speedArc } as React.CSSProperties} />
|
||||
<Gauge size={28} />
|
||||
<strong>{speed === null ? "--" : Math.round(speed)}</strong>
|
||||
<span>km/h</span>
|
||||
</div>
|
||||
<div className="wheel-visual">
|
||||
<div className="wheel-angle"><SteeringWheel size={150} weight="thin" style={{ transform: `rotate(${angle}deg)` }} /></div>
|
||||
<strong>{telemetry?.wheelAngleDegrees.available ? `${telemetry.wheelAngleDegrees.value.toFixed(1)}°` : "Unavailable"}</strong>
|
||||
<span>Steering angle</span>
|
||||
</div>
|
||||
<div className="gear-cluster">
|
||||
<span>Gear signal</span>
|
||||
<strong>{telemetry?.gear.available ? telemetry.gear.value : "--"}</strong>
|
||||
<small>Raw AVM value</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="energy-readout">
|
||||
<TelemetryValue icon={<BatteryMedium size={28} />} label="Traction battery" value={telemetry?.batteryPercent} suffix="%" />
|
||||
<TelemetryValue icon={<RoadHorizon size={28} />} label="Estimated range" value={telemetry?.rangeKm} suffix="km" />
|
||||
<article className="telemetry-source"><Lightning size={28} /><div><strong>SAIC bridge needed</strong><p>Battery and range are not exposed in the captured Android property set. The verified AIDL service is the next data source.</p></div></article>
|
||||
</section>
|
||||
|
||||
<footer className="telemetry-footer">
|
||||
<span>Source: live ADB system properties</span>
|
||||
<span>{telemetry ? `Updated ${new Date(telemetry.collectedAt).toLocaleTimeString()}` : "Waiting for telemetry"}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TelemetryValue({ icon, label, value, suffix }: { icon: React.ReactNode; label: string; value: Availability<number> | undefined; suffix: string }) {
|
||||
return <article className="energy-value">{icon}<span>{label}</span><strong>{value?.available ? `${Math.round(value.value)} ${suffix}` : "Unavailable"}</strong><small>{value && !value.available ? value.reason : "Live vehicle value"}</small></article>;
|
||||
}
|
||||
@@ -100,6 +100,30 @@ export type NetworkActivity = {
|
||||
messages: string[];
|
||||
};
|
||||
|
||||
export type InstalledAdbPackage = {
|
||||
packageName: string;
|
||||
apkPath: string;
|
||||
system: boolean;
|
||||
};
|
||||
|
||||
export type AdbTarget = { type: "component" | "package"; value: string };
|
||||
|
||||
export type AdbShortcut = AdbTarget & {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CarTelemetry = {
|
||||
available: true;
|
||||
collectedAt: string;
|
||||
speedKph: Availability<number>;
|
||||
gear: Availability<string>;
|
||||
wheelAngleDegrees: Availability<number>;
|
||||
batteryPercent: Availability<number>;
|
||||
rangeKm: Availability<number>;
|
||||
};
|
||||
|
||||
type ErrorResponse = { error?: { code?: string; message?: string } };
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -150,3 +174,45 @@ export async function getNetworkStatus(): Promise<NetworkStatus> {
|
||||
export async function getNetworkActivity(): Promise<NetworkActivity> {
|
||||
return parseResponse<NetworkActivity>(await fetch("/api/network/activity", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function getAdbPackages(): Promise<InstalledAdbPackage[]> {
|
||||
const response = await parseResponse<{ packages: InstalledAdbPackage[] }>(
|
||||
await fetch("/api/adb/packages", { credentials: "same-origin" })
|
||||
);
|
||||
return response.packages;
|
||||
}
|
||||
|
||||
export async function getAdbShortcuts(): Promise<AdbShortcut[]> {
|
||||
const response = await parseResponse<{ shortcuts: AdbShortcut[] }>(
|
||||
await fetch("/api/adb/shortcuts", { credentials: "same-origin" })
|
||||
);
|
||||
return response.shortcuts;
|
||||
}
|
||||
|
||||
export async function uploadApk(file: File, csrfToken: string): Promise<void> {
|
||||
await parseResponse(
|
||||
await fetch("/api/adb/install", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.android.package-archive",
|
||||
"X-CSRF-Token": csrfToken
|
||||
},
|
||||
body: file
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAdbShortcut(id: number, csrfToken: string): Promise<void> {
|
||||
await parseResponse(
|
||||
await fetch(`/api/adb/shortcuts/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRF-Token": csrfToken }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCarTelemetry(): Promise<CarTelemetry> {
|
||||
return parseResponse<CarTelemetry>(await fetch("/api/car/telemetry", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
@@ -271,3 +271,110 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
|
||||
.icon-button { width: 44px; height: 44px; display: inline-grid; flex: none; place-items: center; padding: 0; color: var(--muted); background: transparent; border: 1px solid var(--line); border-radius: 9px; cursor: pointer; transition: color 140ms ease, background 140ms ease, transform 120ms ease; }
|
||||
.icon-button:hover { color: var(--text); background: var(--surface-raised); }
|
||||
.icon-button:active { transform: scale(0.96); }
|
||||
|
||||
.adb-page { min-width: 0; display: grid; gap: 18px; }
|
||||
.adb-success { display: flex; align-items: center; gap: 10px; padding: 13px 16px; color: var(--accent); background: rgba(185, 231, 105, 0.07); border: 1px solid rgba(185, 231, 105, 0.25); border-radius: 10px; }
|
||||
.adb-command-grid { display: grid; grid-template-columns: minmax(300px, 0.7fr) minmax(520px, 1.3fr); gap: 18px; }
|
||||
.adb-install-panel, .shortcut-form, .packages-panel { min-width: 0; padding: 25px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||
.panel-heading > svg { flex: none; color: var(--accent); }
|
||||
.panel-heading h2, .packages-toolbar h2 { margin: 0 0 6px; font-size: 1.2rem; }
|
||||
.panel-heading p, .packages-toolbar p { margin: 0; color: var(--muted); font-size: 0.82rem; line-height: 1.45; }
|
||||
.adb-install-panel { display: flex; flex-direction: column; }
|
||||
.adb-install-panel .primary-button { align-self: flex-start; margin-top: 34px; }
|
||||
.adb-install-panel > small { margin-top: 13px; color: var(--muted); line-height: 1.4; }
|
||||
.shortcut-fields { display: grid; grid-template-columns: 1fr 150px; gap: 14px; margin-top: 23px; }
|
||||
.shortcut-fields label { display: grid; gap: 7px; color: var(--muted); font-size: 0.76rem; }
|
||||
.shortcut-fields .target-field { grid-column: 1 / -1; }
|
||||
.shortcut-fields input, .shortcut-fields select, .package-search input { width: 100%; min-height: 48px; padding: 0 13px; color: var(--text); background: #10140f; border: 1px solid #465043; border-radius: 9px; }
|
||||
.shortcut-fields select { appearance: auto; }
|
||||
.shortcut-fields input::placeholder, .package-search input::placeholder { color: #899183; }
|
||||
.shortcut-fields input:focus-visible, .shortcut-fields select:focus-visible, .package-search input:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
.shortcut-form-footer { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-top: 18px; }
|
||||
.suggestion-list { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.suggestion-list button { min-height: 34px; padding: 0 11px; color: var(--muted); background: #121610; border: 1px solid var(--line); border-radius: 7px; font-size: 0.72rem; cursor: pointer; }
|
||||
.suggestion-list button:hover { color: var(--text); border-color: #53604e; }
|
||||
.shortcut-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(245px, 1fr)); gap: 10px; }
|
||||
.shortcut-strip article { min-width: 0; display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 11px; }
|
||||
.shortcut-launch { min-width: 0; flex: 1; display: flex; align-items: center; gap: 12px; padding: 8px 9px; color: var(--text); background: transparent; border: 0; text-align: left; cursor: pointer; }
|
||||
.shortcut-launch > svg { flex: none; color: var(--accent); }
|
||||
.shortcut-launch span { min-width: 0; display: grid; gap: 4px; }
|
||||
.shortcut-launch strong, .shortcut-launch small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.shortcut-launch strong { font-size: 0.88rem; }
|
||||
.shortcut-launch small { color: var(--muted); font: 0.68rem/1.2 ui-monospace, "Cascadia Code", monospace; }
|
||||
.packages-panel { padding: 0; overflow: hidden; }
|
||||
.packages-toolbar { position: relative; display: flex; align-items: center; gap: 13px; padding: 20px 22px; border-bottom: 1px solid var(--line); }
|
||||
.packages-toolbar > div:first-child { margin-right: auto; }
|
||||
.package-search { width: min(330px, 35vw); display: flex; align-items: center; gap: 9px; }
|
||||
.package-search > svg { color: var(--muted); }
|
||||
.package-list { max-height: min(470px, 50dvh); overflow: auto; }
|
||||
.package-list article { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 13px; min-height: 64px; padding: 9px 18px; border-bottom: 1px solid var(--line); }
|
||||
.package-list article:last-child { border-bottom: 0; }
|
||||
.package-list article > svg { color: var(--accent); }
|
||||
.package-list article > div { min-width: 0; display: grid; gap: 5px; }
|
||||
.package-list strong, .package-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.package-list strong { font-size: 0.84rem; }
|
||||
.package-list small { color: var(--muted); font: 0.68rem/1.2 ui-monospace, "Cascadia Code", monospace; }
|
||||
.package-kind { color: var(--muted); font-size: 0.7rem; }
|
||||
.adb-empty { min-height: 130px; display: grid; place-items: center; padding: 24px; color: var(--muted); text-align: center; }
|
||||
|
||||
.car-page { display: grid; gap: 18px; }
|
||||
.drive-stage { min-height: 430px; display: grid; grid-template-columns: minmax(230px, 0.85fr) minmax(330px, 1.3fr) minmax(180px, 0.65fr); align-items: center; gap: 18px; padding: clamp(28px, 4vw, 58px); overflow: hidden; background: radial-gradient(circle at 50% 48%, rgba(185, 231, 105, 0.08), transparent 31%), var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.speed-cluster, .wheel-visual, .gear-cluster { position: relative; display: grid; justify-items: center; }
|
||||
.speed-cluster { align-content: center; min-height: 280px; }
|
||||
.speed-cluster > svg { color: var(--accent); }
|
||||
.speed-cluster strong { margin-top: 15px; font: 750 clamp(4.4rem, 8vw, 7.2rem)/0.82 ui-monospace, "Cascadia Code", monospace; letter-spacing: -0.09em; }
|
||||
.speed-cluster span, .wheel-visual span, .gear-cluster span { margin-top: 12px; color: var(--muted); font-size: 0.78rem; }
|
||||
.speed-arc { position: absolute; inset: 0; width: 280px; max-width: 100%; aspect-ratio: 1; margin: auto; border-radius: 50%; background: conic-gradient(from 220deg, var(--accent) calc(var(--speed) * 280deg), var(--line) 0 280deg, transparent 280deg); mask: radial-gradient(circle, transparent 65%, #000 66%); opacity: 0.85; }
|
||||
.wheel-visual { align-content: center; min-height: 310px; padding: 25px; border-left: 1px solid var(--line); border-right: 1px solid var(--line); }
|
||||
.wheel-angle { width: 220px; height: 220px; display: grid; place-items: center; color: var(--accent); border-radius: 50%; background: #121610; border: 1px solid #3b4537; }
|
||||
.wheel-angle svg { transition: transform 250ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||||
.wheel-visual > strong { margin-top: 22px; font: 750 1.8rem/1 ui-monospace, "Cascadia Code", monospace; }
|
||||
.gear-cluster strong { margin-top: 8px; font: 750 clamp(5rem, 9vw, 8rem)/0.9 ui-monospace, "Cascadia Code", monospace; color: var(--accent); }
|
||||
.gear-cluster small { margin-top: 10px; color: var(--muted); font-size: 0.7rem; }
|
||||
.energy-readout { display: grid; grid-template-columns: minmax(210px, 0.7fr) minmax(210px, 0.7fr) minmax(330px, 1.3fr); gap: 1px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.energy-readout article { min-width: 0; min-height: 150px; padding: 23px; background: var(--surface); }
|
||||
.energy-value { display: grid; grid-template-columns: auto 1fr; align-content: center; gap: 6px 11px; }
|
||||
.energy-value > svg, .telemetry-source > svg { color: var(--accent); }
|
||||
.energy-value > span { align-self: center; color: var(--muted); font-size: 0.8rem; }
|
||||
.energy-value strong { grid-column: 1 / -1; margin-top: 12px; font: 750 1.8rem/1 ui-monospace, "Cascadia Code", monospace; }
|
||||
.energy-value small { grid-column: 1 / -1; margin-top: 3px; color: var(--muted); line-height: 1.4; }
|
||||
.telemetry-source { display: flex; align-items: flex-start; gap: 15px; }
|
||||
.telemetry-source > svg { flex: none; }
|
||||
.telemetry-source strong { display: block; margin-bottom: 8px; }
|
||||
.telemetry-source p { max-width: 55ch; margin: 0; color: var(--muted); font-size: 0.8rem; line-height: 1.5; }
|
||||
.telemetry-footer { display: flex; justify-content: space-between; gap: 18px; color: var(--muted); font: 0.72rem/1.4 ui-monospace, "Cascadia Code", monospace; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.adb-command-grid { grid-template-columns: 1fr; }
|
||||
.drive-stage { grid-template-columns: 1fr 1.25fr; }
|
||||
.gear-cluster { grid-column: 1 / -1; min-height: 130px; border-top: 1px solid var(--line); }
|
||||
.gear-cluster strong { font-size: 4rem; }
|
||||
.energy-readout { grid-template-columns: 1fr 1fr; }
|
||||
.telemetry-source { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar nav .nav-item { flex: 1; width: auto; min-width: 0; }
|
||||
.sidebar .nav-item.logout { width: 52px; min-width: 52px; }
|
||||
.adb-install-panel, .shortcut-form { padding: 21px; }
|
||||
.shortcut-fields { grid-template-columns: 1fr; }
|
||||
.shortcut-fields .target-field { grid-column: auto; }
|
||||
.shortcut-form-footer, .packages-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.shortcut-form-footer .secondary-button { width: 100%; }
|
||||
.package-search { width: 100%; }
|
||||
.packages-toolbar .icon-button { position: absolute; right: 40px; }
|
||||
.package-list article { grid-template-columns: auto minmax(0, 1fr) auto; }
|
||||
.package-kind { display: none; }
|
||||
.drive-stage { min-height: 0; grid-template-columns: 1fr; padding: 22px; }
|
||||
.wheel-visual { border: 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.gear-cluster { grid-column: auto; border-top: 0; }
|
||||
.energy-readout { grid-template-columns: 1fr; }
|
||||
.telemetry-source { grid-column: auto; }
|
||||
.telemetry-footer { flex-direction: column; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user