Add dashboard Bluetooth controls
This commit is contained in:
+52
-2
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user