Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ __screenshots__*
npm/
.idea
**/LICENSE
.DS_Store

packages/protobufs/packages/ts/dist

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/utils/eventSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
}
26 changes: 16 additions & 10 deletions packages/core/src/utils/transform/decodePacket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
56 changes: 33 additions & 23 deletions packages/web/src/components/DeviceInfoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand All @@ -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;
};

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export const NodeMarker = memo(function NodeMarker({
id,
lng,
lat,
label,
longLabel,
tooltipLabel,
hasError,
Expand Down
15 changes: 9 additions & 6 deletions packages/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(/^\//, ""),
Expand Down
70 changes: 70 additions & 0 deletions packages/web/src/core/stores/deviceStore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -907,6 +949,7 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
) => ({
devices: new Map(),
savedConnections: [],
activeConnectionId: null,

addDevice: (id) => {
const existing = get().devices.get(id);
Expand Down Expand Up @@ -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> = {
Expand Down
Loading