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:
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
@@ -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}`;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Separator } from "@components/UI/Separator.tsx";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import {
|
||||
useAppStore /*, useDeviceStore, useNodeDBStore */,
|
||||
} from "@core/stores";
|
||||
import { ListPlusIcon, PlusIcon /*, UsersIcon */ } from "lucide-react";
|
||||
/* import { useMemo } from "react"; */
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { setConnectDialogOpen /*, setSelectedDevice*/ } = useAppStore();
|
||||
/*
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { getNodeDB } = useNodeDBStore();
|
||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Heading as="h3">{t("dashboard.title")}</Heading>
|
||||
<Subtle>{t("dashboard.description")}</Subtle>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||
{
|
||||
/*devices.length ? (
|
||||
<ul className="grow divide-y divide-slate-200">
|
||||
{devices.map((device) => {
|
||||
const nodeDB = getNodeDB(device.id);
|
||||
if (!nodeDB) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-4 py-4 sm:px-6"
|
||||
onClick={() => {
|
||||
setSelectedDevice(device.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{nodeDB.getNode(device.hardware.myNodeNum)?.user
|
||||
?.longName ?? t("unknown.shortName")}
|
||||
</p>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-slate-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{nodeDB.getNodesLength() === 0
|
||||
? 0
|
||||
: nodeDB.getNodesLength() - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : */ <div className="m-auto flex flex-col gap-3 text-center">
|
||||
<ListPlusIcon size={48} className="mx-auto text-text-secondary" />
|
||||
<Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading>
|
||||
<Subtle>{t("dashboard.noDevicesDescription")}</Subtle>
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="default"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
{t("dashboard.button_newConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -40,7 +40,7 @@ export interface DeleteNoteDialogProps {
|
||||
|
||||
const NodesPage = (): JSX.Element => {
|
||||
const { t } = useTranslation("nodes");
|
||||
const { currentLanguage } = useLang();
|
||||
const { current } = useLang();
|
||||
const { hardware, connection, setDialogOpen } = useDevice();
|
||||
|
||||
const { setNodeNumDetails } = useAppStore();
|
||||
@@ -200,7 +200,7 @@ const NodesPage = (): JSX.Element => {
|
||||
) : (
|
||||
<TimeAgo
|
||||
timestamp={node.lastHeard * 1000}
|
||||
locale={currentLanguage?.code}
|
||||
locale={current?.code}
|
||||
/>
|
||||
)}
|
||||
</Mono>
|
||||
|
||||
Reference in New Issue
Block a user