feat(connections): Add connections page (replaces new device dialog) (#919)

* feat(conn): add connection screen and logic

* fixes from code review

* force https

* code review fixes

* add http for self testing

* enable deviceStore persistance

* added translations

* disabled feature flag

* i18n updates

* chore: add new folders to biome config (#910)

* chore(i18n): New Crowdin Translations by GitHub Action (#908)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* fix: use correct deprecated GPS coordinate format enum (#917)

The Config_DisplayConfig_GpsCoordinateFormat export doesn't exist in the protobufs package. The correct export is Config_DisplayConfig_DeprecatedGpsCoordinateFormat, which matches what's used in the validation schema.

Added TODO comment explaining that this field is deprecated since protobufs 2.7.4 and should be migrated to DeviceUIConfig.gps_format when DeviceUI settings are implemented.

* style: fix line wrapping for GPS coordinate format enum (#918)

- Split long enum reference across multiple lines to improve code readability
- Maintains consistent code formatting standards without changing functionality

* fix(core): ensure core package works in browser (#923)

* fix(core): ensure core package works in browser

* style(core): revert new line removal

* fix: add @serialport/bindings-cpp to onlyBuiltDependencies (#914)

* feat(ui): Add UI library (#900)

* feat: scaffold UI library

* Update packages/ui/src/components/theme-provider.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add lock file

* lint/formatting fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* formatting/linting fixes

* fixed some paring logic

* fixed connection issue with serial

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com>
Co-authored-by: Azarattum <43073346+Azarattum@users.noreply.github.com>
Co-authored-by: Ben Allfree <ben@benallfree.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dan Ditomaso
2025-11-03 20:24:24 -05:00
committed by GitHub
co-authored by Copilot github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Crowdin Bot Kamil Dzieniszewski Azarattum Ben Allfree
parent a1a646983e
commit 1af1295b8f
69 changed files with 3628 additions and 1881 deletions
+232
View File
@@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
/**
* A minimal serializable wrapper so we can store metadata alongside items.
*/
type PersistedPayload<J = unknown> = {
v: 1; // schema version
capacity: number; // last used capacity (for info/migrations)
items: J[]; // serialized items (MRU -> LRU)
};
export type UseLRUListOptions<T, J = unknown> = {
/** localStorage key */
key: string;
/** max number of items to keep (>=1) */
capacity: number;
/** optional initial items used when storage is empty/invalid */
initial?: T[];
/** equality to de-duplicate items; default: Object.is */
eq?: (a: T, b: T) => boolean;
/** convert T -> JSON-safe type; default: (x) => x as unknown as J */
toJSON?: (t: T) => J;
/** convert JSON-safe type -> T; default: (x) => x as unknown as T */
fromJSON?: (j: J) => T;
/** storage impl (for tests); default: window.localStorage */
storage?: Storage;
/** listen to storage events and live-sync across tabs/windows; default: true */
syncTabs?: boolean;
};
export type UseLRUListReturn<T> = {
/** Items ordered MRU -> LRU */
items: T[];
/** Add or "touch" an item (move to MRU); inserts if missing */
add: (item: T) => void;
/** Remove a matching item (no-op if missing) */
remove: (item: T) => void;
/** Clear all items */
clear: () => void;
/** Whether a matching item exists */
has: (item: T) => boolean;
/** Replace the entire list (applies LRU trimming) */
replaceAll: (next: T[]) => void;
/** Current capacity (for information) */
capacity: number;
};
/**
* useLRUList<T> – maintains a most-recently-used list and persists it to localStorage.
*
* MRU is index 0. Adding an existing item "touches" it (moves to front).
*/
export function useLRUList<T, J = unknown>(
opts: UseLRUListOptions<T, J>,
): UseLRUListReturn<T> {
const {
key,
capacity,
initial = [],
eq = Object.is,
toJSON = (x: T) => x as unknown as J,
fromJSON = (x: J) => x as unknown as T,
storage = typeof window !== "undefined" ? window.localStorage : undefined,
syncTabs = true,
} = opts;
if (capacity < 1) {
// Fail fast in dev; silently coerce in prod
if (process.env.NODE_ENV !== "production") {
throw new Error("useLRUList: capacity must be >= 1");
}
}
const effectiveCapacity = Math.max(1, capacity);
// Guard against SSR or no-storage environments
const canPersist = !!storage && typeof storage.getItem === "function";
const readPersisted = useCallback((): T[] | null => {
if (!canPersist) {
return null;
}
try {
const raw = storage.getItem(key);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PersistedPayload<J>;
if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.items)) {
return null;
}
const deserialized = parsed.items.map(fromJSON);
return trimToCapacity(deserialized, effectiveCapacity);
} catch {
return null;
}
}, [canPersist, storage, key, fromJSON, effectiveCapacity]);
const writePersisted = useCallback(
(items: T[]) => {
if (!canPersist) {
return;
}
try {
const payload: PersistedPayload<J> = {
v: 1,
capacity: effectiveCapacity,
items: items.map(toJSON),
};
storage.setItem(key, JSON.stringify(payload));
} catch {
// Swallow quota/serialization errors; keep in-memory state working
}
},
[canPersist, storage, key, toJSON, effectiveCapacity],
);
// Initialize from storage (or fallback to `initial`)
const [items, setItems] = useState<T[]>(
() => readPersisted() ?? trimToCapacity([...initial], effectiveCapacity),
);
// Keep a ref to avoid feedback loops when applying remote (storage event) updates
const applyingExternal = useRef(false);
// Persist on changes
useEffect(() => {
if (applyingExternal.current) {
applyingExternal.current = false;
return;
}
writePersisted(items);
}, [items, writePersisted]);
// Cross-tab synchronization via storage events
useEffect(() => {
if (!syncTabs || !canPersist || typeof window === "undefined") {
return;
}
const onStorage = (e: StorageEvent) => {
if (e.storageArea !== storage || e.key !== key) {
return;
}
// Another tab changed it; re-read safely
const next = readPersisted();
if (!next) {
return;
}
applyingExternal.current = true;
setItems(next);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [syncTabs, canPersist, storage, key, readPersisted]);
// Helpers
const indexOf = useCallback(
(arr: T[], needle: T) => arr.findIndex((x) => eq(x, needle)),
[eq],
);
const add = useCallback(
(item: T) => {
setItems((prev) => {
const idx = indexOf(prev, item);
if (idx === 0) {
return prev; // already MRU
}
if (idx > 0) {
const next = [...prev];
next.splice(idx, 1);
next.unshift(item);
return next;
}
// Not present: insert at MRU and trim
const next = [item, ...prev];
return trimToCapacity(next, effectiveCapacity);
});
},
[indexOf, effectiveCapacity],
);
const remove = useCallback(
(item: T) => {
setItems((prev) => {
const idx = indexOf(prev, item);
if (idx === -1) {
return prev;
}
const next = [...prev];
next.splice(idx, 1);
return next;
});
},
[indexOf],
);
const clear = useCallback(() => setItems([]), []);
const has = useCallback(
(item: T) => indexOf(items, item) !== -1,
[items, indexOf],
);
const replaceAll = useCallback(
(next: T[]) => setItems(trimToCapacity([...next], effectiveCapacity)),
[effectiveCapacity],
);
// Stable API shape
return useMemo(
() => ({
items,
add,
remove,
clear,
has,
replaceAll,
capacity: effectiveCapacity,
}),
[items, add, remove, clear, has, replaceAll, effectiveCapacity],
);
}
// --- utils ---
function trimToCapacity<T>(arr: T[], capacity: number): T[] {
if (arr.length <= capacity) {
return arr;
}
return arr.slice(0, capacity);
}
@@ -7,7 +7,6 @@ if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
persistMessages: true,
persistDevices: false,
persistApp: true,
});
}
@@ -29,7 +29,7 @@ export type ValidModuleConfigType =
export type ConfigChangeKey =
| { type: "config"; variant: ValidConfigType }
| { type: "moduleConfig"; variant: ValidModuleConfigType }
| { type: "channel"; index: Types.ChannelNumber }
| { type: "channels"; index: Types.ChannelNumber }
| { type: "user" };
// Serialized key for Map storage
@@ -57,7 +57,7 @@ export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
return `config:${key.variant}`;
case "moduleConfig":
return `moduleConfig:${key.variant}`;
case "channel":
case "channels":
return `channel:${key.index}`;
case "user":
return "user";
@@ -78,9 +78,9 @@ export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
type: "moduleConfig",
variant: variant as ValidModuleConfigType,
};
case "channel":
case "channels":
return {
type: "channel",
type: "channels",
index: Number(variant) as Types.ChannelNumber,
};
case "user":
@@ -126,7 +126,7 @@ export function hasChannelChange(
registry: ChangeRegistry,
index: Types.ChannelNumber,
): boolean {
return registry.changes.has(serializeKey({ type: "channel", index }));
return registry.changes.has(serializeKey({ type: "channels", index }));
}
/**
@@ -171,7 +171,7 @@ export function getChannelChangeCount(registry: ChangeRegistry): number {
let count = 0;
for (const keyStr of registry.changes.keys()) {
const key = deserializeKey(keyStr);
if (key.type === "channel") {
if (key.type === "channels") {
count++;
}
}
@@ -212,7 +212,7 @@ export function getAllModuleConfigChanges(
export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
const changes: ChangeEntry[] = [];
for (const entry of registry.changes.values()) {
if (entry.key.type === "channel") {
if (entry.key.type === "channels") {
changes.push(entry);
}
}
@@ -1,5 +1,4 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { featureFlags } from "@core/services/featureFlags";
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
@@ -26,6 +25,8 @@ import {
serializeKey,
} from "./changeRegistry.ts";
import type {
Connection,
ConnectionId,
Dialogs,
DialogVariant,
ValidConfigType,
@@ -142,6 +143,16 @@ export interface deviceState {
removeDevice: (id: number) => void;
getDevices: () => Device[];
getDevice: (id: number) => Device | undefined;
// Saved connections management
savedConnections: Connection[];
addSavedConnection: (connection: Connection) => void;
updateSavedConnection: (
id: ConnectionId,
updates: Partial<Connection>,
) => void;
removeSavedConnection: (id: ConnectionId) => void;
getSavedConnections: () => Connection[];
}
interface PrivateDeviceState extends deviceState {
@@ -150,6 +161,7 @@ interface PrivateDeviceState extends deviceState {
type DevicePersisted = {
devices: Map<number, DeviceData>;
savedConnections: Connection[];
};
function deviceFactory(
@@ -894,6 +906,7 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
get,
) => ({
devices: new Map(),
savedConnections: [],
addDevice: (id) => {
const existing = get().devices.get(id);
@@ -924,6 +937,41 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
},
getDevices: () => Array.from(get().devices.values()),
getDevice: (id) => get().devices.get(id),
addSavedConnection: (connection) => {
set(
produce<PrivateDeviceState>((draft) => {
draft.savedConnections.push(connection);
}),
);
},
updateSavedConnection: (id, updates) => {
set(
produce<PrivateDeviceState>((draft) => {
const conn = draft.savedConnections.find(
(c: Connection) => c.id === id,
);
if (conn) {
for (const key in updates) {
if (Object.hasOwn(updates, key)) {
(conn as Record<string, unknown>)[key] =
updates[key as keyof typeof updates];
}
}
}
}),
);
},
removeSavedConnection: (id) => {
set(
produce<PrivateDeviceState>((draft) => {
draft.savedConnections = draft.savedConnections.filter(
(c: Connection) => c.id !== id,
);
}),
);
},
getSavedConnections: () => get().savedConnections,
});
const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
@@ -943,6 +991,7 @@ const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
},
]),
),
savedConnections: s.savedConnections,
}),
onRehydrateStorage: () => (state) => {
if (!state) {
@@ -980,14 +1029,6 @@ const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
},
};
// Add persist middleware on the store if the feature flag is enabled
const persistDevices = featureFlags.get("persistDevices");
console.debug(
`DeviceStore: Persisting devices is ${persistDevices ? "enabled" : "disabled"}`,
export const useDeviceStore = createStore(
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
);
export const useDeviceStore = persistDevices
? createStore(
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
)
: createStore(subscribeWithSelector(deviceStoreInitializer));
@@ -28,6 +28,46 @@ type DialogVariant = keyof Dialogs;
type Page = "messages" | "map" | "settings" | "channels" | "nodes";
export type ConnectionId = number;
export type ConnectionType = "http" | "bluetooth" | "serial";
export type ConnectionStatus =
| "connected"
| "connecting"
| "disconnected"
| "disconnecting"
| "configuring"
| "configured"
| "online"
| "error";
export type Connection = {
id: ConnectionId;
type: ConnectionType;
name: string;
createdAt: number;
lastConnectedAt?: number;
isDefault?: boolean;
status: ConnectionStatus;
error?: string;
meshDeviceId?: number;
} & NewConnection;
export type NewConnection =
| { type: "http"; name: string; url: string }
| {
type: "bluetooth";
name: string;
deviceId?: string;
deviceName?: string;
gattServiceUUID?: string;
}
| {
type: "serial";
name: string;
usbVendorId?: number;
usbProductId?: number;
};
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
metadata: {
channel: number; // Channel on which the waypoint was received