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
+52 -2
View File
@@ -29,6 +29,7 @@ import {
getNetworkActivity,
getUpdateStatus,
getMobileDevices,
getBluetoothStatus,
post,
revokeMobileDevice,
type AuthState,
@@ -36,7 +37,8 @@ import {
type NetworkStatus,
type NetworkActivity,
type UpdateStatus,
type MobileDevice
type MobileDevice,
type BluetoothStatus
} from "./api";
const ShellTerminal = lazy(async () => {
@@ -344,8 +346,10 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [confirmHotspot, setConfirmHotspot] = useState(false);
const [networkNotice, setNetworkNotice] = useState("");
const [mobileDevices, setMobileDevices] = useState<MobileDevice[]>([]);
const [bluetooth, setBluetooth] = useState<BluetoothStatus | null>(null);
const [pairingCode, setPairingCode] = useState<{ code: string; expiresAt: string } | null>(null);
const [mobileError, setMobileError] = useState("");
const [mobileNotice, setMobileNotice] = useState("");
const running = update?.state === "checking" || update?.state === "building";
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(() => {
void refreshUpdate();
void refreshNetwork();
void refreshMobile();
void refreshBluetooth();
const interval = window.setInterval(() => {
void refreshUpdate();
void refreshNetwork();
void refreshBluetooth();
}, 3_000);
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() {
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>
{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">
<span>Enter this code in the Android app</span>
<strong>{pairingCode.code}</strong>
+12
View File
@@ -122,6 +122,14 @@ export type MobileDevice = {
revokedAt: string | null;
};
export type BluetoothStatus = {
supported: boolean;
powered: boolean;
discoverable: boolean;
pairable: boolean;
message: string;
};
export type CarTelemetry = {
available: true;
collectedAt: string;
@@ -262,6 +270,10 @@ export async function getMobileDevices(): Promise<MobileDevice[]> {
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> {
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
method: "DELETE",
+11
View File
@@ -139,6 +139,13 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.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); }
.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 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; }
@@ -273,6 +280,10 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.settings-tabs button { flex: 1; justify-content: center; padding: 0 10px; }
.power-actions { grid-template-columns: 1fr; }
.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; }
.skeleton-grid { grid-template-columns: 1fr; }
}