Add dashboard Bluetooth controls

This commit is contained in:
2026-07-31 23:47:20 +02:00
parent af9de7dbdf
commit 3801a91acf
14 changed files with 301 additions and 6 deletions
+76
View File
@@ -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);
}
+21
View File
@@ -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")