feat(protobufs): sync to firmware-current and consume workspace package (#1097)
* feat(protobufs): sync to firmware-current and consume workspace package Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*). Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true. * build(protobufs): vendor generated bindings for workspace consumers The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen * fix(protobufs): clean script removes the actual generated output dir buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback. * refactor: move web app packages/web -> apps/web Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green. * feat: config fields, module pages, key verification, telemetry capture Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green. * chore: drop #1062 (unsaved-change-detection) to match upstream revert #1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green. * fix(nodes): clean up SNR display in node table and map popup SNR is a ratio measured in dB, not dBm (which is absolute power); the node table and map popup both mislabeled it and crammed three values together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same heuristic ((snr+10)*5) shown twice — once clamped, once not. Render SNR in dB rounded to one decimal, color-coded by a 0-100% signal-quality heuristic (green/yellow/red), with the quality percentage as a muted secondary. Drop the redundant raw value. Adds unit.db; this matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
|
||||
import { TimeAgo } from "@app/components/generic/TimeAgo";
|
||||
import LanguageSwitcher from "@app/components/LanguageSwitcher";
|
||||
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,
|
||||
RotateCw,
|
||||
RouterIcon,
|
||||
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,
|
||||
syncConnectionStatuses,
|
||||
} = 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, sync statuses and refresh
|
||||
useEffect(() => {
|
||||
syncConnectionStatuses();
|
||||
refreshStatuses();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
const aConnected = a.status === "connected" || a.status === "configured";
|
||||
const bConnected = b.status === "connected" || b.status === "configured";
|
||||
if (aConnected && !bConnected) {
|
||||
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="lg:w-4/6 md:w-5/6 text-slate-500 dark:text-slate-400 mt-1">
|
||||
{t("page.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end ml-2 gap-2">
|
||||
<Button onClick={() => setAddOpen(true)} className="gap-2">
|
||||
<RouterIcon className="size-5" />
|
||||
{t("button.addConnection")}
|
||||
</Button>
|
||||
<LanguageSwitcher />
|
||||
</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">
|
||||
<RouterIcon className="size-5" />
|
||||
{t("button.addConnection")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl: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.checkConnection"),
|
||||
});
|
||||
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" || created.status === "configured") {
|
||||
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" || connection.status === "configuring";
|
||||
const isConnected = connection.status === "connected" || connection.status === "configured";
|
||||
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: "" })}{" "}
|
||||
<TimeAgo
|
||||
timestamp={connection.lastConnectedAt}
|
||||
className="text-sm text-slate-500 dark:text-slate-400"
|
||||
/>
|
||||
</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,616 @@
|
||||
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 } from "react";
|
||||
|
||||
// Local storage for cleanup only (not in Zustand)
|
||||
const transports = new Map<ConnectionId, BluetoothDevice | SerialPort>();
|
||||
const heartbeats = new Map<ConnectionId, ReturnType<typeof setInterval>>();
|
||||
const configSubscriptions = new Map<ConnectionId, () => void>();
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const CONFIG_HEARTBEAT_INTERVAL_MS = 5000; // 5s during configuration
|
||||
|
||||
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);
|
||||
|
||||
// DeviceStore methods
|
||||
const setActiveConnectionId = useDeviceStore((s) => s.setActiveConnectionId);
|
||||
|
||||
const { addDevice } = useDeviceStore();
|
||||
const { addNodeDB } = useNodeDBStore();
|
||||
const { addMessageStore } = useMessageStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
const selectedDeviceId = useAppStore((s) => s.selectedDeviceId);
|
||||
|
||||
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) => {
|
||||
const conn = connections.find((c) => c.id === id);
|
||||
|
||||
// Stop heartbeat
|
||||
const heartbeatId = heartbeats.get(id);
|
||||
if (heartbeatId) {
|
||||
clearInterval(heartbeatId);
|
||||
heartbeats.delete(id);
|
||||
console.log(`[useConnections] Heartbeat stopped for connection ${id}`);
|
||||
}
|
||||
|
||||
// Unsubscribe from config complete event
|
||||
const unsubConfigComplete = configSubscriptions.get(id);
|
||||
if (unsubConfigComplete) {
|
||||
unsubConfigComplete();
|
||||
configSubscriptions.delete(id);
|
||||
console.log(`[useConnections] Config subscription cleaned up for connection ${id}`);
|
||||
}
|
||||
|
||||
// Get device and MeshDevice from Device.connection
|
||||
if (conn?.meshDeviceId) {
|
||||
const { getDevice, removeDevice } = useDeviceStore.getState();
|
||||
const device = getDevice(conn.meshDeviceId);
|
||||
|
||||
if (device?.connection) {
|
||||
// Disconnect MeshDevice
|
||||
try {
|
||||
device.connection.disconnect();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Close transport if it's BT or Serial
|
||||
const transport = transports.get(id);
|
||||
if (transport) {
|
||||
const bt = transport as BluetoothDevice;
|
||||
if (bt.gatt?.connected) {
|
||||
try {
|
||||
bt.gatt.disconnect();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sp = transport as SerialPort & { close?: () => Promise<void> };
|
||||
if (sp.close) {
|
||||
try {
|
||||
sp.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
transports.delete(id);
|
||||
}
|
||||
|
||||
// Clean up orphaned Device
|
||||
try {
|
||||
removeDevice(conn.meshDeviceId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
removeSavedConnectionFromStore(id);
|
||||
},
|
||||
[connections, 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>>,
|
||||
btDevice?: BluetoothDevice,
|
||||
serialPort?: SerialPort,
|
||||
): number => {
|
||||
// Reuse existing meshDeviceId if available to prevent duplicate nodeDBs,
|
||||
// but only if the corresponding nodeDB still exists. Otherwise, generate a new ID.
|
||||
const conn = connections.find((c) => c.id === id);
|
||||
let deviceId = conn?.meshDeviceId;
|
||||
if (deviceId && !useNodeDBStore.getState().getNodeDB(deviceId)) {
|
||||
deviceId = undefined;
|
||||
}
|
||||
deviceId = deviceId ?? randId();
|
||||
|
||||
const device = addDevice(deviceId);
|
||||
const nodeDB = addNodeDB(deviceId);
|
||||
const messageStore = addMessageStore(deviceId);
|
||||
const meshDevice = new MeshDevice(transport, deviceId);
|
||||
|
||||
setSelectedDevice(deviceId);
|
||||
device.addConnection(meshDevice); // This stores meshDevice in Device.connection
|
||||
subscribeAll(device, meshDevice, messageStore, nodeDB);
|
||||
|
||||
// Store transport locally for cleanup (BT/Serial only)
|
||||
if (btDevice || serialPort) {
|
||||
transports.set(id, btDevice || serialPort);
|
||||
}
|
||||
|
||||
// Set active connection and link device bidirectionally
|
||||
setActiveConnectionId(id);
|
||||
device.setConnectionId(id);
|
||||
|
||||
// Listen for config complete event (with nonce/ID)
|
||||
const unsubConfigComplete = meshDevice.events.onConfigComplete.subscribe(
|
||||
(configCompleteId) => {
|
||||
console.log(`[useConnections] Configuration complete with ID: ${configCompleteId}`);
|
||||
device.setConnectionPhase("configured");
|
||||
updateStatus(id, "configured");
|
||||
|
||||
// Switch from fast config heartbeat to slow maintenance heartbeat
|
||||
const oldHeartbeat = heartbeats.get(id);
|
||||
if (oldHeartbeat) {
|
||||
clearInterval(oldHeartbeat);
|
||||
console.log(`[useConnections] Switching to maintenance heartbeat (5 min interval)`);
|
||||
}
|
||||
|
||||
const maintenanceHeartbeat = setInterval(() => {
|
||||
meshDevice.heartbeat().catch((error) => {
|
||||
console.warn("[useConnections] Heartbeat failed:", error);
|
||||
});
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
heartbeats.set(id, maintenanceHeartbeat);
|
||||
},
|
||||
);
|
||||
configSubscriptions.set(id, unsubConfigComplete);
|
||||
|
||||
// Start configuration
|
||||
device.setConnectionPhase("configuring");
|
||||
updateStatus(id, "configuring");
|
||||
console.log("[useConnections] Starting configuration");
|
||||
|
||||
meshDevice
|
||||
.configure()
|
||||
.then(() => {
|
||||
console.log("[useConnections] Configuration complete, starting heartbeat");
|
||||
// Send initial heartbeat after configure completes
|
||||
meshDevice
|
||||
.heartbeat()
|
||||
.then(() => {
|
||||
// Start fast heartbeat after first successful heartbeat
|
||||
const configHeartbeatId = setInterval(() => {
|
||||
meshDevice.heartbeat().catch((error) => {
|
||||
console.warn("[useConnections] Config heartbeat failed:", error);
|
||||
});
|
||||
}, CONFIG_HEARTBEAT_INTERVAL_MS);
|
||||
heartbeats.set(id, configHeartbeatId);
|
||||
console.log(
|
||||
`[useConnections] Heartbeat started for connection ${id} (5s interval during config)`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[useConnections] Initial heartbeat failed:", error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[useConnections] Failed to configure:`, error);
|
||||
updateStatus(id, "error", error.message);
|
||||
});
|
||||
|
||||
updateSavedConnection(id, { meshDeviceId: deviceId });
|
||||
return deviceId;
|
||||
},
|
||||
[
|
||||
connections,
|
||||
addDevice,
|
||||
addNodeDB,
|
||||
addMessageStore,
|
||||
setSelectedDevice,
|
||||
setActiveConnectionId,
|
||||
updateSavedConnection,
|
||||
updateStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const connect = useCallback(
|
||||
async (id: ConnectionId, opts?: { allowPrompt?: boolean }) => {
|
||||
const conn = connections.find((c) => c.id === id);
|
||||
if (!conn) {
|
||||
return false;
|
||||
}
|
||||
if (conn.status === "configured" || 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);
|
||||
// Status will be set to "configured" by onConfigComplete event
|
||||
return true;
|
||||
}
|
||||
|
||||
if (conn.type === "bluetooth") {
|
||||
if (!("bluetooth" in navigator)) {
|
||||
throw new Error("Web Bluetooth not supported");
|
||||
}
|
||||
let bleDevice = transports.get(id) as BluetoothDevice | undefined;
|
||||
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 (!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.");
|
||||
}
|
||||
|
||||
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
|
||||
setupMeshDevice(id, transport, bleDevice);
|
||||
|
||||
bleDevice.addEventListener("gattserverdisconnected", () => {
|
||||
updateStatus(id, "disconnected");
|
||||
});
|
||||
|
||||
// Status will be set to "configured" by onConfigComplete event
|
||||
return true;
|
||||
}
|
||||
|
||||
if (conn.type === "serial") {
|
||||
if (!("serial" in navigator)) {
|
||||
throw new Error("Web Serial not supported");
|
||||
}
|
||||
let port = transports.get(id) as SerialPort | undefined;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const transport = await TransportWebSerial.createFromPort(port);
|
||||
setupMeshDevice(id, transport, undefined, port);
|
||||
// Status will be set to "configured" by onConfigComplete event
|
||||
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 {
|
||||
// Stop heartbeat
|
||||
const heartbeatId = heartbeats.get(id);
|
||||
if (heartbeatId) {
|
||||
clearInterval(heartbeatId);
|
||||
heartbeats.delete(id);
|
||||
console.log(`[useConnections] Heartbeat stopped for connection ${id}`);
|
||||
}
|
||||
|
||||
// Unsubscribe from config complete event
|
||||
const unsubConfigComplete = configSubscriptions.get(id);
|
||||
if (unsubConfigComplete) {
|
||||
unsubConfigComplete();
|
||||
configSubscriptions.delete(id);
|
||||
console.log(`[useConnections] Config subscription cleaned up for connection ${id}`);
|
||||
}
|
||||
|
||||
// Get device and meshDevice from Device.connection
|
||||
if (conn.meshDeviceId) {
|
||||
const { getDevice } = useDeviceStore.getState();
|
||||
const device = getDevice(conn.meshDeviceId);
|
||||
|
||||
if (device?.connection) {
|
||||
// Disconnect MeshDevice
|
||||
try {
|
||||
device.connection.disconnect();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Close transport connections
|
||||
const transport = transports.get(id);
|
||||
if (transport) {
|
||||
if (conn.type === "bluetooth") {
|
||||
const dev = transport as BluetoothDevice;
|
||||
if (dev.gatt?.connected) {
|
||||
dev.gatt.disconnect();
|
||||
}
|
||||
}
|
||||
if (conn.type === "serial") {
|
||||
const port = transport as SerialPort & {
|
||||
close?: () => Promise<void>;
|
||||
readable?: ReadableStream | null;
|
||||
};
|
||||
if (port.close && port.readable) {
|
||||
try {
|
||||
await port.close();
|
||||
} catch (err) {
|
||||
console.warn("Error closing serial port:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the device's connectionId link
|
||||
if (device) {
|
||||
device.setConnectionId(null);
|
||||
device.setConnectionPhase("disconnected");
|
||||
}
|
||||
}
|
||||
} 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") {
|
||||
transports.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/configured
|
||||
const httpChecks = connections
|
||||
.filter(
|
||||
(c): c is Connection & { type: "http"; url: string } =>
|
||||
c.type === "http" &&
|
||||
c.status !== "connected" &&
|
||||
c.status !== "configured" &&
|
||||
c.status !== "configuring",
|
||||
)
|
||||
.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" &&
|
||||
c.status !== "configured" &&
|
||||
c.status !== "configuring",
|
||||
)
|
||||
.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" &&
|
||||
c.status !== "configured" &&
|
||||
c.status !== "configuring",
|
||||
)
|
||||
.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]);
|
||||
|
||||
const syncConnectionStatuses = useCallback(() => {
|
||||
// Find which connection corresponds to the currently selected device
|
||||
const activeConnection = connections.find((c) => c.meshDeviceId === selectedDeviceId);
|
||||
|
||||
// Update all connection statuses
|
||||
connections.forEach((conn) => {
|
||||
const shouldBeConnected = activeConnection?.id === conn.id;
|
||||
const isConnectedState =
|
||||
conn.status === "connected" ||
|
||||
conn.status === "configured" ||
|
||||
conn.status === "configuring";
|
||||
|
||||
// Update status if it doesn't match reality
|
||||
if (!shouldBeConnected && isConnectedState) {
|
||||
updateSavedConnection(conn.id, { status: "disconnected" });
|
||||
}
|
||||
// Don't force status to "connected" if shouldBeConnected - let the connection flow set the proper status
|
||||
});
|
||||
}, [connections, selectedDeviceId, updateSavedConnection]);
|
||||
|
||||
return {
|
||||
connections,
|
||||
addConnection,
|
||||
addConnectionAndConnect,
|
||||
connect,
|
||||
disconnect,
|
||||
removeConnection,
|
||||
setDefaultConnection,
|
||||
refreshStatuses,
|
||||
syncConnectionStatuses,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
defaultVisibilityState,
|
||||
MapLayerTool,
|
||||
type VisibilityState,
|
||||
} from "@app/components/PageComponents/Map/Tools/MapLayerTool.tsx";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import { type FilterState, useFilterNode } from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { BaseMap } from "@components/Map.tsx";
|
||||
import {
|
||||
HeatmapLayer,
|
||||
type HeatmapMode,
|
||||
} from "@components/PageComponents/Map/Layers/HeatmapLayer.tsx";
|
||||
import { NodesLayer } from "@components/PageComponents/Map/Layers/NodesLayer.tsx";
|
||||
import { PrecisionLayer } from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
|
||||
import {
|
||||
SNRLayer,
|
||||
SNRTooltip,
|
||||
type SNRTooltipProps,
|
||||
} from "@components/PageComponents/Map/Layers/SNRLayer.tsx";
|
||||
import { WaypointLayer } from "@components/PageComponents/Map/Layers/WaypointLayer.tsx";
|
||||
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { useMapFitting } from "@core/hooks/useMapFitting.ts";
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { hasPos, toLngLat } from "@core/utils/geo.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { FunnelIcon, LocateFixedIcon } from "lucide-react";
|
||||
import { useCallback, useDeferredValue, useId, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MapLayerMouseEvent, useMap } from "react-map-gl/maplibre";
|
||||
|
||||
const NODEDB_DEBOUNCE_MS = 250;
|
||||
|
||||
const MapPage = () => {
|
||||
const { t } = useTranslation("map");
|
||||
const { getNode } = useNodeDB();
|
||||
const { nodes: validNodes, myNode } = useNodeDB(
|
||||
(db) => ({
|
||||
// only nodes with a position
|
||||
nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo => Boolean(n.position?.latitudeI)),
|
||||
myNode: db.getMyNode(),
|
||||
|
||||
// References to cause re-render on change
|
||||
_errorsRef: db.nodeErrors,
|
||||
_nodeNumRef: db.myNodeNum,
|
||||
}),
|
||||
{ debounce: NODEDB_DEBOUNCE_MS },
|
||||
);
|
||||
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
||||
const { default: mapRef } = useMap();
|
||||
const { focusLngLat, fitToNodes } = useMapFitting(mapRef);
|
||||
|
||||
const hasFitBoundsOnce = useRef(false);
|
||||
const [snrHover, setSnrHover] = useState<SNRTooltipProps>();
|
||||
const [expandedCluster, setExpandedCluster] = useState<string | undefined>();
|
||||
const [popupState, setPopupState] = useState<PopupState | undefined>();
|
||||
|
||||
const [visibilityState, setVisibilityState] = useState<VisibilityState>(
|
||||
() => defaultVisibilityState,
|
||||
);
|
||||
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>("density");
|
||||
|
||||
// Filters
|
||||
const [filterState, setFilterState] = useState<FilterState>(() => defaultFilterValues);
|
||||
const deferredFilterState = useDeferredValue(filterState);
|
||||
|
||||
const filteredNodes = useMemo(
|
||||
() => validNodes.filter((node) => nodeFilter(node, deferredFilterState)),
|
||||
[validNodes, deferredFilterState, nodeFilter],
|
||||
);
|
||||
|
||||
// Map fitting
|
||||
const getMapBounds = useCallback(() => {
|
||||
if (!hasFitBoundsOnce.current) {
|
||||
fitToNodes(validNodes);
|
||||
hasFitBoundsOnce.current = true;
|
||||
}
|
||||
}, [fitToNodes, validNodes]);
|
||||
|
||||
// SNR lines
|
||||
const snrLayerElementId = useId();
|
||||
const snrLayerElement = useMemo(
|
||||
() => (
|
||||
<SNRLayer
|
||||
id={snrLayerElementId}
|
||||
filteredNodes={filteredNodes}
|
||||
myNode={myNode}
|
||||
visibilityState={visibilityState}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, myNode, visibilityState, snrLayerElementId],
|
||||
);
|
||||
|
||||
// Heatmap
|
||||
const heatmapLayerElementId = useId();
|
||||
const heatmapLayerElement = useMemo(
|
||||
() => (
|
||||
<HeatmapLayer
|
||||
id={heatmapLayerElementId}
|
||||
filteredNodes={filteredNodes}
|
||||
isVisible={visibilityState.heatmap}
|
||||
mode={heatmapMode}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, visibilityState.heatmap, heatmapMode, heatmapLayerElementId],
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MapLayerMouseEvent) => {
|
||||
const {
|
||||
features,
|
||||
point: { x, y },
|
||||
} = event;
|
||||
const hoveredFeature = features?.[0];
|
||||
|
||||
if (hoveredFeature) {
|
||||
const { from, to, snr, name, shortName, num } = hoveredFeature.properties;
|
||||
|
||||
// Handle Heatmap Hover
|
||||
if (
|
||||
hoveredFeature.layer.id === `${heatmapLayerElementId}-interaction` &&
|
||||
name !== undefined
|
||||
) {
|
||||
setSnrHover({
|
||||
pos: { x, y },
|
||||
snr: snr, // Single node SNR
|
||||
from:
|
||||
name ||
|
||||
shortName ||
|
||||
t("fallbackName", {
|
||||
last4: numberToHexUnpadded(num).slice(-4).toUpperCase(),
|
||||
}),
|
||||
to: undefined, // Single node
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle SNR Line Hover
|
||||
const fromLong =
|
||||
getNode(from)?.user?.longName ??
|
||||
t("fallbackName", {
|
||||
last4: numberToHexUnpadded(from).slice(-4).toUpperCase(),
|
||||
});
|
||||
|
||||
const toLong =
|
||||
getNode(to)?.user?.longName ??
|
||||
t("fallbackName", {
|
||||
last4: numberToHexUnpadded(to).slice(-4).toUpperCase(),
|
||||
});
|
||||
|
||||
setSnrHover({ pos: { x, y }, snr, from: fromLong, to: toLong });
|
||||
} else {
|
||||
setSnrHover(undefined);
|
||||
}
|
||||
},
|
||||
[getNode, t, heatmapLayerElementId],
|
||||
);
|
||||
|
||||
// Node markers & clusters
|
||||
const onMapBackgroundClick = useCallback(() => {
|
||||
setExpandedCluster(undefined);
|
||||
}, []);
|
||||
|
||||
const markerElements = useMemo(
|
||||
() => (
|
||||
<NodesLayer
|
||||
mapRef={mapRef}
|
||||
filteredNodes={filteredNodes}
|
||||
myNode={myNode}
|
||||
expandedCluster={expandedCluster}
|
||||
setExpandedCluster={setExpandedCluster}
|
||||
popupState={popupState}
|
||||
setPopupState={setPopupState}
|
||||
isVisible={visibilityState.nodeMarkers}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, expandedCluster, mapRef, myNode, popupState, visibilityState.nodeMarkers],
|
||||
);
|
||||
|
||||
// Precision circles
|
||||
const precisionCirclesElementId = useId();
|
||||
const precisionCirclesElement = useMemo(
|
||||
() => (
|
||||
<PrecisionLayer
|
||||
id={precisionCirclesElementId}
|
||||
filteredNodes={filteredNodes}
|
||||
isVisible={visibilityState.positionPrecision}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, visibilityState.positionPrecision, precisionCirclesElementId],
|
||||
);
|
||||
|
||||
// Waypoints
|
||||
const waypointLayerElement = useMemo(
|
||||
() => (
|
||||
<WaypointLayer
|
||||
mapRef={mapRef}
|
||||
myNode={myNode}
|
||||
isVisible={visibilityState.waypoints}
|
||||
popupState={popupState}
|
||||
setPopupState={setPopupState}
|
||||
/>
|
||||
),
|
||||
[mapRef, myNode, visibilityState.waypoints, popupState],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
|
||||
<BaseMap
|
||||
onLoad={getMapBounds}
|
||||
onMouseMove={onMouseMove}
|
||||
onClick={onMapBackgroundClick}
|
||||
interactiveLayerIds={[snrLayerElementId, `${heatmapLayerElementId}-interaction`]}
|
||||
>
|
||||
{heatmapLayerElement}
|
||||
{markerElements}
|
||||
{snrLayerElement}
|
||||
{precisionCirclesElement}
|
||||
{waypointLayerElement}
|
||||
|
||||
{snrHover && (
|
||||
<SNRTooltip pos={snrHover.pos} snr={snrHover.snr} from={snrHover.from} to={snrHover.to} />
|
||||
)}
|
||||
</BaseMap>
|
||||
<div className="flex flex-col space-y-1 fixed top-35 right-2.5">
|
||||
{myNode && hasPos(myNode?.position) && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded align-center",
|
||||
"w-[29px] px-1 py-1 shadow-l outline-[2px] outline-stone-600/20",
|
||||
"bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 ",
|
||||
"text-slate-600 hover:text-slate-700",
|
||||
"dark:text-slate-600 hover:dark:text-slate-700",
|
||||
)}
|
||||
aria-label={t("mapMenu.locateAria")}
|
||||
onClick={() => focusLngLat(toLngLat(myNode.position))}
|
||||
>
|
||||
{" "}
|
||||
<LocateFixedIcon className="w-[21px]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 7,
|
||||
},
|
||||
popoverTriggerClassName: cn(
|
||||
"w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
|
||||
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 dark:active:bg-stone-300",
|
||||
isFilterDirty(filterState)
|
||||
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
|
||||
: "",
|
||||
),
|
||||
triggerIcon: <FunnelIcon className="w-[21px]" />,
|
||||
showTextSearch: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<MapLayerTool
|
||||
visibilityState={visibilityState}
|
||||
setVisibilityState={setVisibilityState}
|
||||
heatmapMode={heatmapMode}
|
||||
setHeatmapMode={setHeatmapMode}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapPage;
|
||||
@@ -0,0 +1,321 @@
|
||||
import { messagesWithParamsRoute } from "@app/routes.tsx";
|
||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import {
|
||||
MessageState,
|
||||
MessageType,
|
||||
useDevice,
|
||||
useMessages,
|
||||
useNodeDB,
|
||||
useSidebar,
|
||||
} from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { randId } from "@core/utils/randId.ts";
|
||||
import { Protobuf, Types } from "@meshtastic/core";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getChannelName } from "../components/PageComponents/Channels/Channels.tsx";
|
||||
|
||||
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
|
||||
|
||||
function SelectMessageChat() {
|
||||
const { t } = useTranslation("messages");
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
|
||||
{t("selectChatPrompt.text", { ns: "messages" })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MessagesPage = () => {
|
||||
const { channels, getUnreadCount, resetUnread, connection } = useDevice();
|
||||
const { getNodes, getNode, getMyNode, hasNodeError } = useNodeDB();
|
||||
|
||||
const { getMessages, setMessageState } = useMessages();
|
||||
|
||||
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { isCollapsed } = useSidebar();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const { t } = useTranslation(["messages", "channels", "ui"]);
|
||||
const deferredSearch = useDeferredValue(searchTerm);
|
||||
|
||||
const navigateToChat = useCallback(
|
||||
(type: MessageType, id: string) => {
|
||||
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
|
||||
navigate({ to: `/messages/${typeParam}/${id}` });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const chatType = type === "direct" ? MessageType.Direct : MessageType.Broadcast;
|
||||
const numericChatId = Number(chatId);
|
||||
|
||||
const allChannels = Array.from(channels.values());
|
||||
const filteredChannels = allChannels.filter(
|
||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!type && !chatId && filteredChannels.length > 0) {
|
||||
const defaultChannel = filteredChannels[0];
|
||||
navigateToChat(MessageType.Broadcast, defaultChannel?.index.toString() ?? "0");
|
||||
}
|
||||
}, [type, chatId, filteredChannels, navigateToChat]);
|
||||
|
||||
const currentChannel = channels.get(numericChatId);
|
||||
const otherNode = getNode(numericChatId);
|
||||
|
||||
const isDirect = chatType === MessageType.Direct;
|
||||
const isBroadcast = chatType === MessageType.Broadcast;
|
||||
|
||||
const filteredNodes = useCallback((): NodeInfoWithUnread[] => {
|
||||
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
|
||||
|
||||
return getNodes((node: Protobuf.Mesh.NodeInfo) => {
|
||||
const longName = node.user?.longName?.toLowerCase() ?? "";
|
||||
const shortName = node.user?.shortName?.toLowerCase() ?? "";
|
||||
return longName.includes(lowerCaseSearchTerm) || shortName.includes(lowerCaseSearchTerm);
|
||||
}, true)
|
||||
.map((node: Protobuf.Mesh.NodeInfo) => ({
|
||||
...node,
|
||||
unreadCount: getUnreadCount(node.num) ?? 0,
|
||||
}))
|
||||
.sort((a: NodeInfoWithUnread, b: NodeInfoWithUnread) => {
|
||||
const diff = b.unreadCount - a.unreadCount;
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
return Number(b.isFavorite) - Number(a.isFavorite);
|
||||
});
|
||||
}, [deferredSearch, getNodes, getUnreadCount]);
|
||||
|
||||
const sendText = useCallback(
|
||||
async (message: string) => {
|
||||
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
|
||||
const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
|
||||
|
||||
let messageId: number | undefined;
|
||||
|
||||
try {
|
||||
messageId = await connection?.sendText(message, toValue, true, channelValue);
|
||||
if (messageId !== undefined) {
|
||||
if (chatType === MessageType.Broadcast) {
|
||||
setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelValue,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
} else {
|
||||
setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNode().num,
|
||||
nodeB: numericChatId,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("sendText completed but messageId is undefined");
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to send message:", e);
|
||||
const failedId = messageId ?? randId();
|
||||
if (chatType === MessageType.Broadcast) {
|
||||
setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelValue,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
} else {
|
||||
setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNode().num,
|
||||
nodeB: numericChatId,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[numericChatId, chatType, connection, getMyNode, setMessageState, isDirect],
|
||||
);
|
||||
|
||||
const renderChatContent = () => {
|
||||
switch (chatType) {
|
||||
case MessageType.Broadcast:
|
||||
return (
|
||||
<ChannelChat
|
||||
messages={getMessages({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: numericChatId,
|
||||
}).reverse()}
|
||||
/>
|
||||
);
|
||||
case MessageType.Direct:
|
||||
return (
|
||||
<ChannelChat
|
||||
messages={getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNode().num,
|
||||
nodeB: numericChatId,
|
||||
}).reverse()}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <SelectMessageChat />;
|
||||
}
|
||||
};
|
||||
|
||||
const leftSidebar = useMemo(
|
||||
() => (
|
||||
<Sidebar>
|
||||
<SidebarSection label={t("navigation.channels")} className="py-2 px-0">
|
||||
{filteredChannels?.map((channel) => (
|
||||
<SidebarButton
|
||||
key={channel.index}
|
||||
count={getUnreadCount(channel.index)}
|
||||
label={
|
||||
channel.settings?.name ||
|
||||
(channel.index === 0
|
||||
? t("page.broadcastLabel", { ns: "channels" })
|
||||
: t("page.channelLabel", {
|
||||
index: channel.index,
|
||||
ns: "channels",
|
||||
}))
|
||||
}
|
||||
active={numericChatId === channel.index && chatType === MessageType.Broadcast}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Broadcast, channel.index.toString());
|
||||
resetUnread(channel.index);
|
||||
}}
|
||||
>
|
||||
<HashIcon size={16} className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")} />
|
||||
</SidebarButton>
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
),
|
||||
[
|
||||
filteredChannels,
|
||||
numericChatId,
|
||||
chatType,
|
||||
isCollapsed,
|
||||
getUnreadCount,
|
||||
navigateToChat,
|
||||
resetUnread,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const rightSidebar = (
|
||||
<SidebarSection label="" className="px-0 flex flex-col h-full overflow-y-auto">
|
||||
<label className="p-2 block" htmlFor="nodeSearch">
|
||||
<Input
|
||||
type="text"
|
||||
name="nodeSearch"
|
||||
placeholder={t("search.nodes")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
showClearButton={!!searchTerm}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className={cn("flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ")}
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "100px" }}
|
||||
>
|
||||
{filteredNodes()?.map((node) => (
|
||||
<SidebarButton
|
||||
key={node.num}
|
||||
preventCollapse
|
||||
label={node.user?.longName ?? t("unknown.shortName")}
|
||||
count={node.unreadCount > 0 ? node.unreadCount : undefined}
|
||||
active={numericChatId === node.num && chatType === MessageType.Direct}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Direct, node.num.toString());
|
||||
resetUnread(node.num);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
nodeNum={node.num}
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||
showError={hasNodeError(node.num)}
|
||||
showFavorite={node.isFavorite}
|
||||
size="sm"
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
label={`${t("page.title", {
|
||||
interpolation: { escapeValue: false },
|
||||
chatName:
|
||||
isBroadcast && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: isDirect && otherNode
|
||||
? (otherNode.user?.longName ?? t("unknown.longName"))
|
||||
: t("emptyState.title"),
|
||||
})}
|
||||
`}
|
||||
rightBar={rightSidebar}
|
||||
leftBar={leftSidebar}
|
||||
actions={
|
||||
isDirect && otherNode
|
||||
? [
|
||||
{
|
||||
key: "encryption",
|
||||
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
|
||||
iconClasses: otherNode.user?.publicKey?.length
|
||||
? "text-green-600"
|
||||
: "text-yellow-300",
|
||||
onClick() {
|
||||
toast({
|
||||
title: otherNode.user?.publicKey?.length
|
||||
? t("toast.messages.pkiEncryption.title")
|
||||
: t("toast.messages.pskEncryption.title"),
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{renderChatContent()}
|
||||
|
||||
<div className="flex-none dark:bg-slate-900 p-2">
|
||||
{isBroadcast || isDirect ? (
|
||||
<MessageInput
|
||||
to={isDirect ? numericChatId : MessageType.Broadcast}
|
||||
onSend={sendText}
|
||||
maxBytes={200}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-slate-400 italic">
|
||||
{t("sendMessage.sendButton", { ns: "messages" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesPage;
|
||||
@@ -0,0 +1,282 @@
|
||||
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
|
||||
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import { type FilterState, useFilterNode } from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { type DataRow, type Heading, Table } from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import useLang from "@core/hooks/useLang.ts";
|
||||
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { type JSX, useCallback, useDeferredValue, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { base16 } from "rfc4648";
|
||||
|
||||
const NODEDB_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface DeleteNoteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const NodesPage = (): JSX.Element => {
|
||||
const { t } = useTranslation("nodes");
|
||||
const { current } = useLang();
|
||||
const { hardware, connection, setDialogOpen } = useDevice();
|
||||
|
||||
const { setNodeNumDetails } = useAppStore();
|
||||
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
||||
|
||||
const [selectedTraceroute, setSelectedTraceroute] = useState<
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
|
||||
>();
|
||||
const [selectedLocation, setSelectedLocation] = useState<
|
||||
Types.PacketMetadata<Protobuf.Mesh.Position> | undefined
|
||||
>();
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(() => defaultFilterValues);
|
||||
const deferredFilterState = useDeferredValue(filterState);
|
||||
|
||||
// stable predicate so the selector identity doesn’t thrash
|
||||
const predicate = useCallback(
|
||||
(node: Protobuf.Mesh.NodeInfo) => nodeFilter(node, deferredFilterState),
|
||||
[nodeFilter, deferredFilterState],
|
||||
);
|
||||
|
||||
// subscribe to actual data (nodes array) and to nodeErrors ref for badge updates
|
||||
const { nodes: filteredNodes, hasNodeError } = useNodeDB(
|
||||
(db) => ({
|
||||
nodes: db.getNodes(predicate, true),
|
||||
hasNodeError: db.hasNodeError,
|
||||
_errorsRef: db.nodeErrors, // include the Map ref so UI also re-renders on error changes
|
||||
}),
|
||||
{ debounce: NODEDB_DEBOUNCE_MS },
|
||||
);
|
||||
const handleTraceroute = useCallback(
|
||||
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => {
|
||||
setSelectedTraceroute(traceroute);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLocation = useCallback(
|
||||
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
|
||||
if (
|
||||
location.to.valueOf() !== hardware.myNodeNum ||
|
||||
location.from.valueOf() === hardware.myNodeNum
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSelectedLocation(location);
|
||||
},
|
||||
[hardware.myNodeNum],
|
||||
);
|
||||
|
||||
function handleNodeInfoDialog(nodeNum: number): void {
|
||||
setNodeNumDetails(nodeNum);
|
||||
setDialogOpen("nodeDetails", true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
connection.events.onTraceRoutePacket.subscribe(handleTraceroute);
|
||||
return () => {
|
||||
connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute);
|
||||
};
|
||||
}, [connection, handleTraceroute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
connection.events.onPositionPacket.subscribe(handleLocation);
|
||||
return () => {
|
||||
connection.events.onPositionPacket.unsubscribe(handleLocation);
|
||||
};
|
||||
}, [connection, handleLocation]);
|
||||
|
||||
const tableHeadings: Heading[] = [
|
||||
{ title: "", sortable: false },
|
||||
{ title: t("nodesTable.headings.longName"), sortable: true },
|
||||
{ title: t("nodesTable.headings.connection"), sortable: true },
|
||||
{ title: t("nodesTable.headings.lastHeard"), sortable: true },
|
||||
{ title: t("nodesTable.headings.encryption"), sortable: false },
|
||||
{ title: t("unit.snr"), sortable: true },
|
||||
{ title: t("nodesTable.headings.model"), sortable: true },
|
||||
{ title: t("nodesTable.headings.macAddress"), sortable: true },
|
||||
];
|
||||
|
||||
const tableRows: DataRow[] = filteredNodes.map((node) => {
|
||||
const macAddress =
|
||||
base16
|
||||
.stringify(node.user?.macaddr ?? [])
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? t("unknown.shortName");
|
||||
|
||||
const shortName = node.user?.shortName ?? numberToHexUnpadded(node.num).slice(-4).toUpperCase();
|
||||
const longName =
|
||||
node.user?.longName ??
|
||||
t("fallbackName", {
|
||||
last4: shortName,
|
||||
});
|
||||
|
||||
// SNR is reported in dB. Map it to a 0–100% signal-quality heuristic
|
||||
// (-10 dB → 0%, +10 dB → 100%) for an at-a-glance read, and pick a tone.
|
||||
const snr = node.snr ?? 0;
|
||||
const snrQuality = Math.min(Math.max(Math.round((snr + 10) * 5), 0), 100);
|
||||
const snrTone =
|
||||
snrQuality >= 67 ? "text-green-500" : snrQuality >= 34 ? "text-yellow-500" : "text-red-500";
|
||||
|
||||
return {
|
||||
id: node.num,
|
||||
isFavorite: node.isFavorite,
|
||||
cells: [
|
||||
{
|
||||
content: (
|
||||
<Avatar
|
||||
nodeNum={node.num}
|
||||
showFavorite={node.isFavorite}
|
||||
showError={hasNodeError(node.num)}
|
||||
/>
|
||||
),
|
||||
sortValue: shortName, // Non-sortable column
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<h1
|
||||
onMouseDown={() => handleNodeInfoDialog(node.num)}
|
||||
onKeyUp={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
handleNodeInfoDialog(node.num);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer underline ml-2 whitespace-break-spaces"
|
||||
>
|
||||
{longName}
|
||||
</h1>
|
||||
),
|
||||
sortValue: longName,
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono className="w-16">
|
||||
{node.hopsAway !== undefined
|
||||
? node?.viaMqtt === false && node.hopsAway === 0
|
||||
? t("nodesTable.connectionStatus.direct")
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
(node.hopsAway ?? 0) > 1 ? t("unit.hop.plural") : t("unit.hop.one")
|
||||
} ${t("nodesTable.connectionStatus.away")}`
|
||||
: t("unknown.longName")}
|
||||
{node?.viaMqtt === true ? t("nodesTable.connectionStatus.viaMqtt") : ""}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{node.lastHeard === 0 ? (
|
||||
t("unknown.longName")
|
||||
) : (
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} locale={current?.code} />
|
||||
)}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.lastHeard,
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||
<LockIcon className="text-green-600 mx-auto" />
|
||||
) : (
|
||||
<LockOpenIcon className="text-yellow-300 mx-auto" />
|
||||
)}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: "", // Non-sortable column
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono className="flex items-baseline gap-1.5">
|
||||
<span className={snrTone}>
|
||||
{Number(snr.toFixed(1))} {t("unit.db")}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400/70">{snrQuality}%</span>
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.snr,
|
||||
},
|
||||
{
|
||||
content: <Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>,
|
||||
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? "UNSET",
|
||||
},
|
||||
{
|
||||
content: <Mono>{macAddress}</Mono>,
|
||||
sortValue: macAddress,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<PageLayout label="" leftBar={<Sidebar />}>
|
||||
<div className="pl-2 pt-2 flex flex-row">
|
||||
<div className="flex-1 mr-2">
|
||||
<Input
|
||||
placeholder={t("search.nodes")}
|
||||
value={filterState.nodeName}
|
||||
className="bg-transparent"
|
||||
showClearButton={!!filterState.nodeName}
|
||||
onChange={(e) =>
|
||||
setFilterState((prev) => ({
|
||||
...prev,
|
||||
nodeName: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 12,
|
||||
},
|
||||
popoverTriggerClassName: "mr-1 p-2",
|
||||
showTextSearch: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
<Table headings={tableHeadings} rows={tableRows} />
|
||||
<TracerouteResponseDialog
|
||||
traceroute={selectedTraceroute}
|
||||
open={!!selectedTraceroute}
|
||||
onOpenChange={() => setSelectedTraceroute(undefined)}
|
||||
/>
|
||||
<LocationResponseDialog
|
||||
location={selectedLocation}
|
||||
open={!!selectedLocation}
|
||||
onOpenChange={() => setSelectedLocation(undefined)}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodesPage;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Bluetooth } from "@components/PageComponents/Settings/Bluetooth.tsx";
|
||||
import { Device } from "@components/PageComponents/Settings/Device/index.tsx";
|
||||
import { Display } from "@components/PageComponents/Settings/Display.tsx";
|
||||
import { Network } from "@components/PageComponents/Settings/Network/index.tsx";
|
||||
import { Position } from "@components/PageComponents/Settings/Position.tsx";
|
||||
import { Power } from "@components/PageComponents/Settings/Power.tsx";
|
||||
import { User } from "@components/PageComponents/Settings/User.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import { useDevice, type ValidConfigType } from "@core/stores";
|
||||
import { type ComponentType, Suspense, useMemo } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfigProps {
|
||||
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
|
||||
}
|
||||
|
||||
type TabItem = {
|
||||
case: ValidConfigType | "user";
|
||||
label: string;
|
||||
element: ComponentType<ConfigProps>;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
const { hasConfigChange, hasUserChange } = useDevice();
|
||||
const { t } = useTranslation("config");
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
case: "user",
|
||||
label: t("page.tabUser"),
|
||||
element: User,
|
||||
},
|
||||
{
|
||||
case: "device",
|
||||
label: t("page.tabDevice"),
|
||||
element: Device,
|
||||
},
|
||||
{
|
||||
case: "position",
|
||||
label: t("page.tabPosition"),
|
||||
element: Position,
|
||||
},
|
||||
{
|
||||
case: "power",
|
||||
label: t("page.tabPower"),
|
||||
element: Power,
|
||||
},
|
||||
{
|
||||
case: "network",
|
||||
label: t("page.tabNetwork"),
|
||||
element: Network,
|
||||
},
|
||||
{
|
||||
case: "display",
|
||||
label: t("page.tabDisplay"),
|
||||
element: Display,
|
||||
},
|
||||
{
|
||||
case: "bluetooth",
|
||||
label: t("page.tabBluetooth"),
|
||||
element: Bluetooth,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const flags = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
tabs.map((tab) => [
|
||||
tab.case,
|
||||
tab.case === "user" ? hasUserChange() : hasConfigChange(tab.case as ValidConfigType),
|
||||
]),
|
||||
),
|
||||
[tabs, hasConfigChange, hasUserChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={t("page.tabUser")}>
|
||||
<TabsList className="w-full dark:bg-slate-700">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.label} value={tab.label} className="dark:text-white relative">
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { AmbientLighting } from "@components/PageComponents/ModuleConfig/AmbientLighting.tsx";
|
||||
import { Audio } from "@components/PageComponents/ModuleConfig/Audio.tsx";
|
||||
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.tsx";
|
||||
import { DetectionSensor } from "@components/PageComponents/ModuleConfig/DetectionSensor.tsx";
|
||||
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.tsx";
|
||||
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.tsx";
|
||||
import { NeighborInfo } from "@components/PageComponents/ModuleConfig/NeighborInfo.tsx";
|
||||
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.tsx";
|
||||
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.tsx";
|
||||
import { RemoteHardware } from "@components/PageComponents/ModuleConfig/RemoteHardware.tsx";
|
||||
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.tsx";
|
||||
import { StatusMessage } from "@components/PageComponents/ModuleConfig/StatusMessage.tsx";
|
||||
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.tsx";
|
||||
import { Tak } from "@components/PageComponents/ModuleConfig/Tak.tsx";
|
||||
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx";
|
||||
import { TrafficManagement } from "@components/PageComponents/ModuleConfig/TrafficManagement.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import { useDevice, type ValidModuleConfigType } from "@core/stores";
|
||||
import { type ComponentType, Suspense, useMemo } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfigProps {
|
||||
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
|
||||
}
|
||||
|
||||
type TabItem = {
|
||||
case: ValidModuleConfigType;
|
||||
label: string;
|
||||
element: ComponentType<ConfigProps>;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
||||
const { hasModuleConfigChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
case: "mqtt",
|
||||
label: t("page.tabMqtt"),
|
||||
element: MQTT,
|
||||
},
|
||||
{
|
||||
case: "serial",
|
||||
label: t("page.tabSerial"),
|
||||
element: Serial,
|
||||
},
|
||||
{
|
||||
case: "externalNotification",
|
||||
label: t("page.tabExternalNotification"),
|
||||
element: ExternalNotification,
|
||||
},
|
||||
{
|
||||
case: "storeForward",
|
||||
label: t("page.tabStoreAndForward"),
|
||||
element: StoreForward,
|
||||
},
|
||||
{
|
||||
case: "rangeTest",
|
||||
label: t("page.tabRangeTest"),
|
||||
element: RangeTest,
|
||||
},
|
||||
{
|
||||
case: "telemetry",
|
||||
label: t("page.tabTelemetry"),
|
||||
element: Telemetry,
|
||||
},
|
||||
{
|
||||
case: "cannedMessage",
|
||||
label: t("page.tabCannedMessage"),
|
||||
element: CannedMessage,
|
||||
},
|
||||
{
|
||||
case: "audio",
|
||||
label: t("page.tabAudio"),
|
||||
element: Audio,
|
||||
},
|
||||
{
|
||||
case: "neighborInfo",
|
||||
label: t("page.tabNeighborInfo"),
|
||||
element: NeighborInfo,
|
||||
},
|
||||
{
|
||||
case: "ambientLighting",
|
||||
label: t("page.tabAmbientLighting"),
|
||||
element: AmbientLighting,
|
||||
},
|
||||
{
|
||||
case: "detectionSensor",
|
||||
label: t("page.tabDetectionSensor"),
|
||||
element: DetectionSensor,
|
||||
},
|
||||
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
|
||||
{
|
||||
case: "remoteHardware",
|
||||
label: t("page.tabRemoteHardware"),
|
||||
element: RemoteHardware,
|
||||
},
|
||||
{
|
||||
case: "trafficManagement",
|
||||
label: t("page.tabTrafficManagement"),
|
||||
element: TrafficManagement,
|
||||
},
|
||||
{
|
||||
case: "statusmessage",
|
||||
label: t("page.tabStatusMessage"),
|
||||
element: StatusMessage,
|
||||
},
|
||||
{ case: "tak", label: t("page.tabTak"), element: Tak },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const flags = useMemo(
|
||||
() => new Map(tabs.map((tab) => [tab.case, hasModuleConfigChange(tab.case)])),
|
||||
[tabs, hasModuleConfigChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={t("page.tabMqtt")}>
|
||||
<TabsList className="w-full dark:bg-slate-800">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.label} value={tab.label} className="dark:text-white relative">
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Channels } from "@app/components/PageComponents/Channels/Channels";
|
||||
import { LoRa } from "@components/PageComponents/Settings/LoRa.tsx";
|
||||
import { Security } from "@components/PageComponents/Settings/Security/Security.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import { useDevice, type ValidConfigType } from "@core/stores";
|
||||
import { type ComponentType, Suspense, useMemo } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfigProps {
|
||||
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
|
||||
}
|
||||
|
||||
type TabItem = {
|
||||
case: ValidConfigType | "channels";
|
||||
label: string;
|
||||
element: ComponentType<ConfigProps>;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export const RadioConfig = ({ onFormInit }: ConfigProps) => {
|
||||
const { hasConfigChange } = useDevice();
|
||||
const { t } = useTranslation("config");
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
case: "lora",
|
||||
label: t("page.tabLora"),
|
||||
element: LoRa,
|
||||
},
|
||||
{
|
||||
case: "channels",
|
||||
label: t("page.tabChannels"),
|
||||
element: Channels,
|
||||
},
|
||||
{
|
||||
case: "security",
|
||||
label: t("page.tabSecurity"),
|
||||
element: Security,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const flags = useMemo(
|
||||
() =>
|
||||
new Map(tabs.map((tab) => [tab.case, tab.case !== "channels" && hasConfigChange(tab.case)])),
|
||||
[tabs, hasConfigChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={t("page.tabLora")}>
|
||||
<TabsList className="w-full dark:bg-slate-700">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.label} value={tab.label} className="dark:text-white relative">
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
|
||||
import { toBinary } from "@bufbuild/protobuf";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
|
||||
import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import {
|
||||
LayersIcon,
|
||||
RadioTowerIcon,
|
||||
RefreshCwIcon,
|
||||
RouterIcon,
|
||||
SaveIcon,
|
||||
SaveOff,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FieldValues, UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RadioConfig } from "./RadioConfig.tsx";
|
||||
|
||||
const ConfigPage = () => {
|
||||
const {
|
||||
getAllConfigChanges,
|
||||
getAllModuleConfigChanges,
|
||||
getAllChannelChanges,
|
||||
getAllQueuedAdminMessages,
|
||||
connection,
|
||||
clearAllChanges,
|
||||
setConfig,
|
||||
setModuleConfig,
|
||||
addChannel,
|
||||
getConfigChangeCount,
|
||||
getModuleConfigChangeCount,
|
||||
getChannelChangeCount,
|
||||
getAdminMessageChangeCount,
|
||||
} = useDevice();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
|
||||
const unsubRef = useRef<(() => void) | null>(null);
|
||||
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const configChangeCount = getConfigChangeCount();
|
||||
const moduleConfigChangeCount = getModuleConfigChangeCount();
|
||||
const channelChangeCount = getChannelChangeCount();
|
||||
const adminMessageChangeCount = getAdminMessageChangeCount();
|
||||
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "radio",
|
||||
route: radioRoute,
|
||||
label: t("navigation.radioConfig"),
|
||||
icon: RadioTowerIcon,
|
||||
changeCount: configChangeCount,
|
||||
component: RadioConfig,
|
||||
},
|
||||
{
|
||||
key: "device",
|
||||
route: deviceRoute,
|
||||
label: t("navigation.deviceConfig"),
|
||||
icon: RouterIcon,
|
||||
changeCount: moduleConfigChangeCount,
|
||||
component: DeviceConfig,
|
||||
},
|
||||
{
|
||||
key: "module",
|
||||
route: moduleRoute,
|
||||
label: t("navigation.moduleConfig"),
|
||||
icon: LayersIcon,
|
||||
changeCount: channelChangeCount,
|
||||
component: ModuleConfig,
|
||||
},
|
||||
],
|
||||
[t, configChangeCount, moduleConfigChangeCount, channelChangeCount],
|
||||
);
|
||||
|
||||
const activeSection =
|
||||
sections.find((section) =>
|
||||
routerState.location.pathname.includes(`/settings/${section.key}`),
|
||||
) ?? sections[0];
|
||||
|
||||
const onFormInit = useCallback(<T extends FieldValues>(methods: UseFormReturn<T>) => {
|
||||
setFormMethods(methods as UseFormReturn);
|
||||
|
||||
setRhfState({
|
||||
// Assume defailt on init, changes will be caught by subscription
|
||||
isDirty: false,
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
// Unsubscribe from previous subscriptions & subscribe to form changes
|
||||
unsubRef.current?.();
|
||||
unsubRef.current = methods.subscribe({
|
||||
formState: { isDirty: true, isValid: true },
|
||||
callback: ({ isValid, isDirty }) => {
|
||||
setRhfState({
|
||||
isDirty: isDirty ?? false,
|
||||
isValid: isValid ?? true,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => unsubRef.current?.();
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const channelChanges = getAllChannelChanges();
|
||||
const configChanges = getAllConfigChanges();
|
||||
const moduleConfigChanges = getAllModuleConfigChanges();
|
||||
const adminMessages = getAllQueuedAdminMessages();
|
||||
|
||||
await Promise.all(
|
||||
channelChanges.map((channel) =>
|
||||
connection?.setChannel(channel).then(() => {
|
||||
toast({
|
||||
title: t("toast.savedChannel.title", {
|
||||
ns: "ui",
|
||||
channelName: channel.settings?.name,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
configChanges.map((newConfig) =>
|
||||
connection?.setConfig(newConfig).then(() => {
|
||||
toast({
|
||||
title: t("toast.saveSuccess.title"),
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: newConfig.payloadVariant.case,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
moduleConfigChanges.map((newModuleConfig) =>
|
||||
connection?.setModuleConfig(newModuleConfig).then(() =>
|
||||
toast({
|
||||
title: t("toast.saveSuccess.title"),
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: newModuleConfig.payloadVariant.case,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (configChanges.length > 0 || moduleConfigChanges.length > 0) {
|
||||
await connection?.commitEditSettings();
|
||||
}
|
||||
|
||||
// Send queued admin messages after configs are committed
|
||||
if (adminMessages.length > 0) {
|
||||
await Promise.all(
|
||||
adminMessages.map((message) =>
|
||||
connection?.sendPacket(
|
||||
toBinary(Protobuf.Admin.AdminMessageSchema, message),
|
||||
Protobuf.Portnums.PortNum.ADMIN_APP,
|
||||
"self",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
channelChanges.forEach((newChannel) => {
|
||||
addChannel(newChannel);
|
||||
});
|
||||
configChanges.forEach((newConfig) => {
|
||||
setConfig(newConfig);
|
||||
});
|
||||
moduleConfigChanges.forEach((newModuleConfig) => {
|
||||
setModuleConfig(newModuleConfig);
|
||||
});
|
||||
|
||||
clearAllChanges();
|
||||
|
||||
if (formMethods) {
|
||||
formMethods.reset(formMethods.getValues(), {
|
||||
keepDirty: false,
|
||||
keepErrors: false,
|
||||
keepTouched: false,
|
||||
keepValues: true,
|
||||
});
|
||||
|
||||
formMethods.trigger();
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: t("toast.configSaveError.title"),
|
||||
description: t("toast.configSaveError.description"),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
toast({
|
||||
title: t("toast.saveAllSuccess.title"),
|
||||
description: t("toast.saveAllSuccess.description"),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
toast,
|
||||
t,
|
||||
getAllConfigChanges,
|
||||
connection,
|
||||
getAllModuleConfigChanges,
|
||||
getAllChannelChanges,
|
||||
getAllQueuedAdminMessages,
|
||||
formMethods,
|
||||
addChannel,
|
||||
setConfig,
|
||||
setModuleConfig,
|
||||
clearAllChanges,
|
||||
]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (formMethods) {
|
||||
formMethods.reset();
|
||||
}
|
||||
clearAllChanges();
|
||||
}, [formMethods, clearAllChanges]);
|
||||
|
||||
const leftSidebar = useMemo(
|
||||
() => (
|
||||
<Sidebar>
|
||||
<SidebarSection label={t("sidebar.label")} className="py-2 px-0">
|
||||
{sections.map((section) => (
|
||||
<SidebarButton
|
||||
key={section.key}
|
||||
label={section.label}
|
||||
active={activeSection?.key === section.key}
|
||||
onClick={() => navigate({ to: section.route.to })}
|
||||
Icon={section.icon}
|
||||
isDirty={section.changeCount > 0}
|
||||
count={section.changeCount}
|
||||
/>
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
),
|
||||
[sections, activeSection?.key, navigate, t],
|
||||
);
|
||||
|
||||
const hasDrafts =
|
||||
getConfigChangeCount() > 0 ||
|
||||
getModuleConfigChangeCount() > 0 ||
|
||||
getChannelChangeCount() > 0 ||
|
||||
adminMessageChangeCount > 0;
|
||||
const hasPending = hasDrafts || rhfState.isDirty;
|
||||
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
|
||||
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "unsavedChanges",
|
||||
label: t("common:formValidation.unsavedChanges"),
|
||||
onClick: () => {},
|
||||
className: cn([
|
||||
"bg-blue-500 text-slate-900 hover:bg-initial",
|
||||
"transition-colors duration-200",
|
||||
buttonOpacity,
|
||||
"transition-opacity",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
icon: RefreshCwIcon,
|
||||
label: t("common:button.reset"),
|
||||
onClick: handleReset,
|
||||
className: cn([
|
||||
buttonOpacity,
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "save",
|
||||
icon: !hasPending ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
disabled: saveDisabled,
|
||||
iconClasses:
|
||||
!rhfState.isValid && hasPending ? "text-red-400 cursor-not-allowed" : "cursor-pointer",
|
||||
className: cn([
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black",
|
||||
"disabled:hover:cursor-not-allowed cursor-pointer",
|
||||
]),
|
||||
onClick: handleSave,
|
||||
label: t("common:button.save"),
|
||||
},
|
||||
],
|
||||
[
|
||||
isSaving,
|
||||
hasPending,
|
||||
rhfState.isValid,
|
||||
saveDisabled,
|
||||
buttonOpacity,
|
||||
handleReset,
|
||||
handleSave,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const ActiveComponent = activeSection?.component;
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
contentClassName="overflow-auto"
|
||||
leftBar={leftSidebar}
|
||||
label={activeSection?.label ?? ""}
|
||||
actions={actions}
|
||||
>
|
||||
{ActiveComponent && <ActiveComponent onFormInit={onFormInit} />}
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigPage;
|
||||
Reference in New Issue
Block a user