feat(connections): Add connections page (replaces new device dialog) (#919)

* feat(conn): add connection screen and logic

* fixes from code review

* force https

* code review fixes

* add http for self testing

* enable deviceStore persistance

* added translations

* disabled feature flag

* i18n updates

* chore: add new folders to biome config (#910)

* chore(i18n): New Crowdin Translations by GitHub Action (#908)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* fix: use correct deprecated GPS coordinate format enum (#917)

The Config_DisplayConfig_GpsCoordinateFormat export doesn't exist in the protobufs package. The correct export is Config_DisplayConfig_DeprecatedGpsCoordinateFormat, which matches what's used in the validation schema.

Added TODO comment explaining that this field is deprecated since protobufs 2.7.4 and should be migrated to DeviceUIConfig.gps_format when DeviceUI settings are implemented.

* style: fix line wrapping for GPS coordinate format enum (#918)

- Split long enum reference across multiple lines to improve code readability
- Maintains consistent code formatting standards without changing functionality

* fix(core): ensure core package works in browser (#923)

* fix(core): ensure core package works in browser

* style(core): revert new line removal

* fix: add @serialport/bindings-cpp to onlyBuiltDependencies (#914)

* feat(ui): Add UI library (#900)

* feat: scaffold UI library

* Update packages/ui/src/components/theme-provider.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add lock file

* lint/formatting fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* formatting/linting fixes

* fixed some paring logic

* fixed connection issue with serial

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com>
Co-authored-by: Azarattum <43073346+Azarattum@users.noreply.github.com>
Co-authored-by: Ben Allfree <ben@benallfree.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dan Ditomaso
2025-11-03 20:24:24 -05:00
committed by GitHub
co-authored by Copilot github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Crowdin Bot Kamil Dzieniszewski Azarattum Ben Allfree
parent a1a646983e
commit 1af1295b8f
69 changed files with 3628 additions and 1881 deletions
@@ -0,0 +1,411 @@
import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
import type { Connection } from "@app/core/stores/deviceStore/types";
import { useConnections } from "@app/pages/Connections/useConnections";
import {
connectionTypeIcon,
formatConnectionSubtext,
} from "@app/pages/Connections/utils";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@components/UI/AlertDialog.tsx";
import { Badge } from "@components/UI/Badge.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@components/UI/Card.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/UI/DropdownMenu.tsx";
import { Separator } from "@components/UI/Separator.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useNavigate } from "@tanstack/react-router";
import {
ArrowLeft,
LinkIcon,
MoreHorizontal,
PlugZap,
RotateCw,
Star,
StarOff,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export const Connections = () => {
const {
connections,
addConnectionAndConnect,
connect,
disconnect,
removeConnection,
setDefaultConnection,
refreshStatuses,
} = useConnections();
const { toast } = useToast();
const navigate = useNavigate({ from: "/" });
const [addOpen, setAddOpen] = useState(false);
const isURLHTTPS = useMemo(() => location.protocol === "https:", []);
const { t } = useTranslation("connections");
// On first mount, try to refresh statuses
// biome-ignore lint/correctness/useExhaustiveDependencies: This can cause the icon to refresh too often
useEffect(() => {
refreshStatuses();
}, []);
const sorted = useMemo(() => {
const copy = [...connections];
return copy.sort((a, b) => {
if (a.isDefault && !b.isDefault) {
return -1;
}
if (!a.isDefault && b.isDefault) {
return 1;
}
if (a.status === "connected" && b.status !== "connected") {
return -1;
}
return a.name.localeCompare(b.name);
});
}, [connections]);
return (
<div className="space-y-6 p-6">
<header className="flex items-start justify-between">
<div className="flex items-stretch gap-3">
<button
type="button"
onClick={() => navigate({ to: "/" })}
className="flex items-center justify-center w-9 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{t("page.title")}
</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">
{t("page.description")}
</p>
</div>
</div>
<div className="flex items-center ml-2 gap-2">
<Button onClick={() => setAddOpen(true)} className="gap-2">
<PlugZap className="size-4" />
{t("button.addConnection")}
</Button>
</div>
</header>
<Separator />
{sorted.length === 0 ? (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-lg">
{t("noConnections.title")}{" "}
</CardTitle>
</CardHeader>
<CardContent className="text-slate-500 dark:text-slate-400">
{t("noConnections.description")}
</CardContent>
<CardFooter>
<Button onClick={() => setAddOpen(true)} className="gap-2">
<PlugZap className="size-4" />
{t("button.addConnection")}
</Button>
</CardFooter>
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map((c) => (
<ConnectionCard
key={c.id}
connection={c}
onConnect={async () => {
const ok = await connect(c.id, { allowPrompt: true });
toast({
title: ok ? t("toasts.connected") : t("toasts.failed"),
description: ok
? t("toasts.nowConnected", {
name: c.name,
interpolation: { escapeValue: false },
})
: t("toasts.checkConnetion"),
});
if (ok) {
navigate({ to: "/" });
}
}}
onDisconnect={async () => {
await disconnect(c.id);
toast({
title: t("toasts.disconnected"),
description: t("toasts.nowDisconnected", {
name: c.name,
interpolation: { escapeValue: false },
}),
});
}}
onSetDefault={() => {
setDefaultConnection(c.id);
toast({
title: t("toasts.defaultSet"),
description: t("toasts.defaultConnection", {
name: c.name,
interpolation: { escapeValue: false },
}),
});
}}
onDelete={async () => {
await disconnect(c.id);
removeConnection(c.id);
toast({
title: t("toasts.deleted"),
description: t("toasts.deletedByName", {
name: c.name,
interpolation: { escapeValue: false },
}),
});
}}
onRetry={async () => {
const ok = await connect(c.id, { allowPrompt: true });
toast({
title: ok ? t("toasts.connected") : t("toasts.failed"),
description: ok
? t("toasts.nowConnected", {
name: c.name,
interpolation: { escapeValue: false },
})
: t("toasts.pickConnectionAgain"),
});
if (ok) {
navigate({ to: "/" });
}
}}
/>
))}
</div>
)}
<AddConnectionDialog
open={addOpen}
onOpenChange={setAddOpen}
isHTTPS={isURLHTTPS}
onSave={async (partial, btDevice) => {
const created = await addConnectionAndConnect(partial, btDevice);
if (created) {
setAddOpen(false);
toast({
title: t("toasts.added"),
description: t("toasts.savedByName", {
name: created.name,
interpolation: { escapeValue: false },
}),
});
if (created.status === "connected") {
navigate({ to: "/" });
}
} else {
toast({
title: "Unable to connect",
description: "savedCantConnect",
});
}
}}
/>
</div>
);
};
function TypeBadge({ type }: { type: Connection["type"] }) {
const Icon = connectionTypeIcon(type);
const label =
type === "http" ? "HTTP" : type === "bluetooth" ? "Bluetooth" : "Serial";
return (
<Badge variant="default" className="gap-1.5">
<Icon className="h-3.5 w-3.5" />
{label}
</Badge>
);
}
function ConnectionCard({
connection,
onConnect,
onDisconnect,
onSetDefault,
onDelete,
onRetry,
}: {
connection: Connection;
onConnect: () => Promise<boolean> | Promise<void>;
onDisconnect: () => Promise<void> | Promise<void>;
onSetDefault: () => void;
onDelete: () => void;
onRetry: () => Promise<boolean> | Promise<void>;
}) {
const { t } = useTranslation("connections");
const Icon = connectionTypeIcon(connection.type);
const isBusy = connection.status === "connecting";
const isConnected = connection.status === "connected";
const isError = connection.status === "error";
return (
<Card className="flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2">
<Icon className="size-4 text-slate-500 dark:text-slate-400" />
<span className="truncate">{connection.name}</span>
{connection.isDefault ? (
<Badge variant="secondary" className="gap-1">
<Star className="h-3.5 w-3.5 fill-yellow-500 text-yellow-500" />
{t("default")}
</Badge>
) : null}
</CardTitle>
<div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
<TypeBadge type={connection.type} />
<span className="text-slate-500 dark:text-slate-400 truncate">
{formatConnectionSubtext(connection)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<ConnectionStatusBadge status={connection.status} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="size-4" />
<span className="sr-only">{t("moreActions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{connection.type === "http" && connection.isDefault && (
<DropdownMenuItem
className="gap-2"
onClick={() => onSetDefault()}
>
<StarOff className="size-4" />
{t("button.unsetDefault")}
</DropdownMenuItem>
)}
{connection.type === "http" && !connection.isDefault && (
<DropdownMenuItem
className="gap-2"
onClick={() => onSetDefault()}
>
<Star className="size-4" />
{t("button.setDefault")}
</DropdownMenuItem>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="gap-2 text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
onSelect={(e) => e.preventDefault()}
>
<Trash2 className="size-4" />
{t("button.delete")}
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("deleteConnection")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSure", { name: connection.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => onDelete()}
>
{t("button.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{connection.error ? (
<p className="text-sm text-red-600 dark:text-red-400">
{connection.error}
</p>
) : connection.lastConnectedAt ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{t("lastConnectedAt", {
date: new Date(connection.lastConnectedAt),
})}
:{new Date(connection.lastConnectedAt).toLocaleString()}
</p>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">
{t("neverConnected")}
</p>
)}
</CardContent>
<CardFooter className="flex items-center gap-2 mt-auto">
{isConnected ? (
<Button
variant="subtle"
className="gap-2"
onClick={() => onDisconnect()}
disabled={isBusy}
>
{t("button.disconnect")}
</Button>
) : (
<Button
className="gap-2"
onClick={() => (isError ? onRetry() : onConnect())}
disabled={isBusy}
>
{isError ? (
<>
<RotateCw className="size-4" />
{t("button.retry")}
</>
) : (
<>
<LinkIcon className="size-4" />
{t("button.connect")}
</>
)}
</Button>
)}
</CardFooter>
</Card>
);
}
@@ -0,0 +1,496 @@
import type {
Connection,
ConnectionId,
ConnectionStatus,
NewConnection,
} from "@app/core/stores/deviceStore/types";
import {
createConnectionFromInput,
testHttpReachable,
} from "@app/pages/Connections/utils";
import {
useAppStore,
useDeviceStore,
useMessageStore,
useNodeDBStore,
} from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useRef } from "react";
type LiveRefs = {
bt: Map<ConnectionId, BluetoothDevice>;
serial: Map<ConnectionId, SerialPort>;
meshDevices: Map<ConnectionId, MeshDevice>;
};
export function useConnections() {
const connections = useDeviceStore((s) => s.savedConnections);
const addSavedConnection = useDeviceStore((s) => s.addSavedConnection);
const updateSavedConnection = useDeviceStore((s) => s.updateSavedConnection);
const removeSavedConnectionFromStore = useDeviceStore(
(s) => s.removeSavedConnection,
);
const live = useRef<LiveRefs>({
bt: new Map(),
serial: new Map(),
meshDevices: new Map(),
});
const { addDevice } = useDeviceStore();
const { addNodeDB } = useNodeDBStore();
const { addMessageStore } = useMessageStore();
const { setSelectedDevice } = useAppStore();
const updateStatus = useCallback(
(id: ConnectionId, status: ConnectionStatus, error?: string) => {
const updates: Partial<Connection> = {
status,
error: error || undefined,
...(status === "disconnected" ? { lastConnectedAt: Date.now() } : {}),
};
updateSavedConnection(id, updates);
},
[updateSavedConnection],
);
const removeConnection = useCallback(
(id: ConnectionId) => {
// Disconnect MeshDevice first
const meshDevice = live.current.meshDevices.get(id);
if (meshDevice) {
try {
meshDevice.disconnect();
} catch {}
live.current.meshDevices.delete(id);
}
// Close live refs if open
const bt = live.current.bt.get(id);
if (bt?.gatt?.connected) {
try {
bt.gatt.disconnect();
} catch {
// Ignore errors
}
}
const sp = live.current.serial.get(id);
if (sp && "close" in sp) {
try {
(sp as SerialPort & { close: () => Promise<void> }).close();
} catch {
// Ignore errors
}
}
live.current.bt.delete(id);
live.current.serial.delete(id);
removeSavedConnectionFromStore(id);
},
[removeSavedConnectionFromStore],
);
const setDefaultConnection = useCallback(
(id: ConnectionId) => {
for (const connection of connections) {
if (connection.id === id) {
updateSavedConnection(connection.id, {
isDefault: !connection.isDefault,
});
}
}
},
[connections, updateSavedConnection],
);
const setupMeshDevice = useCallback(
(
id: ConnectionId,
transport:
| Awaited<ReturnType<typeof TransportHTTP.create>>
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>,
options?: {
setHeartbeat?: boolean;
onDisconnect?: () => void;
},
): number => {
const deviceId = randId();
const device = addDevice(deviceId);
const nodeDB = addNodeDB(deviceId);
const messageStore = addMessageStore(deviceId);
const meshDevice = new MeshDevice(transport, deviceId);
meshDevice.configure();
setSelectedDevice(deviceId);
device.addConnection(meshDevice);
subscribeAll(device, meshDevice, messageStore, nodeDB);
live.current.meshDevices.set(id, meshDevice);
if (options?.setHeartbeat) {
const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
meshDevice.setHeartbeatInterval(HEARTBEAT_INTERVAL);
}
updateSavedConnection(id, { meshDeviceId: deviceId });
return deviceId;
},
[
addDevice,
addNodeDB,
addMessageStore,
setSelectedDevice,
updateSavedConnection,
],
);
const connect = useCallback(
async (id: ConnectionId, opts?: { allowPrompt?: boolean }) => {
const conn = connections.find((c) => c.id === id);
if (!conn) {
return false;
}
if (conn.status === "connected") {
return true;
}
updateStatus(id, "connecting");
try {
if (conn.type === "http") {
const ok = await testHttpReachable(conn.url);
if (!ok) {
const url = new URL(conn.url);
const isHTTPS = url.protocol === "https:";
const message = isHTTPS
? `Cannot reach HTTPS endpoint. If using a self-signed certificate, open ${conn.url} in a new tab, accept the certificate warning, then try connecting again.`
: "HTTP endpoint not reachable (may be blocked by CORS)";
throw new Error(message);
}
const url = new URL(conn.url);
const isTLS = url.protocol === "https:";
const transport = await TransportHTTP.create(url.host, isTLS);
setupMeshDevice(id, transport);
updateStatus(id, "connected");
return true;
}
if (conn.type === "bluetooth") {
if (!("bluetooth" in navigator)) {
throw new Error("Web Bluetooth not supported");
}
let bleDevice = live.current.bt.get(id);
if (!bleDevice) {
// Try to recover permitted devices
const getDevices = (
navigator.bluetooth as Navigator["bluetooth"] & {
getDevices?: () => Promise<BluetoothDevice[]>;
}
).getDevices;
if (getDevices) {
const known = await getDevices();
if (known && known.length > 0 && conn.deviceId) {
bleDevice = known.find(
(d: BluetoothDevice) => d.id === conn.deviceId,
);
// If found, store it for future use
if (bleDevice) {
live.current.bt.set(id, bleDevice);
}
}
}
}
if (!bleDevice && opts?.allowPrompt) {
// Prompt user to reselect (filter by optional service if provided)
bleDevice = await navigator.bluetooth.requestDevice({
acceptAllDevices: !conn.gattServiceUUID,
optionalServices: conn.gattServiceUUID
? [conn.gattServiceUUID]
: undefined,
filters: conn.gattServiceUUID
? [{ services: [conn.gattServiceUUID] }]
: undefined,
});
}
if (!bleDevice) {
throw new Error(
"Bluetooth device not available. Re-select the device.",
);
}
live.current.bt.set(id, bleDevice);
const transport =
await TransportWebBluetooth.createFromDevice(bleDevice);
setupMeshDevice(id, transport, { setHeartbeat: true });
bleDevice.addEventListener("gattserverdisconnected", () => {
updateStatus(id, "disconnected");
});
updateStatus(id, "connected");
return true;
}
if (conn.type === "serial") {
if (!("serial" in navigator)) {
throw new Error("Web Serial not supported");
}
let port = live.current.serial.get(id);
if (!port) {
// Find a previously granted port by vendor/product
const ports: SerialPort[] = await (
navigator as Navigator & {
serial: { getPorts: () => Promise<SerialPort[]> };
}
).serial.getPorts();
if (ports && conn.usbVendorId && conn.usbProductId) {
port = ports.find((p: SerialPort) => {
const info =
(
p as SerialPort & {
getInfo?: () => {
usbVendorId?: number;
usbProductId?: number;
};
}
).getInfo?.() ?? {};
return (
info.usbVendorId === conn.usbVendorId &&
info.usbProductId === conn.usbProductId
);
});
}
}
if (!port && opts?.allowPrompt) {
port = await (
navigator as Navigator & {
serial: {
requestPort: (
options: Record<string, unknown>,
) => Promise<SerialPort>;
};
}
).serial.requestPort({});
}
if (!port) {
throw new Error("Serial port not available. Re-select the port.");
}
// Ensure the port is closed before opening it
const portWithStreams = port as SerialPort & {
readable: ReadableStream | null;
writable: WritableStream | null;
close: () => Promise<void>;
};
if (portWithStreams.readable || portWithStreams.writable) {
try {
await portWithStreams.close();
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (err) {
console.warn("Error closing port before reconnect:", err);
}
}
live.current.serial.set(id, port);
const transport = await TransportWebSerial.createFromPort(port);
setupMeshDevice(id, transport, { setHeartbeat: true });
updateStatus(id, "connected");
return true;
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
updateStatus(id, "error", message);
return false;
}
return false;
},
[connections, updateStatus, setupMeshDevice],
);
const disconnect = useCallback(
async (id: ConnectionId) => {
const conn = connections.find((c) => c.id === id);
if (!conn) {
return;
}
try {
// Disconnect MeshDevice first
const meshDevice = live.current.meshDevices.get(id);
if (meshDevice) {
try {
meshDevice.disconnect();
} catch {
// Ignore errors
}
live.current.meshDevices.delete(id);
}
if (conn.type === "bluetooth") {
const dev = live.current.bt.get(id);
if (dev?.gatt?.connected) {
dev.gatt.disconnect();
}
}
if (conn.type === "serial") {
const port = live.current.serial.get(id);
if (port) {
try {
const portWithClose = port as SerialPort & {
close: () => Promise<void>;
readable: ReadableStream | null;
};
// Only close if the port is open (has readable stream)
if (portWithClose.readable) {
await portWithClose.close();
}
} catch (err) {
console.warn("Error closing serial port:", err);
}
live.current.serial.delete(id);
}
}
} finally {
updateSavedConnection(id, {
status: "disconnected",
error: undefined,
});
}
},
[connections, updateSavedConnection],
);
const addConnection = useCallback(
(input: NewConnection) => {
const conn = createConnectionFromInput(input);
addSavedConnection(conn);
return conn;
},
[addSavedConnection],
);
const addConnectionAndConnect = useCallback(
async (input: NewConnection, btDevice?: BluetoothDevice) => {
const conn = addConnection(input);
// If a Bluetooth device was provided, store it to avoid re-prompting
if (btDevice && conn.type === "bluetooth") {
live.current.bt.set(conn.id, btDevice);
}
await connect(conn.id, { allowPrompt: true });
// Get updated connection from store after connect
if (conn.id) {
return conn;
}
},
[addConnection, connect],
);
const refreshStatuses = useCallback(async () => {
// Check reachability/availability without auto-connecting
// HTTP: test endpoint reachability
// Bluetooth/Serial: check permission grants
// HTTP connections: test reachability if not already connected
const httpChecks = connections
.filter(
(c): c is Connection & { type: "http"; url: string } =>
c.type === "http" && c.status !== "connected",
)
.map(async (c) => {
const ok = await testHttpReachable(c.url);
updateSavedConnection(c.id, {
status: ok ? "online" : "error",
});
});
// Bluetooth connections: check permission grants
const btChecks = connections
.filter(
(c): c is Connection & { type: "bluetooth"; deviceId?: string } =>
c.type === "bluetooth" && c.status !== "connected",
)
.map(async (c) => {
if (!("bluetooth" in navigator)) {
return;
}
try {
const known = await (
navigator.bluetooth as Navigator["bluetooth"] & {
getDevices?: () => Promise<BluetoothDevice[]>;
}
).getDevices?.();
const hasPermission = known?.some(
(d: BluetoothDevice) => d.id === c.deviceId,
);
updateSavedConnection(c.id, {
status: hasPermission ? "configured" : "disconnected",
});
} catch {
// getDevices not supported or failed
updateSavedConnection(c.id, { status: "disconnected" });
}
});
// Serial connections: check permission grants
const serialChecks = connections
.filter(
(
c,
): c is Connection & {
type: "serial";
usbVendorId?: number;
usbProductId?: number;
} => c.type === "serial" && c.status !== "connected",
)
.map(async (c) => {
if (!("serial" in navigator)) {
return;
}
try {
const ports: SerialPort[] = await (
navigator as Navigator & {
serial: { getPorts: () => Promise<SerialPort[]> };
}
).serial.getPorts();
const hasPermission = ports.some((p: SerialPort) => {
const info =
(
p as SerialPort & {
getInfo?: () => {
usbVendorId?: number;
usbProductId?: number;
};
}
).getInfo?.() ?? {};
return (
info.usbVendorId === c.usbVendorId &&
info.usbProductId === c.usbProductId
);
});
updateSavedConnection(c.id, {
status: hasPermission ? "configured" : "disconnected",
});
} catch {
// getPorts failed
updateSavedConnection(c.id, { status: "disconnected" });
}
});
await Promise.all([...httpChecks, ...btChecks, ...serialChecks]);
}, [connections, updateSavedConnection]);
return {
connections,
addConnection,
addConnectionAndConnect,
connect,
disconnect,
removeConnection,
setDefaultConnection,
refreshStatuses,
};
}
@@ -0,0 +1,84 @@
import type {
Connection,
ConnectionStatus,
ConnectionType,
NewConnection,
} from "@app/core/stores/deviceStore/types";
import { randId } from "@app/core/utils/randId";
import { Bluetooth, Cable, Globe, type LucideIcon } from "lucide-react";
export function createConnectionFromInput(input: NewConnection): Connection {
const base = {
id: randId(),
name: input.name,
createdAt: Date.now(),
status: "disconnected" as ConnectionStatus,
};
if (input.type === "http") {
return {
...base,
type: "http",
url: input.url,
isDefault: false,
name: input.name.length === 0 ? input.url : input.name,
};
}
if (input.type === "bluetooth") {
return {
...base,
type: "bluetooth",
deviceId: input.deviceId,
deviceName: input.deviceName,
gattServiceUUID: input.gattServiceUUID,
};
}
return {
...base,
type: "serial",
usbVendorId: input.usbVendorId,
usbProductId: input.usbProductId,
};
}
export async function testHttpReachable(
url: string,
timeoutMs = 2500,
): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// Use no-cors to avoid CORS failure; opaque responses resolve but status is 0
await fetch(url, {
method: "GET",
mode: "no-cors",
cache: "no-store",
signal: controller.signal,
});
clearTimeout(timer);
return true;
} catch {
return false;
}
}
export function connectionTypeIcon(type: ConnectionType): LucideIcon {
if (type === "http") {
return Globe;
}
if (type === "bluetooth") {
return Bluetooth;
}
return Cable;
}
export function formatConnectionSubtext(conn: Connection): string {
if (conn.type === "http") {
return conn.url;
}
if (conn.type === "bluetooth") {
return conn.deviceName || conn.deviceId || "No device selected";
}
const v = conn.usbVendorId ? conn.usbVendorId.toString(16) : "?";
const p = conn.usbProductId ? conn.usbProductId.toString(16) : "?";
return `USB ${v}:${p}`;
}