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:
@@ -9,6 +9,7 @@ __screenshots__*
|
|||||||
npm/
|
npm/
|
||||||
.idea
|
.idea
|
||||||
**/LICENSE
|
**/LICENSE
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
packages/protobufs/packages/ts/dist
|
packages/protobufs/packages/ts/dist
|
||||||
|
|
||||||
|
|||||||
@@ -387,4 +387,12 @@ export class EventSystem {
|
|||||||
*/
|
*/
|
||||||
public readonly onQueueStatus: SimpleEventDispatcher<Protobuf.Mesh.QueueStatus> =
|
public readonly onQueueStatus: SimpleEventDispatcher<Protobuf.Mesh.QueueStatus> =
|
||||||
new 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": {
|
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(
|
device.log.info(
|
||||||
Types.Emitter[Types.Emitter.HandleFromRadio],
|
Types.Emitter[Types.Emitter.HandleFromRadio],
|
||||||
`⚙️ Valid config id received from device: ${device.configId}`,
|
`⚙️ Received config complete id: ${decodedMessage.payloadVariant.value}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
device.updateDeviceStatus(
|
// Emit the configCompleteId event for MeshService to handle two-stage flow
|
||||||
Types.DeviceStatusEnum.DeviceConfigured,
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface DeviceInfoPanelProps {
|
|||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
deviceMetrics: DeviceMetrics;
|
deviceMetrics: DeviceMetrics;
|
||||||
firmwareVersion: string;
|
firmwareVersion: string;
|
||||||
user: Protobuf.Mesh.User;
|
user?: Protobuf.Mesh.User;
|
||||||
setDialogOpen: () => void;
|
setDialogOpen: () => void;
|
||||||
setCommandPaletteOpen: () => void;
|
setCommandPaletteOpen: () => void;
|
||||||
disableHover?: boolean;
|
disableHover?: boolean;
|
||||||
@@ -70,8 +70,12 @@ export const DeviceInfoPanel = ({
|
|||||||
}
|
}
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "connected":
|
case "connected":
|
||||||
|
case "configured":
|
||||||
|
case "online":
|
||||||
return "bg-emerald-500";
|
return "bg-emerald-500";
|
||||||
case "connecting":
|
case "connecting":
|
||||||
|
case "configuring":
|
||||||
|
case "disconnecting":
|
||||||
return "bg-amber-500";
|
return "bg-amber-500";
|
||||||
case "error":
|
case "error":
|
||||||
return "bg-red-500";
|
return "bg-red-500";
|
||||||
@@ -84,6 +88,10 @@ export const DeviceInfoPanel = ({
|
|||||||
if (!status) {
|
if (!status) {
|
||||||
return t("unknown.notAvailable", "N/A");
|
return t("unknown.notAvailable", "N/A");
|
||||||
}
|
}
|
||||||
|
// Show "connected" for configured state
|
||||||
|
if (status === "configured") {
|
||||||
|
return t("toasts.connected", { ns: "connections" });
|
||||||
|
}
|
||||||
return status;
|
return status;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,28 +143,30 @@ export const DeviceInfoPanel = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
{user && (
|
||||||
className={cn(
|
<div
|
||||||
"flex items-center gap-3 p-1 flex-shrink-0",
|
className={cn(
|
||||||
isCollapsed && "justify-center",
|
"flex items-center gap-3 p-1 flex-shrink-0",
|
||||||
)}
|
isCollapsed && "justify-center",
|
||||||
>
|
)}
|
||||||
<Avatar
|
>
|
||||||
nodeNum={parseInt(user.id.slice(1), 16)}
|
<Avatar
|
||||||
className={cn("flex-shrink-0", isCollapsed && "")}
|
nodeNum={parseInt(user.id.slice(1), 16)}
|
||||||
size="sm"
|
className={cn("flex-shrink-0", isCollapsed && "")}
|
||||||
/>
|
size="sm"
|
||||||
{!isCollapsed && (
|
/>
|
||||||
<p
|
{!isCollapsed && (
|
||||||
className={cn(
|
<p
|
||||||
"text-sm font-medium text-gray-800 dark:text-gray-200",
|
className={cn(
|
||||||
"transition-opacity duration-300 ease-in-out truncate",
|
"text-sm font-medium text-gray-800 dark:text-gray-200",
|
||||||
)}
|
"transition-opacity duration-300 ease-in-out truncate",
|
||||||
>
|
)}
|
||||||
{user.longName}
|
>
|
||||||
</p>
|
{user.longName}
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{connectionStatus && (
|
{connectionStatus && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx";
|
import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx";
|
||||||
import { Switch } from "@app/components/UI/Switch.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 { testHttpReachable } from "@app/pages/Connections/utils";
|
||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
@@ -34,7 +37,7 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||||
import { urlOrIpv4Schema } from "./validation.ts";
|
import { urlOrIpv4Schema } from "./validation.ts";
|
||||||
|
|
||||||
type TabKey = "http" | "bluetooth" | "serial";
|
type TabKey = ConnectionType;
|
||||||
type TestingStatus = "idle" | "testing" | "success" | "failure";
|
type TestingStatus = "idle" | "testing" | "success" | "failure";
|
||||||
type DialogState = {
|
type DialogState = {
|
||||||
tab: TabKey;
|
tab: TabKey;
|
||||||
@@ -390,12 +393,6 @@ export default function AddConnectionDialog({
|
|||||||
const reachable = await testHttpReachable(validatedURL.data);
|
const reachable = await testHttpReachable(validatedURL.data);
|
||||||
if (reachable) {
|
if (reachable) {
|
||||||
dispatch({ type: "SET_TEST_STATUS", payload: "success" });
|
dispatch({ type: "SET_TEST_STATUS", payload: "success" });
|
||||||
toast({
|
|
||||||
title: t("addConnection.httpConnection.connectionTest.success.title"),
|
|
||||||
description: t(
|
|
||||||
"addConnection.httpConnection.connectionTest.success.description",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: "SET_TEST_STATUS", payload: "failure" });
|
dispatch({ type: "SET_TEST_STATUS", payload: "failure" });
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ export function ConnectionStatusBadge({
|
|||||||
status: Connection["status"];
|
status: Connection["status"];
|
||||||
}) {
|
}) {
|
||||||
let color = "";
|
let color = "";
|
||||||
|
let displayStatus = status;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "connected":
|
case "connected":
|
||||||
|
case "configured":
|
||||||
color = "bg-emerald-500";
|
color = "bg-emerald-500";
|
||||||
|
displayStatus = "connected";
|
||||||
break;
|
break;
|
||||||
case "connecting":
|
case "connecting":
|
||||||
|
case "configuring":
|
||||||
color = "bg-amber-500";
|
color = "bg-amber-500";
|
||||||
break;
|
break;
|
||||||
case "online":
|
case "online":
|
||||||
@@ -31,7 +35,7 @@ export function ConnectionStatusBadge({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">
|
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">
|
||||||
{status}
|
{displayStatus}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export const NodeMarker = memo(function NodeMarker({
|
|||||||
id,
|
id,
|
||||||
lng,
|
lng,
|
||||||
lat,
|
lat,
|
||||||
label,
|
|
||||||
longLabel,
|
longLabel,
|
||||||
tooltipLabel,
|
tooltipLabel,
|
||||||
hasError,
|
hasError,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { useFirstSavedConnection } from "@app/core/stores/deviceStore/selectors.ts";
|
||||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||||
import {
|
import {
|
||||||
type Page,
|
type Page,
|
||||||
|
useActiveConnection,
|
||||||
useAppStore,
|
useAppStore,
|
||||||
|
useDefaultConnection,
|
||||||
useDevice,
|
useDevice,
|
||||||
useDeviceStore,
|
|
||||||
useNodeDB,
|
useNodeDB,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@core/stores";
|
} from "@core/stores";
|
||||||
@@ -71,17 +73,18 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
|
const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
|
||||||
const { getNode, getNodesLength } = useNodeDB();
|
const { getNode, getNodesLength } = useNodeDB();
|
||||||
const { setCommandPaletteOpen } = useAppStore();
|
const { setCommandPaletteOpen } = useAppStore();
|
||||||
const savedConnections = useDeviceStore((s) => s.savedConnections);
|
|
||||||
const myNode = getNode(hardware.myNodeNum);
|
const myNode = getNode(hardware.myNodeNum);
|
||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
const { t } = useTranslation("ui");
|
const { t } = useTranslation("ui");
|
||||||
const navigate = useNavigate({ from: "/" });
|
const navigate = useNavigate({ from: "/" });
|
||||||
|
|
||||||
// Get the active connection (connected > default > first)
|
// Get the active connection from selector (connected > default > first)
|
||||||
const activeConnection =
|
const activeConnection =
|
||||||
savedConnections.find((c) => c.status === "connected") ||
|
useActiveConnection() ||
|
||||||
savedConnections.find((c) => c.isDefault) ||
|
// biome-ignore lint/correctness/useHookAtTopLevel: not a react hook
|
||||||
savedConnections[0];
|
useDefaultConnection() ||
|
||||||
|
// biome-ignore lint/correctness/useHookAtTopLevel: not a hook
|
||||||
|
useFirstSavedConnection();
|
||||||
|
|
||||||
const pathname = useLocation({
|
const pathname = useLocation({
|
||||||
select: (location) => location.pathname.replace(/^\//, ""),
|
select: (location) => location.pathname.replace(/^\//, ""),
|
||||||
|
|||||||
@@ -52,9 +52,17 @@ type DeviceData = {
|
|||||||
waypoints: WaypointWithMetadata[];
|
waypoints: WaypointWithMetadata[];
|
||||||
neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
|
neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
|
||||||
};
|
};
|
||||||
|
export type ConnectionPhase =
|
||||||
|
| "disconnected"
|
||||||
|
| "connecting"
|
||||||
|
| "configuring"
|
||||||
|
| "configured";
|
||||||
|
|
||||||
export interface Device extends DeviceData {
|
export interface Device extends DeviceData {
|
||||||
// Ephemeral state (not persisted)
|
// Ephemeral state (not persisted)
|
||||||
status: Types.DeviceStatusEnum;
|
status: Types.DeviceStatusEnum;
|
||||||
|
connectionPhase: ConnectionPhase;
|
||||||
|
connectionId: ConnectionId | null;
|
||||||
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
|
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
|
||||||
config: Protobuf.LocalOnly.LocalConfig;
|
config: Protobuf.LocalOnly.LocalConfig;
|
||||||
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
|
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
|
||||||
@@ -70,6 +78,8 @@ export interface Device extends DeviceData {
|
|||||||
clientNotifications: Protobuf.Mesh.ClientNotification[];
|
clientNotifications: Protobuf.Mesh.ClientNotification[];
|
||||||
|
|
||||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||||
|
setConnectionPhase: (phase: ConnectionPhase) => void;
|
||||||
|
setConnectionId: (id: ConnectionId | null) => void;
|
||||||
setConfig: (config: Protobuf.Config.Config) => void;
|
setConfig: (config: Protobuf.Config.Config) => void;
|
||||||
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||||
getEffectiveConfig<K extends ValidConfigType>(
|
getEffectiveConfig<K extends ValidConfigType>(
|
||||||
@@ -153,6 +163,16 @@ export interface deviceState {
|
|||||||
) => void;
|
) => void;
|
||||||
removeSavedConnection: (id: ConnectionId) => void;
|
removeSavedConnection: (id: ConnectionId) => void;
|
||||||
getSavedConnections: () => Connection[];
|
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 {
|
interface PrivateDeviceState extends deviceState {
|
||||||
@@ -185,6 +205,8 @@ function deviceFactory(
|
|||||||
neighborInfo,
|
neighborInfo,
|
||||||
|
|
||||||
status: Types.DeviceStatusEnum.DeviceDisconnected,
|
status: Types.DeviceStatusEnum.DeviceDisconnected,
|
||||||
|
connectionPhase: "disconnected",
|
||||||
|
connectionId: null,
|
||||||
channels: new Map(),
|
channels: new Map(),
|
||||||
config: create(Protobuf.LocalOnly.LocalConfigSchema),
|
config: create(Protobuf.LocalOnly.LocalConfigSchema),
|
||||||
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
|
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) => {
|
setConfig: (config: Protobuf.Config.Config) => {
|
||||||
set(
|
set(
|
||||||
produce<PrivateDeviceState>((draft) => {
|
produce<PrivateDeviceState>((draft) => {
|
||||||
@@ -907,6 +949,7 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
|
|||||||
) => ({
|
) => ({
|
||||||
devices: new Map(),
|
devices: new Map(),
|
||||||
savedConnections: [],
|
savedConnections: [],
|
||||||
|
activeConnectionId: null,
|
||||||
|
|
||||||
addDevice: (id) => {
|
addDevice: (id) => {
|
||||||
const existing = get().devices.get(id);
|
const existing = get().devices.get(id);
|
||||||
@@ -972,6 +1015,33 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
getSavedConnections: () => get().savedConnections,
|
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> = {
|
const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
||||||
|
|||||||
@@ -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";
|
} from "@core/hooks/useDeviceContext";
|
||||||
export { useAppStore } from "@core/stores/appStore/index.ts";
|
export { useAppStore } from "@core/stores/appStore/index.ts";
|
||||||
export { type Device, useDeviceStore } from "@core/stores/deviceStore/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 {
|
export type {
|
||||||
Page,
|
Page,
|
||||||
ValidConfigType,
|
ValidConfigType,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { featureFlags } from "@core/services/featureFlags";
|
import { featureFlags } from "@core/services/featureFlags";
|
||||||
import { validateIncomingNode } from "@core/stores/nodeDBStore/nodeValidation";
|
import { validateIncomingNode } from "@core/stores/nodeDBStore/nodeValidation";
|
||||||
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
|
|
||||||
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||||
import { Protobuf, type Types } from "@meshtastic/core";
|
import { Protobuf, type Types } from "@meshtastic/core";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
@@ -15,7 +14,7 @@ import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
|
|||||||
|
|
||||||
const IDB_KEY_NAME = "meshtastic-nodedb-store";
|
const IDB_KEY_NAME = "meshtastic-nodedb-store";
|
||||||
const CURRENT_STORE_VERSION = 0;
|
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 = {
|
type NodeDBData = {
|
||||||
// Persisted data
|
// Persisted data
|
||||||
@@ -30,6 +29,7 @@ export interface NodeDB extends NodeDBData {
|
|||||||
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
||||||
removeNode: (nodeNum: number) => void;
|
removeNode: (nodeNum: number) => void;
|
||||||
removeAllNodes: (keepMyNode?: boolean) => void;
|
removeAllNodes: (keepMyNode?: boolean) => void;
|
||||||
|
pruneStaleNodes: () => number;
|
||||||
processPacket: (data: ProcessPacketParams) => void;
|
processPacket: (data: ProcessPacketParams) => void;
|
||||||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
||||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||||
@@ -90,6 +90,11 @@ function nodeDBFactory(
|
|||||||
if (!nodeDB) {
|
if (!nodeDB) {
|
||||||
throw new Error(`No nodeDB found (id: ${id})`);
|
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
|
// Use validation to check the new node before adding
|
||||||
const next = validateIncomingNode(
|
const next = validateIncomingNode(
|
||||||
node,
|
node,
|
||||||
@@ -105,7 +110,30 @@ function nodeDBFactory(
|
|||||||
return;
|
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) =>
|
setNodeError: (nodeNum, error) =>
|
||||||
set(
|
set(
|
||||||
produce<PrivateNodeDBState>((draft) => {
|
produce<PrivateNodeDBState>((draft) => {
|
||||||
@@ -220,11 +298,20 @@ function nodeDBFactory(
|
|||||||
if (!nodeDB) {
|
if (!nodeDB) {
|
||||||
throw new Error(`No nodeDB found (id: ${id})`);
|
throw new Error(`No nodeDB found (id: ${id})`);
|
||||||
}
|
}
|
||||||
const current =
|
const current = nodeDB.nodeMap.get(user.from);
|
||||||
nodeDB.nodeMap.get(user.from) ??
|
const isNew = !current;
|
||||||
create(Protobuf.Mesh.NodeInfoSchema);
|
const updated = {
|
||||||
const updated = { ...current, user: user.data, num: user.from };
|
...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
|
||||||
|
user: user.data,
|
||||||
|
num: user.from,
|
||||||
|
};
|
||||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(user.from, updated);
|
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) {
|
if (!nodeDB) {
|
||||||
throw new Error(`No nodeDB found (id: ${id})`);
|
throw new Error(`No nodeDB found (id: ${id})`);
|
||||||
}
|
}
|
||||||
const current =
|
const current = nodeDB.nodeMap.get(position.from);
|
||||||
nodeDB.nodeMap.get(position.from) ??
|
const isNew = !current;
|
||||||
create(Protobuf.Mesh.NodeInfoSchema);
|
|
||||||
const updated = {
|
const updated = {
|
||||||
...current,
|
...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
|
||||||
position: position.data,
|
position: position.data,
|
||||||
num: position.from,
|
num: position.from,
|
||||||
};
|
};
|
||||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(position.from, updated);
|
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) => {
|
addNodeDB: (id) => {
|
||||||
const existing = get().nodeDBs.get(id);
|
const existing = get().nodeDBs.get(id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
// Prune stale nodes when accessing existing nodeDB
|
||||||
|
existing.pruneStaleNodes();
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,12 +512,12 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
|
|||||||
set(
|
set(
|
||||||
produce<PrivateNodeDBState>((draft) => {
|
produce<PrivateNodeDBState>((draft) => {
|
||||||
draft.nodeDBs = new Map(draft.nodeDBs).set(id, nodeDB);
|
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;
|
return nodeDB;
|
||||||
},
|
},
|
||||||
removeNodeDB: (id) => {
|
removeNodeDB: (id) => {
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ export const mockNodeDBStore: NodeDB = {
|
|||||||
addUser: vi.fn(),
|
addUser: vi.fn(),
|
||||||
addPosition: vi.fn(),
|
addPosition: vi.fn(),
|
||||||
removeNode: vi.fn(),
|
removeNode: vi.fn(),
|
||||||
|
removeAllNodes: vi.fn(),
|
||||||
|
pruneStaleNodes: vi.fn().mockReturnValue(0),
|
||||||
processPacket: vi.fn(),
|
processPacket: vi.fn(),
|
||||||
setNodeError: vi.fn(),
|
setNodeError: vi.fn(),
|
||||||
clearNodeError: vi.fn(),
|
clearNodeError: vi.fn(),
|
||||||
|
removeAllNodeErrors: vi.fn(),
|
||||||
getNodeError: vi.fn().mockReturnValue(undefined),
|
getNodeError: vi.fn().mockReturnValue(undefined),
|
||||||
hasNodeError: vi.fn().mockReturnValue(false),
|
hasNodeError: vi.fn().mockReturnValue(false),
|
||||||
getNodes: vi.fn().mockReturnValue([]),
|
getNodes: vi.fn().mockReturnValue([]),
|
||||||
@@ -27,6 +30,4 @@ export const mockNodeDBStore: NodeDB = {
|
|||||||
updateFavorite: vi.fn(),
|
updateFavorite: vi.fn(),
|
||||||
updateIgnore: vi.fn(),
|
updateIgnore: vi.fn(),
|
||||||
setNodeNum: 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 db1 = useNodeDBStore.getState().addNodeDB(123);
|
||||||
const db2 = useNodeDBStore.getState().addNodeDB(123);
|
const db2 = useNodeDBStore.getState().addNodeDB(123);
|
||||||
expect(db1).toBe(db2);
|
expect(db1).toStrictEqual(db2);
|
||||||
|
|
||||||
const got = useNodeDBStore.getState().getNodeDB(123);
|
const got = useNodeDBStore.getState().getNodeDB(123);
|
||||||
expect(got).toBe(db1);
|
expect(got).toStrictEqual(db1);
|
||||||
|
|
||||||
expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1);
|
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
|
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 { useNodeDBStore } = await freshStore();
|
||||||
const st = useNodeDBStore.getState();
|
const st = useNodeDBStore.getState();
|
||||||
for (let i = 1; i <= 10; i++) {
|
st.addNodeDB(1).addNode(
|
||||||
st.addNodeDB(i);
|
makeNode(1, { lastHeard: Date.now() / 1000 - 15 * 24 * 3600 }),
|
||||||
}
|
); // 15 days ago
|
||||||
st.addNodeDB(11);
|
st.addNodeDB(1).addNode(
|
||||||
expect(st.getNodeDB(1)).toBeUndefined();
|
makeNode(2, { lastHeard: Date.now() / 1000 - 7 * 24 * 3600 }),
|
||||||
expect(st.getNodeDB(11)).toBeDefined();
|
); // 7 days ago
|
||||||
|
|
||||||
|
st.getNodeDB(1)!.pruneStaleNodes();
|
||||||
|
expect(st.getNodeDB(1)?.getNode(2)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removeNodeDB persists removal across reload", async () => {
|
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
|
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 () => {
|
it("removeAllNodes (optionally keeping my node) and removeAllNodeErrors persist across reload", async () => {
|
||||||
{
|
{
|
||||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
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 PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||||
import { useNewNodeNum } from "@core/hooks/useNewNodeNum";
|
import { useNewNodeNum } from "@core/hooks/useNewNodeNum";
|
||||||
import {
|
import {
|
||||||
@@ -68,11 +67,11 @@ export const subscribeAll = (
|
|||||||
nodeDB.addPosition(position);
|
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) => {
|
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
|
||||||
const nodeWithUser = ensureDefaultUser(nodeInfo);
|
nodeDB.addNode(nodeInfo);
|
||||||
|
|
||||||
// PKI sanity check is handled inside nodeDB.addNode
|
|
||||||
nodeDB.addNode(nodeWithUser);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.events.onChannelPacket.subscribe((channel) => {
|
connection.events.onChannelPacket.subscribe((channel) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
|
import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
|
||||||
|
import { TimeAgo } from "@app/components/generic/TimeAgo";
|
||||||
import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
|
import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
|
||||||
import type { Connection } from "@app/core/stores/deviceStore/types";
|
import type { Connection } from "@app/core/stores/deviceStore/types";
|
||||||
import { useConnections } from "@app/pages/Connections/useConnections";
|
import { useConnections } from "@app/pages/Connections/useConnections";
|
||||||
@@ -39,8 +40,8 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PlugZap,
|
|
||||||
RotateCw,
|
RotateCw,
|
||||||
|
RouterIcon,
|
||||||
Star,
|
Star,
|
||||||
StarOff,
|
StarOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -71,7 +72,6 @@ export const Connections = () => {
|
|||||||
syncConnectionStatuses();
|
syncConnectionStatuses();
|
||||||
refreshStatuses();
|
refreshStatuses();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const copy = [...connections];
|
const copy = [...connections];
|
||||||
return copy.sort((a, b) => {
|
return copy.sort((a, b) => {
|
||||||
@@ -81,7 +81,9 @@ export const Connections = () => {
|
|||||||
if (!a.isDefault && b.isDefault) {
|
if (!a.isDefault && b.isDefault) {
|
||||||
return 1;
|
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 -1;
|
||||||
}
|
}
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
@@ -111,7 +113,7 @@ export const Connections = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center ml-2 gap-2">
|
<div className="flex items-center ml-2 gap-2">
|
||||||
<Button onClick={() => setAddOpen(true)} className="gap-2">
|
<Button onClick={() => setAddOpen(true)} className="gap-2">
|
||||||
<PlugZap className="size-4" />
|
<RouterIcon className="size-5" />
|
||||||
{t("button.addConnection")}
|
{t("button.addConnection")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,13 +133,13 @@ export const Connections = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button onClick={() => setAddOpen(true)} className="gap-2">
|
<Button onClick={() => setAddOpen(true)} className="gap-2">
|
||||||
<PlugZap className="size-4" />
|
<RouterIcon className="size-5" />
|
||||||
{t("button.addConnection")}
|
{t("button.addConnection")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</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) => (
|
{sorted.map((c) => (
|
||||||
<ConnectionCard
|
<ConnectionCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -223,7 +225,10 @@ export const Connections = () => {
|
|||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (created.status === "connected") {
|
if (
|
||||||
|
created.status === "connected" ||
|
||||||
|
created.status === "configured"
|
||||||
|
) {
|
||||||
navigate({ to: "/" });
|
navigate({ to: "/" });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -268,8 +273,10 @@ function ConnectionCard({
|
|||||||
const { t } = useTranslation("connections");
|
const { t } = useTranslation("connections");
|
||||||
|
|
||||||
const Icon = connectionTypeIcon(connection.type);
|
const Icon = connectionTypeIcon(connection.type);
|
||||||
const isBusy = connection.status === "connecting";
|
const isBusy =
|
||||||
const isConnected = connection.status === "connected";
|
connection.status === "connecting" || connection.status === "configuring";
|
||||||
|
const isConnected =
|
||||||
|
connection.status === "connected" || connection.status === "configured";
|
||||||
const isError = connection.status === "error";
|
const isError = connection.status === "error";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -367,10 +374,11 @@ function ConnectionCard({
|
|||||||
</p>
|
</p>
|
||||||
) : connection.lastConnectedAt ? (
|
) : connection.lastConnectedAt ? (
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{t("lastConnectedAt", {
|
{t("lastConnectedAt", { date: "" })}{" "}
|
||||||
date: new Date(connection.lastConnectedAt),
|
<TimeAgo
|
||||||
})}
|
timestamp={connection.lastConnectedAt}
|
||||||
:{new Date(connection.lastConnectedAt).toLocaleString()}
|
className="text-sm text-slate-500 dark:text-slate-400"
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<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 { TransportHTTP } from "@meshtastic/transport-http";
|
||||||
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
|
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
|
||||||
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
type LiveRefs = {
|
// Local storage for cleanup only (not in Zustand)
|
||||||
bt: Map<ConnectionId, BluetoothDevice>;
|
const transports = new Map<ConnectionId, BluetoothDevice | SerialPort>();
|
||||||
serial: Map<ConnectionId, SerialPort>;
|
const heartbeats = new Map<ConnectionId, ReturnType<typeof setInterval>>();
|
||||||
meshDevices: Map<ConnectionId, MeshDevice>;
|
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() {
|
export function useConnections() {
|
||||||
const connections = useDeviceStore((s) => s.savedConnections);
|
const connections = useDeviceStore((s) => s.savedConnections);
|
||||||
@@ -37,11 +39,9 @@ export function useConnections() {
|
|||||||
(s) => s.removeSavedConnection,
|
(s) => s.removeSavedConnection,
|
||||||
);
|
);
|
||||||
|
|
||||||
const live = useRef<LiveRefs>({
|
// DeviceStore methods
|
||||||
bt: new Map(),
|
const setActiveConnectionId = useDeviceStore((s) => s.setActiveConnectionId);
|
||||||
serial: new Map(),
|
|
||||||
meshDevices: new Map(),
|
|
||||||
});
|
|
||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
const { addNodeDB } = useNodeDBStore();
|
const { addNodeDB } = useNodeDBStore();
|
||||||
const { addMessageStore } = useMessageStore();
|
const { addMessageStore } = useMessageStore();
|
||||||
@@ -62,37 +62,67 @@ export function useConnections() {
|
|||||||
|
|
||||||
const removeConnection = useCallback(
|
const removeConnection = useCallback(
|
||||||
(id: ConnectionId) => {
|
(id: ConnectionId) => {
|
||||||
// Disconnect MeshDevice first
|
const conn = connections.find((c) => c.id === id);
|
||||||
const meshDevice = live.current.meshDevices.get(id);
|
|
||||||
if (meshDevice) {
|
// Stop heartbeat
|
||||||
try {
|
const heartbeatId = heartbeats.get(id);
|
||||||
meshDevice.disconnect();
|
if (heartbeatId) {
|
||||||
} catch {}
|
clearInterval(heartbeatId);
|
||||||
live.current.meshDevices.delete(id);
|
heartbeats.delete(id);
|
||||||
|
console.log(`[useConnections] Heartbeat stopped for connection ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close live refs if open
|
// Unsubscribe from config complete event
|
||||||
const bt = live.current.bt.get(id);
|
const unsubConfigComplete = configSubscriptions.get(id);
|
||||||
if (bt?.gatt?.connected) {
|
if (unsubConfigComplete) {
|
||||||
try {
|
unsubConfigComplete();
|
||||||
bt.gatt.disconnect();
|
configSubscriptions.delete(id);
|
||||||
} catch {
|
console.log(
|
||||||
// Ignore errors
|
`[useConnections] Config subscription cleaned up for connection ${id}`,
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
const sp = live.current.serial.get(id);
|
|
||||||
if (sp && "close" in sp) {
|
// Get device and MeshDevice from Device.connection
|
||||||
try {
|
if (conn?.meshDeviceId) {
|
||||||
(sp as SerialPort & { close: () => Promise<void> }).close();
|
const { getDevice, removeDevice } = useDeviceStore.getState();
|
||||||
} catch {
|
const device = getDevice(conn.meshDeviceId);
|
||||||
// Ignore errors
|
|
||||||
|
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(id);
|
||||||
},
|
},
|
||||||
[removeSavedConnectionFromStore],
|
[connections, removeSavedConnectionFromStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setDefaultConnection = useCallback(
|
const setDefaultConnection = useCallback(
|
||||||
@@ -115,36 +145,114 @@ export function useConnections() {
|
|||||||
| Awaited<ReturnType<typeof TransportHTTP.create>>
|
| Awaited<ReturnType<typeof TransportHTTP.create>>
|
||||||
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
|
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
|
||||||
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>,
|
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>,
|
||||||
options?: {
|
btDevice?: BluetoothDevice,
|
||||||
setHeartbeat?: boolean;
|
serialPort?: SerialPort,
|
||||||
onDisconnect?: () => void;
|
|
||||||
},
|
|
||||||
): number => {
|
): 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 device = addDevice(deviceId);
|
||||||
const nodeDB = addNodeDB(deviceId);
|
const nodeDB = addNodeDB(deviceId);
|
||||||
const messageStore = addMessageStore(deviceId);
|
const messageStore = addMessageStore(deviceId);
|
||||||
const meshDevice = new MeshDevice(transport, 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) {
|
setSelectedDevice(deviceId);
|
||||||
const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
|
device.addConnection(meshDevice); // This stores meshDevice in Device.connection
|
||||||
meshDevice.setHeartbeatInterval(HEARTBEAT_INTERVAL);
|
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 });
|
updateSavedConnection(id, { meshDeviceId: deviceId });
|
||||||
return deviceId;
|
return deviceId;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
connections,
|
||||||
addDevice,
|
addDevice,
|
||||||
addNodeDB,
|
addNodeDB,
|
||||||
addMessageStore,
|
addMessageStore,
|
||||||
setSelectedDevice,
|
setSelectedDevice,
|
||||||
|
setActiveConnectionId,
|
||||||
updateSavedConnection,
|
updateSavedConnection,
|
||||||
|
updateStatus,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -154,7 +262,7 @@ export function useConnections() {
|
|||||||
if (!conn) {
|
if (!conn) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (conn.status === "connected") {
|
if (conn.status === "configured" || conn.status === "connected") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +283,7 @@ export function useConnections() {
|
|||||||
const isTLS = url.protocol === "https:";
|
const isTLS = url.protocol === "https:";
|
||||||
const transport = await TransportHTTP.create(url.host, isTLS);
|
const transport = await TransportHTTP.create(url.host, isTLS);
|
||||||
setupMeshDevice(id, transport);
|
setupMeshDevice(id, transport);
|
||||||
updateStatus(id, "connected");
|
// Status will be set to "configured" by onConfigComplete event
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +291,7 @@ export function useConnections() {
|
|||||||
if (!("bluetooth" in navigator)) {
|
if (!("bluetooth" in navigator)) {
|
||||||
throw new Error("Web Bluetooth not supported");
|
throw new Error("Web Bluetooth not supported");
|
||||||
}
|
}
|
||||||
let bleDevice = live.current.bt.get(id);
|
let bleDevice = transports.get(id) as BluetoothDevice | undefined;
|
||||||
if (!bleDevice) {
|
if (!bleDevice) {
|
||||||
// Try to recover permitted devices
|
// Try to recover permitted devices
|
||||||
const getDevices = (
|
const getDevices = (
|
||||||
@@ -198,10 +306,6 @@ export function useConnections() {
|
|||||||
bleDevice = known.find(
|
bleDevice = known.find(
|
||||||
(d: BluetoothDevice) => d.id === conn.deviceId,
|
(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.",
|
"Bluetooth device not available. Re-select the device.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
live.current.bt.set(id, bleDevice);
|
|
||||||
|
|
||||||
const transport =
|
const transport =
|
||||||
await TransportWebBluetooth.createFromDevice(bleDevice);
|
await TransportWebBluetooth.createFromDevice(bleDevice);
|
||||||
setupMeshDevice(id, transport, { setHeartbeat: true });
|
setupMeshDevice(id, transport, bleDevice);
|
||||||
|
|
||||||
bleDevice.addEventListener("gattserverdisconnected", () => {
|
bleDevice.addEventListener("gattserverdisconnected", () => {
|
||||||
updateStatus(id, "disconnected");
|
updateStatus(id, "disconnected");
|
||||||
});
|
});
|
||||||
|
|
||||||
updateStatus(id, "connected");
|
// Status will be set to "configured" by onConfigComplete event
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +343,7 @@ export function useConnections() {
|
|||||||
if (!("serial" in navigator)) {
|
if (!("serial" in navigator)) {
|
||||||
throw new Error("Web Serial not supported");
|
throw new Error("Web Serial not supported");
|
||||||
}
|
}
|
||||||
let port = live.current.serial.get(id);
|
let port = transports.get(id) as SerialPort | undefined;
|
||||||
if (!port) {
|
if (!port) {
|
||||||
// Find a previously granted port by vendor/product
|
// Find a previously granted port by vendor/product
|
||||||
const ports: SerialPort[] = await (
|
const ports: SerialPort[] = await (
|
||||||
@@ -296,11 +399,9 @@ export function useConnections() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
live.current.serial.set(id, port);
|
|
||||||
|
|
||||||
const transport = await TransportWebSerial.createFromPort(port);
|
const transport = await TransportWebSerial.createFromPort(port);
|
||||||
setupMeshDevice(id, transport, { setHeartbeat: true });
|
setupMeshDevice(id, transport, undefined, port);
|
||||||
updateStatus(id, "connected");
|
// Status will be set to "configured" by onConfigComplete event
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -320,39 +421,68 @@ export function useConnections() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Disconnect MeshDevice first
|
// Stop heartbeat
|
||||||
const meshDevice = live.current.meshDevices.get(id);
|
const heartbeatId = heartbeats.get(id);
|
||||||
if (meshDevice) {
|
if (heartbeatId) {
|
||||||
try {
|
clearInterval(heartbeatId);
|
||||||
meshDevice.disconnect();
|
heartbeats.delete(id);
|
||||||
} catch {
|
console.log(
|
||||||
// Ignore errors
|
`[useConnections] Heartbeat stopped for connection ${id}`,
|
||||||
}
|
);
|
||||||
live.current.meshDevices.delete(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conn.type === "bluetooth") {
|
// Unsubscribe from config complete event
|
||||||
const dev = live.current.bt.get(id);
|
const unsubConfigComplete = configSubscriptions.get(id);
|
||||||
if (dev?.gatt?.connected) {
|
if (unsubConfigComplete) {
|
||||||
dev.gatt.disconnect();
|
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);
|
// Get device and meshDevice from Device.connection
|
||||||
if (port) {
|
if (conn.meshDeviceId) {
|
||||||
|
const { getDevice } = useDeviceStore.getState();
|
||||||
|
const device = getDevice(conn.meshDeviceId);
|
||||||
|
|
||||||
|
if (device?.connection) {
|
||||||
|
// Disconnect MeshDevice
|
||||||
try {
|
try {
|
||||||
const portWithClose = port as SerialPort & {
|
device.connection.disconnect();
|
||||||
close: () => Promise<void>;
|
} catch {
|
||||||
readable: ReadableStream | null;
|
// Ignore errors
|
||||||
};
|
|
||||||
// Only close if the port is open (has readable stream)
|
|
||||||
if (portWithClose.readable) {
|
|
||||||
await portWithClose.close();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Error closing serial port:", err);
|
|
||||||
}
|
}
|
||||||
live.current.serial.delete(id);
|
}
|
||||||
|
|
||||||
|
// 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 {
|
} finally {
|
||||||
@@ -379,7 +509,7 @@ export function useConnections() {
|
|||||||
const conn = addConnection(input);
|
const conn = addConnection(input);
|
||||||
// If a Bluetooth device was provided, store it to avoid re-prompting
|
// If a Bluetooth device was provided, store it to avoid re-prompting
|
||||||
if (btDevice && conn.type === "bluetooth") {
|
if (btDevice && conn.type === "bluetooth") {
|
||||||
live.current.bt.set(conn.id, btDevice);
|
transports.set(conn.id, btDevice);
|
||||||
}
|
}
|
||||||
await connect(conn.id, { allowPrompt: true });
|
await connect(conn.id, { allowPrompt: true });
|
||||||
// Get updated connection from store after connect
|
// Get updated connection from store after connect
|
||||||
@@ -395,11 +525,14 @@ export function useConnections() {
|
|||||||
// HTTP: test endpoint reachability
|
// HTTP: test endpoint reachability
|
||||||
// Bluetooth/Serial: check permission grants
|
// 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
|
const httpChecks = connections
|
||||||
.filter(
|
.filter(
|
||||||
(c): c is Connection & { type: "http"; url: string } =>
|
(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) => {
|
.map(async (c) => {
|
||||||
const ok = await testHttpReachable(c.url);
|
const ok = await testHttpReachable(c.url);
|
||||||
@@ -412,7 +545,10 @@ export function useConnections() {
|
|||||||
const btChecks = connections
|
const btChecks = connections
|
||||||
.filter(
|
.filter(
|
||||||
(c): c is Connection & { type: "bluetooth"; deviceId?: string } =>
|
(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) => {
|
.map(async (c) => {
|
||||||
if (!("bluetooth" in navigator)) {
|
if (!("bluetooth" in navigator)) {
|
||||||
@@ -445,7 +581,11 @@ export function useConnections() {
|
|||||||
type: "serial";
|
type: "serial";
|
||||||
usbVendorId?: number;
|
usbVendorId?: number;
|
||||||
usbProductId?: number;
|
usbProductId?: number;
|
||||||
} => c.type === "serial" && c.status !== "connected",
|
} =>
|
||||||
|
c.type === "serial" &&
|
||||||
|
c.status !== "connected" &&
|
||||||
|
c.status !== "configured" &&
|
||||||
|
c.status !== "configuring",
|
||||||
)
|
)
|
||||||
.map(async (c) => {
|
.map(async (c) => {
|
||||||
if (!("serial" in navigator)) {
|
if (!("serial" in navigator)) {
|
||||||
@@ -493,13 +633,16 @@ export function useConnections() {
|
|||||||
// Update all connection statuses
|
// Update all connection statuses
|
||||||
connections.forEach((conn) => {
|
connections.forEach((conn) => {
|
||||||
const shouldBeConnected = activeConnection?.id === conn.id;
|
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
|
// Update status if it doesn't match reality
|
||||||
if (shouldBeConnected && conn.status !== "connected") {
|
if (!shouldBeConnected && isConnectedState) {
|
||||||
updateSavedConnection(conn.id, { status: "connected" });
|
|
||||||
} else if (!shouldBeConnected && conn.status === "connected") {
|
|
||||||
updateSavedConnection(conn.id, { status: "disconnected" });
|
updateSavedConnection(conn.id, { status: "disconnected" });
|
||||||
}
|
}
|
||||||
|
// Don't force status to "connected" if shouldBeConnected - let the connection flow set the proper status
|
||||||
});
|
});
|
||||||
}, [connections, selectedDeviceId, updateSavedConnection]);
|
}, [connections, selectedDeviceId, updateSavedConnection]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user