feat: add Bluetooth Android companion

This commit is contained in:
2026-07-31 23:32:57 +02:00
parent e53b7400bd
commit 2d715abb12
35 changed files with 1739 additions and 4 deletions
+73 -3
View File
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from
import {
ArrowClockwise,
AndroidLogo,
Bluetooth,
CarProfile,
CheckCircle,
Cpu,
@@ -14,6 +15,7 @@ import {
Power,
SignOut,
TerminalWindow,
Trash,
WarningCircle,
WifiHigh
} from "@phosphor-icons/react";
@@ -26,12 +28,15 @@ import {
getNetworkStatus,
getNetworkActivity,
getUpdateStatus,
getMobileDevices,
post,
revokeMobileDevice,
type AuthState,
type SystemStatus,
type NetworkStatus,
type NetworkActivity,
type UpdateStatus
type UpdateStatus,
type MobileDevice
} from "./api";
const ShellTerminal = lazy(async () => {
@@ -322,7 +327,7 @@ function portLabel(port: number): string {
}
function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [tab, setTab] = useState<"network" | "system" | "shell">("network");
const [tab, setTab] = useState<"network" | "system" | "mobile" | "shell">("network");
const [update, setUpdate] = useState<UpdateStatus | null>(null);
const [network, setNetwork] = useState<NetworkStatus | null>(null);
const [updateError, setUpdateError] = useState("");
@@ -338,6 +343,9 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [ssidTouched, setSsidTouched] = useState(false);
const [confirmHotspot, setConfirmHotspot] = useState(false);
const [networkNotice, setNetworkNotice] = useState("");
const [mobileDevices, setMobileDevices] = useState<MobileDevice[]>([]);
const [pairingCode, setPairingCode] = useState<{ code: string; expiresAt: string } | null>(null);
const [mobileError, setMobileError] = useState("");
const running = update?.state === "checking" || update?.state === "building";
const refreshUpdate = useCallback(async () => {
@@ -358,15 +366,49 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
}
}, []);
const refreshMobile = useCallback(async () => {
try {
setMobileDevices(await getMobileDevices());
setMobileError("");
} catch (caught) {
setMobileError(caught instanceof Error ? caught.message : "Paired phones could not be loaded");
}
}, []);
useEffect(() => {
void refreshUpdate();
void refreshNetwork();
void refreshMobile();
const interval = window.setInterval(() => {
void refreshUpdate();
void refreshNetwork();
}, 3_000);
return () => window.clearInterval(interval);
}, [refreshNetwork, refreshUpdate]);
}, [refreshMobile, refreshNetwork, refreshUpdate]);
async function createPairingCode() {
setRequesting(true);
setMobileError("");
try {
setPairingCode(await post<{ code: string; expiresAt: string }>("/api/mobile/pairing", {}, csrfToken));
} catch (caught) {
setMobileError(caught instanceof Error ? caught.message : "A pairing code could not be created");
} finally {
setRequesting(false);
}
}
async function revokePhone(device: MobileDevice) {
setRequesting(true);
try {
await revokeMobileDevice(device.id, csrfToken);
await refreshMobile();
} catch (caught) {
setMobileError(caught instanceof Error ? caught.message : "The phone could not be revoked");
} finally {
setRequesting(false);
}
}
useEffect(() => {
if (!ssidTouched && network?.hotspotSsid) setHotspotSsid(network.hotspotSsid);
@@ -489,6 +531,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
<nav className="settings-tabs" aria-label="Settings categories" role="tablist">
<button role="tab" aria-selected={tab === "network"} className={tab === "network" ? "active" : ""} onClick={() => setTab("network")}><WifiHigh size={20} />Network</button>
<button role="tab" aria-selected={tab === "system"} className={tab === "system" ? "active" : ""} onClick={() => setTab("system")}><Gear size={20} />System</button>
<button role="tab" aria-selected={tab === "mobile"} className={tab === "mobile" ? "active" : ""} onClick={() => setTab("mobile")}><Bluetooth size={20} />Mobile</button>
<button role="tab" aria-selected={tab === "shell"} className={tab === "shell" ? "active" : ""} onClick={() => setTab("shell")}><TerminalWindow size={20} />Shell</button>
</nav>
@@ -612,6 +655,33 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
</section>
</div>}
{tab === "mobile" && <div className="settings-layout mobile-settings-layout" role="tabpanel">
<section className="settings-panel mobile-pairing-panel">
<div className="settings-title">
<div className="settings-icon"><Bluetooth size={30} weight="duotone" /></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>
{mobileError && <div className="form-error" role="alert"><WarningCircle size={20} />{mobileError}</div>}
{pairingCode ? <div className="pairing-code" role="status">
<span>Enter this code in the Android app</span>
<strong>{pairingCode.code}</strong>
<small>Expires {new Date(pairingCode.expiresAt).toLocaleTimeString()}</small>
</div> : <p className="settings-note">Codes work once and expire after ten minutes. The phone receives a revocable credential stored in Android Keystore.</p>}
<button className="primary-button" onClick={() => void createPairingCode()} disabled={requesting}><Bluetooth size={22} />{pairingCode ? "Generate another code" : "Pair a phone"}</button>
</section>
<section className="settings-panel mobile-devices-panel">
<div className="panel-heading"><div><h2>Paired phones</h2><p>Revoke a phone immediately if it is lost or replaced.</p></div></div>
<div className="mobile-device-list">
{mobileDevices.filter((device) => !device.revokedAt).map((device) => <div className="mobile-device" key={device.id}>
<Bluetooth size={22} />
<div><strong>{device.name}</strong><small>Last seen {new Date(device.lastSeenAt).toLocaleString()}</small></div>
<button className="icon-button" aria-label={`Revoke ${device.name}`} onClick={() => void revokePhone(device)} disabled={requesting}><Trash size={19} /></button>
</div>)}
{mobileDevices.every((device) => device.revokedAt) && <div className="activity-empty">No phones are paired yet.</div>}
</div>
</section>
</div>}
{tab === "shell" && <div role="tabpanel"><Suspense fallback={<div className="shell-loading">Loading terminal</div>}><ShellTerminal csrfToken={csrfToken} /></Suspense></div>}
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
+23
View File
@@ -114,6 +114,14 @@ export type AdbShortcut = AdbTarget & {
createdAt: string;
};
export type MobileDevice = {
id: string;
name: string;
createdAt: string;
lastSeenAt: string;
revokedAt: string | null;
};
export type CarTelemetry = {
available: true;
collectedAt: string;
@@ -247,6 +255,21 @@ export async function getAdbShortcuts(): Promise<AdbShortcut[]> {
return response.shortcuts;
}
export async function getMobileDevices(): Promise<MobileDevice[]> {
const response = await parseResponse<{ devices: MobileDevice[] }>(
await fetch("/api/mobile/devices", { credentials: "same-origin" })
);
return response.devices;
}
export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> {
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "same-origin",
headers: { "X-CSRF-Token": csrfToken }
}));
}
export async function uploadApk(file: File, csrfToken: string): Promise<void> {
await parseResponse(
await fetch("/api/adb/install", {
+9
View File
@@ -138,6 +138,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; }
.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); }
.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; }
.mobile-device-list { display: grid; gap: 9px; margin-top: 22px; }
.mobile-device { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; min-height: 65px; padding: 10px 12px; background: #121610; border-radius: 10px; }
.mobile-device > svg { color: var(--accent); }
.mobile-device div { min-width: 0; display: grid; gap: 4px; }
.mobile-device small { overflow: hidden; color: var(--muted); font-size: 0.72rem; text-overflow: ellipsis; white-space: nowrap; }
.settings-page > [role="tabpanel"] { min-width: 0; width: 100%; max-width: 100%; }
.settings-tabs { width: fit-content; display: flex; gap: 5px; padding: 5px; background: #121610; border: 1px solid var(--line); border-radius: 12px; }
.settings-tabs button { min-height: 48px; display: inline-flex; align-items: center; gap: 9px; padding: 0 19px; color: var(--muted); background: transparent; border: 0; border-radius: 8px; font-weight: 700; cursor: pointer; transition: color 160ms ease, background 160ms ease, transform 120ms ease; }