Add dashboard Bluetooth controls
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { z } from "zod";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const helperPath = "/usr/local/sbin/pi-car-companion-bluetooth-control";
|
||||
|
||||
export const bluetoothActionSchema = z.enum(["on", "off", "discoverable"]);
|
||||
export type BluetoothAction = z.infer<typeof bluetoothActionSchema>;
|
||||
|
||||
export const bluetoothStatusSchema = z.object({
|
||||
supported: z.boolean(),
|
||||
powered: z.boolean(),
|
||||
discoverable: z.boolean(),
|
||||
pairable: z.boolean(),
|
||||
message: z.string()
|
||||
});
|
||||
export type BluetoothStatus = z.infer<typeof bluetoothStatusSchema>;
|
||||
|
||||
function unavailableStatus(): BluetoothStatus {
|
||||
return {
|
||||
supported: false,
|
||||
powered: false,
|
||||
discoverable: false,
|
||||
pairable: false,
|
||||
message: "Bluetooth controls are available after installing the Pi system service."
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBluetoothStatus(output: string): BluetoothStatus {
|
||||
return bluetoothStatusSchema.parse(JSON.parse(output));
|
||||
}
|
||||
|
||||
export function parseBluetoothctlStatus(output: string): BluetoothStatus {
|
||||
if (!/^Controller /m.test(output)) {
|
||||
return { ...unavailableStatus(), message: "No Bluetooth adapter was found." };
|
||||
}
|
||||
const property = (name: string) => new RegExp(`^\\s*${name}: yes$`, "m").test(output);
|
||||
const powered = property("Powered");
|
||||
const discoverable = property("Discoverable");
|
||||
return {
|
||||
supported: true,
|
||||
powered,
|
||||
discoverable,
|
||||
pairable: property("Pairable"),
|
||||
message: discoverable
|
||||
? "The Pi is visible to nearby phones for three minutes."
|
||||
: powered
|
||||
? "Bluetooth is on and the companion bridge is available."
|
||||
: "Bluetooth is off."
|
||||
};
|
||||
}
|
||||
|
||||
async function runHelper(action: BluetoothAction): Promise<BluetoothStatus> {
|
||||
const { stdout } = await execFileAsync("/usr/bin/sudo", ["-n", helperPath, action], {
|
||||
timeout: 12_000,
|
||||
maxBuffer: 16 * 1024
|
||||
});
|
||||
return parseBluetoothStatus(stdout);
|
||||
}
|
||||
|
||||
export async function readBluetoothStatus(): Promise<BluetoothStatus> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/bluetoothctl", ["show"], {
|
||||
timeout: 5_000,
|
||||
maxBuffer: 16 * 1024
|
||||
});
|
||||
return parseBluetoothctlStatus(stdout);
|
||||
} catch {
|
||||
return unavailableStatus();
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerBluetoothAction(action: BluetoothAction): Promise<BluetoothStatus> {
|
||||
return runHelper(action);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AdbConfig } from "./adb.js";
|
||||
import { launchAdbTarget } from "./adb.js";
|
||||
import { bluetoothActionSchema, readBluetoothStatus, triggerBluetoothAction } from "./bluetooth.js";
|
||||
import type { CarTelemetryRecorder } from "./car-telemetry.js";
|
||||
import type { CompanionDatabase } from "./database.js";
|
||||
import { hashToken, randomToken } from "./security.js";
|
||||
@@ -93,6 +94,26 @@ export function registerMobileRoutes(
|
||||
return { devices };
|
||||
});
|
||||
|
||||
app.get("/api/mobile/bluetooth", async (request, reply) => {
|
||||
if (!requireWebUser(request, reply)) return;
|
||||
return readBluetoothStatus();
|
||||
});
|
||||
|
||||
app.post("/api/mobile/bluetooth", { config: { rateLimit: { max: 12, timeWindow: "1 minute" } } }, async (request, reply) => {
|
||||
if (!requireWebUser(request, reply)) return;
|
||||
const parsed = z.object({ action: bluetoothActionSchema }).safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_BLUETOOTH_ACTION", message: "That Bluetooth action is not available" } });
|
||||
}
|
||||
try {
|
||||
return await triggerBluetoothAction(parsed.data.action);
|
||||
} catch {
|
||||
return reply.code(503).send({
|
||||
error: { code: "BLUETOOTH_ACTION_FAILED", message: "The Pi could not change its Bluetooth state" }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/mobile/devices/:id", async (request, reply) => {
|
||||
if (!requireWebUser(request, reply)) return;
|
||||
const result = database.prepare("UPDATE mobile_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL")
|
||||
|
||||
@@ -356,6 +356,18 @@ describe("authentication boundary", () => {
|
||||
const webHeaders = { ...mutationHeaders(csrfToken), cookie };
|
||||
|
||||
expect((await app.inject({ method: "GET", url: "/api/mobile/devices" })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "GET", url: "/api/mobile/bluetooth" })).statusCode).toBe(401);
|
||||
const bluetooth = await app.inject({ method: "GET", url: "/api/mobile/bluetooth", headers: { cookie } });
|
||||
expect(bluetooth.statusCode).toBe(200);
|
||||
expect(bluetooth.json()).toMatchObject({ powered: expect.any(Boolean), discoverable: expect.any(Boolean) });
|
||||
const invalidBluetoothAction = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/mobile/bluetooth",
|
||||
headers: webHeaders,
|
||||
payload: { action: "arbitrary-command" }
|
||||
});
|
||||
expect(invalidBluetoothAction.statusCode).toBe(400);
|
||||
expect(invalidBluetoothAction.json()).toMatchObject({ error: { code: "INVALID_BLUETOOTH_ACTION" } });
|
||||
const pairing = await app.inject({ method: "POST", url: "/api/mobile/pairing", headers: webHeaders });
|
||||
expect(pairing.statusCode).toBe(200);
|
||||
const code = pairing.json<{ code: string }>().code;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bluetoothActionSchema, parseBluetoothctlStatus, parseBluetoothStatus } from "../src/bluetooth.js";
|
||||
|
||||
describe("Bluetooth controls", () => {
|
||||
it("accepts only fixed radio actions", () => {
|
||||
expect(bluetoothActionSchema.safeParse("on").success).toBe(true);
|
||||
expect(bluetoothActionSchema.safeParse("off").success).toBe(true);
|
||||
expect(bluetoothActionSchema.safeParse("discoverable").success).toBe(true);
|
||||
expect(bluetoothActionSchema.safeParse("scan --all").success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates status returned by the privileged helper", () => {
|
||||
expect(parseBluetoothStatus(JSON.stringify({
|
||||
supported: true,
|
||||
powered: true,
|
||||
discoverable: false,
|
||||
pairable: true,
|
||||
message: "Bluetooth is on."
|
||||
}))).toMatchObject({ supported: true, powered: true, discoverable: false });
|
||||
|
||||
expect(() => parseBluetoothStatus('{"powered":"yes"}')).toThrow();
|
||||
});
|
||||
|
||||
it("reads the adapter state from BlueZ without privileged polling", () => {
|
||||
const status = parseBluetoothctlStatus(`Controller AA:BB:CC:DD:EE:FF Pi [default]\n\tPowered: yes\n\tDiscoverable: yes\n\tPairable: yes\n`);
|
||||
expect(status).toMatchObject({ supported: true, powered: true, discoverable: true, pairable: true });
|
||||
expect(parseBluetoothctlStatus("")).toMatchObject({ supported: false, powered: false });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user