Add dashboard Bluetooth controls
This commit is contained in:
@@ -4,3 +4,4 @@ app/build/
|
|||||||
local.properties
|
local.properties
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
release-keystore-password.txt
|
||||||
|
|||||||
+3
-2
@@ -12,8 +12,9 @@ The Android app is a focused native companion rather than a copy of the web dash
|
|||||||
|
|
||||||
1. Deploy the latest Pi release with `./install.sh` so BlueZ and the Bluetooth bridge are installed.
|
1. Deploy the latest Pi release with `./install.sh` so BlueZ and the Bluetooth bridge are installed.
|
||||||
2. Open **Settings → Mobile** in the web dashboard and select **Pair a phone**.
|
2. Open **Settings → Mobile** in the web dashboard and select **Pair a phone**.
|
||||||
3. Install and open the Android application and grant Bluetooth, notification, and precise-location permissions.
|
3. If needed, turn Bluetooth on and select **Make discoverable**. The Pi remains discoverable for three minutes.
|
||||||
4. Enter the six-digit code shown by the dashboard.
|
4. Install and open the Android application and grant Bluetooth, notification, and precise-location permissions.
|
||||||
|
5. Enter the six-digit code shown by the dashboard.
|
||||||
|
|
||||||
The one-use code expires after ten minutes. The resulting credential is encrypted with Android Keystore and can be revoked from **Settings → Mobile**. A phone can only launch existing allowlisted ADB shortcuts; the Bluetooth protocol has no shell, arbitrary ADB, APK upload, update, or power operation.
|
The one-use code expires after ten minutes. The resulting credential is encrypted with Android Keystore and can be revoked from **Settings → Mobile**. A phone can only launch existing allowlisted ADB shortcuts; the Bluetooth protocol has no shell, arbitrary ADB, APK upload, update, or power operation.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -56,6 +56,7 @@ apt-get install -y \
|
|||||||
python3 \
|
python3 \
|
||||||
python3-dbus \
|
python3-dbus \
|
||||||
python3-gi \
|
python3-gi \
|
||||||
|
rfkill \
|
||||||
rsync \
|
rsync \
|
||||||
sqlite3 \
|
sqlite3 \
|
||||||
sudo \
|
sudo \
|
||||||
@@ -64,7 +65,7 @@ apt-get install -y \
|
|||||||
wireless-regdb \
|
wireless-regdb \
|
||||||
wpasupplicant
|
wpasupplicant
|
||||||
|
|
||||||
for required_command in conntrack dnsmasq ip journalctl nmcli nft uuidgen wpa_passphrase; do
|
for required_command in conntrack dnsmasq ip journalctl nmcli nft rfkill uuidgen wpa_passphrase; do
|
||||||
if ! command -v "${required_command}" >/dev/null 2>&1; then
|
if ! command -v "${required_command}" >/dev/null 2>&1; then
|
||||||
echo "Required network command was not installed: ${required_command}" >&2
|
echo "Required network command was not installed: ${required_command}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -143,6 +144,7 @@ mv -Tf "${INSTALL_ROOT}/current.next" "${CURRENT}"
|
|||||||
echo "[5/7] Installing system services"
|
echo "[5/7] Installing system services"
|
||||||
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup
|
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup
|
||||||
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power
|
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power
|
||||||
|
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-bluetooth-control" /usr/local/sbin/pi-car-companion-bluetooth-control
|
||||||
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
|
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
|
||||||
install -d -m 0755 /usr/local/libexec
|
install -d -m 0755 /usr/local/libexec
|
||||||
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-bluetooth" /usr/local/libexec/pi-car-companion-bluetooth
|
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-bluetooth" /usr/local/libexec/pi-car-companion-bluetooth
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup scripts/pi-car-companion-power && python3 -c \"compile(open('scripts/pi-car-companion-bluetooth', encoding='utf-8').read(), 'scripts/pi-car-companion-bluetooth', 'exec')\"",
|
"check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup scripts/pi-car-companion-power scripts/pi-car-companion-bluetooth-control && python3 -c \"compile(open('scripts/pi-car-companion-bluetooth', encoding='utf-8').read(), 'scripts/pi-car-companion-bluetooth', 'exec')\"",
|
||||||
"check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build"
|
"check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
if [[ ${EUID} -ne 0 ]]; then
|
||||||
|
echo "The Bluetooth control helper must run as root." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BLUETOOTHCTL=/usr/bin/bluetoothctl
|
||||||
|
SYSTEMCTL=/usr/bin/systemctl
|
||||||
|
BRIDGE_SERVICE=pi-car-companion-bluetooth.service
|
||||||
|
|
||||||
|
print_status() {
|
||||||
|
local details powered discoverable pairable message
|
||||||
|
details="$(LC_ALL=C "${BLUETOOTHCTL}" show 2>/dev/null || true)"
|
||||||
|
if ! grep -q '^Controller ' <<<"${details}"; then
|
||||||
|
printf '%s\n' '{"supported":false,"powered":false,"discoverable":false,"pairable":false,"message":"No Bluetooth adapter was found."}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
powered="$(sed -n 's/^[[:space:]]*Powered: //p' <<<"${details}" | head -n 1)"
|
||||||
|
discoverable="$(sed -n 's/^[[:space:]]*Discoverable: //p' <<<"${details}" | head -n 1)"
|
||||||
|
pairable="$(sed -n 's/^[[:space:]]*Pairable: //p' <<<"${details}" | head -n 1)"
|
||||||
|
[[ "${powered}" == "yes" ]] && powered=true || powered=false
|
||||||
|
[[ "${discoverable}" == "yes" ]] && discoverable=true || discoverable=false
|
||||||
|
[[ "${pairable}" == "yes" ]] && pairable=true || pairable=false
|
||||||
|
if [[ "${discoverable}" == "true" ]]; then
|
||||||
|
message="The Pi is visible to nearby phones for three minutes."
|
||||||
|
elif [[ "${powered}" == "true" ]]; then
|
||||||
|
message="Bluetooth is on and the companion bridge is available."
|
||||||
|
else
|
||||||
|
message="Bluetooth is off."
|
||||||
|
fi
|
||||||
|
printf '{"supported":true,"powered":%s,"discoverable":%s,"pairable":%s,"message":"%s"}\n' \
|
||||||
|
"${powered}" "${discoverable}" "${pairable}" "${message}"
|
||||||
|
}
|
||||||
|
|
||||||
|
power_on() {
|
||||||
|
"${SYSTEMCTL}" start bluetooth.service
|
||||||
|
/usr/sbin/rfkill unblock bluetooth
|
||||||
|
"${BLUETOOTHCTL}" power on >/dev/null
|
||||||
|
"${SYSTEMCTL}" enable --now "${BRIDGE_SERVICE}" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
status)
|
||||||
|
;;
|
||||||
|
on)
|
||||||
|
power_on
|
||||||
|
;;
|
||||||
|
off)
|
||||||
|
"${SYSTEMCTL}" disable --now "${BRIDGE_SERVICE}" >/dev/null
|
||||||
|
"${BLUETOOTHCTL}" discoverable off >/dev/null 2>&1 || true
|
||||||
|
"${BLUETOOTHCTL}" pairable off >/dev/null 2>&1 || true
|
||||||
|
"${BLUETOOTHCTL}" power off >/dev/null
|
||||||
|
/usr/sbin/rfkill block bluetooth
|
||||||
|
;;
|
||||||
|
discoverable)
|
||||||
|
power_on
|
||||||
|
"${BLUETOOTHCTL}" discoverable-timeout 180 >/dev/null
|
||||||
|
"${BLUETOOTHCTL}" pairable-timeout 180 >/dev/null
|
||||||
|
"${BLUETOOTHCTL}" pairable on >/dev/null
|
||||||
|
"${BLUETOOTHCTL}" discoverable on >/dev/null
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: pi-car-companion-bluetooth-control status|on|off|discoverable" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
print_status
|
||||||
@@ -66,12 +66,18 @@ install_integration_files() {
|
|||||||
install -m 0755 "${source_release}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
|
install -m 0755 "${source_release}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
|
||||||
install -m 0755 "${source_release}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup
|
install -m 0755 "${source_release}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup
|
||||||
install -m 0755 "${source_release}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power
|
install -m 0755 "${source_release}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power
|
||||||
|
if [[ -f "${source_release}/scripts/pi-car-companion-bluetooth-control" ]]; then
|
||||||
|
install -m 0755 "${source_release}/scripts/pi-car-companion-bluetooth-control" /usr/local/sbin/pi-car-companion-bluetooth-control
|
||||||
|
fi
|
||||||
|
install -d -m 0755 /usr/local/libexec
|
||||||
|
install -m 0755 "${source_release}/scripts/pi-car-companion-bluetooth" /usr/local/libexec/pi-car-companion-bluetooth
|
||||||
install -m 0644 "${source_release}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service
|
install -m 0644 "${source_release}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service
|
||||||
install -m 0644 "${source_release}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
|
install -m 0644 "${source_release}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
|
||||||
install -m 0644 "${source_release}/systemd/pi-car-companion-update-check.service" /etc/systemd/system/pi-car-companion-update-check.service
|
install -m 0644 "${source_release}/systemd/pi-car-companion-update-check.service" /etc/systemd/system/pi-car-companion-update-check.service
|
||||||
install -m 0644 "${source_release}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
|
install -m 0644 "${source_release}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
|
||||||
install -m 0644 "${source_release}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
|
install -m 0644 "${source_release}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
|
||||||
install -m 0644 "${source_release}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service
|
install -m 0644 "${source_release}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service
|
||||||
|
install -m 0644 "${source_release}/systemd/pi-car-companion-bluetooth.service" /etc/systemd/system/pi-car-companion-bluetooth.service
|
||||||
install -m 0440 "${source_release}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
|
install -m 0440 "${source_release}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
|
||||||
install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d
|
install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d
|
||||||
install -m 0644 "${source_release}/config/dnsmasq-shared-pi-car-companion.conf" /etc/NetworkManager/dnsmasq-shared.d/pi-car-companion.conf
|
install -m 0644 "${source_release}/config/dnsmasq-shared-pi-car-companion.conf" /etc/NetworkManager/dnsmasq-shared.d/pi-car-companion.conf
|
||||||
@@ -204,3 +210,4 @@ write_status "success" "Update installed and the companion service is healthy."
|
|||||||
systemctl try-restart pi-car-companion-network.service || true
|
systemctl try-restart pi-car-companion-network.service || true
|
||||||
systemctl enable pi-car-companion-network-observer.service || true
|
systemctl enable pi-car-companion-network-observer.service || true
|
||||||
systemctl restart pi-car-companion-network-observer.service || true
|
systemctl restart pi-car-companion-network-observer.service || true
|
||||||
|
systemctl try-restart pi-car-companion-bluetooth.service || true
|
||||||
|
|||||||
@@ -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 { z } from "zod";
|
||||||
import type { AdbConfig } from "./adb.js";
|
import type { AdbConfig } from "./adb.js";
|
||||||
import { launchAdbTarget } from "./adb.js";
|
import { launchAdbTarget } from "./adb.js";
|
||||||
|
import { bluetoothActionSchema, readBluetoothStatus, triggerBluetoothAction } from "./bluetooth.js";
|
||||||
import type { CarTelemetryRecorder } from "./car-telemetry.js";
|
import type { CarTelemetryRecorder } from "./car-telemetry.js";
|
||||||
import type { CompanionDatabase } from "./database.js";
|
import type { CompanionDatabase } from "./database.js";
|
||||||
import { hashToken, randomToken } from "./security.js";
|
import { hashToken, randomToken } from "./security.js";
|
||||||
@@ -93,6 +94,26 @@ export function registerMobileRoutes(
|
|||||||
return { devices };
|
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) => {
|
app.delete<{ Params: { id: string } }>("/api/mobile/devices/:id", async (request, reply) => {
|
||||||
if (!requireWebUser(request, reply)) return;
|
if (!requireWebUser(request, reply)) return;
|
||||||
const result = database.prepare("UPDATE mobile_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL")
|
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 };
|
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/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 });
|
const pairing = await app.inject({ method: "POST", url: "/api/mobile/pairing", headers: webHeaders });
|
||||||
expect(pairing.statusCode).toBe(200);
|
expect(pairing.statusCode).toBe(200);
|
||||||
const code = pairing.json<{ code: string }>().code;
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,3 +3,6 @@ pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-com
|
|||||||
pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-network-configure.service
|
pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-network-configure.service
|
||||||
pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-power reboot
|
pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-power reboot
|
||||||
pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-power poweroff
|
pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-power poweroff
|
||||||
|
pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-bluetooth-control on
|
||||||
|
pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-bluetooth-control off
|
||||||
|
pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-bluetooth-control discoverable
|
||||||
|
|||||||
+52
-2
@@ -29,6 +29,7 @@ import {
|
|||||||
getNetworkActivity,
|
getNetworkActivity,
|
||||||
getUpdateStatus,
|
getUpdateStatus,
|
||||||
getMobileDevices,
|
getMobileDevices,
|
||||||
|
getBluetoothStatus,
|
||||||
post,
|
post,
|
||||||
revokeMobileDevice,
|
revokeMobileDevice,
|
||||||
type AuthState,
|
type AuthState,
|
||||||
@@ -36,7 +37,8 @@ import {
|
|||||||
type NetworkStatus,
|
type NetworkStatus,
|
||||||
type NetworkActivity,
|
type NetworkActivity,
|
||||||
type UpdateStatus,
|
type UpdateStatus,
|
||||||
type MobileDevice
|
type MobileDevice,
|
||||||
|
type BluetoothStatus
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
const ShellTerminal = lazy(async () => {
|
const ShellTerminal = lazy(async () => {
|
||||||
@@ -344,8 +346,10 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
const [confirmHotspot, setConfirmHotspot] = useState(false);
|
const [confirmHotspot, setConfirmHotspot] = useState(false);
|
||||||
const [networkNotice, setNetworkNotice] = useState("");
|
const [networkNotice, setNetworkNotice] = useState("");
|
||||||
const [mobileDevices, setMobileDevices] = useState<MobileDevice[]>([]);
|
const [mobileDevices, setMobileDevices] = useState<MobileDevice[]>([]);
|
||||||
|
const [bluetooth, setBluetooth] = useState<BluetoothStatus | null>(null);
|
||||||
const [pairingCode, setPairingCode] = useState<{ code: string; expiresAt: string } | null>(null);
|
const [pairingCode, setPairingCode] = useState<{ code: string; expiresAt: string } | null>(null);
|
||||||
const [mobileError, setMobileError] = useState("");
|
const [mobileError, setMobileError] = useState("");
|
||||||
|
const [mobileNotice, setMobileNotice] = useState("");
|
||||||
const running = update?.state === "checking" || update?.state === "building";
|
const running = update?.state === "checking" || update?.state === "building";
|
||||||
|
|
||||||
const refreshUpdate = useCallback(async () => {
|
const refreshUpdate = useCallback(async () => {
|
||||||
@@ -375,16 +379,43 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshBluetooth = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setBluetooth(await getBluetoothStatus());
|
||||||
|
} catch (caught) {
|
||||||
|
setMobileError(caught instanceof Error ? caught.message : "Bluetooth status could not be loaded");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshUpdate();
|
void refreshUpdate();
|
||||||
void refreshNetwork();
|
void refreshNetwork();
|
||||||
void refreshMobile();
|
void refreshMobile();
|
||||||
|
void refreshBluetooth();
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
void refreshUpdate();
|
void refreshUpdate();
|
||||||
void refreshNetwork();
|
void refreshNetwork();
|
||||||
|
void refreshBluetooth();
|
||||||
}, 3_000);
|
}, 3_000);
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [refreshMobile, refreshNetwork, refreshUpdate]);
|
}, [refreshBluetooth, refreshMobile, refreshNetwork, refreshUpdate]);
|
||||||
|
|
||||||
|
async function requestBluetoothAction(action: "on" | "off" | "discoverable") {
|
||||||
|
setRequesting(true);
|
||||||
|
setMobileError("");
|
||||||
|
setMobileNotice("");
|
||||||
|
try {
|
||||||
|
const status = await post<BluetoothStatus>("/api/mobile/bluetooth", { action }, csrfToken);
|
||||||
|
setBluetooth(status);
|
||||||
|
setMobileNotice(action === "discoverable"
|
||||||
|
? "The Pi is discoverable to nearby phones for three minutes."
|
||||||
|
: `Bluetooth turned ${action}.`);
|
||||||
|
} catch (caught) {
|
||||||
|
setMobileError(caught instanceof Error ? caught.message : "Bluetooth could not be changed");
|
||||||
|
} finally {
|
||||||
|
setRequesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createPairingCode() {
|
async function createPairingCode() {
|
||||||
setRequesting(true);
|
setRequesting(true);
|
||||||
@@ -662,6 +693,25 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
<div><h2>Android companion</h2><p>Pair the phone app over Bluetooth to receive live Pi and car status and contribute phone GPS.</p></div>
|
<div><h2>Android companion</h2><p>Pair the phone app over Bluetooth to receive live Pi and car status and contribute phone GPS.</p></div>
|
||||||
</div>
|
</div>
|
||||||
{mobileError && <div className="form-error" role="alert"><WarningCircle size={20} />{mobileError}</div>}
|
{mobileError && <div className="form-error" role="alert"><WarningCircle size={20} />{mobileError}</div>}
|
||||||
|
{mobileNotice && <div className="network-notice" role="status"><CheckCircle size={20} />{mobileNotice}</div>}
|
||||||
|
<div className="bluetooth-control">
|
||||||
|
<div className="bluetooth-state">
|
||||||
|
<span className={`client-dot ${bluetooth?.powered ? "online" : ""}`} />
|
||||||
|
<div>
|
||||||
|
<strong>{!bluetooth ? "Checking Bluetooth…" : !bluetooth.supported ? "Bluetooth unavailable" : bluetooth.powered ? "Bluetooth on" : "Bluetooth off"}</strong>
|
||||||
|
<small>{bluetooth?.message ?? "Reading the Pi radio state."}</small>
|
||||||
|
</div>
|
||||||
|
{bluetooth?.discoverable && <span className="bluetooth-visible">Discoverable</span>}
|
||||||
|
</div>
|
||||||
|
<div className="bluetooth-actions">
|
||||||
|
<button className="secondary-button text-button" onClick={() => void requestBluetoothAction(bluetooth?.powered ? "off" : "on")} disabled={!bluetooth?.supported || requesting}>
|
||||||
|
<Power size={21} />Turn {bluetooth?.powered ? "off" : "on"}
|
||||||
|
</button>
|
||||||
|
<button className="secondary-button text-button" onClick={() => void requestBluetoothAction("discoverable")} disabled={!bluetooth?.supported || requesting || bluetooth.discoverable}>
|
||||||
|
<Bluetooth size={21} />{bluetooth?.discoverable ? "Visible now" : "Make discoverable"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{pairingCode ? <div className="pairing-code" role="status">
|
{pairingCode ? <div className="pairing-code" role="status">
|
||||||
<span>Enter this code in the Android app</span>
|
<span>Enter this code in the Android app</span>
|
||||||
<strong>{pairingCode.code}</strong>
|
<strong>{pairingCode.code}</strong>
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ export type MobileDevice = {
|
|||||||
revokedAt: string | null;
|
revokedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BluetoothStatus = {
|
||||||
|
supported: boolean;
|
||||||
|
powered: boolean;
|
||||||
|
discoverable: boolean;
|
||||||
|
pairable: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CarTelemetry = {
|
export type CarTelemetry = {
|
||||||
available: true;
|
available: true;
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
@@ -262,6 +270,10 @@ export async function getMobileDevices(): Promise<MobileDevice[]> {
|
|||||||
return response.devices;
|
return response.devices;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBluetoothStatus(): Promise<BluetoothStatus> {
|
||||||
|
return parseResponse<BluetoothStatus>(await fetch("/api/mobile/bluetooth", { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
|
|
||||||
export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> {
|
export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> {
|
||||||
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
|
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|||||||
@@ -139,6 +139,13 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
|
|
||||||
.settings-page { min-width: 0; width: 100%; display: grid; gap: 18px; }
|
.settings-page { min-width: 0; width: 100%; display: grid; gap: 18px; }
|
||||||
.mobile-settings-layout { grid-template-columns: minmax(340px, 0.8fr) minmax(420px, 1.2fr); }
|
.mobile-settings-layout { grid-template-columns: minmax(340px, 0.8fr) minmax(420px, 1.2fr); }
|
||||||
|
.bluetooth-control { display: grid; gap: 14px; margin: 22px 0; padding: 17px; background: #10140f; border: 1px solid var(--line); border-radius: 11px; }
|
||||||
|
.bluetooth-state { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; }
|
||||||
|
.bluetooth-state > div { min-width: 0; display: grid; gap: 4px; }
|
||||||
|
.bluetooth-state small { overflow: hidden; color: var(--muted); font-size: 0.74rem; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.bluetooth-visible { padding: 5px 8px; color: var(--accent); background: rgba(185, 231, 105, 0.08); border: 1px solid rgba(185, 231, 105, 0.22); border-radius: 999px; font-size: 0.66rem; font-weight: 750; text-transform: uppercase; }
|
||||||
|
.bluetooth-actions { display: flex; flex-wrap: wrap; gap: 9px; }
|
||||||
|
.bluetooth-actions button { min-height: 44px; }
|
||||||
.pairing-code { display: grid; justify-items: center; gap: 8px; margin: 28px 0 20px; padding: 24px; background: #10140f; border: 1px solid var(--line); border-radius: 12px; }
|
.pairing-code { display: grid; justify-items: center; gap: 8px; margin: 28px 0 20px; padding: 24px; background: #10140f; border: 1px solid var(--line); border-radius: 12px; }
|
||||||
.pairing-code span, .pairing-code small { color: var(--muted); font-size: 0.78rem; }
|
.pairing-code span, .pairing-code small { color: var(--muted); font-size: 0.78rem; }
|
||||||
.pairing-code strong { color: var(--accent); font: 800 2.7rem/1 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.18em; }
|
.pairing-code strong { color: var(--accent); font: 800 2.7rem/1 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.18em; }
|
||||||
@@ -273,6 +280,10 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
.settings-tabs button { flex: 1; justify-content: center; padding: 0 10px; }
|
.settings-tabs button { flex: 1; justify-content: center; padding: 0 10px; }
|
||||||
.power-actions { grid-template-columns: 1fr; }
|
.power-actions { grid-template-columns: 1fr; }
|
||||||
.power-actions .secondary-button { width: 100%; padding: 0 18px; font-size: inherit; }
|
.power-actions .secondary-button { width: 100%; padding: 0 18px; font-size: inherit; }
|
||||||
|
.bluetooth-actions { flex-direction: column; }
|
||||||
|
.bluetooth-actions button { width: 100%; padding: 0 18px; font-size: inherit; }
|
||||||
|
.bluetooth-state { grid-template-columns: auto minmax(0, 1fr); }
|
||||||
|
.bluetooth-visible { grid-column: 2; justify-self: start; }
|
||||||
.terminal-host { height: 58dvh; min-height: 320px; }
|
.terminal-host { height: 58dvh; min-height: 320px; }
|
||||||
.skeleton-grid { grid-template-columns: 1fr; }
|
.skeleton-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user