mirror of
https://github.com/meshtastic/web.git
synced 2025-12-23 15:51:28 -05:00
refactor: device connection logic, added nonce to get config only (#946)
* refactor: device connection logic, added nonce to get config only on connect. * Update packages/web/src/core/services/MeshService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/pages/Connections/useConnections.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * code review fixes * fixes from code review * ui fixes * refactored meshService, moved code into deviceStore. Fixed some connnection issues * formatting fixes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@ __screenshots__*
|
||||
npm/
|
||||
.idea
|
||||
**/LICENSE
|
||||
.DS_Store
|
||||
|
||||
packages/protobufs/packages/ts/dist
|
||||
|
||||
|
||||
@@ -387,4 +387,12 @@ export class EventSystem {
|
||||
*/
|
||||
public readonly onQueueStatus: SimpleEventDispatcher<Protobuf.Mesh.QueueStatus> =
|
||||
new SimpleEventDispatcher<Protobuf.Mesh.QueueStatus>();
|
||||
|
||||
/**
|
||||
* Fires when a configCompleteId message is received from the device
|
||||
*
|
||||
* @event onConfigComplete
|
||||
*/
|
||||
public readonly onConfigComplete: SimpleEventDispatcher<number> =
|
||||
new SimpleEventDispatcher<number>();
|
||||
}
|
||||
|
||||
@@ -135,21 +135,27 @@ export const decodePacket = (device: MeshDevice) =>
|
||||
}
|
||||
|
||||
case "configCompleteId": {
|
||||
if (decodedMessage.payloadVariant.value !== device.configId) {
|
||||
device.log.error(
|
||||
Types.Emitter[Types.Emitter.HandleFromRadio],
|
||||
`❌ Invalid config id received from device, expected ${device.configId} but received ${decodedMessage.payloadVariant.value}`,
|
||||
);
|
||||
}
|
||||
|
||||
device.log.info(
|
||||
Types.Emitter[Types.Emitter.HandleFromRadio],
|
||||
`⚙️ Valid config id received from device: ${device.configId}`,
|
||||
`⚙️ Received config complete id: ${decodedMessage.payloadVariant.value}`,
|
||||
);
|
||||
|
||||
device.updateDeviceStatus(
|
||||
Types.DeviceStatusEnum.DeviceConfigured,
|
||||
// Emit the configCompleteId event for MeshService to handle two-stage flow
|
||||
device.events.onConfigComplete.dispatch(
|
||||
decodedMessage.payloadVariant.value,
|
||||
);
|
||||
|
||||
// For backward compatibility: if configId matches, update device status
|
||||
// MeshService will override this behavior for two-stage flow
|
||||
if (decodedMessage.payloadVariant.value === device.configId) {
|
||||
device.log.info(
|
||||
Types.Emitter[Types.Emitter.HandleFromRadio],
|
||||
`⚙️ Config id matches device.configId: ${device.configId}`,
|
||||
);
|
||||
device.updateDeviceStatus(
|
||||
Types.DeviceStatusEnum.DeviceConfigured,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ interface DeviceInfoPanelProps {
|
||||
isCollapsed: boolean;
|
||||
deviceMetrics: DeviceMetrics;
|
||||
firmwareVersion: string;
|
||||
user: Protobuf.Mesh.User;
|
||||
user?: Protobuf.Mesh.User;
|
||||
setDialogOpen: () => void;
|
||||
setCommandPaletteOpen: () => void;
|
||||
disableHover?: boolean;
|
||||
@@ -70,8 +70,12 @@ export const DeviceInfoPanel = ({
|
||||
}
|
||||
switch (status) {
|
||||
case "connected":
|
||||
case "configured":
|
||||
case "online":
|
||||
return "bg-emerald-500";
|
||||
case "connecting":
|
||||
case "configuring":
|
||||
case "disconnecting":
|
||||
return "bg-amber-500";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
@@ -84,6 +88,10 @@ export const DeviceInfoPanel = ({
|
||||
if (!status) {
|
||||
return t("unknown.notAvailable", "N/A");
|
||||
}
|
||||
// Show "connected" for configured state
|
||||
if (status === "configured") {
|
||||
return t("toasts.connected", { ns: "connections" });
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
@@ -135,28 +143,30 @@ export const DeviceInfoPanel = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-1 flex-shrink-0",
|
||||
isCollapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
nodeNum={parseInt(user.id.slice(1), 16)}
|
||||
className={cn("flex-shrink-0", isCollapsed && "")}
|
||||
size="sm"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium text-gray-800 dark:text-gray-200",
|
||||
"transition-opacity duration-300 ease-in-out truncate",
|
||||
)}
|
||||
>
|
||||
{user.longName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-1 flex-shrink-0",
|
||||
isCollapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
nodeNum={parseInt(user.id.slice(1), 16)}
|
||||
className={cn("flex-shrink-0", isCollapsed && "")}
|
||||
size="sm"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium text-gray-800 dark:text-gray-200",
|
||||
"transition-opacity duration-300 ease-in-out truncate",
|
||||
)}
|
||||
>
|
||||
{user.longName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionStatus && (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx";
|
||||
import { Switch } from "@app/components/UI/Switch.tsx";
|
||||
import type { NewConnection } from "@app/core/stores/deviceStore/types.ts";
|
||||
import type {
|
||||
ConnectionType,
|
||||
NewConnection,
|
||||
} from "@app/core/stores/deviceStore/types.ts";
|
||||
import { testHttpReachable } from "@app/pages/Connections/utils";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
@@ -34,7 +37,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
import { urlOrIpv4Schema } from "./validation.ts";
|
||||
|
||||
type TabKey = "http" | "bluetooth" | "serial";
|
||||
type TabKey = ConnectionType;
|
||||
type TestingStatus = "idle" | "testing" | "success" | "failure";
|
||||
type DialogState = {
|
||||
tab: TabKey;
|
||||
@@ -390,12 +393,6 @@ export default function AddConnectionDialog({
|
||||
const reachable = await testHttpReachable(validatedURL.data);
|
||||
if (reachable) {
|
||||
dispatch({ type: "SET_TEST_STATUS", payload: "success" });
|
||||
toast({
|
||||
title: t("addConnection.httpConnection.connectionTest.success.title"),
|
||||
description: t(
|
||||
"addConnection.httpConnection.connectionTest.success.description",
|
||||
),
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: "SET_TEST_STATUS", payload: "failure" });
|
||||
toast({
|
||||
|
||||
@@ -7,12 +7,16 @@ export function ConnectionStatusBadge({
|
||||
status: Connection["status"];
|
||||
}) {
|
||||
let color = "";
|
||||
let displayStatus = status;
|
||||
|
||||
switch (status) {
|
||||
case "connected":
|
||||
case "configured":
|
||||
color = "bg-emerald-500";
|
||||
displayStatus = "connected";
|
||||
break;
|
||||
case "connecting":
|
||||
case "configuring":
|
||||
color = "bg-amber-500";
|
||||
break;
|
||||
case "online":
|
||||
@@ -31,7 +35,7 @@ export function ConnectionStatusBadge({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">
|
||||
{status}
|
||||
{displayStatus}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ export const NodeMarker = memo(function NodeMarker({
|
||||
id,
|
||||
lng,
|
||||
lat,
|
||||
label,
|
||||
longLabel,
|
||||
tooltipLabel,
|
||||
hasError,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useFirstSavedConnection } from "@app/core/stores/deviceStore/selectors.ts";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import {
|
||||
type Page,
|
||||
useActiveConnection,
|
||||
useAppStore,
|
||||
useDefaultConnection,
|
||||
useDevice,
|
||||
useDeviceStore,
|
||||
useNodeDB,
|
||||
useSidebar,
|
||||
} from "@core/stores";
|
||||
@@ -71,17 +73,18 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
||||
const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
|
||||
const { getNode, getNodesLength } = useNodeDB();
|
||||
const { setCommandPaletteOpen } = useAppStore();
|
||||
const savedConnections = useDeviceStore((s) => s.savedConnections);
|
||||
const myNode = getNode(hardware.myNodeNum);
|
||||
const { isCollapsed } = useSidebar();
|
||||
const { t } = useTranslation("ui");
|
||||
const navigate = useNavigate({ from: "/" });
|
||||
|
||||
// Get the active connection (connected > default > first)
|
||||
// Get the active connection from selector (connected > default > first)
|
||||
const activeConnection =
|
||||
savedConnections.find((c) => c.status === "connected") ||
|
||||
savedConnections.find((c) => c.isDefault) ||
|
||||
savedConnections[0];
|
||||
useActiveConnection() ||
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: not a react hook
|
||||
useDefaultConnection() ||
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: not a hook
|
||||
useFirstSavedConnection();
|
||||
|
||||
const pathname = useLocation({
|
||||
select: (location) => location.pathname.replace(/^\//, ""),
|
||||
|
||||
@@ -52,9 +52,17 @@ type DeviceData = {
|
||||
waypoints: WaypointWithMetadata[];
|
||||
neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
|
||||
};
|
||||
export type ConnectionPhase =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "configuring"
|
||||
| "configured";
|
||||
|
||||
export interface Device extends DeviceData {
|
||||
// Ephemeral state (not persisted)
|
||||
status: Types.DeviceStatusEnum;
|
||||
connectionPhase: ConnectionPhase;
|
||||
connectionId: ConnectionId | null;
|
||||
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
|
||||
config: Protobuf.LocalOnly.LocalConfig;
|
||||
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
|
||||
@@ -70,6 +78,8 @@ export interface Device extends DeviceData {
|
||||
clientNotifications: Protobuf.Mesh.ClientNotification[];
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||
setConnectionPhase: (phase: ConnectionPhase) => void;
|
||||
setConnectionId: (id: ConnectionId | null) => void;
|
||||
setConfig: (config: Protobuf.Config.Config) => void;
|
||||
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||
getEffectiveConfig<K extends ValidConfigType>(
|
||||
@@ -153,6 +163,16 @@ export interface deviceState {
|
||||
) => void;
|
||||
removeSavedConnection: (id: ConnectionId) => void;
|
||||
getSavedConnections: () => Connection[];
|
||||
|
||||
// Active connection tracking
|
||||
activeConnectionId: ConnectionId | null;
|
||||
setActiveConnectionId: (id: ConnectionId | null) => void;
|
||||
getActiveConnectionId: () => ConnectionId | null;
|
||||
|
||||
// Helper selectors for connection ↔ device relationships
|
||||
getActiveConnection: () => Connection | undefined;
|
||||
getDeviceForConnection: (id: ConnectionId) => Device | undefined;
|
||||
getConnectionForDevice: (deviceId: number) => Connection | undefined;
|
||||
}
|
||||
|
||||
interface PrivateDeviceState extends deviceState {
|
||||
@@ -185,6 +205,8 @@ function deviceFactory(
|
||||
neighborInfo,
|
||||
|
||||
status: Types.DeviceStatusEnum.DeviceDisconnected,
|
||||
connectionPhase: "disconnected",
|
||||
connectionId: null,
|
||||
channels: new Map(),
|
||||
config: create(Protobuf.LocalOnly.LocalConfigSchema),
|
||||
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
|
||||
@@ -227,6 +249,26 @@ function deviceFactory(
|
||||
}),
|
||||
);
|
||||
},
|
||||
setConnectionPhase: (phase: ConnectionPhase) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
device.connectionPhase = phase;
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
setConnectionId: (connectionId: ConnectionId | null) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
device.connectionId = connectionId;
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
setConfig: (config: Protobuf.Config.Config) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
@@ -907,6 +949,7 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
|
||||
) => ({
|
||||
devices: new Map(),
|
||||
savedConnections: [],
|
||||
activeConnectionId: null,
|
||||
|
||||
addDevice: (id) => {
|
||||
const existing = get().devices.get(id);
|
||||
@@ -972,6 +1015,33 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
|
||||
);
|
||||
},
|
||||
getSavedConnections: () => get().savedConnections,
|
||||
|
||||
setActiveConnectionId: (id) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
draft.activeConnectionId = id;
|
||||
}),
|
||||
);
|
||||
},
|
||||
getActiveConnectionId: () => get().activeConnectionId,
|
||||
|
||||
getActiveConnection: () => {
|
||||
const activeId = get().activeConnectionId;
|
||||
if (!activeId) {
|
||||
return undefined;
|
||||
}
|
||||
return get().savedConnections.find((c) => c.id === activeId);
|
||||
},
|
||||
getDeviceForConnection: (id) => {
|
||||
const connection = get().savedConnections.find((c) => c.id === id);
|
||||
if (!connection?.meshDeviceId) {
|
||||
return undefined;
|
||||
}
|
||||
return get().devices.get(connection.meshDeviceId);
|
||||
},
|
||||
getConnectionForDevice: (deviceId) => {
|
||||
return get().savedConnections.find((c) => c.meshDeviceId === deviceId);
|
||||
},
|
||||
});
|
||||
|
||||
const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
||||
|
||||
109
packages/web/src/core/stores/deviceStore/selectors.ts
Normal file
109
packages/web/src/core/stores/deviceStore/selectors.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Device } from "./index.ts";
|
||||
import { useDeviceStore } from "./index.ts";
|
||||
import type { Connection, ConnectionId } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Hook to get the currently active connection
|
||||
*/
|
||||
export function useActiveConnection(): Connection | undefined {
|
||||
return useDeviceStore((s) => s.getActiveConnection());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the HTTP connection marked as default
|
||||
*/
|
||||
export function useDefaultConnection(): Connection | undefined {
|
||||
return useDeviceStore((s) => s.savedConnections.find((c) => c.isDefault));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the first saved connection
|
||||
*/
|
||||
export function useFirstSavedConnection(): Connection | undefined {
|
||||
return useDeviceStore((s) => s.savedConnections.at(0));
|
||||
}
|
||||
|
||||
export function useAddSavedConnection() {
|
||||
return useDeviceStore((s) => s.addSavedConnection);
|
||||
}
|
||||
|
||||
export function useUpdateSavedConnection() {
|
||||
return useDeviceStore((s) => s.updateSavedConnection);
|
||||
}
|
||||
|
||||
export function useRemoveSavedConnection() {
|
||||
return useDeviceStore((s) => s.removeSavedConnection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the active connection ID
|
||||
*/
|
||||
export function useActiveConnectionId(): ConnectionId | null {
|
||||
return useDeviceStore((s) => s.activeConnectionId);
|
||||
}
|
||||
|
||||
export function useSetActiveConnectionId() {
|
||||
return useDeviceStore((s) => s.setActiveConnectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a specific connection's status
|
||||
*/
|
||||
export function useConnectionStatus(id: ConnectionId): string | undefined {
|
||||
return useDeviceStore(
|
||||
(s) => s.savedConnections.find((c) => c.id === id)?.status,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a device for a specific connection
|
||||
*/
|
||||
export function useDeviceForConnection(id: ConnectionId): Device | undefined {
|
||||
return useDeviceStore((s) => s.getDeviceForConnection(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a connection for a specific device
|
||||
*/
|
||||
export function useConnectionForDevice(
|
||||
deviceId: number,
|
||||
): Connection | undefined {
|
||||
return useDeviceStore((s) => s.getConnectionForDevice(deviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if any connection is currently connecting
|
||||
*/
|
||||
export function useIsConnecting(): boolean {
|
||||
return useDeviceStore((s) =>
|
||||
s.savedConnections.some(
|
||||
(c) => c.status === "connecting" || c.status === "configuring",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get error message for a specific connection
|
||||
*/
|
||||
export function useConnectionError(id: ConnectionId): string | null {
|
||||
return useDeviceStore(
|
||||
(s) => s.savedConnections.find((c) => c.id === id)?.error ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get all saved connections
|
||||
*/
|
||||
export function useSavedConnections(): Connection[] {
|
||||
return useDeviceStore((s) => s.savedConnections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a connection is connected
|
||||
*/
|
||||
export function useIsConnected(id: ConnectionId): boolean {
|
||||
return useDeviceStore((s) => {
|
||||
const status = s.savedConnections.find((c) => c.id === id)?.status;
|
||||
return status === "connected" || status === "configured";
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,22 @@ export {
|
||||
} from "@core/hooks/useDeviceContext";
|
||||
export { useAppStore } from "@core/stores/appStore/index.ts";
|
||||
export { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
|
||||
export {
|
||||
useActiveConnection,
|
||||
useActiveConnectionId,
|
||||
useAddSavedConnection,
|
||||
useConnectionError,
|
||||
useConnectionForDevice,
|
||||
useConnectionStatus,
|
||||
useDefaultConnection,
|
||||
useDeviceForConnection,
|
||||
useFirstSavedConnection,
|
||||
useIsConnected,
|
||||
useIsConnecting,
|
||||
useRemoveSavedConnection,
|
||||
useSavedConnections,
|
||||
useUpdateSavedConnection,
|
||||
} from "@core/stores/deviceStore/selectors.ts";
|
||||
export type {
|
||||
Page,
|
||||
ValidConfigType,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { featureFlags } from "@core/services/featureFlags";
|
||||
import { validateIncomingNode } from "@core/stores/nodeDBStore/nodeValidation";
|
||||
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
|
||||
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { produce } from "immer";
|
||||
@@ -15,7 +14,7 @@ import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
|
||||
|
||||
const IDB_KEY_NAME = "meshtastic-nodedb-store";
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
const NODEDB_RETENTION_NUM = 10;
|
||||
const NODE_RETENTION_DAYS = 14; // Remove nodes not heard from in 14 days
|
||||
|
||||
type NodeDBData = {
|
||||
// Persisted data
|
||||
@@ -30,6 +29,7 @@ export interface NodeDB extends NodeDBData {
|
||||
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
||||
removeNode: (nodeNum: number) => void;
|
||||
removeAllNodes: (keepMyNode?: boolean) => void;
|
||||
pruneStaleNodes: () => number;
|
||||
processPacket: (data: ProcessPacketParams) => void;
|
||||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||
@@ -90,6 +90,11 @@ function nodeDBFactory(
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
|
||||
// Check if node already exists
|
||||
const existing = nodeDB.nodeMap.get(node.num);
|
||||
const isNew = !existing;
|
||||
|
||||
// Use validation to check the new node before adding
|
||||
const next = validateIncomingNode(
|
||||
node,
|
||||
@@ -105,7 +110,30 @@ function nodeDBFactory(
|
||||
return;
|
||||
}
|
||||
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(node.num, next);
|
||||
// Merge with existing node data if it exists
|
||||
const merged = existing
|
||||
? {
|
||||
...existing,
|
||||
...next,
|
||||
// Preserve existing fields if new node doesn't have them
|
||||
user: next.user ?? existing.user,
|
||||
position: next.position ?? existing.position,
|
||||
deviceMetrics: next.deviceMetrics ?? existing.deviceMetrics,
|
||||
}
|
||||
: next;
|
||||
|
||||
// Use the validated node's num to ensure consistency
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(merged.num, merged);
|
||||
|
||||
if (isNew) {
|
||||
console.log(
|
||||
`[NodeDB] Adding new node from NodeInfo packet: ${merged.num} (${merged.user?.longName || "unknown"})`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[NodeDB] Updating existing node from NodeInfo packet: ${merged.num} (${merged.user?.longName || "unknown"})`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -145,6 +173,56 @@ function nodeDBFactory(
|
||||
}),
|
||||
),
|
||||
|
||||
pruneStaleNodes: () => {
|
||||
const nodeDB = get().nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const cutoffSec = nowSec - NODE_RETENTION_DAYS * 24 * 60 * 60;
|
||||
let prunedCount = 0;
|
||||
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
|
||||
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>();
|
||||
|
||||
for (const [nodeNum, node] of nodeDB.nodeMap) {
|
||||
// Keep myNode regardless of lastHeard
|
||||
// Keep nodes that have been heard recently
|
||||
// Keep nodes without lastHeard (just in case)
|
||||
if (
|
||||
nodeNum === nodeDB.myNodeNum ||
|
||||
!node.lastHeard ||
|
||||
node.lastHeard >= cutoffSec
|
||||
) {
|
||||
newNodeMap.set(nodeNum, node);
|
||||
} else {
|
||||
prunedCount++;
|
||||
console.log(
|
||||
`[NodeDB] Pruning stale node ${nodeNum} (last heard ${Math.floor((nowSec - node.lastHeard) / 86400)} days ago)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
nodeDB.nodeMap = newNodeMap;
|
||||
}),
|
||||
);
|
||||
|
||||
if (prunedCount > 0) {
|
||||
console.log(
|
||||
`[NodeDB] Pruned ${prunedCount} stale node(s) older than ${NODE_RETENTION_DAYS} days`,
|
||||
);
|
||||
}
|
||||
|
||||
return prunedCount;
|
||||
},
|
||||
|
||||
setNodeError: (nodeNum, error) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
@@ -220,11 +298,20 @@ function nodeDBFactory(
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const current =
|
||||
nodeDB.nodeMap.get(user.from) ??
|
||||
create(Protobuf.Mesh.NodeInfoSchema);
|
||||
const updated = { ...current, user: user.data, num: user.from };
|
||||
const current = nodeDB.nodeMap.get(user.from);
|
||||
const isNew = !current;
|
||||
const updated = {
|
||||
...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
|
||||
user: user.data,
|
||||
num: user.from,
|
||||
};
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(user.from, updated);
|
||||
|
||||
if (isNew) {
|
||||
console.log(
|
||||
`[NodeDB] Adding new node from user packet: ${user.from} (${user.data.longName || "unknown"})`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -235,15 +322,20 @@ function nodeDBFactory(
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const current =
|
||||
nodeDB.nodeMap.get(position.from) ??
|
||||
create(Protobuf.Mesh.NodeInfoSchema);
|
||||
const current = nodeDB.nodeMap.get(position.from);
|
||||
const isNew = !current;
|
||||
const updated = {
|
||||
...current,
|
||||
...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
|
||||
position: position.data,
|
||||
num: position.from,
|
||||
};
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(position.from, updated);
|
||||
|
||||
if (isNew) {
|
||||
console.log(
|
||||
`[NodeDB] Adding new node from position packet: ${position.from}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -411,6 +503,8 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
|
||||
addNodeDB: (id) => {
|
||||
const existing = get().nodeDBs.get(id);
|
||||
if (existing) {
|
||||
// Prune stale nodes when accessing existing nodeDB
|
||||
existing.pruneStaleNodes();
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -418,12 +512,12 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
draft.nodeDBs = new Map(draft.nodeDBs).set(id, nodeDB);
|
||||
|
||||
// Enforce retention limit
|
||||
evictOldestEntries(draft.nodeDBs, NODEDB_RETENTION_NUM);
|
||||
}),
|
||||
);
|
||||
|
||||
// Prune stale nodes on creation (useful when rehydrating from storage)
|
||||
nodeDB.pruneStaleNodes();
|
||||
|
||||
return nodeDB;
|
||||
},
|
||||
removeNodeDB: (id) => {
|
||||
|
||||
@@ -15,9 +15,12 @@ export const mockNodeDBStore: NodeDB = {
|
||||
addUser: vi.fn(),
|
||||
addPosition: vi.fn(),
|
||||
removeNode: vi.fn(),
|
||||
removeAllNodes: vi.fn(),
|
||||
pruneStaleNodes: vi.fn().mockReturnValue(0),
|
||||
processPacket: vi.fn(),
|
||||
setNodeError: vi.fn(),
|
||||
clearNodeError: vi.fn(),
|
||||
removeAllNodeErrors: vi.fn(),
|
||||
getNodeError: vi.fn().mockReturnValue(undefined),
|
||||
hasNodeError: vi.fn().mockReturnValue(false),
|
||||
getNodes: vi.fn().mockReturnValue([]),
|
||||
@@ -27,6 +30,4 @@ export const mockNodeDBStore: NodeDB = {
|
||||
updateFavorite: vi.fn(),
|
||||
updateIgnore: vi.fn(),
|
||||
setNodeNum: vi.fn(),
|
||||
removeAllNodeErrors: vi.fn(),
|
||||
removeAllNodes: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -66,10 +66,10 @@ describe("NodeDB store", () => {
|
||||
|
||||
const db1 = useNodeDBStore.getState().addNodeDB(123);
|
||||
const db2 = useNodeDBStore.getState().addNodeDB(123);
|
||||
expect(db1).toBe(db2);
|
||||
expect(db1).toStrictEqual(db2);
|
||||
|
||||
const got = useNodeDBStore.getState().getNodeDB(123);
|
||||
expect(got).toBe(db1);
|
||||
expect(got).toStrictEqual(db1);
|
||||
|
||||
expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1);
|
||||
});
|
||||
@@ -204,15 +204,18 @@ describe("NodeDB store", () => {
|
||||
expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11
|
||||
});
|
||||
|
||||
it("when exceeding cap, evicts earliest inserted, not the newly added", async () => {
|
||||
it("will prune nodes after 14 days of inactivitiy", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
st.addNodeDB(i);
|
||||
}
|
||||
st.addNodeDB(11);
|
||||
expect(st.getNodeDB(1)).toBeUndefined();
|
||||
expect(st.getNodeDB(11)).toBeDefined();
|
||||
st.addNodeDB(1).addNode(
|
||||
makeNode(1, { lastHeard: Date.now() / 1000 - 15 * 24 * 3600 }),
|
||||
); // 15 days ago
|
||||
st.addNodeDB(1).addNode(
|
||||
makeNode(2, { lastHeard: Date.now() / 1000 - 7 * 24 * 3600 }),
|
||||
); // 7 days ago
|
||||
|
||||
st.getNodeDB(1)!.pruneStaleNodes();
|
||||
expect(st.getNodeDB(1)?.getNode(2)).toBeDefined();
|
||||
});
|
||||
|
||||
it("removeNodeDB persists removal across reload", async () => {
|
||||
@@ -401,28 +404,6 @@ describe("NodeDB – merge semantics, PKI checks & extras", () => {
|
||||
expect(newDB.getNodeError(2)!.error).toBe("NEW_ERR"); // new added
|
||||
});
|
||||
|
||||
it("eviction still honors cap after merge", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
st.addNodeDB(i);
|
||||
}
|
||||
const oldDB = st.addNodeDB(100);
|
||||
oldDB.setNodeNum(12345);
|
||||
oldDB.addNode(makeNode(2000));
|
||||
|
||||
const newDB = st.addNodeDB(101);
|
||||
newDB.setNodeNum(12345); // merges + deletes 100
|
||||
|
||||
// adding another to trigger eviction of earliest non-merged entry (which was 1)
|
||||
st.addNodeDB(102);
|
||||
|
||||
expect(st.getNodeDB(1)).toBeUndefined(); // evicted
|
||||
expect(st.getNodeDB(101)).toBeDefined(); // merged entry exists
|
||||
expect(st.getNodeDB(101)!.getNode(2000)).toBeTruthy(); // carried over
|
||||
});
|
||||
|
||||
it("removeAllNodes (optionally keeping my node) and removeAllNodeErrors persist across reload", async () => {
|
||||
{
|
||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ensureDefaultUser } from "@core/dto/NodeNumToNodeInfoDTO.ts";
|
||||
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||
import { useNewNodeNum } from "@core/hooks/useNewNodeNum";
|
||||
import {
|
||||
@@ -68,11 +67,11 @@ export const subscribeAll = (
|
||||
nodeDB.addPosition(position);
|
||||
});
|
||||
|
||||
// NOTE: Node handling is managed by the nodeDB
|
||||
// Nodes are added via subscriptions.ts and stored in nodeDB
|
||||
// Configuration is handled directly by meshDevice.configure() in useConnections
|
||||
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
|
||||
const nodeWithUser = ensureDefaultUser(nodeInfo);
|
||||
|
||||
// PKI sanity check is handled inside nodeDB.addNode
|
||||
nodeDB.addNode(nodeWithUser);
|
||||
nodeDB.addNode(nodeInfo);
|
||||
});
|
||||
|
||||
connection.events.onChannelPacket.subscribe((channel) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
|
||||
import { TimeAgo } from "@app/components/generic/TimeAgo";
|
||||
import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
|
||||
import type { Connection } from "@app/core/stores/deviceStore/types";
|
||||
import { useConnections } from "@app/pages/Connections/useConnections";
|
||||
@@ -39,8 +40,8 @@ import {
|
||||
ArrowLeft,
|
||||
LinkIcon,
|
||||
MoreHorizontal,
|
||||
PlugZap,
|
||||
RotateCw,
|
||||
RouterIcon,
|
||||
Star,
|
||||
StarOff,
|
||||
Trash2,
|
||||
@@ -71,7 +72,6 @@ export const Connections = () => {
|
||||
syncConnectionStatuses();
|
||||
refreshStatuses();
|
||||
}, []);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...connections];
|
||||
return copy.sort((a, b) => {
|
||||
@@ -81,7 +81,9 @@ export const Connections = () => {
|
||||
if (!a.isDefault && b.isDefault) {
|
||||
return 1;
|
||||
}
|
||||
if (a.status === "connected" && b.status !== "connected") {
|
||||
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);
|
||||
@@ -111,7 +113,7 @@ export const Connections = () => {
|
||||
</div>
|
||||
<div className="flex items-center ml-2 gap-2">
|
||||
<Button onClick={() => setAddOpen(true)} className="gap-2">
|
||||
<PlugZap className="size-4" />
|
||||
<RouterIcon className="size-5" />
|
||||
{t("button.addConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -131,13 +133,13 @@ export const Connections = () => {
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={() => setAddOpen(true)} className="gap-2">
|
||||
<PlugZap className="size-4" />
|
||||
<RouterIcon className="size-5" />
|
||||
{t("button.addConnection")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{sorted.map((c) => (
|
||||
<ConnectionCard
|
||||
key={c.id}
|
||||
@@ -223,7 +225,10 @@ export const Connections = () => {
|
||||
interpolation: { escapeValue: false },
|
||||
}),
|
||||
});
|
||||
if (created.status === "connected") {
|
||||
if (
|
||||
created.status === "connected" ||
|
||||
created.status === "configured"
|
||||
) {
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
} else {
|
||||
@@ -268,8 +273,10 @@ function ConnectionCard({
|
||||
const { t } = useTranslation("connections");
|
||||
|
||||
const Icon = connectionTypeIcon(connection.type);
|
||||
const isBusy = connection.status === "connecting";
|
||||
const isConnected = connection.status === "connected";
|
||||
const isBusy =
|
||||
connection.status === "connecting" || connection.status === "configuring";
|
||||
const isConnected =
|
||||
connection.status === "connected" || connection.status === "configured";
|
||||
const isError = connection.status === "error";
|
||||
|
||||
return (
|
||||
@@ -367,10 +374,11 @@ function ConnectionCard({
|
||||
</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()}
|
||||
{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">
|
||||
|
||||
@@ -20,13 +20,15 @@ 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";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type LiveRefs = {
|
||||
bt: Map<ConnectionId, BluetoothDevice>;
|
||||
serial: Map<ConnectionId, SerialPort>;
|
||||
meshDevices: Map<ConnectionId, MeshDevice>;
|
||||
};
|
||||
// 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);
|
||||
@@ -37,11 +39,9 @@ export function useConnections() {
|
||||
(s) => s.removeSavedConnection,
|
||||
);
|
||||
|
||||
const live = useRef<LiveRefs>({
|
||||
bt: new Map(),
|
||||
serial: new Map(),
|
||||
meshDevices: new Map(),
|
||||
});
|
||||
// DeviceStore methods
|
||||
const setActiveConnectionId = useDeviceStore((s) => s.setActiveConnectionId);
|
||||
|
||||
const { addDevice } = useDeviceStore();
|
||||
const { addNodeDB } = useNodeDBStore();
|
||||
const { addMessageStore } = useMessageStore();
|
||||
@@ -62,37 +62,67 @@ export function useConnections() {
|
||||
|
||||
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);
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Close live refs if open
|
||||
const bt = live.current.bt.get(id);
|
||||
if (bt?.gatt?.connected) {
|
||||
try {
|
||||
bt.gatt.disconnect();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
// 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}`,
|
||||
);
|
||||
}
|
||||
const sp = live.current.serial.get(id);
|
||||
if (sp && "close" in sp) {
|
||||
try {
|
||||
(sp as SerialPort & { close: () => Promise<void> }).close();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
|
||||
// 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 {}
|
||||
}
|
||||
live.current.bt.delete(id);
|
||||
live.current.serial.delete(id);
|
||||
|
||||
removeSavedConnectionFromStore(id);
|
||||
},
|
||||
[removeSavedConnectionFromStore],
|
||||
[connections, removeSavedConnectionFromStore],
|
||||
);
|
||||
|
||||
const setDefaultConnection = useCallback(
|
||||
@@ -115,36 +145,114 @@ export function useConnections() {
|
||||
| Awaited<ReturnType<typeof TransportHTTP.create>>
|
||||
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
|
||||
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>,
|
||||
options?: {
|
||||
setHeartbeat?: boolean;
|
||||
onDisconnect?: () => void;
|
||||
},
|
||||
btDevice?: BluetoothDevice,
|
||||
serialPort?: SerialPort,
|
||||
): number => {
|
||||
const deviceId = randId();
|
||||
// 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);
|
||||
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);
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -154,7 +262,7 @@ export function useConnections() {
|
||||
if (!conn) {
|
||||
return false;
|
||||
}
|
||||
if (conn.status === "connected") {
|
||||
if (conn.status === "configured" || conn.status === "connected") {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -175,7 +283,7 @@ export function useConnections() {
|
||||
const isTLS = url.protocol === "https:";
|
||||
const transport = await TransportHTTP.create(url.host, isTLS);
|
||||
setupMeshDevice(id, transport);
|
||||
updateStatus(id, "connected");
|
||||
// Status will be set to "configured" by onConfigComplete event
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -183,7 +291,7 @@ export function useConnections() {
|
||||
if (!("bluetooth" in navigator)) {
|
||||
throw new Error("Web Bluetooth not supported");
|
||||
}
|
||||
let bleDevice = live.current.bt.get(id);
|
||||
let bleDevice = transports.get(id) as BluetoothDevice | undefined;
|
||||
if (!bleDevice) {
|
||||
// Try to recover permitted devices
|
||||
const getDevices = (
|
||||
@@ -198,10 +306,6 @@ export function useConnections() {
|
||||
bleDevice = known.find(
|
||||
(d: BluetoothDevice) => d.id === conn.deviceId,
|
||||
);
|
||||
// If found, store it for future use
|
||||
if (bleDevice) {
|
||||
live.current.bt.set(id, bleDevice);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,17 +326,16 @@ export function useConnections() {
|
||||
"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 });
|
||||
setupMeshDevice(id, transport, bleDevice);
|
||||
|
||||
bleDevice.addEventListener("gattserverdisconnected", () => {
|
||||
updateStatus(id, "disconnected");
|
||||
});
|
||||
|
||||
updateStatus(id, "connected");
|
||||
// Status will be set to "configured" by onConfigComplete event
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -240,7 +343,7 @@ export function useConnections() {
|
||||
if (!("serial" in navigator)) {
|
||||
throw new Error("Web Serial not supported");
|
||||
}
|
||||
let port = live.current.serial.get(id);
|
||||
let port = transports.get(id) as SerialPort | undefined;
|
||||
if (!port) {
|
||||
// Find a previously granted port by vendor/product
|
||||
const ports: SerialPort[] = await (
|
||||
@@ -296,11 +399,9 @@ export function useConnections() {
|
||||
}
|
||||
}
|
||||
|
||||
live.current.serial.set(id, port);
|
||||
|
||||
const transport = await TransportWebSerial.createFromPort(port);
|
||||
setupMeshDevice(id, transport, { setHeartbeat: true });
|
||||
updateStatus(id, "connected");
|
||||
setupMeshDevice(id, transport, undefined, port);
|
||||
// Status will be set to "configured" by onConfigComplete event
|
||||
return true;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
@@ -320,39 +421,68 @@ export function useConnections() {
|
||||
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);
|
||||
// Stop heartbeat
|
||||
const heartbeatId = heartbeats.get(id);
|
||||
if (heartbeatId) {
|
||||
clearInterval(heartbeatId);
|
||||
heartbeats.delete(id);
|
||||
console.log(
|
||||
`[useConnections] Heartbeat stopped for connection ${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (conn.type === "bluetooth") {
|
||||
const dev = live.current.bt.get(id);
|
||||
if (dev?.gatt?.connected) {
|
||||
dev.gatt.disconnect();
|
||||
}
|
||||
// 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}`,
|
||||
);
|
||||
}
|
||||
if (conn.type === "serial") {
|
||||
const port = live.current.serial.get(id);
|
||||
if (port) {
|
||||
|
||||
// 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 {
|
||||
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);
|
||||
device.connection.disconnect();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
live.current.serial.delete(id);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -379,7 +509,7 @@ export function useConnections() {
|
||||
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);
|
||||
transports.set(conn.id, btDevice);
|
||||
}
|
||||
await connect(conn.id, { allowPrompt: true });
|
||||
// Get updated connection from store after connect
|
||||
@@ -395,11 +525,14 @@ export function useConnections() {
|
||||
// HTTP: test endpoint reachability
|
||||
// Bluetooth/Serial: check permission grants
|
||||
|
||||
// HTTP connections: test reachability if not already connected
|
||||
// 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.type === "http" &&
|
||||
c.status !== "connected" &&
|
||||
c.status !== "configured" &&
|
||||
c.status !== "configuring",
|
||||
)
|
||||
.map(async (c) => {
|
||||
const ok = await testHttpReachable(c.url);
|
||||
@@ -412,7 +545,10 @@ export function useConnections() {
|
||||
const btChecks = connections
|
||||
.filter(
|
||||
(c): c is Connection & { type: "bluetooth"; deviceId?: string } =>
|
||||
c.type === "bluetooth" && c.status !== "connected",
|
||||
c.type === "bluetooth" &&
|
||||
c.status !== "connected" &&
|
||||
c.status !== "configured" &&
|
||||
c.status !== "configuring",
|
||||
)
|
||||
.map(async (c) => {
|
||||
if (!("bluetooth" in navigator)) {
|
||||
@@ -445,7 +581,11 @@ export function useConnections() {
|
||||
type: "serial";
|
||||
usbVendorId?: number;
|
||||
usbProductId?: number;
|
||||
} => c.type === "serial" && c.status !== "connected",
|
||||
} =>
|
||||
c.type === "serial" &&
|
||||
c.status !== "connected" &&
|
||||
c.status !== "configured" &&
|
||||
c.status !== "configuring",
|
||||
)
|
||||
.map(async (c) => {
|
||||
if (!("serial" in navigator)) {
|
||||
@@ -493,13 +633,16 @@ export function useConnections() {
|
||||
// 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 && conn.status !== "connected") {
|
||||
updateSavedConnection(conn.id, { status: "connected" });
|
||||
} else if (!shouldBeConnected && conn.status === "connected") {
|
||||
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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user