feat(protobufs): sync to firmware-current and consume workspace package (#1097)

* feat(protobufs): sync to firmware-current and consume workspace package

Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*).

Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true.

* build(protobufs): vendor generated bindings for workspace consumers

The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen

* fix(protobufs): clean script removes the actual generated output dir

buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback.

* refactor: move web app packages/web -> apps/web

Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green.

* feat: config fields, module pages, key verification, telemetry capture

Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green.

* chore: drop #1062 (unsaved-change-detection) to match upstream revert

#1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green.

* fix(nodes): clean up SNR display in node table and map popup

SNR is a ratio measured in dB, not dBm (which is absolute power); the
node table and map popup both mislabeled it and crammed three values
together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same
heuristic ((snr+10)*5) shown twice — once clamped, once not.

Render SNR in dB rounded to one decimal, color-coded by a 0-100%
signal-quality heuristic (green/yellow/red), with the quality percentage
as a muted secondary. Drop the redundant raw value. Adds unit.db; this
matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
Ben Meadors
2026-06-15 13:23:28 -05:00
committed by GitHub
parent 7a8529b841
commit bbe9a0d5cd
695 changed files with 27038 additions and 786 deletions
@@ -0,0 +1,37 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/core";
function createDefaultUser(num: number): Protobuf.Mesh.User {
const userIdHex = num.toString(16).toUpperCase().padStart(2, "0");
const userId = `!${userIdHex}`;
const last4 = userIdHex.slice(-4);
const longName = `Meshtastic ${last4}`;
const shortName = last4;
return create(Protobuf.Mesh.UserSchema, {
id: userId,
longName: longName.toString(),
shortName: shortName.toString(),
});
}
export function ensureDefaultUser(node: Protobuf.Mesh.NodeInfo): Protobuf.Mesh.NodeInfo {
if (!node) {
return node;
}
if (!node.user) {
if (node.num === undefined || node.num === null) {
console.error(
`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`,
node,
);
return node;
}
// Assign the created user directly
node.user = createDefaultUser(node.num);
}
return node;
}
@@ -0,0 +1,53 @@
import { MessageState, MessageType } from "@core/stores";
import type { Message } from "@core/stores/messageStore/types.ts";
import type { Types } from "@meshtastic/core";
class PacketToMessageDTO {
channel: Types.ChannelNumber;
to: number;
from: number;
date: number; // (timestamp ms)
messageId: number;
state: MessageState;
message: string;
type: MessageType;
constructor(data: Types.PacketMetadata<string>, nodeNum: number) {
this.channel = data.channel;
this.to = data.to;
this.from = data.from;
this.messageId = data.id;
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting;
this.message = data.data;
this.type = data.type === "direct" ? MessageType.Direct : MessageType.Broadcast;
let dateTimestamp = Date.now();
if (data.rxTime instanceof Date) {
const timeValue = data.rxTime.getTime();
if (!Number.isNaN(timeValue)) {
dateTimestamp = timeValue;
}
} else if (data.rxTime != null) {
console.warn(
`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`,
);
}
this.date = dateTimestamp;
}
toMessage(): Message {
return {
channel: this.channel,
to: this.to,
from: this.from,
date: this.date,
messageId: this.messageId,
state: this.state,
message: this.message,
type: this.type,
};
}
}
export default PacketToMessageDTO;
@@ -0,0 +1,32 @@
import { useMemo } from "react";
export type BrowserFeature = "Web Bluetooth" | "Web Serial" | "Secure Context";
interface BrowserSupport {
supported: BrowserFeature[];
unsupported: BrowserFeature[];
}
export function useBrowserFeatureDetection(): BrowserSupport {
const support = useMemo(() => {
const features: [BrowserFeature, boolean][] = [
["Web Bluetooth", !!navigator.bluetooth],
["Web Serial", !!navigator.serial],
[
"Secure Context",
globalThis.location.protocol === "https:" || globalThis.location.hostname === "localhost",
],
];
return features.reduce<BrowserSupport>(
(acc, [feature, isSupported]) => {
const list = isSupported ? acc.supported : acc.unsupported;
list.push(feature);
return acc;
},
{ supported: [], unsupported: [] },
);
}, []);
return support;
}
+49
View File
@@ -0,0 +1,49 @@
import Cookies from "js-cookie";
import { useCallback, useState } from "react";
interface CookieHookResult<T> {
value: T | undefined;
setCookie: (value: T, options?: Cookies.CookieAttributes) => void;
removeCookie: () => void;
}
function useCookie<T extends object>(cookieName: string, initialValue?: T): CookieHookResult<T> {
const [cookieValue, setCookieValue] = useState<T | undefined>(() => {
try {
const cookie = Cookies.get(cookieName);
return cookie ? (JSON.parse(cookie) as T) : initialValue;
} catch (error) {
console.error(`Error parsing cookie ${cookieName}:`, error);
return initialValue;
}
});
const setCookie = useCallback(
(value: T, options?: Cookies.CookieAttributes) => {
try {
Cookies.set(cookieName, JSON.stringify(value), options);
setCookieValue(value);
} catch (error) {
console.error(`Error setting cookie ${cookieName}:`, error);
}
},
[cookieName],
);
const removeCookie = useCallback(() => {
try {
Cookies.remove(cookieName);
setCookieValue(undefined);
} catch (error) {
console.error(`Error removing cookie ${cookieName}:`, error);
}
}, [cookieName]);
return {
value: cookieValue,
setCookie,
removeCookie,
};
}
export default useCookie;
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface UseCopyToClipboardProps {
timeout?: number;
}
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) {
const [isCopied, setIsCopied] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
globalThis.clearTimeout(timeoutRef.current);
}
};
}, []);
const copy = useCallback(
async (text: string) => {
if (!navigator?.clipboard) {
console.warn("Clipboard API not available");
setIsCopied(false);
return false;
}
if (timeoutRef.current) {
globalThis.clearTimeout(timeoutRef.current);
}
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
timeoutRef.current = globalThis.setTimeout(() => {
setIsCopied(false);
timeoutRef.current = null;
}, timeout);
return true;
} catch (error) {
console.error("Failed to copy text to clipboard:", error);
setIsCopied(false);
return false;
}
},
[timeout],
);
return { isCopied, copy };
}
@@ -0,0 +1,15 @@
import { createContext, useContext } from "react";
export type DeviceContext = {
deviceId: number; // Unique identifier for the device, not nodeNum
};
export const CurrentDeviceContext = createContext<DeviceContext | undefined>(undefined);
export function useDeviceContext(): DeviceContext {
const ctx = useContext(CurrentDeviceContext);
if (!ctx) {
throw new Error("useDeviceContext must be used within CurrentDeviceContext provider");
}
return ctx;
}
@@ -0,0 +1,100 @@
import type { Protobuf } from "@meshtastic/core";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useFavoriteNode } from "./useFavoriteNode.ts";
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
},
isFavorite: true,
} as unknown | Protobuf.Mesh.NodeInfo;
const mockUpdateFavorite = vi.fn();
const mockGetNode = vi.fn(() => mockNode);
const mockToast = vi.fn();
const mockSendAdminMessage = vi.fn();
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useNodeDB: () => ({
updateFavorite: mockUpdateFavorite,
getNode: mockGetNode,
}),
useDevice: () => ({
sendAdminMessage: mockSendAdminMessage,
}),
}));
vi.mock("@core/hooks/useToast.ts", () => ({
useToast: () => ({
toast: mockToast,
}),
}));
describe("useFavoriteNode hook", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls updateFavorite and shows correct toast", () => {
const { result } = renderHook(() => useFavoriteNode());
act(() => {
result.current.updateFavorite({ nodeNum: 1234, isFavorite: true });
});
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, true);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Added Test Node to favorites.",
});
});
it("handles removal case", () => {
const { result } = renderHook(() => useFavoriteNode());
act(() => {
result.current.updateFavorite({ nodeNum: 1234, isFavorite: false });
});
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, false);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Removed Test Node from favorites.",
});
});
it("falls back to 'node' if longName is missing", () => {
mockGetNode.mockReturnValueOnce({
num: 5678,
user: {},
}); // no longName
const { result } = renderHook(() => useFavoriteNode());
act(() => {
result.current.updateFavorite({ nodeNum: 5678, isFavorite: true });
});
expect(mockToast).toHaveBeenCalledWith({
title: "Added Node to favorites.",
});
});
it("falls back to 'node' if getNode returns undefined", () => {
mockGetNode.mockReturnValueOnce(undefined);
const { result } = renderHook(() => useFavoriteNode());
act(() => {
result.current.updateFavorite({ nodeNum: 9999, isFavorite: false });
});
expect(mockUpdateFavorite).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,54 @@
import { create } from "@bufbuild/protobuf";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice, useNodeDB } from "@core/stores";
import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
interface FavoriteNodeOptions {
nodeNum: number;
isFavorite: boolean;
}
export function useFavoriteNode() {
const { sendAdminMessage } = useDevice();
const { getNode, updateFavorite } = useNodeDB();
const { t } = useTranslation();
const { toast } = useToast();
const updateFavoriteCB = useCallback(
({ nodeNum, isFavorite }: FavoriteNodeOptions) => {
const node = getNode(nodeNum);
if (!node) {
return;
}
sendAdminMessage(
create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
case: isFavorite ? "setFavoriteNode" : "removeFavoriteNode",
value: nodeNum,
},
}),
);
// TODO: Wait for response before changing the store
updateFavorite(nodeNum, isFavorite);
toast({
title: t("toast.favoriteNode.title", {
action: isFavorite
? t("toast.favoriteNode.action.added")
: t("toast.favoriteNode.action.removed"),
nodeName: node?.user?.longName ?? t("node"),
direction: isFavorite
? t("toast.favoriteNode.action.to")
: t("toast.favoriteNode.action.from"),
}),
});
},
[updateFavorite, sendAdminMessage, getNode, t, toast],
);
return { updateFavorite: updateFavoriteCB };
}
@@ -0,0 +1,18 @@
import { type FlagKey, type Flags, featureFlags } from "@core/services/featureFlags.ts";
import * as React from "react";
export function useFeatureFlags(): Flags {
return React.useSyncExternalStore(
(cb) => featureFlags.subscribe(cb),
() => featureFlags.all(),
() => featureFlags.all(),
);
}
export function useFeatureFlag(key: FlagKey): boolean {
return React.useSyncExternalStore(
(cb) => featureFlags.subscribe(cb),
() => featureFlags.get(key),
() => featureFlags.get(key),
);
}
@@ -0,0 +1,103 @@
import type { Protobuf } from "@meshtastic/core";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useIgnoreNode } from "./useIgnoreNode.ts";
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
},
isIgnored: true,
} as unknown | Protobuf.Mesh.NodeInfo;
const mockUpdateIgnore = vi.fn();
const mockGetNode = vi.fn(() => mockNode);
const mockToast = vi.fn();
const mockSendAdminMessage = vi.fn();
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useNodeDB: () => ({
updateIgnore: mockUpdateIgnore,
getNode: mockGetNode,
}),
useDevice: () => ({
sendAdminMessage: mockSendAdminMessage,
}),
}));
vi.mock("@core/hooks/useToast.ts", () => ({
useToast: () => ({
toast: mockToast,
}),
}));
describe("useIgnoreNode hook", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls updateIgnored and shows correct toast", () => {
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({ nodeNum: 1234, isIgnored: true });
});
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, true);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Added Test Node to ignore list",
});
});
it("handles removal case", () => {
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({
nodeNum: 1234,
isIgnored: false,
});
});
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, false);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Removed Test Node from ignore list",
});
});
it("falls back to 'node' if longName is missing", () => {
mockGetNode.mockReturnValueOnce({
num: 5678,
user: {},
}); // no longName
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({ nodeNum: 5678, isIgnored: true });
});
expect(mockToast).toHaveBeenCalledWith({
title: "Added node to ignore list",
});
});
it("falls back to 'node' if getNode returns undefined", () => {
mockGetNode.mockReturnValueOnce(undefined);
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({ nodeNum: 9999, isIgnored: false });
});
expect(mockUpdateIgnore).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { create } from "@bufbuild/protobuf";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice, useNodeDB } from "@core/stores";
import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
interface IgnoreNodeOptions {
nodeNum: number;
isIgnored: boolean;
}
export function useIgnoreNode() {
const { sendAdminMessage } = useDevice();
const { getNode, updateIgnore } = useNodeDB();
const { t } = useTranslation();
const { toast } = useToast();
const updateIgnoredCB = useCallback(
({ nodeNum, isIgnored }: IgnoreNodeOptions) => {
const node = getNode(nodeNum);
if (!node) {
return;
}
sendAdminMessage(
create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
case: isIgnored ? "setIgnoredNode" : "removeIgnoredNode",
value: nodeNum,
},
}),
);
// TODO: Wait for response before changing the store
updateIgnore(nodeNum, isIgnored);
toast({
title: t("toast.ignoreNode.title", {
nodeName: node?.user?.longName ?? "node",
action: isIgnored
? t("toast.ignoreNode.action.added")
: t("toast.ignoreNode.action.removed"),
direction: isIgnored
? t("toast.ignoreNode.action.to")
: t("toast.ignoreNode.action.from"),
}),
});
},
[sendAdminMessage, updateIgnore, getNode, t, toast],
);
return { updateIgnored: updateIgnoredCB };
}
@@ -0,0 +1,134 @@
import { Button } from "@components/UI/Button.tsx";
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
interface UseBackupReminderOptions {
reminderInDays?: number;
message: string;
onAccept?: () => void | Promise<void>;
enabled: boolean;
}
interface ReminderState {
expires: string;
}
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds
const TOAST_DURATION = 30_000; // 30 seconds
const REMINDER_DAYS_ONE_WEEK = 7;
const REMINDER_DAYS_ONE_YEAR = 365;
const REMINDER_DAYS_FOREVER = 3650;
const STORAGE_KEY = "key_backup_reminder";
function isReminderExpired(expires?: string): boolean {
if (!expires) {
return true;
}
const expiryDate = new Date(expires);
if (Number.isNaN(expiryDate.getTime())) {
return true; // Invalid date passed
}
const now = new Date();
return now.getTime() >= expiryDate.getTime();
}
export function useBackupReminder({
enabled,
message,
onAccept = () => {},
reminderInDays = REMINDER_DAYS_ONE_WEEK,
}: UseBackupReminderOptions) {
const { t } = useTranslation("dialog");
const { toast } = useToast();
const toastShownRef = useRef(false);
const [reminderState, setReminderState] = useLocalStorage<ReminderState | null>(
STORAGE_KEY,
null,
);
const setReminderExpiry = useCallback(
(days: number) => {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + days);
setReminderState({ expires: expiryDate.toISOString() });
},
[setReminderState],
);
useEffect(() => {
if (!enabled || toastShownRef.current) {
return;
}
if (!isReminderExpired(reminderState?.expires)) {
return;
}
toastShownRef.current = true;
const { dismiss } = toast({
title: t("pkiBackupReminder.title"),
duration: TOAST_DURATION,
delay: TOAST_APPEAR_DELAY,
description: message,
action: (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="p-1"
onClick={() => {
dismiss();
setReminderExpiry(reminderInDays);
}}
>
{t("pkiBackupReminder.remindLaterPrefix")} {reminderInDays}{" "}
{reminderInDays > 1
? t("common:unit.day.plural").toLowerCase()
: t("common:unit.day.one").toLowerCase()}
</Button>
<Button
type="button"
variant="outline"
className="p-1"
onClick={() => {
dismiss();
setReminderExpiry(REMINDER_DAYS_FOREVER);
}}
>
{t("pkiBackupReminder.remindNever")}
</Button>
</div>
<Button
type="button"
variant="default"
className="w-full"
onClick={() => {
onAccept();
dismiss();
setReminderExpiry(REMINDER_DAYS_ONE_YEAR);
}}
>
{t("pkiBackupReminder.backupNow")}
</Button>
</div>
),
});
return () => dismiss();
}, [
enabled,
message,
onAccept,
reminderInDays,
reminderState?.expires,
setReminderExpiry,
t,
toast,
]);
}
+224
View File
@@ -0,0 +1,224 @@
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);
}
+65
View File
@@ -0,0 +1,65 @@
import {
FALLBACK_LANGUAGE_CODE,
type Lang,
type LangCode,
supportedLanguages,
} from "@app/i18n-config.ts";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import useLocalStorage from "./useLocalStorage.ts";
const STORAGE_KEY = "language";
type LanguageState = {
language: LangCode;
};
function useLang() {
const { i18n } = useTranslation();
const [_, setLanguageInStorage] = useLocalStorage<LanguageState | null>(STORAGE_KEY, null);
const currentLanguage = useMemo((): Lang | undefined => {
const lang = supportedLanguages.find((l) => l.code === i18n.language);
if (lang) {
return lang;
}
return supportedLanguages.find((l) => l.code === FALLBACK_LANGUAGE_CODE);
}, [i18n.language]);
const collator = useMemo(() => {
return new Intl.Collator(i18n.language, { sensitivity: "base" });
}, [i18n.language]);
const set = useCallback(
async (lng: LangCode, persist = true) => {
if (i18n.language === lng) {
return;
}
try {
if (persist) {
setLanguageInStorage({ language: lng });
}
await i18n.changeLanguage(lng);
} catch (e) {
console.warn("Failed to change language:", e);
}
},
[i18n, setLanguageInStorage],
);
const getSupportedLangs = useMemo(
() => supportedLanguages.toSorted((a, b) => a.name.localeCompare(b.name)),
[],
);
const compare = useCallback(
(a: string, b: string) => {
return collator.compare(a, b);
},
[collator],
);
return { compare, set, current: currentLanguage, getSupportedLangs };
}
export default useLang;
@@ -0,0 +1,52 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import useLocalStorage from "./useLocalStorage.ts";
describe("useLocalStorage", () => {
const key = "test-key";
beforeEach(() => {
localStorage.clear();
});
it("should initialize with initial value if localStorage is empty", () => {
const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [value] = result.current;
expect(value).toBe("initial");
});
it("should read existing value from localStorage", () => {
localStorage.setItem(key, JSON.stringify("stored"));
const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [value] = result.current;
expect(value).toBe("stored");
});
it("should update localStorage when setValue is called", () => {
const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [, setValue] = result.current;
act(() => {
setValue("updated");
});
expect(localStorage.getItem(key)).toBe(JSON.stringify("updated"));
expect(result.current[0]).toBe("updated");
});
it("should remove value from localStorage when removeValue is called", () => {
const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [, setValue, removeValue] = result.current;
act(() => {
setValue("to-be-removed");
});
act(() => {
removeValue();
});
expect(localStorage.getItem(key)).toBeNull();
expect(result.current[0]).toBe("initial");
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
declare global {
interface WindowEventMap {
"local-storage": CustomEvent<{ key: string }>;
}
}
type UseLocalStorageOptions<T> = {
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
initializeWithValue?: boolean;
};
const IS_SERVER = typeof window === "undefined";
export default function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
options: UseLocalStorageOptions<T> = {},
): [T, Dispatch<SetStateAction<T>>, () => void] {
const { initializeWithValue = true } = options;
const serializerRef = useRef(options.serializer ?? JSON.stringify);
const deserializerRef = useRef(options.deserializer ?? JSON.parse);
const initialValueRef = useRef<T>(
typeof initialValue === "function" ? (initialValue as () => T)() : initialValue,
);
const getInitialValue = useCallback(() => initialValueRef.current, []);
const [storedValue, setStoredValue] = useState<T>(() => {
if (!initializeWithValue || IS_SERVER) {
return getInitialValue();
}
try {
const item = window.localStorage.getItem(key);
return item ? deserializerRef.current(item) : getInitialValue();
} catch (err) {
console.warn(`Error reading localStorage key “${key}”:`, err);
return getInitialValue();
}
});
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(value) => {
if (IS_SERVER) {
console.warn(`Tried setting localStorage key “${key}” in a non-client environment.`);
return;
}
try {
setStoredValue((prev) => {
const newValue = typeof value === "function" ? (value as (prev: T) => T)(prev) : value;
window.localStorage.setItem(key, serializerRef.current(newValue));
window.dispatchEvent(new CustomEvent("local-storage", { detail: { key } }));
return newValue;
});
} catch (err) {
console.warn(`Error setting localStorage key “${key}”:`, err);
}
},
[key],
);
const removeValue = useCallback(() => {
if (IS_SERVER) {
console.warn(`Tried removing localStorage key “${key}” in a non-client environment.`);
return;
}
try {
window.localStorage.removeItem(key);
setStoredValue(getInitialValue());
window.dispatchEvent(new CustomEvent("local-storage", { detail: { key } }));
} catch (err) {
console.warn(`Error removing localStorage key “${key}”:`, err);
}
}, [key, getInitialValue]);
useEffect(() => {
if (IS_SERVER) {
return;
}
const handleStorageChange = (event: StorageEvent | CustomEvent<{ key: string }>) => {
const eventKey = "key" in event ? event.key : event.detail?.key;
if (eventKey === key) {
try {
const item = window.localStorage.getItem(key);
setStoredValue(item ? deserializerRef.current(item) : getInitialValue());
} catch (err) {
console.warn(`Error syncing localStorage key “${key}”:`, err);
setStoredValue(getInitialValue());
}
}
};
window.addEventListener("storage", handleStorageChange);
window.addEventListener("local-storage", handleStorageChange as EventListener);
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener("local-storage", handleStorageChange as EventListener);
};
}, [key, getInitialValue]);
return [storedValue, setValue, removeValue];
}
+50
View File
@@ -0,0 +1,50 @@
import { boundsFromLngLat, type LngLat, toLngLat } from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import type { MapRef } from "react-map-gl/maplibre";
export function useMapFitting(map: MapRef | undefined) {
const focusLngLat = useCallback(
(position: LngLat) => {
if (!map) {
return;
}
const [lng, lat] = position;
map.easeTo({
center: [lng, lat],
zoom: map.getZoom(),
});
},
[map],
);
const fitToNodes = useCallback(
(nodes: Protobuf.Mesh.NodeInfo[]) => {
if (!map || nodes.length === 0) {
return;
}
if (nodes.length === 1 && nodes[0]) {
return focusLngLat(toLngLat(nodes[0].position));
}
// Build [lng, lat] coords, then let boundsFromLngLat do the turf dance
const coords = nodes.map((n) => toLngLat(n.position));
const bounds = boundsFromLngLat(coords);
if (!bounds) {
return;
}
const center = map.cameraForBounds(bounds, {
padding: { top: 10, bottom: 10, left: 10, right: 10 },
});
if (center) {
map.easeTo(center);
}
},
[map, focusLngLat],
);
return { focusLngLat, fitToNodes };
}
+8
View File
@@ -0,0 +1,8 @@
import { useDeviceStore, useMessageStore, useNodeDBStore } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
export function useNewNodeNum(id: number, nodeInfo: Protobuf.Mesh.MyNodeInfo): void {
useDeviceStore.getState().getDevice(id)?.setHardware(nodeInfo);
useNodeDBStore.getState().getNodeDB(id)?.setNodeNum(nodeInfo.myNodeNum);
useMessageStore.getState().getMessageStore(id)?.setNodeNum(nodeInfo.myNodeNum);
}
@@ -0,0 +1,62 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { usePasswordVisibilityToggle } from "./usePasswordVisibilityToggle.ts";
describe("usePasswordVisibilityToggle Hook", () => {
it("should initialize with visibility set to false by default", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false);
expect(typeof result.current.toggleVisibility).toBe("function");
});
it("should initialize with visibility set to true if initialVisible is true", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle({ initialVisible: true }));
expect(result.current.isVisible).toBe(true);
});
it("should toggle visibility from false to true when toggleVisibility is called", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(true);
});
it("should toggle visibility from true to false when toggleVisibility is called", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle({ initialVisible: true }));
expect(result.current.isVisible).toBe(true);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(false);
});
it("should toggle visibility correctly multiple times", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(true);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(false);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(true);
});
it("should return a stable toggleVisibility function reference (due to useCallback)", () => {
const { result, rerender } = renderHook(() => usePasswordVisibilityToggle());
const initialToggleFunc = result.current.toggleVisibility;
rerender();
expect(result.current.toggleVisibility).toBe(initialToggleFunc);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(true);
});
});
@@ -0,0 +1,22 @@
import { useCallback, useState } from "react";
interface UsePasswordVisibilityToggleProps {
initialVisible?: boolean;
}
/**
* Manages the state for toggling password visibility.
*
* @param {boolean} [options.initialVisible=false]
* @returns {{isVisible: boolean, toggleVisibility: () => void}}
*/
export function usePasswordVisibilityToggle({
initialVisible = false,
}: UsePasswordVisibilityToggleProps = {}) {
const [isVisible, setIsVisible] = useState<boolean>(initialVisible);
const toggleVisibility = useCallback(() => {
setIsVisible((prev) => !prev);
}, []);
return { isVisible, toggleVisibility };
}
+20
View File
@@ -0,0 +1,20 @@
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
import { useCallback } from "react";
export function usePinnedItems({ storageName }: { storageName: string }) {
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []);
const togglePinnedItem = useCallback(
(label: string) => {
setPinnedItems((prev) =>
prev.includes(label) ? prev.filter((g) => g !== label) : [...prev, label],
);
},
[setPinnedItems],
);
return {
pinnedItems,
togglePinnedItem,
};
}
+147
View File
@@ -0,0 +1,147 @@
import { useCallback, useMemo, useState } from "react";
export const FLAGS_CONFIG = {
UNSET: { value: 0, i18nKey: "position.flags.unset" },
ALTITUDE: { value: 1, i18nKey: "position.flags.altitude" },
ALTITUDE_MSL: { value: 2, i18nKey: "position.flags.altitudeMsl" },
ALTITUDE_GEOIDAL_SEPARATION: {
value: 4,
i18nKey: "position.flags.altitudeGeoidalSeparation",
},
DOP: {
value: 8,
i18nKey: "position.flags.dop",
},
HDOP_VDOP: {
value: 16,
i18nKey: "position.flags.hdopVdop",
},
NUM_SATELLITES: {
value: 32,
i18nKey: "position.flags.numSatellites",
},
SEQUENCE_NUMBER: {
value: 64,
i18nKey: "position.flags.sequenceNumber",
},
TIMESTAMP: { value: 128, i18nKey: "position.flags.timestamp" },
VEHICLE_HEADING: {
value: 256,
i18nKey: "position.flags.vehicleHeading",
},
VEHICLE_SPEED: { value: 512, i18nKey: "position.flags.vehicleSpeed" },
} as const;
export type FlagName = keyof typeof FLAGS_CONFIG;
type UsePositionFlagsProps = {
decode: (value: number) => FlagName[];
encode: (flagNames: FlagName[]) => number;
hasFlag: (value: number, flagName: FlagName) => boolean;
getAllFlags: () => typeof FLAGS_CONFIG;
isValidValue: (value: number) => boolean;
flagsValue: number;
activeFlags: FlagName[];
toggleFlag: (flagName: FlagName) => void;
setFlag: (flagName: FlagName, enabled: boolean) => void;
setFlags: (value: number) => void;
clearFlags: () => void;
};
export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
const [flagsValue, setFlagsValue] = useState<number>(initialValue);
const FLAGS_BITMASKS = useMemo(() => {
return Object.fromEntries(
Object.entries(FLAGS_CONFIG).map(([key, conf]) => [key, conf.value]),
) as { [K in FlagName]: (typeof FLAGS_CONFIG)[K]["value"] };
}, []);
const utils = useMemo(() => {
const decode = (value: number): FlagName[] => {
if (value === FLAGS_CONFIG.UNSET.value) {
return ["UNSET"];
}
const activeFlags: FlagName[] = [];
for (const key in FLAGS_CONFIG) {
const flagName = key as FlagName;
const flagConfig = FLAGS_CONFIG[flagName];
if (flagConfig.value !== 0 && (value & flagConfig.value) === flagConfig.value) {
activeFlags.push(flagName);
}
}
return activeFlags;
};
const encode = (flagNames: FlagName[]): number => {
if (flagNames.includes("UNSET") && flagNames.length === 1) {
return FLAGS_CONFIG.UNSET.value;
}
return flagNames.reduce((acc, name) => {
if (name === "UNSET") {
return acc;
}
return acc | FLAGS_CONFIG[name].value;
}, 0);
};
const hasFlag = (value: number, flagName: FlagName): boolean => {
return (value & FLAGS_CONFIG[flagName].value) === FLAGS_CONFIG[flagName].value;
};
const getAllFlags = (): typeof FLAGS_CONFIG => {
return FLAGS_CONFIG;
};
const isValidValue = (value: number): boolean => {
const maxValue = Object.values(FLAGS_BITMASKS)
.filter((val) => val !== 0)
.reduce((acc, val) => acc | val, 0);
return Number.isInteger(value) && value >= 0 && value <= maxValue;
};
return {
decode,
encode,
hasFlag,
getAllFlags,
isValidValue,
};
}, [FLAGS_BITMASKS]);
const toggleFlag = useCallback((flagName: FlagName) => {
setFlagsValue((prev) => prev ^ FLAGS_CONFIG[flagName].value);
}, []);
const setFlag = useCallback((flagName: FlagName, enabled: boolean) => {
const currentFlagValue = FLAGS_CONFIG[flagName].value;
setFlagsValue((prev) => (enabled ? prev | currentFlagValue : prev & ~currentFlagValue));
}, []);
const setFlags = useCallback(
(value: number) => {
if (!utils.isValidValue(value)) {
throw new Error(`Invalid flags value: ${value}`);
}
setFlagsValue(value);
},
[utils],
);
const clearFlags = useCallback(() => {
setFlagsValue(FLAGS_CONFIG.UNSET.value);
}, []);
const activeFlags = utils.decode(flagsValue);
return {
...utils,
flagsValue,
activeFlags,
toggleFlag,
setFlag,
setFlags,
clearFlags,
};
};
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
export function useTheme() {
const getSystemTheme = () =>
globalThis.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const getStoredPreference = useCallback(
(): Theme => (localStorage.getItem("theme") as Theme) || "system",
[],
);
const [preference, setPreference] = useState<Theme>(() =>
typeof window !== "undefined" ? getStoredPreference() : "light",
);
const theme = preference === "system" ? getSystemTheme() : preference;
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
useEffect(() => {
if (preference !== "system") {
return;
}
const media = globalThis.matchMedia("(prefers-color-scheme: dark)");
const updateTheme = () => setPreference(getStoredPreference());
media.addEventListener("change", updateTheme);
return () => media.removeEventListener("change", updateTheme);
}, [preference, getStoredPreference]);
const setPreferenceValue = (newPreference: Theme) => {
localStorage.setItem("theme", newPreference);
setPreference(newPreference);
};
return { theme, preference, setPreference: setPreferenceValue };
}
+79
View File
@@ -0,0 +1,79 @@
import { Button } from "@components/UI/Button.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("useToast", () => {
beforeEach(() => {
// Reset toast memory state before each test
// our hook uses global memory to store toasts
// @ts-expect-error - internal test reset
globalThis.memoryState = { toasts: [] };
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should create a toast with title, description, and action", () => {
const { result } = renderHook(() => useToast());
act(() => {
result.current.toast({
title: "Backup Reminder",
description: "Don't forget to backup!",
action: <Button>Backup Now</Button>,
});
vi.runAllTimers();
});
const toast = result.current.toasts[0]!;
expect(result.current.toasts.length).toBe(1);
expect(toast.title).toBe("Backup Reminder");
expect(toast.description).toBe("Don't forget to backup!");
expect(toast.action).toBeTruthy();
expect(toast.open).toBe(true);
});
it("should dismiss a toast using returned dismiss function", () => {
const { result } = renderHook(() => useToast());
vi.useFakeTimers();
let toastRef: { id: string; dismiss: () => void };
act(() => {
toastRef = result.current.toast({ title: "Dismiss Me" });
vi.runAllTimers(); // Flush ADD_TOAST
});
act(() => {
toastRef.dismiss();
});
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
expect(toast?.open).toBe(false);
vi.useRealTimers();
});
it("should allow dismiss via hook dismiss function", () => {
const { result } = renderHook(() => useToast());
vi.useFakeTimers();
let toastRef: { id: string };
act(() => {
toastRef = result.current.toast({ title: "Manual Dismiss" });
vi.runAllTimers();
});
act(() => {
result.current.dismiss(toastRef.id);
});
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
expect(toast?.open).toBe(false);
vi.useRealTimers();
});
});
+195
View File
@@ -0,0 +1,195 @@
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.tsx";
import { type ReactNode, useSyncExternalStore } from "react";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: ReactNode;
description?: ReactNode;
action?: ToastActionElement;
delay?: number;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
for (const toast of state.toasts) {
addToRemoveQueue(toast.id);
}
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST": {
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
for (const listener of listeners) {
listener(memoryState);
}
}
type Toast = Omit<ToasterToast, "id">;
function toast({ delay = 0, ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
setTimeout(() => {
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open) {
dismiss();
}
},
},
});
}, delay);
return {
id: id,
dismiss,
update,
};
}
const subscribe = (listener: () => void) => {
listeners.push(listener);
return function unsubscribe() {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
};
const getState = () => {
return memoryState;
};
function useToast() {
const state = useSyncExternalStore(subscribe, getState);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { toast, useToast };
@@ -0,0 +1,32 @@
import { useCallback, useEffect, useRef, useState } from "react";
export function useToggleVisibility({ timeout }: { timeout?: number } = {}) {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const show = useCallback(() => {
setIsVisible(true);
if (timeout) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsVisible(false);
timeoutRef.current = null;
}, timeout);
}
}, [timeout]);
// Clear timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return [isVisible, show] as const;
}
@@ -0,0 +1,17 @@
import { useDevice, type ValidConfigType, type ValidModuleConfigType } from "@core/stores";
type UseWaitForConfigProps =
| { configCase: ValidConfigType; moduleConfigCase?: never }
| { configCase?: never; moduleConfigCase: ValidModuleConfigType };
export function useWaitForConfig({ configCase, moduleConfigCase }: UseWaitForConfigProps): void {
const { config, moduleConfig } = useDevice();
const isDataDefined = configCase
? config[configCase] !== undefined
: moduleConfig[moduleConfigCase as ValidModuleConfigType] !== undefined;
if (!isDataDefined) {
throw new Promise<void>(() => {});
}
}
@@ -0,0 +1,12 @@
import { featureFlags } from "@core/services/featureFlags";
const isDev = typeof import.meta !== "undefined" && import.meta.env?.DEV;
console.log(`Dev mode: ${isDev}`);
if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
persistMessages: true,
persistApp: true,
});
}
@@ -0,0 +1,88 @@
import { z } from "zod";
/** Map feature keys -> env var names (Vite exposes only VITE_*). */
export const FLAG_ENV = {
persistNodeDB: "VITE_PERSIST_NODE_DB",
persistMessages: "VITE_PERSIST_MESSAGES",
persistDevices: "VITE_PERSIST_DEVICES",
persistApp: "VITE_PERSIST_APP",
} as const;
export type FlagKey = keyof typeof FLAG_ENV;
export type Flags = Record<FlagKey, boolean>;
type Listener = () => void;
const present = z
.string()
.optional()
.transform((v) => v !== undefined);
const mutableShape: Record<string, z.ZodTypeAny> = {};
for (const envName of Object.values(FLAG_ENV)) {
mutableShape[envName] = present;
}
const EnvSchema = z.object(mutableShape);
class FeatureFlags {
private base: Flags;
private overrides: Partial<Flags> = {};
private listeners = new Set<Listener>();
constructor(base: Flags) {
this.base = base;
}
get(key: FlagKey): boolean {
return this.overrides[key] ?? this.base[key];
}
/** Get all flags */
all(): Flags {
return { ...this.base, ...this.overrides };
}
/** Optional dev/test override. Pass null to clear. */
setOverride(key: FlagKey, val: boolean | null) {
if (val === null) {
delete this.overrides[key];
} else {
this.overrides[key] = val;
}
this.emit();
}
/** Set many at once */
setOverrides(partial: Partial<Flags>) {
for (const [k, v] of Object.entries(partial)) {
this.setOverride(k as FlagKey, v as boolean);
if (v === null) {
delete this.overrides[k as FlagKey];
} else {
this.overrides[k as FlagKey] = v as boolean;
}
}
this.emit();
}
subscribe(fn: Listener) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private emit() {
for (const listener of this.listeners) {
listener();
}
}
}
export function createFeatureFlags(env: Record<string, unknown>): FeatureFlags {
const parsed = EnvSchema.parse(env);
const base = Object.fromEntries(
(Object.keys(FLAG_ENV) as FlagKey[]).map((k) => [k, parsed[FLAG_ENV[k]] as boolean]),
) as Flags;
return new FeatureFlags(base);
}
export const featureFlags = createFeatureFlags(import.meta.env);
@@ -0,0 +1,165 @@
import type { RasterSource } from "@core/stores/appStore/types.ts";
import { beforeEach, describe, expect, it, vi } from "vitest";
const idbMem = new Map<string, string>();
vi.mock("idb-keyval", () => ({
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
set: vi.fn((key: string, val: string) => {
idbMem.set(key, val);
return Promise.resolve();
}),
del: vi.fn((key: string) => {
idbMem.delete(key);
return Promise.resolve();
}),
}));
async function freshStore(persistApp = false) {
vi.resetModules();
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
vi.doMock("@core/services/featureFlags.ts", () => ({
featureFlags: {
get: vi.fn((key: string) => (key === "persistApp" ? persistApp : false)),
},
}));
const storeMod = await import("./index.ts");
return storeMod as typeof import("./index.ts");
}
function makeRaster(fields: Record<string, any>): RasterSource {
return {
enabled: true,
title: "default",
tiles: `https://default.com/default.json`,
tileSize: 256,
...fields,
};
}
describe("AppStore – basic state & actions", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("setters flip UI flags and numeric fields", async () => {
const { useAppStore } = await freshStore(false);
const state = useAppStore.getState();
state.setSelectedDevice(42);
expect(useAppStore.getState().selectedDeviceId).toBe(42);
state.setCommandPaletteOpen(true);
expect(useAppStore.getState().commandPaletteOpen).toBe(true);
state.setConnectDialogOpen(true);
expect(useAppStore.getState().connectDialogOpen).toBe(true);
state.setNodeNumToBeRemoved(123);
expect(useAppStore.getState().nodeNumToBeRemoved).toBe(123);
state.setNodeNumDetails(777);
expect(useAppStore.getState().nodeNumDetails).toBe(777);
});
it("setRasterSources replaces; addRasterSource appends; removeRasterSource splices by index", async () => {
const { useAppStore } = await freshStore(false);
const state = useAppStore.getState();
const a = makeRaster({ title: "a" });
const b = makeRaster({ title: "b" });
const c = makeRaster({ title: "c" });
state.setRasterSources([a, b]);
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual(["a", "b"]);
state.addRasterSource(c);
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual([
"a",
"b",
"c",
]);
// "b"
state.removeRasterSource(1);
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual(["a", "c"]);
});
});
describe("AppStore – persistence: partialize + rehydrate", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("persists only rasterSources; methods still work after rehydrate", async () => {
// Write data
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
state.setRasterSources([makeRaster({ title: "x" }), makeRaster({ title: "y" })]);
state.setSelectedDevice(99);
state.setCommandPaletteOpen(true);
// Only rasterSources should persist by partialize
expect(useAppStore.getState().rasterSources.length).toBe(2);
}
// Rehydrate from idbMem
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
// persisted slice:
expect(state.rasterSources.map((raster) => raster.title)).toEqual(["x", "y"]);
// ephemeral fields reset to defaults:
expect(state.selectedDeviceId).toBe(0);
expect(state.commandPaletteOpen).toBe(false);
expect(state.connectDialogOpen).toBe(false);
expect(state.nodeNumToBeRemoved).toBe(0);
expect(state.nodeNumDetails).toBe(0);
// methods still work post-rehydrate:
state.addRasterSource(makeRaster({ title: "z" }));
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual([
"x",
"y",
"z",
]);
state.removeRasterSource(0);
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual([
"y",
"z",
]);
}
});
it("removing and resetting sources persists across reload", async () => {
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
state.setRasterSources([makeRaster({ title: "keep" }), makeRaster({ title: "drop" })]);
state.removeRasterSource(1); // drop "drop"
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual(["keep"]);
}
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
expect(state.rasterSources.map((raster) => raster.title)).toEqual(["keep"]);
// Now replace entirely
state.setRasterSources([]);
}
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
expect(state.rasterSources).toEqual([]); // stayed cleared
}
});
});
+112
View File
@@ -0,0 +1,112 @@
import { featureFlags } from "@core/services/featureFlags.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { produce } from "immer";
import { create as createStore, type StateCreator } from "zustand";
import { type PersistOptions, persist, subscribeWithSelector } from "zustand/middleware";
import type { RasterSource } from "./types.ts";
const IDB_KEY_NAME = "meshtastic-app-store";
const CURRENT_STORE_VERSION = 0;
type AppData = {
// Persisted data
rasterSources: RasterSource[];
};
export interface AppState extends AppData {
// Ephemeral state (not persisted)
selectedDeviceId: number;
nodeNumToBeRemoved: number;
connectDialogOpen: boolean;
nodeNumDetails: number;
commandPaletteOpen: boolean;
setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void;
removeRasterSource: (index: number) => void;
setSelectedDevice: (deviceId: number) => void;
setCommandPaletteOpen: (open: boolean) => void;
setNodeNumToBeRemoved: (nodeNum: number) => void;
setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
}
export const deviceStoreInitializer: StateCreator<AppState> = (set, _get) => ({
selectedDeviceId: 0,
rasterSources: [],
commandPaletteOpen: false,
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
setRasterSources: (sources: RasterSource[]) => {
set(
produce<AppState>((draft) => {
draft.rasterSources = sources;
}),
);
},
addRasterSource: (source: RasterSource) => {
set(
produce<AppState>((draft) => {
draft.rasterSources.push(source);
}),
);
},
removeRasterSource: (index: number) => {
set(
produce<AppState>((draft) => {
draft.rasterSources.splice(index, 1);
}),
);
},
setSelectedDevice: (deviceId) =>
set(() => ({
selectedDeviceId: deviceId,
})),
setCommandPaletteOpen: (open: boolean) => {
set(
produce<AppState>((draft) => {
draft.commandPaletteOpen = open;
}),
);
},
setNodeNumToBeRemoved: (nodeNum) =>
set(() => ({
nodeNumToBeRemoved: nodeNum,
})),
setConnectDialogOpen: (open: boolean) => {
set(
produce<AppState>((draft) => {
draft.connectDialogOpen = open;
}),
);
},
setNodeNumDetails: (nodeNum) =>
set(() => ({
nodeNumDetails: nodeNum,
})),
});
const persistOptions: PersistOptions<AppState, AppData> = {
name: IDB_KEY_NAME,
storage: createStorage<AppData>(),
version: CURRENT_STORE_VERSION,
partialize: (s): AppData => ({
rasterSources: s.rasterSources,
}),
onRehydrateStorage: () => (state) => {
if (!state) {
return;
}
console.debug("AppStore: Rehydrating state", state);
},
};
// Add persist middleware on the store if the feature flag is enabled
const persistApps = featureFlags.get("persistApp");
console.debug(`AppStore: Persisting app is ${persistApps ? "enabled" : "disabled"}`);
export const useAppStore = persistApps
? createStore(subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)))
: createStore(subscribeWithSelector(deviceStoreInitializer));
@@ -0,0 +1,6 @@
export interface RasterSource {
enabled: boolean;
title: string;
tiles: string;
tileSize: number;
}
@@ -0,0 +1,256 @@
import type { Types } from "@meshtastic/core";
// Config type discriminators
export type ValidConfigType =
| "device"
| "position"
| "power"
| "network"
| "display"
| "lora"
| "bluetooth"
| "security";
export type ValidModuleConfigType =
| "mqtt"
| "serial"
| "externalNotification"
| "storeForward"
| "rangeTest"
| "telemetry"
| "cannedMessage"
| "audio"
| "neighborInfo"
| "ambientLighting"
| "detectionSensor"
| "paxcounter"
| "remoteHardware"
| "statusmessage"
| "trafficManagement"
| "tak";
// Admin message types that can be queued
export type ValidAdminMessageType = "setFixedPosition" | "other";
// Unified config change key type
export type ConfigChangeKey =
| { type: "config"; variant: ValidConfigType }
| { type: "moduleConfig"; variant: ValidModuleConfigType }
| { type: "channel"; index: Types.ChannelNumber }
| { type: "user" }
| { type: "adminMessage"; variant: ValidAdminMessageType; id: string };
// Serialized key for Map storage
export type ConfigChangeKeyString = string;
// Registry entry
export interface ChangeEntry {
key: ConfigChangeKey;
value: unknown;
timestamp: number;
originalValue?: unknown;
}
// The unified registry
export interface ChangeRegistry {
changes: Map<ConfigChangeKeyString, ChangeEntry>;
}
/**
* Convert structured key to string for Map lookup
*/
export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
switch (key.type) {
case "config":
return `config:${key.variant}`;
case "moduleConfig":
return `moduleConfig:${key.variant}`;
case "channel":
return `channel:${key.index}`;
case "user":
return "user";
case "adminMessage":
return `adminMessage:${key.variant}:${key.id}`;
}
}
/**
* Reverse operation for type-safe retrieval
*/
export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
const parts = keyStr.split(":");
const type = parts[0];
switch (type) {
case "config":
return { type: "config", variant: parts[1] as ValidConfigType };
case "moduleConfig":
return {
type: "moduleConfig",
variant: parts[1] as ValidModuleConfigType,
};
case "channel":
return {
type: "channel",
index: Number(parts[1]) as Types.ChannelNumber,
};
case "user":
return { type: "user" };
case "adminMessage":
return {
type: "adminMessage",
variant: parts[1] as ValidAdminMessageType,
id: parts[2] ?? "",
};
default:
throw new Error(`Unknown key type: ${type}`);
}
}
/**
* Create an empty change registry
*/
export function createChangeRegistry(): ChangeRegistry {
return {
changes: new Map(),
};
}
/**
* Check if a config variant has changes
*/
export function hasConfigChange(registry: ChangeRegistry, variant: ValidConfigType): boolean {
return registry.changes.has(serializeKey({ type: "config", variant }));
}
/**
* Check if a module config variant has changes
*/
export function hasModuleConfigChange(
registry: ChangeRegistry,
variant: ValidModuleConfigType,
): boolean {
return registry.changes.has(serializeKey({ type: "moduleConfig", variant }));
}
/**
* Check if a channel has changes
*/
export function hasChannelChange(registry: ChangeRegistry, index: Types.ChannelNumber): boolean {
return registry.changes.has(serializeKey({ type: "channel", index }));
}
/**
* Check if user config has changes
*/
export function hasUserChange(registry: ChangeRegistry): boolean {
return registry.changes.has(serializeKey({ type: "user" }));
}
/**
* Get count of config changes
*/
export function getConfigChangeCount(registry: ChangeRegistry): number {
let count = 0;
for (const keyStr of registry.changes.keys()) {
const key = deserializeKey(keyStr);
if (key.type === "config") {
count++;
}
}
return count;
}
/**
* Get count of module config changes
*/
export function getModuleConfigChangeCount(registry: ChangeRegistry): number {
let count = 0;
for (const keyStr of registry.changes.keys()) {
const key = deserializeKey(keyStr);
if (key.type === "moduleConfig") {
count++;
}
}
return count;
}
/**
* Get count of channel changes
*/
export function getChannelChangeCount(registry: ChangeRegistry): number {
let count = 0;
for (const keyStr of registry.changes.keys()) {
const key = deserializeKey(keyStr);
if (key.type === "channel") {
count++;
}
}
return count;
}
/**
* Get all config changes as an array
*/
export function getAllConfigChanges(registry: ChangeRegistry): ChangeEntry[] {
const changes: ChangeEntry[] = [];
for (const entry of registry.changes.values()) {
if (entry.key.type === "config") {
changes.push(entry);
}
}
return changes;
}
/**
* Get all module config changes as an array
*/
export function getAllModuleConfigChanges(registry: ChangeRegistry): ChangeEntry[] {
const changes: ChangeEntry[] = [];
for (const entry of registry.changes.values()) {
if (entry.key.type === "moduleConfig") {
changes.push(entry);
}
}
return changes;
}
/**
* Get all channel changes as an array
*/
export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
const changes: ChangeEntry[] = [];
for (const entry of registry.changes.values()) {
if (entry.key.type === "channel") {
changes.push(entry);
}
}
return changes;
}
/**
* Get all admin message changes as an array
*/
export function getAllAdminMessages(registry: ChangeRegistry): ChangeEntry[] {
const changes: ChangeEntry[] = [];
for (const entry of registry.changes.values()) {
if (entry.key.type === "adminMessage") {
changes.push(entry);
}
}
return changes;
}
/**
* Get count of admin message changes
*/
export function getAdminMessageChangeCount(registry: ChangeRegistry): number {
let count = 0;
for (const keyStr of registry.changes.keys()) {
const key = deserializeKey(keyStr);
if (key.type === "adminMessage") {
count++;
}
}
return count;
}
@@ -0,0 +1,99 @@
import type { Protobuf } from "@meshtastic/core";
import { vi } from "vitest";
import type { Device } from "./index.ts";
/**
* You can spread this base mock in your tests and override only the
* properties relevant to a specific test case.
*
* @example
* vi.mocked(useDevice).mockReturnValue({
* ...mockDeviceStore,
* getNode: (nodeNum) => mockNodes.get(nodeNum),
* });
*/
export const mockDeviceStore: Device = {
id: 0,
myNodeNum: 123456,
status: 5 as const,
channels: new Map(),
config: {} as Protobuf.LocalOnly.LocalConfig,
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
changeRegistry: { changes: new Map() },
hardware: {} as Protobuf.Mesh.MyNodeInfo,
metadata: new Map(),
traceroutes: new Map(),
connection: undefined,
activeNode: 0,
waypoints: [],
pendingSettingsChanges: false,
messageDraft: "",
unreadCounts: new Map(),
dialog: {
import: false,
QR: false,
shutdown: false,
reboot: false,
deviceName: false,
nodeRemoval: false,
pkiBackup: false,
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
deleteMessages: false,
managedMode: false,
clientNotification: false,
resetNodeDb: false,
clearAllStores: false,
factoryResetConfig: false,
factoryResetDevice: false,
},
clientNotifications: [],
neighborInfo: new Map(),
setStatus: vi.fn(),
setConfig: vi.fn(),
setModuleConfig: vi.fn(),
getEffectiveConfig: vi.fn(),
getEffectiveModuleConfig: vi.fn(),
setHardware: vi.fn(),
setActiveNode: vi.fn(),
setPendingSettingsChanges: vi.fn(),
addChannel: vi.fn(),
addWaypoint: vi.fn(),
removeWaypoint: vi.fn(),
getWaypoint: vi.fn(),
addConnection: vi.fn(),
addTraceRoute: vi.fn(),
addMetadata: vi.fn(),
setDialogOpen: vi.fn(),
getDialogOpen: vi.fn().mockReturnValue(false),
setMessageDraft: vi.fn(),
incrementUnread: vi.fn(),
resetUnread: vi.fn(),
sendAdminMessage: vi.fn(),
addClientNotification: vi.fn(),
removeClientNotification: vi.fn(),
getClientNotification: vi.fn(),
getAllUnreadCount: vi.fn().mockReturnValue(0),
getUnreadCount: vi.fn().mockReturnValue(0),
getNeighborInfo: vi.fn(),
addNeighborInfo: vi.fn(),
// New unified change tracking methods
setChange: vi.fn(),
removeChange: vi.fn(),
hasChange: vi.fn().mockReturnValue(false),
getChange: vi.fn(),
clearAllChanges: vi.fn(),
hasConfigChange: vi.fn().mockReturnValue(false),
hasModuleConfigChange: vi.fn().mockReturnValue(false),
hasChannelChange: vi.fn().mockReturnValue(false),
hasUserChange: vi.fn().mockReturnValue(false),
getConfigChangeCount: vi.fn().mockReturnValue(0),
getModuleConfigChangeCount: vi.fn().mockReturnValue(0),
getChannelChangeCount: vi.fn().mockReturnValue(0),
getAllConfigChanges: vi.fn().mockReturnValue([]),
getAllModuleConfigChanges: vi.fn().mockReturnValue([]),
getAllChannelChanges: vi.fn().mockReturnValue([]),
};
@@ -0,0 +1,531 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { Protobuf, type Types } from "@meshtastic/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const idbMem = new Map<string, string>();
vi.mock("idb-keyval", () => ({
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
set: vi.fn((key: string, val: string) => {
idbMem.set(key, val);
return Promise.resolve();
}),
del: vi.fn((k: string) => {
idbMem.delete(k);
return Promise.resolve();
}),
}));
// Helper to load a fresh copy of the store with persist flag on/off
async function freshStore(persist = false) {
vi.resetModules();
// suppress console output from the store during tests (for github actions)
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
vi.doMock("@core/services/featureFlags", () => ({
featureFlags: {
get: vi.fn((key: string) => (key === "persistDevices" ? persist : false)),
},
}));
const storeMod = await import("./index.ts");
const { useNodeDB } = await import("../index.ts");
return { ...storeMod, useNodeDB };
}
function makeHardware(myNodeNum: number) {
return create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum });
}
function makeRoute(from: number, time = Date.now() / 1000) {
return {
from,
rxTime: time,
portnum: Protobuf.Portnums.PortNum.ROUTING_APP,
data: create(Protobuf.Mesh.RouteDiscoverySchema, {}),
} as unknown as Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>;
}
function makeChannel(index: number) {
return create(Protobuf.Channel.ChannelSchema, { index });
}
function makeWaypoint(id: number, expire?: number) {
return create(Protobuf.Mesh.WaypointSchema, { id, expire });
}
function makeAdminMessage(fields: Record<string, any>) {
return create(Protobuf.Admin.AdminMessageSchema, fields);
}
describe("DeviceStore – basic map ops & retention", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("addDevice returns same instance on repeated calls; getDevice(s) works; retention evicts oldest after cap", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const a = state.addDevice(1);
const b = state.addDevice(1);
expect(a).toBe(b);
expect(state.getDevice(1)).toBe(a);
expect(state.getDevices().length).toBe(1);
// DEVICESTORE_RETENTION_NUM = 10; create 11 to evict #1
for (let i = 2; i <= 11; i++) {
state.addDevice(i);
}
expect(state.getDevice(1)).toBeUndefined();
expect(state.getDevice(11)).toBeDefined();
expect(state.getDevices().length).toBe(10);
});
it("removeDevice deletes only that entry", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
state.addDevice(10);
state.addDevice(11);
expect(state.getDevices().length).toBe(2);
state.removeDevice(10);
expect(state.getDevice(10)).toBeUndefined();
expect(state.getDevice(11)).toBeDefined();
expect(state.getDevices().length).toBe(1);
});
});
describe("DeviceStore – change registry API", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("setChange/hasChange/getChange for config and getEffectiveConfig merges base + working", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(42);
// config deviceConfig.role = CLIENT
device.setConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "device",
value: create(Protobuf.Config.Config_DeviceConfigSchema, {
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
}),
},
}),
);
// working deviceConfig.role = ROUTER
const routerConfig = create(Protobuf.Config.Config_DeviceConfigSchema, {
role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
});
device.setChange({ type: "config", variant: "device" }, routerConfig);
// expect change is tracked
expect(device.hasConfigChange("device")).toBe(true);
const working = device.getChange({
type: "config",
variant: "device",
}) as Protobuf.Config.Config_DeviceConfig;
expect(working?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER);
// expect effective deviceConfig.role = ROUTER
const effective = device.getEffectiveConfig("device");
expect(effective?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER);
// remove change, effective should equal base
device.removeChange({ type: "config", variant: "device" });
expect(device.hasConfigChange("device")).toBe(false);
expect(device.getEffectiveConfig("device")?.role).toBe(
Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
);
// add multiple, then clear all
device.setChange({ type: "config", variant: "device" }, routerConfig);
device.setChange({ type: "config", variant: "position" }, {});
device.clearAllChanges();
expect(device.hasConfigChange("device")).toBe(false);
expect(device.hasConfigChange("position")).toBe(false);
});
it("setChange/hasChange for moduleConfig and getEffectiveModuleConfig", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(7);
// base moduleConfig.mqtt with base address
device.setModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "mqtt",
value: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, {
address: "mqtt://base",
}),
},
}),
);
// working mqtt config
const workingMqtt = create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, {
address: "mqtt://working",
});
device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt);
expect(device.hasModuleConfigChange("mqtt")).toBe(true);
const mqtt = device.getChange({
type: "moduleConfig",
variant: "mqtt",
}) as Protobuf.ModuleConfig.ModuleConfig_MQTTConfig;
expect(mqtt?.address).toBe("mqtt://working");
// effective should return working value
expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe("mqtt://working");
// remove change
device.removeChange({ type: "moduleConfig", variant: "mqtt" });
expect(device.hasModuleConfigChange("mqtt")).toBe(false);
expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe("mqtt://base");
// Clear all
device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt);
device.clearAllChanges();
expect(device.hasModuleConfigChange("mqtt")).toBe(false);
});
it("setModuleConfig stores the firmware-current module variants", async () => {
const { useDeviceStore } = await freshStore(false);
const device = useDeviceStore.getState().addDevice(11);
device.setModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "trafficManagement",
value: create(Protobuf.ModuleConfig.ModuleConfig_TrafficManagementConfigSchema, {
enabled: true,
rateLimitMaxPackets: 5,
}),
},
}),
);
device.setModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "statusmessage",
value: create(Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfigSchema, {
nodeStatus: "online",
}),
},
}),
);
device.setModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "remoteHardware",
value: create(Protobuf.ModuleConfig.ModuleConfig_RemoteHardwareConfigSchema, {
enabled: true,
}),
},
}),
);
expect(device.getEffectiveModuleConfig("trafficManagement")?.enabled).toBe(true);
expect(device.getEffectiveModuleConfig("trafficManagement")?.rateLimitMaxPackets).toBe(5);
expect(device.getEffectiveModuleConfig("statusmessage")?.nodeStatus).toBe("online");
expect(device.getEffectiveModuleConfig("remoteHardware")?.enabled).toBe(true);
});
it("channel change tracking add/update/remove/get", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(9);
const channel0 = makeChannel(0);
const channel1 = create(Protobuf.Channel.ChannelSchema, {
index: 1,
settings: { name: "one" },
});
device.setChange({ type: "channel", index: 0 }, channel0);
device.setChange({ type: "channel", index: 1 }, channel1);
expect(device.hasChannelChange(0)).toBe(true);
expect(device.hasChannelChange(1)).toBe(true);
const ch0 = device.getChange({ type: "channel", index: 0 }) as
| Protobuf.Channel.Channel
| undefined;
expect(ch0?.index).toBe(0);
const ch1 = device.getChange({ type: "channel", index: 1 }) as
| Protobuf.Channel.Channel
| undefined;
expect(ch1?.settings?.name).toBe("one");
// update channel 1
const channel1Updated = create(Protobuf.Channel.ChannelSchema, {
index: 1,
settings: { name: "uno" },
});
device.setChange({ type: "channel", index: 1 }, channel1Updated);
const ch1Updated = device.getChange({ type: "channel", index: 1 }) as
| Protobuf.Channel.Channel
| undefined;
expect(ch1Updated?.settings?.name).toBe("uno");
// remove specific
device.removeChange({ type: "channel", index: 1 });
expect(device.hasChannelChange(1)).toBe(false);
// remove all
device.clearAllChanges();
expect(device.hasChannelChange(0)).toBe(false);
});
});
describe("DeviceStore – metadata, dialogs, unread counts, message draft", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("addMetadata stores by node id", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(1);
const metadata = create(Protobuf.Mesh.DeviceMetadataSchema, {
firmwareVersion: "1.2.3",
});
device.addMetadata(123, metadata);
expect(useDeviceStore.getState().devices.get(1)?.metadata.get(123)).toEqual(metadata);
});
it("dialogs set/get work and throw if device missing", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(5);
device.setDialogOpen("reboot", true);
expect(device.getDialogOpen("reboot")).toBe(true);
device.setDialogOpen("reboot", false);
expect(device.getDialogOpen("reboot")).toBe(false);
// getDialogOpen uses getDevice or throws if device missing
state.removeDevice(5);
expect(() => device.getDialogOpen("reboot")).toThrow(/Device 5 not found/);
});
it("unread counts: increment/get/getAll/reset", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(2);
expect(device.getUnreadCount(10)).toBe(0);
device.incrementUnread(10);
device.incrementUnread(10);
device.incrementUnread(11);
expect(device.getUnreadCount(10)).toBe(2);
expect(device.getUnreadCount(11)).toBe(1);
expect(device.getAllUnreadCount()).toBe(3);
device.resetUnread(10);
expect(device.getUnreadCount(10)).toBe(0);
expect(device.getAllUnreadCount()).toBe(1);
});
it("setMessageDraft stores the text", async () => {
const { useDeviceStore } = await freshStore(false);
const device = useDeviceStore.getState().addDevice(3);
device.setMessageDraft("hello");
expect(useDeviceStore.getState().devices.get(3)?.messageDraft).toBe("hello");
});
});
describe("DeviceStore – traceroutes & waypoints retention + merge on setHardware", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("addTraceRoute appends and enforces per-target and target caps", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(100);
// Per target: cap = 100; push 101 for from=7
for (let i = 0; i < 101; i++) {
device.addTraceRoute(makeRoute(7, i));
}
const routesFor7 = useDeviceStore.getState().devices.get(100)?.traceroutes.get(7)!;
expect(routesFor7.length).toBe(100);
expect(routesFor7[0]?.rxTime).toBe(1); // first (0) evicted
// Target map cap: 100 keys, add 101 unique "from"
for (let from = 0; from <= 100; from++) {
device.addTraceRoute(makeRoute(1000 + from));
}
const keys = Array.from(useDeviceStore.getState().devices.get(100)!.traceroutes.keys());
expect(keys.length).toBe(100);
});
it("addWaypoint upserts by id and enforces retention; setHardware moves traceroutes + prunes expired waypoints", async () => {
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
// Old device with myNodeNum=777 and some waypoints (one expired)
const oldDevice = state.addDevice(1);
const mockSendWaypoint = vi.fn();
oldDevice.addConnection({ sendWaypoint: mockSendWaypoint } as any);
oldDevice.setHardware(makeHardware(777));
oldDevice.addWaypoint(
makeWaypoint(1, Date.parse("2024-12-31T23:59:59Z")), // This is expired, will not be added
0,
0,
new Date(),
); // expired
oldDevice.addWaypoint(makeWaypoint(2, 0), 0, 0, new Date()); // no expire
oldDevice.addWaypoint(makeWaypoint(3, Date.parse("2026-01-01T00:00:00Z")), 0, 0, new Date()); // ok
oldDevice.addTraceRoute(makeRoute(55));
oldDevice.addTraceRoute(makeRoute(56));
// Upsert waypoint by id
oldDevice.addWaypoint(makeWaypoint(2, Date.parse("2027-01-01T00:00:00Z")), 0, 0, new Date());
const wps = useDeviceStore.getState().devices.get(1)!.waypoints;
expect(wps.length).toBe(2);
expect(wps.find((w) => w.id === 2)?.expire).toBe(Date.parse("2027-01-01T00:00:00Z"));
// Retention: push 102 total waypoints -> capped at 100. Oldest evicted
for (let i = 3; i <= 102; i++) {
oldDevice.addWaypoint(makeWaypoint(i), 0, 0, new Date());
}
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(100);
// Remove waypoint
oldDevice.removeWaypoint(102, false);
expect(mockSendWaypoint).not.toHaveBeenCalled();
await oldDevice.removeWaypoint(101, true); // toMesh=true
expect(mockSendWaypoint).toHaveBeenCalled();
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(98);
// New device shares myNodeNum; setHardware should:
// - move traceroutes from old device
// - copy waypoints minus expired
// - delete old device entry
const newDevice = state.addDevice(2);
newDevice.setHardware(makeHardware(777));
expect(state.getDevice(1)).toBeUndefined();
expect(state.getDevice(2)).toBeDefined();
// traceroutes moved:
expect(state.getDevice(2)!.traceroutes.size).toBe(2);
// Getter for waypoint by id works
expect(newDevice.getWaypoint(1)).toBeUndefined();
expect(newDevice.getWaypoint(2)).toBeUndefined();
expect(newDevice.getWaypoint(3)).toBeTruthy();
vi.useRealTimers();
});
});
describe("DeviceStore – persistence partialize & rehydrate", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("partialize stores only DeviceData; onRehydrateStorage rebuilds only devices with myNodeNum set (orphans dropped)", async () => {
// First run: persist=true
{
const { useDeviceStore } = await freshStore(true);
const state = useDeviceStore.getState();
const orphan = state.addDevice(500); // no myNodeNum -> should be dropped
orphan.addWaypoint(makeWaypoint(123), 0, 0, new Date());
const good = state.addDevice(501);
good.setHardware(makeHardware(42)); // sets myNodeNum
good.addTraceRoute(makeRoute(77));
good.addWaypoint(makeWaypoint(1), 0, 0, new Date());
// ensure some ephemeral fields differ so we can verify methods work after rehydrate
good.setMessageDraft("draft");
}
// Reload: persist=true -> rehydrate from idbMem
{
const { useDeviceStore } = await freshStore(true);
const state = useDeviceStore.getState();
expect(state.getDevice(500)).toBeUndefined(); // orphan dropped
const device = state.getDevice(501)!;
expect(device).toBeDefined();
// methods should work
device.addWaypoint(makeWaypoint(2), 0, 0, new Date());
expect(useDeviceStore.getState().devices.get(501)!.waypoints.length).toBeGreaterThan(0);
// traceroutes survived
expect(useDeviceStore.getState().devices.get(501)!.traceroutes.size).toBeGreaterThan(0);
}
});
it("removing a device persists across reload", async () => {
{
const { useDeviceStore } = await freshStore(true);
const state = useDeviceStore.getState();
const device = state.addDevice(900);
device.setHardware(makeHardware(9)); // ensure it will be rehydrated
expect(state.getDevice(900)).toBeDefined();
state.removeDevice(900);
expect(state.getDevice(900)).toBeUndefined();
}
{
const { useDeviceStore } = await freshStore(true);
expect(useDeviceStore.getState().getDevice(900)).toBeUndefined();
}
});
});
describe("DeviceStore – connection & sendAdminMessage", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("sendAdminMessage calls through to connection.sendPacket with correct args", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(77);
const sendPacket = vi.fn();
device.addConnection({ sendPacket } as any);
const message = makeAdminMessage({ logVerbosity: 1 });
device.sendAdminMessage(message);
expect(sendPacket).toHaveBeenCalledTimes(1);
const [bytes, port, dest] = sendPacket.mock.calls[0]!;
expect(port).toBe(Protobuf.Portnums.PortNum.ADMIN_APP);
expect(dest).toBe("self");
// sanity: encoded bytes match toBinary on the same schema
const expected = toBinary(Protobuf.Admin.AdminMessageSchema, message);
expect(bytes).toBeInstanceOf(Uint8Array);
// compare content length as minimal assertion (exact byte-for-byte is fine too)
expect((bytes as Uint8Array).length).toBe(expected.length);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
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";
});
}
@@ -0,0 +1,84 @@
import type { Protobuf } from "@meshtastic/core";
import type { ValidConfigType, ValidModuleConfigType } from "./changeRegistry.ts";
interface Dialogs {
import: boolean;
QR: boolean;
shutdown: boolean;
reboot: boolean;
deviceName: boolean;
nodeRemoval: boolean;
pkiBackup: boolean;
nodeDetails: boolean;
unsafeRoles: boolean;
refreshKeys: boolean;
deleteMessages: boolean;
managedMode: boolean;
clientNotification: boolean;
resetNodeDb: boolean;
clearAllStores: boolean;
factoryResetDevice: boolean;
factoryResetConfig: boolean;
}
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
created: Date; // Timestamp when the waypoint was received
updated?: Date; // Timestamp when the waypoint was last updated
from: number; // Node number of the device that sent the waypoint
};
};
export type {
Page,
Dialogs,
DialogVariant,
ValidConfigType,
ValidModuleConfigType,
WaypointWithMetadata,
};
+70
View File
@@ -0,0 +1,70 @@
import { useDeviceContext } from "@core/hooks/useDeviceContext.ts";
import { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
import { type MessageStore, useMessageStore } from "@core/stores/messageStore/index.ts";
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
import { bindStoreToDevice } from "@core/stores/utils/bindStoreToDevice.ts";
export {
CurrentDeviceContext,
type DeviceContext,
useDeviceContext,
} from "@core/hooks/useDeviceContext";
export { useAppStore } from "@core/stores/appStore/index.ts";
export { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
export {
useActiveConnection,
useActiveConnectionId,
useAddSavedConnection,
useConnectionError,
useConnectionForDevice,
useConnectionStatus,
useDefaultConnection,
useDeviceForConnection,
useFirstSavedConnection,
useIsConnected,
useIsConnecting,
useRemoveSavedConnection,
useSavedConnections,
useUpdateSavedConnection,
} from "@core/stores/deviceStore/selectors.ts";
export type {
Page,
ValidConfigType,
ValidModuleConfigType,
WaypointWithMetadata,
} from "@core/stores/deviceStore/types.ts";
export {
MessageState,
type MessageStore,
MessageType,
useMessageStore,
} from "@core/stores/messageStore";
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
export type { NodeErrorType } from "@core/stores/nodeDBStore/types.ts";
export {
SidebarProvider,
useSidebar, // TODO: Bring hook into this file
} from "@core/stores/sidebarStore/index.tsx";
// Re-export idb-keyval functions for clearing all stores, expand this if we add more local storage types
export { clear as clearAllStores } from "idb-keyval";
// Define hooks to access the stores
export const useNodeDB = bindStoreToDevice(
useNodeDBStore,
(s, deviceId): NodeDB => s.getNodeDB(deviceId) ?? s.addNodeDB(deviceId),
);
export const useDevice = (): Device => {
const { deviceId } = useDeviceContext();
const device = useDeviceStore((s) => s.getDevice(deviceId) ?? s.addDevice(deviceId));
return device;
};
export const useMessages = (): MessageStore => {
const { deviceId } = useDeviceContext();
const device = useMessageStore((s) => s.getMessageStore(deviceId) ?? s.addMessageStore(deviceId));
return device;
};
@@ -0,0 +1,427 @@
import { featureFlags } from "@core/services/featureFlags";
import type {
ChannelId,
ClearMessageParams,
ConversationId,
GetMessagesParams,
Message,
MessageId,
MessageLogMap,
NodeNum,
SetMessageStateParams,
} from "@core/stores/messageStore/types.ts";
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import type { Types } from "@meshtastic/core";
import { produce } from "immer";
import { create as createStore, type StateCreator } from "zustand";
import { type PersistOptions, persist } from "zustand/middleware";
const IDB_KEY_NAME = "meshtastic-message-store";
const CURRENT_STORE_VERSION = 0;
const MESSAGESTORE_RETENTION_NUM = 10;
const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel
export enum MessageState {
Ack = "ack",
Waiting = "waiting",
Failed = "failed",
}
export enum MessageType {
Direct = "direct",
Broadcast = "broadcast",
}
export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId {
return [node1, node2].sort((a, b) => a - b).join(":");
}
export interface MessageBuckets {
direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
}
type MessageStoreData = {
// Persisted data
id: number;
myNodeNum: number | undefined;
messages: MessageBuckets;
drafts: Map<Types.Destination, string>;
};
export interface MessageStore extends MessageStoreData {
// Ephemeral state (not persisted)
activeChat: number;
chatType: MessageType;
setNodeNum: (nodeNum: number) => void;
saveMessage: (message: Message) => void;
setMessageState: (params: SetMessageStateParams) => void;
getMessages: (params: GetMessagesParams) => Message[];
getDraft: (key: Types.Destination) => string;
setDraft: (key: Types.Destination, message: string) => void;
clearDraft: (key: Types.Destination) => void;
//clearAllDrafts: (key: Types.Destination) => void;
deleteAllMessages: () => void;
clearMessageByMessageId: (params: ClearMessageParams) => void;
}
export interface MessageStoreState {
addMessageStore: (id: number) => MessageStore;
removeMessageStore: (id: number) => void;
getMessageStore: (id: number) => MessageStore | undefined;
getMessageStores: () => MessageStore[];
}
interface PrivateMessageStoreState extends MessageStoreState {
messageStores: Map<number, MessageStore>;
}
type MessageStorePersisted = {
messageStores: Map<number, MessageStoreData>;
};
function messageStoreFactory(
id: number,
get: () => PrivateMessageStoreState,
set: typeof useMessageStore.setState,
data?: Partial<MessageStoreData>,
): MessageStore {
const messages = data?.messages ?? {
direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
};
const drafts = data?.drafts ?? new Map<Types.Destination, string>();
const myNodeNum = data?.myNodeNum;
const activeChat = 0;
const chatType = MessageType.Broadcast;
return {
id,
myNodeNum,
messages,
drafts,
activeChat,
chatType,
setNodeNum: (nodeNum) => {
set(
produce<PrivateMessageStoreState>((draft) => {
const newStore = draft.messageStores.get(id);
if (!newStore) {
throw new Error(`No MessageStore found for id: ${id}`);
}
newStore.myNodeNum = nodeNum;
for (const [otherId, oldStore] of draft.messageStores) {
if (otherId === id || oldStore.myNodeNum !== nodeNum) {
continue;
}
// Adopt broadcast conversations (reuses inner Map references)
for (const [channelId, logMap] of oldStore.messages.broadcast) {
newStore.messages.broadcast.set(channelId, logMap);
}
// Adopt direct conversations
for (const [conversationId, logMap] of oldStore.messages.direct) {
newStore.messages.direct.set(conversationId, logMap);
}
// Adopt drafts
for (const [destination, draftText] of oldStore.drafts) {
newStore.drafts.set(destination, draftText);
}
// Drop old store
draft.messageStores.delete(otherId);
}
}),
);
},
saveMessage: (message: Message) => {
set(
produce<PrivateMessageStoreState>((draft) => {
const state = draft.messageStores.get(id);
if (!state) {
throw new Error(`No MessageStore found for id: ${id}`);
}
let log: MessageLogMap | undefined;
if (message.type === MessageType.Direct) {
const conversationId = getConversationId(message.from, message.to);
if (!state.messages.direct.has(conversationId)) {
state.messages.direct.set(conversationId, new Map<MessageId, Message>());
}
log = state.messages.direct.get(conversationId);
log?.set(message.messageId, message);
} else if (message.type === MessageType.Broadcast) {
const channelId = message.channel as ChannelId;
if (!state.messages.broadcast.has(channelId)) {
state.messages.broadcast.set(channelId, new Map<MessageId, Message>());
}
log = state.messages.broadcast.get(channelId);
log?.set(message.messageId, message);
}
if (log) {
// Enforce retention limit
evictOldestEntries(log, MESSAGELOG_RETENTION_NUM);
}
}),
);
},
setMessageState: (params: SetMessageStateParams) => {
set(
produce<PrivateMessageStoreState>((draft) => {
const state = draft.messageStores.get(id);
if (!state) {
throw new Error(`No MessageStore found for id: ${id}`);
}
let messageLog: MessageLogMap | undefined;
let targetMessage: Message | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageLog = state.messages.direct.get(conversationId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
}
} else {
// Broadcast
messageLog = state.messages.broadcast.get(params.channelId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
}
}
if (targetMessage) {
targetMessage.state = params.newState ?? MessageState.Ack;
} else {
console.warn(
`Message or conversation/channel not found for state update. Params: ${JSON.stringify(
params,
)}`,
);
}
}),
);
},
getMessages: (params: GetMessagesParams): Message[] => {
const state = get().messageStores.get(id);
if (!state) {
throw new Error(`No MessageStore found for id: ${id}`);
}
let messageMap: MessageLogMap | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageMap = state.messages.direct.get(conversationId);
} else {
messageMap = state.messages.broadcast.get(params.channelId);
}
if (messageMap === undefined) {
return [];
}
const messagesArray = Array.from(messageMap.values());
messagesArray.sort((a, b) => a.date - b.date);
return messagesArray;
},
getDraft: (key) => {
const state = get().messageStores.get(id);
if (!state) {
throw new Error(`No MessageStore found for id: ${id}`);
}
return state.drafts.get(key) ?? "";
},
setDraft: (key, message) => {
set(
produce<PrivateMessageStoreState>((draft) => {
const state = draft.messageStores.get(id);
if (!state) {
throw new Error(`No MessageStore found for id: ${id}`);
}
state.drafts.set(key, message);
}),
);
},
clearDraft: (key) => {
set(
produce<PrivateMessageStoreState>((draft) => {
const state = draft.messageStores.get(id);
if (!state) {
throw new Error(`No MessageStore found for id: ${id}`);
}
state.drafts.delete(key);
}),
);
},
deleteAllMessages: () => {
set(
produce<PrivateMessageStoreState>((draft) => {
const state = draft.messageStores.get(id);
if (!state) {
throw new Error(`No MessageStore found for id: ${id}`);
}
state.messages.direct = new Map<ConversationId, MessageLogMap>();
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
}),
);
},
clearMessageByMessageId: (params: ClearMessageParams) => {
set(
produce<PrivateMessageStoreState>((draft) => {
const state = draft.messageStores.get(id);
if (!state) {
throw new Error(`No MessageStore found for id: ${id}`);
}
let messageLog: MessageLogMap | undefined;
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
let parentKey: ConversationId | ChannelId;
if (params.type === MessageType.Direct) {
parentKey = getConversationId(params.nodeA, params.nodeB);
parentMap = state.messages.direct;
messageLog = parentMap.get(parentKey);
} else {
// Broadcast
parentKey = params.channelId;
parentMap = state.messages.broadcast;
messageLog = parentMap.get(parentKey);
}
if (messageLog) {
const deleted = messageLog.delete(params.messageId);
if (deleted) {
console.log(
`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`,
);
// Clean up empty MessageLogMap and its entry in the parent map
if (messageLog.size === 0) {
parentMap.delete(parentKey);
console.log(`Cleaned up empty message entry for ${parentKey}`);
}
} else {
console.warn(
`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`,
);
}
} else {
console.warn(`Message entry ${parentKey} not found for message deletion.`);
}
}),
);
},
};
}
export const messageStoreInitializer: StateCreator<PrivateMessageStoreState> = (set, get) => ({
messageStores: new Map(),
addMessageStore: (id) => {
const existing = get().messageStores.get(id);
if (existing) {
return existing;
}
const nodeStore = messageStoreFactory(id, get, set);
set(
produce<PrivateMessageStoreState>((draft) => {
draft.messageStores.set(id, nodeStore);
// Enforce retention limit
evictOldestEntries(draft.messageStores, MESSAGESTORE_RETENTION_NUM);
}),
);
return nodeStore;
},
removeMessageStore: (id) => {
set(
produce<PrivateMessageStoreState>((draft) => {
draft.messageStores.delete(id);
}),
);
},
getMessageStores: () => Array.from(get().messageStores.values()),
getMessageStore: (id) => get().messageStores.get(id),
});
const persistOptions: PersistOptions<PrivateMessageStoreState, MessageStorePersisted> = {
name: IDB_KEY_NAME,
storage: createStorage<MessageStorePersisted>(),
version: CURRENT_STORE_VERSION,
partialize: (s): MessageStorePersisted => ({
messageStores: new Map(
Array.from(s.messageStores.entries()).map(([id, db]) => [
id,
{
id: db.id,
myNodeNum: db.myNodeNum,
messages: db.messages,
drafts: db.drafts,
},
]),
),
}),
onRehydrateStorage: () => (state) => {
if (!state) {
return;
}
console.debug(
"MessageStoreStore: Rehydrating state with ",
state.messageStores.size,
" MessageStores -",
state.messageStores,
);
useMessageStore.setState(
produce<PrivateMessageStoreState>((draft) => {
const rebuilt = new Map<number, MessageStore>();
for (const [id, data] of (
draft.messageStores as unknown as Map<number, MessageStoreData>
).entries()) {
if (data.myNodeNum !== undefined) {
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
rebuilt.set(
id,
messageStoreFactory(id, useMessageStore.getState, useMessageStore.setState, data),
);
}
}
draft.messageStores = rebuilt;
}),
);
},
};
// Add persist middleware on the store if the feature flag is enabled
const persistMessages = featureFlags.get("persistMessages");
console.debug(`MessageStore: Persisting messages is ${persistMessages ? "enabled" : "disabled"}`);
export const useMessageStore = persistMessages
? createStore<PrivateMessageStoreState, [["zustand/persist", MessageStorePersisted]]>(
persist(messageStoreInitializer, persistOptions),
)
: createStore<PrivateMessageStoreState>()(messageStoreInitializer);
@@ -0,0 +1,727 @@
/** biome-ignore-all lint/style/noNonNullAssertion: <tests> */
import { Types } from "@meshtastic/core";
import { setAutoFreeze } from "immer";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getConversationId, MessageState, MessageType } from "./index.ts";
import type { ChannelId, Message } from "./types.ts";
const idbMem = new Map<string, string>();
vi.mock("idb-keyval", () => ({
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
set: vi.fn((key: string, val: string) => {
idbMem.set(key, val);
return Promise.resolve();
}),
del: vi.fn((k: string) => {
idbMem.delete(k);
return Promise.resolve();
}),
}));
async function freshStore(persist = false) {
vi.resetModules();
// suppress console output from the store during tests (for github actions)
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
// Mock feature flag for persistence
vi.doMock("@core/services/featureFlags", () => ({
featureFlags: {
get: vi.fn((key: string) => (key === "persistMessages" ? persist : false)),
},
}));
const mod = await import("./index.ts");
return mod;
}
const myNodeNum = 111;
const otherNodeNum1 = 222;
const otherNodeNum2 = 333;
const broadcastChannel: ChannelId = 0;
const directMessageToOther1: Message = {
type: MessageType.Direct,
from: myNodeNum,
to: otherNodeNum1,
channel: 0,
date: Date.now(),
messageId: 101,
state: MessageState.Waiting,
message: "Hello other 1 from me",
};
const directMessageFromOther1: Message = {
type: MessageType.Direct,
from: otherNodeNum1,
to: myNodeNum,
channel: 0,
date: Date.now() + 1000,
messageId: 102,
state: MessageState.Waiting,
message: "Hello me from other 1",
};
const directMessageToOther2: Message = {
type: MessageType.Direct,
from: myNodeNum,
to: otherNodeNum2,
channel: 0,
date: Date.now() + 2000,
messageId: 103,
state: MessageState.Waiting,
message: "Hello other 2 from me",
};
const broadcastMessage1: Message = {
type: MessageType.Broadcast,
from: otherNodeNum1,
to: 0xffffffff,
channel: broadcastChannel,
date: Date.now() + 3000,
messageId: 201,
state: MessageState.Waiting,
message: "Broadcast message 1",
};
const broadcastMessage2: Message = {
type: MessageType.Broadcast,
from: myNodeNum,
to: 0xffffffff,
channel: broadcastChannel,
date: Date.now() + 4000,
messageId: 202,
state: MessageState.Waiting,
message: "Broadcast message 2",
};
describe("MessageStore persistence & rehydrate", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("should have correct initial state", async () => {
const { useMessageStore } = await freshStore();
const state = useMessageStore.getState();
const store = state.addMessageStore(123);
expect(store.messages.direct).toBeInstanceOf(Map);
expect(store.messages.direct.size).toBe(0);
expect(store.messages.broadcast).toBeInstanceOf(Map);
expect(store.messages.broadcast.size).toBe(0);
expect(store.drafts).toBeInstanceOf(Map);
expect(store.drafts.size).toBe(0);
expect(store.myNodeNum).toBe(undefined);
expect(store.activeChat).toBe(0);
expect(store.chatType).toBe(MessageType.Broadcast);
});
it("should set nodeNum", async () => {
const { useMessageStore } = await freshStore();
const state = useMessageStore.getState();
const db = state.addMessageStore(123);
db.setNodeNum(myNodeNum);
expect(useMessageStore.getState().getMessageStore(123)?.myNodeNum).toBe(myNodeNum);
});
describe("saveMessage", async () => {
const { useMessageStore } = await freshStore();
const state = useMessageStore.getState();
state.addMessageStore(123);
it("should save a direct message with correct Map structure", () => {
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
const conversationId = getConversationId(
directMessageToOther1.from,
directMessageToOther1.to,
);
const store = state.getMessageStore(123)!;
// Check if the conversation Map exists
expect(store.messages.direct.has(conversationId)).toBe(true);
const conversationLog = store.messages.direct.get(conversationId);
// Check if the inner Map (MessageLogMap) exists and is a Map
expect(conversationLog).toBeInstanceOf(Map);
// Check if the message exists within the inner Map
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
// Check the message content
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
});
it("should save a broadcast message with correct Map structure", () => {
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
const store = state.getMessageStore(123)!;
const channelId = broadcastMessage1.channel;
expect(store.messages.broadcast.has(channelId)).toBe(true);
const channelLog = store.messages.broadcast.get(channelId);
expect(channelLog).toBeInstanceOf(Map);
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true);
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
});
it("should save multiple messages correctly", () => {
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
const store = state.getMessageStore(123)!;
const convId1 = getConversationId(myNodeNum, otherNodeNum1);
expect(store.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual(
directMessageToOther1,
);
expect(store.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual(
directMessageFromOther1,
);
const channelId = broadcastMessage1.channel;
expect(store.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual(
broadcastMessage1,
);
});
});
describe("getMessages", async () => {
const { useMessageStore } = await freshStore();
const state = useMessageStore.getState();
state.addMessageStore(123);
beforeEach(() => {
state.getMessageStore(123)?.setNodeNum(myNodeNum);
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
state.getMessageStore(123)?.saveMessage(directMessageToOther2);
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
state.getMessageStore(123)?.saveMessage(broadcastMessage2);
});
it("should return broadcast messages for a channel, sorted by date", () => {
const messages = state.getMessageStore(123)!.getMessages({
type: MessageType.Broadcast,
channelId: broadcastChannel,
});
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(broadcastMessage1);
expect(messages[1]).toEqual(broadcastMessage2);
});
it("should return empty array for broadcast if channel has no messages", () => {
const messages = state.getMessageStore(123)!.getMessages({
type: MessageType.Broadcast,
channelId: Types.ChannelNumber.Channel1,
});
expect(messages).toEqual([]);
});
it("should return combined direct messages for a specific chat pair, sorted by date", () => {
const messages = state.getMessageStore(123)!.getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
});
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(directMessageToOther1);
expect(messages[1]).toEqual(directMessageFromOther1);
});
it("should return only relevant direct messages for a different chat pair", () => {
const messages = state.getMessageStore(123)!.getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum2,
});
expect(messages).toHaveLength(1);
expect(messages[0]).toEqual(directMessageToOther2);
});
it("should return empty array for direct chat if no messages exist", () => {
const messages = state.getMessageStore(123)!.getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 999,
});
expect(messages).toEqual([]);
});
});
describe("setMessageState", async () => {
const { useMessageStore } = await freshStore();
const state = useMessageStore.getState();
state.addMessageStore(123);
beforeEach(() => {
state.getMessageStore(123)?.setNodeNum(myNodeNum);
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
});
it("should update state for a direct message", () => {
state.getMessageStore(123)!.setMessageState({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: directMessageToOther1.messageId,
newState: MessageState.Ack,
});
const conversationId = getConversationId(
directMessageToOther1.from,
directMessageToOther1.to,
);
const message = useMessageStore
.getState()
.getMessageStore(123)!
.messages.direct.get(conversationId)
?.get(directMessageToOther1.messageId);
expect(message?.state).toBe(MessageState.Ack);
});
it("should update state for another direct message in the same conversation", () => {
state.getMessageStore(123)!.setMessageState({
type: MessageType.Direct,
nodeA: directMessageFromOther1.from,
nodeB: directMessageFromOther1.to,
messageId: directMessageFromOther1.messageId,
newState: MessageState.Failed,
});
const conversationId = getConversationId(
directMessageFromOther1.from,
directMessageFromOther1.to,
);
const message = useMessageStore
.getState()
.getMessageStore(123)!
.messages.direct.get(conversationId)
?.get(directMessageFromOther1.messageId);
expect(message?.state).toBe(MessageState.Failed);
});
it("should update state for a broadcast message", () => {
state.getMessageStore(123)!.setMessageState({
type: MessageType.Broadcast,
channelId: broadcastChannel,
messageId: broadcastMessage1.messageId,
newState: MessageState.Ack,
});
const message = useMessageStore
.getState()
.getMessageStore(123)!
.messages.broadcast.get(broadcastChannel)
?.get(broadcastMessage1.messageId);
expect(message?.state).toBe(MessageState.Ack);
});
it("should warn if message is not found (direct)", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
state.getMessageStore(123)!.setMessageState({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
messageId: 999,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Message or conversation/channel not found for state update"),
);
warnSpy.mockRestore();
});
it("should warn if message is not found (broadcast)", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
state.getMessageStore(123)!.setMessageState({
type: MessageType.Broadcast,
channelId: broadcastChannel,
messageId: 999,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Message or conversation/channel not found for state update"),
);
warnSpy.mockRestore();
});
it("should warn if conversation/channel is not found", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
state.getMessageStore(123)!.setMessageState({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 998,
messageId: 101,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Message or conversation/channel not found for state update"),
);
warnSpy.mockRestore();
});
});
describe("clearMessageByMessageId", async () => {
const { useMessageStore } = await freshStore();
const state = useMessageStore.getState();
state.addMessageStore(123);
const extraDirectMessageId = 1011;
beforeEach(() => {
state.getMessageStore(123)?.setNodeNum(myNodeNum);
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
state.getMessageStore(123)?.saveMessage({
...directMessageToOther1,
messageId: extraDirectMessageId,
date: Date.now() + 50,
});
});
it("should delete a specific direct message", () => {
const messageIdToDelete = directMessageToOther1.messageId;
const nodeA = directMessageToOther1.from;
const nodeB = directMessageToOther1.to;
const conversationId = getConversationId(nodeA, nodeB);
state.getMessageStore(123)?.clearMessageByMessageId({
type: MessageType.Direct,
nodeA: nodeA,
nodeB: nodeB,
messageId: messageIdToDelete,
});
const store = useMessageStore.getState().getMessageStore(123)!;
const conversationLog = store.messages.direct.get(conversationId);
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(true);
expect(store.messages.direct.has(conversationId)).toBe(true);
});
it("should delete another specific direct message", () => {
const messageIdToDelete = directMessageFromOther1.messageId;
const nodeA = directMessageFromOther1.from;
const nodeB = directMessageFromOther1.to;
const conversationId = getConversationId(nodeA, nodeB);
state.getMessageStore(123)?.clearMessageByMessageId({
type: MessageType.Direct,
nodeA: nodeA,
nodeB: nodeB,
messageId: messageIdToDelete,
});
const store = useMessageStore.getState().getMessageStore(123)!;
const conversationLog = store.messages.direct.get(conversationId);
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
});
it("should delete a specific broadcast message", () => {
const messageIdToDelete = broadcastMessage1.messageId;
const channelId = broadcastMessage1.channel;
state.getMessageStore(123)?.clearMessageByMessageId({
type: MessageType.Broadcast,
channelId: channelId,
messageId: messageIdToDelete,
});
const store = useMessageStore.getState().getMessageStore(123)!;
expect(store.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined();
});
it("should clean up empty conversation/channel Maps", () => {
const directConvId = getConversationId(
directMessageFromOther1.from,
directMessageFromOther1.to,
);
const broadcastChanId = broadcastMessage1.channel;
state.getMessageStore(123)?.clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: directMessageToOther1.messageId,
});
state.getMessageStore(123)?.clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageFromOther1.from,
nodeB: directMessageFromOther1.to,
messageId: directMessageFromOther1.messageId,
});
state.getMessageStore(123)?.clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: extraDirectMessageId,
});
expect(state.getMessageStore(123)?.messages.direct.has(directConvId)).toBe(false);
state.getMessageStore(123)?.clearMessageByMessageId({
type: MessageType.Broadcast,
channelId: broadcastChanId,
messageId: broadcastMessage1.messageId,
});
expect(state.getMessageStore(123)?.messages.broadcast.has(broadcastChanId)).toBe(false);
});
it("should not error when trying to delete non-existent message", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const conversationId = getConversationId(myNodeNum, otherNodeNum1);
expect(() => {
state.getMessageStore(123)?.clearMessageByMessageId({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
messageId: 9999,
});
}).not.toThrow();
const store = useMessageStore.getState().getMessageStore(123)!;
const conversationLog = store.messages.direct.get(conversationId);
expect(conversationLog?.size).toBe(3); // 101, 102, 1011
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not found in direct chat"));
warnSpy.mockRestore();
});
it("should not error when trying to delete from non-existent conversation/channel", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => {
state.getMessageStore(123)?.clearMessageByMessageId({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 9998,
messageId: 101,
});
}).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry"));
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
});
describe("Drafts", async () => {
const { useMessageStore } = await freshStore();
const state = useMessageStore.getState();
state.addMessageStore(123);
const draftKeyDirect = otherNodeNum1;
const draftKeyBroadcast = broadcastChannel;
const draftMessage = "This is a draft";
it("should set and get a draft for direct chat", () => {
state.getMessageStore(123)?.setDraft(draftKeyDirect, draftMessage);
expect(state.getMessageStore(123)?.drafts.get(draftKeyDirect)).toBe(draftMessage);
expect(state.getMessageStore(123)?.getDraft(draftKeyDirect)).toBe(draftMessage);
});
it("should set and get a draft for broadcast chat", () => {
state.getMessageStore(123)?.setDraft(draftKeyBroadcast, draftMessage);
expect(state.getMessageStore(123)?.drafts.get(draftKeyBroadcast)).toBe(draftMessage);
expect(state.getMessageStore(123)?.getDraft(draftKeyBroadcast)).toBe(draftMessage);
});
it("should return empty string for non-existent draft", () => {
expect(state.getMessageStore(123)?.getDraft(999)).toBe("");
});
it("should clear a draft", () => {
state.getMessageStore(123)?.setDraft(draftKeyDirect, draftMessage);
expect(state.getMessageStore(123)?.drafts.has(draftKeyDirect)).toBe(true);
state.getMessageStore(123)?.clearDraft(draftKeyDirect);
expect(state.getMessageStore(123)?.drafts.has(draftKeyDirect)).toBe(false);
expect(state.getMessageStore(123)?.getDraft(draftKeyDirect)).toBe("");
});
});
describe("deleteAllMessages", async () => {
const { useMessageStore } = await freshStore();
const state = useMessageStore.getState();
state.addMessageStore(123);
it("should clear all direct and broadcast messages, leaving empty Maps", () => {
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
expect(state.getMessageStore(123)?.messages.direct.size).toBeGreaterThan(0);
expect(state.getMessageStore(123)?.messages.broadcast.size).toBeGreaterThan(0);
state.getMessageStore(123)?.deleteAllMessages();
const store = useMessageStore.getState().getMessageStore(123)!;
expect(store.messages.direct).toBeInstanceOf(Map);
expect(store.messages.direct.size).toBe(0);
expect(store.messages.broadcast).toBeInstanceOf(Map);
expect(store.messages.broadcast.size).toBe(0);
});
});
describe("persistence", () => {
it("partialize persists data; onRehydrateStorage rebuilds methods (messages + drafts survive)", async () => {
{
const { useMessageStore } = await freshStore(true);
const state = useMessageStore.getState();
const store = state.addMessageStore(123);
store.setNodeNum(321);
const convId = getConversationId(myNodeNum, otherNodeNum1);
store.saveMessage(directMessageToOther1);
store.saveMessage(broadcastMessage1);
store.setDraft(otherNodeNum1 as unknown as Types.Destination, "draft-text");
const store2 = state.addMessageStore(123);
expect(store2.messages.direct.has(convId)).toBe(true);
expect(store2.messages.direct.get(convId)?.has(101)).toBe(true);
expect(store2.messages.broadcast.get(broadcastChannel)?.has(201)).toBe(true);
expect(store2.getDraft(otherNodeNum1 as unknown as Types.Destination)).toBe("draft-text");
}
{
const { useMessageStore } = await freshStore(true);
const state = useMessageStore.getState();
const store = state.getMessageStore(123)!; // rebuilt instance
expect(store).toBeTruthy();
// Methods should work after rehydrate
const directMsgs = store.getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
});
expect(directMsgs.map((m) => m.messageId)).toEqual([101]);
const bMsgs = store.getMessages({
type: MessageType.Broadcast,
channelId: broadcastChannel,
});
expect(bMsgs.map((m) => m.messageId)).toEqual([201]);
expect(store.getDraft(otherNodeNum1 as unknown as Types.Destination)).toBe("draft-text");
store.saveMessage(directMessageFromOther1);
const after = store.getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
});
expect(after.map((m) => m.messageId)).toEqual([101, 102]);
expect(after[1]?.state).toBe(MessageState.Waiting);
}
});
it("removeMessageStore persists removal across reload", async () => {
{
const { useMessageStore } = await freshStore(true);
const state = useMessageStore.getState();
const store = state.addMessageStore(99);
store.setNodeNum(42);
expect(state.getMessageStore(99)).toBeDefined();
state.removeMessageStore(99);
expect(state.getMessageStore(99)).toBeUndefined();
}
{
const { useMessageStore } = await freshStore(true);
const state = useMessageStore.getState();
expect(state.getMessageStore(99)).toBeUndefined(); // still gone
}
});
it("rehydrate only rebuilds stores with myNodeNum set (orphans dropped)", async () => {
{
const { useMessageStore } = await freshStore(true);
const state = useMessageStore.getState();
// Orphan (no myNodeNum)
const orphan = state.addMessageStore(500);
orphan.saveMessage(broadcastMessage1);
// Proper store
const good = state.addMessageStore(501);
good.setNodeNum(777);
good.saveMessage(broadcastMessage2);
}
{
const { useMessageStore } = await freshStore(true);
const state = useMessageStore.getState();
expect(state.getMessageStore(500)).toBeUndefined(); // orphan dropped
const kept = state.getMessageStore(501);
expect(kept).toBeDefined();
expect(kept?.messages.broadcast.get(broadcastChannel)?.has(202)).toBe(true);
}
});
it("evicts the earliest-added message store when exceeding cap of 10", async () => {
const { useMessageStore } = await freshStore();
const state = useMessageStore.getState();
for (let i = 1; i <= 10; i++) {
state.addMessageStore(i);
}
// Adding the 11th should evict the earliest (id=1)
state.addMessageStore(11);
expect(state.getMessageStore(1)).toBeUndefined(); // evicted
expect(state.getMessageStore(2)).toBeDefined(); // still there
expect(state.getMessageStore(11)).toBeDefined(); // newest kept
});
it("keeps only the latest 1000 messages in a broadcast channel (oldest trimmed)", async () => {
setAutoFreeze(false); // Disable immer auto-freeze for performance in this test
try {
const { useMessageStore, MessageType, MessageState } = await freshStore();
const state = useMessageStore.getState();
const store = state.addMessageStore(123);
const channelId = 0 as number;
const total = 1005;
for (let i = 1; i <= total; i++) {
store.saveMessage({
type: MessageType.Broadcast,
from: 123,
to: 0xffffffff,
channel: channelId,
date: Date.now() + i,
messageId: i,
state: MessageState.Waiting,
message: `m${i}`,
});
}
const fresh = useMessageStore.getState().getMessageStore(123)!;
const log = fresh.messages.broadcast.get(channelId)!;
expect(log.size).toBe(1000); // capped
for (let i = 1; i <= 5; i++) {
expect(log.has(i)).toBe(false); // oldest removed
}
for (let i = 6; i <= 1005; i++) {
expect(log.has(i)).toBe(true); // newest kept
}
} finally {
setAutoFreeze(true); // Restore immer auto-freeze
}
});
});
});
@@ -0,0 +1,68 @@
import type { MessageState, MessageType } from "@core/stores";
import type { Types } from "@meshtastic/core";
type NodeNum = number;
type MessageId = number;
type ChannelId = Types.ChannelNumber;
type ConversationId = string;
type MessageLogMap = Map<MessageId, Message>;
interface MessageBase {
channel: Types.ChannelNumber;
to: number;
from: number;
date: number;
messageId: number;
state: MessageState;
message: string;
}
interface GenericMessage<T extends MessageType> extends MessageBase {
type: T;
}
type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
type GetMessagesParams =
| { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum }
| { type: MessageType.Broadcast; channelId: ChannelId };
type SetMessageStateParams =
| {
type: MessageType.Direct;
nodeA: NodeNum;
nodeB: NodeNum;
messageId: MessageId; // ID of the message within that chat
newState?: MessageState; // Optional new state, defaults to Ack
}
| {
type: MessageType.Broadcast;
channelId: ChannelId;
messageId: MessageId;
newState?: MessageState; // Optional new state, defaults to Ack
};
type ClearMessageParams =
| {
type: MessageType.Direct;
nodeA: NodeNum;
nodeB: NodeNum;
messageId: MessageId;
}
| {
type: MessageType.Broadcast;
channelId: ChannelId;
messageId: MessageId;
};
export type {
ChannelId,
ClearMessageParams,
ConversationId,
GetMessagesParams,
Message,
MessageId,
MessageLogMap,
NodeNum,
SetMessageStateParams,
};
@@ -0,0 +1,589 @@
import { create } from "@bufbuild/protobuf";
import { featureFlags } from "@core/services/featureFlags";
import { validateIncomingNode } from "@core/stores/nodeDBStore/nodeValidation";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { Protobuf, type Types } from "@meshtastic/core";
import { produce } from "immer";
import { create as createStore, type StateCreator } from "zustand";
import { type PersistOptions, persist, subscribeWithSelector } from "zustand/middleware";
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
const IDB_KEY_NAME = "meshtastic-nodedb-store";
const CURRENT_STORE_VERSION = 0;
const NODE_RETENTION_DAYS = 14; // Remove nodes not heard from in 14 days
type NodeDBData = {
// Persisted data
id: number;
myNodeNum: number | undefined;
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
nodeErrors: Map<number, NodeError>;
};
export interface NodeDB extends NodeDBData {
// Ephemeral state (not persisted)
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
removeNode: (nodeNum: number) => void;
removeAllNodes: (keepMyNode?: boolean) => void;
pruneStaleNodes: () => number;
processPacket: (data: ProcessPacketParams) => void;
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addDeviceMetrics: (metrics: Types.PacketMetadata<Protobuf.Telemetry.DeviceMetrics>) => void;
updateFavorite: (nodeNum: number, isFavorite: boolean) => void;
updateIgnore: (nodeNum: number, isIgnored: boolean) => void;
setNodeNum: (nodeNum: number) => void;
setNodeError: (nodeNum: number, error: NodeErrorType) => void;
clearNodeError: (nodeNum: number) => void;
removeAllNodeErrors: () => void;
getNodesLength: () => number;
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
getNodes: (
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
includeSelf?: boolean,
) => Protobuf.Mesh.NodeInfo[];
getMyNode: () => Protobuf.Mesh.NodeInfo | undefined;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean;
}
export interface nodeDBState {
addNodeDB: (id: number) => NodeDB;
removeNodeDB: (id: number) => void;
getNodeDBs: () => NodeDB[];
getNodeDB: (id: number) => NodeDB | undefined;
}
interface PrivateNodeDBState extends nodeDBState {
nodeDBs: Map<number, NodeDB>;
}
type NodeDBPersisted = {
nodeDBs: Map<number, NodeDBData>;
};
function nodeDBFactory(
id: number,
get: () => PrivateNodeDBState,
set: typeof useNodeDBStore.setState,
data?: Partial<NodeDBData>,
): NodeDB {
const nodeMap = data?.nodeMap ?? new Map<number, Protobuf.Mesh.NodeInfo>();
const nodeErrors = data?.nodeErrors ?? new Map<number, NodeError>();
const myNodeNum = data?.myNodeNum;
return {
id,
myNodeNum,
nodeMap,
nodeErrors,
addNode: (node) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
// Check if node already exists
const existing = nodeDB.nodeMap.get(node.num);
const isNew = !existing;
// Use validation to check the new node before adding
const next = validateIncomingNode(
node,
(nodeNum: number, err: NodeErrorType) => {
nodeDB.setNodeError(nodeNum, err);
},
(filter?: (node: Protobuf.Mesh.NodeInfo) => boolean) => nodeDB.getNodes(filter, true),
);
if (!next) {
// Validation failed and error has been set inside validateIncomingNode
return;
}
// 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"})`,
);
}
}),
),
removeNode: (nodeNum) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const updated = new Map(nodeDB.nodeMap);
updated.delete(nodeNum);
nodeDB.nodeMap = updated;
}),
),
removeAllNodes: (keepMyNode) =>
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>();
if (
keepMyNode &&
nodeDB.myNodeNum !== undefined &&
nodeDB.nodeMap.has(nodeDB.myNodeNum)
) {
newNodeMap.set(
nodeDB.myNodeNum,
nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? create(Protobuf.Mesh.NodeInfoSchema),
);
}
nodeDB.nodeMap = newNodeMap;
}),
),
pruneStaleNodes: () => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const nowSec = Math.floor(Date.now() / 1000);
const cutoffSec = nowSec - NODE_RETENTION_DAYS * 24 * 60 * 60;
let prunedCount = 0;
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>();
for (const [nodeNum, node] of nodeDB.nodeMap) {
// Keep myNode regardless of lastHeard
// Keep nodes that have been heard recently
// Keep nodes without lastHeard (just in case)
if (nodeNum === nodeDB.myNodeNum || !node.lastHeard || node.lastHeard >= cutoffSec) {
newNodeMap.set(nodeNum, node);
} else {
prunedCount++;
console.log(
`[NodeDB] Pruning stale node ${nodeNum} (last heard ${Math.floor((nowSec - node.lastHeard) / 86400)} days ago)`,
);
}
}
nodeDB.nodeMap = newNodeMap;
}),
);
if (prunedCount > 0) {
console.log(
`[NodeDB] Pruned ${prunedCount} stale node(s) older than ${NODE_RETENTION_DAYS} days`,
);
}
return prunedCount;
},
setNodeError: (nodeNum, error) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
nodeDB.nodeErrors = new Map(nodeDB.nodeErrors).set(nodeNum, {
node: nodeNum,
error,
});
}),
),
clearNodeError: (nodeNum) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const updated = new Map(nodeDB.nodeErrors);
updated.delete(nodeNum);
nodeDB.nodeErrors = updated;
}),
),
removeAllNodeErrors: () =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
nodeDB.nodeErrors = new Map<number, NodeError>();
}),
),
processPacket: (data) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const node = nodeDB.nodeMap.get(data.from);
const nowSec = Math.floor(Date.now() / 1000); // lastHeard is in seconds(!)
if (node) {
const updated = {
...node,
lastHeard: data.time > 0 ? data.time : nowSec,
snr: data.snr,
};
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(data.from, updated);
} else {
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(
data.from,
create(Protobuf.Mesh.NodeInfoSchema, {
num: data.from,
lastHeard: data.time > 0 ? data.time : nowSec, // fallback to now if time is 0 or negative,
snr: data.snr,
}),
);
}
}),
),
addUser: (user) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const current = nodeDB.nodeMap.get(user.from);
const isNew = !current;
const updated = {
...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
user: user.data,
num: user.from,
};
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(user.from, updated);
if (isNew) {
console.log(
`[NodeDB] Adding new node from user packet: ${user.from} (${user.data.longName || "unknown"})`,
);
}
}),
),
addPosition: (position) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const current = nodeDB.nodeMap.get(position.from);
const isNew = !current;
const updated = {
...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
position: position.data,
num: position.from,
};
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(position.from, updated);
if (isNew) {
console.log(`[NodeDB] Adding new node from position packet: ${position.from}`);
}
}),
),
addDeviceMetrics: (metrics) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const current = nodeDB.nodeMap.get(metrics.from);
// Only fold live device metrics into nodes we already know about, to
// avoid creating identity-less phantom nodes from a stray telemetry packet.
if (!current) {
return;
}
const updated = {
...current,
deviceMetrics: metrics.data,
num: metrics.from,
};
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(metrics.from, updated);
}),
),
setNodeNum: (nodeNum) =>
set(
produce<PrivateNodeDBState>((draft) => {
const newDB = draft.nodeDBs.get(id);
if (!newDB) {
throw new Error(`No nodeDB found for id: ${id}`);
}
newDB.myNodeNum = nodeNum;
for (const [key, oldDB] of draft.nodeDBs) {
if (key === id) {
// short-circuit self
continue;
}
if (oldDB.myNodeNum === nodeNum) {
// We found the oldDB (same myNodeNum). Merge node-by-node as if the new nodes are added with addNode
const mergedNodes = new Map(oldDB.nodeMap);
const mergedErrors = new Map(oldDB.nodeErrors);
const getNodesProxy = (
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
): Protobuf.Mesh.NodeInfo[] => {
const arr = Array.from(mergedNodes.values());
return filter ? arr.filter(filter) : arr;
};
const setErrorProxy = (nodeNum: number, err: NodeErrorType) => {
mergedErrors.set(nodeNum, {
node: nodeNum,
error: err,
});
};
for (const [num, newNode] of newDB.nodeMap) {
const next = validateIncomingNode(newNode, setErrorProxy, getNodesProxy);
if (next) {
mergedNodes.set(num, next);
}
const err = newDB.getNodeError(num);
if (err && !oldDB.hasNodeError(num)) {
mergedErrors.set(num, err);
}
}
// finalize: move maps into newDB and drop oldDB entry
newDB.nodeMap = mergedNodes;
newDB.nodeErrors = mergedErrors;
draft.nodeDBs.delete(oldDB.id);
}
}
}),
),
updateFavorite: (nodeNum, isFavorite) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const node = nodeDB.nodeMap.get(nodeNum);
if (node) {
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(nodeNum, {
...node,
isFavorite: isFavorite,
});
}
}),
),
updateIgnore: (nodeNum, isIgnored) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const node = nodeDB.nodeMap.get(nodeNum);
if (node) {
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(nodeNum, {
...node,
isIgnored: isIgnored,
});
}
}),
),
getNodesLength: () => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
return nodeDB.nodeMap.size;
},
getNode: (nodeNum) => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
return nodeDB.nodeMap.get(nodeNum);
},
getNodes: (filter, includeSelf) => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const all = Array.from(nodeDB.nodeMap.values()).filter((n) =>
includeSelf ? true : n.num !== nodeDB.myNodeNum,
);
return filter ? all.filter(filter) : all;
},
getMyNode: () => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
if (nodeDB.myNodeNum) {
return nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? create(Protobuf.Mesh.NodeInfoSchema);
}
},
getNodeError: (nodeNum) => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
return nodeDB.nodeErrors.get(nodeNum);
},
hasNodeError: (nodeNum) => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
return nodeDB.nodeErrors.has(nodeNum);
},
};
}
export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (set, get) => ({
nodeDBs: new Map(),
addNodeDB: (id) => {
const existing = get().nodeDBs.get(id);
if (existing) {
// Prune stale nodes when accessing existing nodeDB
existing.pruneStaleNodes();
return existing;
}
const nodeDB = nodeDBFactory(id, get, set);
set(
produce<PrivateNodeDBState>((draft) => {
draft.nodeDBs = new Map(draft.nodeDBs).set(id, nodeDB);
}),
);
// Prune stale nodes on creation (useful when rehydrating from storage)
nodeDB.pruneStaleNodes();
return nodeDB;
},
removeNodeDB: (id) => {
set(
produce<PrivateNodeDBState>((draft) => {
const updated = new Map(draft.nodeDBs);
updated.delete(id);
draft.nodeDBs = updated;
}),
);
},
getNodeDBs: () => Array.from(get().nodeDBs.values()),
getNodeDB: (id) => get().nodeDBs.get(id),
});
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
name: IDB_KEY_NAME,
storage: createStorage<NodeDBPersisted>(),
version: CURRENT_STORE_VERSION,
partialize: (s): NodeDBPersisted => ({
nodeDBs: new Map(
Array.from(s.nodeDBs.entries()).map(([id, db]) => [
id,
{
id: db.id,
myNodeNum: db.myNodeNum,
nodeMap: db.nodeMap,
nodeErrors: db.nodeErrors,
},
]),
),
}),
onRehydrateStorage: () => (state) => {
if (!state) {
return;
}
console.debug(
"NodeDBStore: Rehydrating state with ",
state.nodeDBs.size,
" nodeDBs -",
state.nodeDBs,
);
useNodeDBStore.setState(
produce<PrivateNodeDBState>((draft) => {
const rebuilt = new Map<number, NodeDB>();
for (const [id, data] of (draft.nodeDBs as unknown as Map<number, NodeDBData>).entries()) {
if (data.myNodeNum !== undefined) {
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
rebuilt.set(
id,
nodeDBFactory(id, useNodeDBStore.getState, useNodeDBStore.setState, data),
);
}
}
draft.nodeDBs = rebuilt;
}),
);
},
};
// Add persist middleware on the store if the feature flag is enabled
const persistNodes = featureFlags.get("persistNodeDB");
console.debug(`NodeDBStore: Persisting nodes is ${persistNodes ? "enabled" : "disabled"}`);
export const useNodeDBStore = persistNodes
? createStore(subscribeWithSelector(persist(nodeDBInitializer, persistOptions)))
: createStore(subscribeWithSelector(nodeDBInitializer));
@@ -0,0 +1,34 @@
import { vi } from "vitest";
import type { NodeDB } from "./index.ts";
/**
* You can spread this base mock in your tests and override only the
* properties relevant to a specific test case.
*/
export const mockNodeDBStore: NodeDB = {
id: 0,
myNodeNum: 0,
nodeErrors: new Map(),
nodeMap: new Map(),
addNode: vi.fn(),
addUser: vi.fn(),
addPosition: vi.fn(),
addDeviceMetrics: vi.fn(),
removeNode: vi.fn(),
removeAllNodes: vi.fn(),
pruneStaleNodes: vi.fn().mockReturnValue(0),
processPacket: vi.fn(),
setNodeError: vi.fn(),
clearNodeError: vi.fn(),
removeAllNodeErrors: vi.fn(),
getNodeError: vi.fn().mockReturnValue(undefined),
hasNodeError: vi.fn().mockReturnValue(false),
getNodes: vi.fn().mockReturnValue([]),
getNodesLength: vi.fn().mockReturnValue(0),
getNode: vi.fn().mockReturnValue(undefined),
getMyNode: vi.fn(),
updateFavorite: vi.fn(),
updateIgnore: vi.fn(),
setNodeNum: vi.fn(),
};
@@ -0,0 +1,68 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf, type Types } from "@meshtastic/core";
import { describe, expect, it, vi } from "vitest";
const idbMem = new Map<string, string>();
vi.mock("idb-keyval", () => ({
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
set: vi.fn((key: string, val: string) => {
idbMem.set(key, val);
return Promise.resolve();
}),
del: vi.fn((k: string) => {
idbMem.delete(k);
return Promise.resolve();
}),
}));
// Load a fresh, non-persisting copy of the nodeDB store.
async function freshNodeDB() {
vi.resetModules();
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
vi.doMock("@core/services/featureFlags", () => ({
featureFlags: { get: vi.fn(() => false) },
}));
const { useNodeDBStore } = await import("./index.ts");
return useNodeDBStore;
}
function metricsPacket(
from: number,
data: Protobuf.Telemetry.DeviceMetrics,
): Types.PacketMetadata<Protobuf.Telemetry.DeviceMetrics> {
return { id: 1, rxTime: new Date(), type: "broadcast", from, to: 0, channel: 0, data };
}
describe("nodeDBStore – addDeviceMetrics", () => {
it("folds live device metrics into an existing node", async () => {
const useNodeDBStore = await freshNodeDB();
const db = useNodeDBStore.getState().addNodeDB(1);
db.addNode(create(Protobuf.Mesh.NodeInfoSchema, { num: 5 }));
db.addDeviceMetrics(
metricsPacket(
5,
create(Protobuf.Telemetry.DeviceMetricsSchema, { batteryLevel: 80, voltage: 4.1 }),
),
);
expect(db.getNode(5)?.deviceMetrics?.batteryLevel).toBe(80);
expect(db.getNode(5)?.deviceMetrics?.voltage).toBeCloseTo(4.1);
});
it("ignores metrics for an unknown node (no phantom node is created)", async () => {
const useNodeDBStore = await freshNodeDB();
const db = useNodeDBStore.getState().addNodeDB(1);
db.addDeviceMetrics(
metricsPacket(999, create(Protobuf.Telemetry.DeviceMetricsSchema, { batteryLevel: 50 })),
);
expect(db.getNode(999)).toBeUndefined();
expect(db.getNodesLength()).toBe(0);
});
});
@@ -0,0 +1,549 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/core";
import { act, render, screen } from "@testing-library/react";
import { toByteArray } from "base64-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const idbMem = new Map<string, string>();
vi.mock("idb-keyval", () => ({
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
set: vi.fn((key: string, val: string) => {
idbMem.set(key, val);
return Promise.resolve();
}),
del: vi.fn((k: string) => {
idbMem.delete(k);
return Promise.resolve();
}),
}));
let deviceIdForTests = 1;
vi.mock("@core/hooks/useDeviceContext", () => ({
useDeviceContext: () => ({ deviceId: deviceIdForTests }),
__setDeviceId: (id: number) => {
deviceIdForTests = id;
},
}));
// import a fresh copy of the store module (because the store is created at import time)
async function freshStore(persist = false) {
vi.resetModules();
// suppress console output from the store during tests (for github actions)
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
vi.doMock("@core/services/featureFlags", () => ({
featureFlags: {
get: vi.fn((key: string) => (key === "persistNodeDB" ? persist : false)),
},
}));
const storeMod = await import("./index.ts");
const { useNodeDB } = await import("../index.ts");
return { ...storeMod, useNodeDB };
}
function makeNode(num: number, extras: Record<string, any> = {}) {
return create(Protobuf.Mesh.NodeInfoSchema, { num, ...extras });
}
function makeUser(fields: Record<string, any>) {
return create(Protobuf.Mesh.UserSchema, fields);
}
function makePosition(fields: Record<string, any>) {
return create(Protobuf.Mesh.PositionSchema, fields);
}
describe("NodeDB store", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("addNodeDB returns same instance on repeated calls; getNodeDB works", async () => {
const { useNodeDBStore } = await freshStore();
const db1 = useNodeDBStore.getState().addNodeDB(123);
const db2 = useNodeDBStore.getState().addNodeDB(123);
expect(db1).toStrictEqual(db2);
const got = useNodeDBStore.getState().getNodeDB(123);
expect(got).toStrictEqual(db1);
expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1);
});
it("addNode, getNode(s), getNodesLength, removeNode", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.addNode(makeNode(10));
db.addNode(makeNode(11));
expect(db.getNodesLength()).toBe(2);
expect(db.getNode(10)?.num).toBe(10);
const all = db.getNodes();
expect(all.map((n) => n.num).sort()).toEqual([10, 11]);
db.removeNode(10);
expect(db.getNodesLength()).toBe(1);
expect(db.getNode(10)).toBeUndefined();
});
it("processPacket creates or updates a node", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.processPacket({ from: 50, time: 1111, snr: 7 } as any);
expect(db.getNode(50)).toBeTruthy();
expect(db.getNode(50)?.lastHeard).toBe(1111);
expect(db.getNode(50)?.snr).toBe(7);
db.processPacket({ from: 50, time: 2222, snr: 9 } as any);
expect(db.getNode(50)?.lastHeard).toBe(2222);
expect(db.getNode(50)?.snr).toBe(9);
db.processPacket({ from: 50, time: 0, snr: 9 } as any);
expect(db.getNode(50)?.lastHeard).toBeCloseTo(Date.now() / 1000, -1); // within 1s, note lastHeard is in seconds
expect(db.getNode(50)?.snr).toBe(9);
});
it("addUser and addPosition updates existing or creates new nodes", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
// addUser creates node if missing
db.addUser({ from: 77, data: { id: "u" } } as any);
expect(db.getNode(77)?.user).toEqual({ id: "u" });
// addPosition updates same node
db.addPosition({ from: 77, data: { lat: 1, lon: 2 } } as any);
expect(db.getNode(77)?.position).toEqual({ lat: 1, lon: 2 });
expect(db.getNode(77)?.num).toBe(77);
});
it("errors map: setNodeError, getNodeError, hasNodeError, clearNodeError", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.setNodeError(10, "BadFoo" as any);
expect(db.hasNodeError(10)).toBe(true);
expect(db.getNodeError(10)).toEqual({ node: 10, error: "BadFoo" });
db.clearNodeError(10);
expect(db.hasNodeError(10)).toBe(false);
expect(db.getNodeError(10)).toBeUndefined();
});
it("getMyNode returns undefined before setNodeNum; works after", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.addNode(makeNode(123));
expect(db.getMyNode()).toBeUndefined();
db.setNodeNum(123);
const me = db.getMyNode();
expect(me?.num).toBe(123);
});
it("setNodeNum merges with existing DB with same myNodeNum", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(10);
oldDB.setNodeNum(999);
oldDB.addNode(makeNode(200));
oldDB.setNodeError(200, "ERROR" as any);
const newDB = st.addNodeDB(11);
// newDB currently empty; setting same myNodeNum should copy maps from oldDB and delete old
newDB.setNodeNum(999);
expect(st.getNodeDB(10)).toBeUndefined();
expect(st.getNodeDB(11)).toBeDefined();
expect(newDB.getNode(200)).toBeTruthy();
expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" });
});
it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => {
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const st = useNodeDBStore.getState();
const db = st.addNodeDB(123);
db.setNodeNum(321);
db.addNode(makeNode(50));
db.setNodeError(50, "ERROR" as any);
}
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const st = useNodeDBStore.getState();
const db = st.getNodeDB(123)!;
// methods should work after rehydrate
expect(db.getNode(50)?.num).toBe(50);
expect(db.getNodeError(50)).toEqual({ node: 50, error: "ERROR" });
db.addNode(makeNode(51));
expect(db.getNode(51)).toBeTruthy();
}
});
it("getNodes applies filter and excludes myNodeNum", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.setNodeNum(11);
db.addNode(makeNode(10));
db.addNode(makeNode(11));
db.addNode(makeNode(12));
const all = db.getNodes();
expect(all.map((n) => n.num).sort()).toEqual([10, 12]); // excludes my (11)
const filtered = db.getNodes((n) => n.num > 10);
expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11
});
it("will prune nodes after 14 days of inactivitiy", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
st.addNodeDB(1).addNode(makeNode(1, { lastHeard: Date.now() / 1000 - 15 * 24 * 3600 })); // 15 days ago
st.addNodeDB(1).addNode(makeNode(2, { lastHeard: Date.now() / 1000 - 7 * 24 * 3600 })); // 7 days ago
st.getNodeDB(1)!.pruneStaleNodes();
expect(st.getNodeDB(1)?.getNode(2)).toBeDefined();
});
it("removeNodeDB persists removal across reload", async () => {
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const st = useNodeDBStore.getState();
st.addNodeDB(99);
expect(st.getNodeDB(99)).toBeDefined();
st.removeNodeDB(99);
expect(st.getNodeDB(99)).toBeUndefined();
}
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const st = useNodeDBStore.getState();
expect(st.getNodeDB(99)).toBeUndefined(); // still gone
}
});
it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => {
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const st = useNodeDBStore.getState();
const orphan = st.addNodeDB(500); // no setNodeNum
orphan.addNode(makeNode(1));
const good = st.addNodeDB(501);
good.setNodeNum(42);
good.addNode(makeNode(2));
}
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const st = useNodeDBStore.getState();
expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped
expect(st.getNodeDB(501)).toBeDefined(); // kept
expect(st.getNodeDB(501)!.getNode(2)).toBeTruthy();
}
});
it("methods throw after their DB is removed from the map", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const db = st.addNodeDB(800);
st.removeNodeDB(800);
expect(() => db.getNodesLength()).toThrow(/No nodeDB found/);
expect(() => db.addNode(makeNode(1))).toThrow(/No nodeDB found/);
});
});
describe("NodeDB – merge semantics, PKI checks & extras", () => {
const keyOld = toByteArray("40g5tLC6A+tXE92EyhwVwdiKsXwa1QUjZjkzEi0pCy4=");
const keyNew = toByteArray("osxYoEP43oDeWZyjyKx1wz/5cvwEOthHB6AhO2fXEQg=");
it("upserts node", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(10);
oldDB.setNodeNum(999);
oldDB.addNode(makeNode(200, { position: { altitude: 100 } }));
const newDB = st.addNodeDB(11);
newDB.addNode(makeNode(300));
newDB.addNode(makeNode(200, { position: { altitude: 120 } }));
newDB.setNodeNum(999);
expect(st.getNodeDB(10)).toBeUndefined(); // old db removed
expect(newDB.getNode(300)).toBeTruthy(); // node kept
const n200 = newDB.getNode(200)!;
expect(n200.position?.altitude).toBe(120); // replace existing
});
it("key conflict: keep old node, flag error", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(20);
oldDB.setNodeNum(42);
oldDB.addNode(
makeNode(7, {
user: makeUser({ publicKey: keyOld, longName: "old-7" }),
position: makePosition({ latitudeI: 11, longitudeI: 22 }),
}),
);
const newDB = st.addNodeDB(21);
newDB.addNode(
makeNode(7, {
user: makeUser({ publicKey: keyNew, longName: "new-7" }),
position: makePosition({ latitudeI: 33 }),
}),
);
newDB.setNodeNum(42);
const n7 = newDB.getNode(7)!;
// node from old
expect(n7.user?.longName).toBe("old-7");
expect(n7.user?.publicKey).toEqual(keyOld);
expect(n7.position?.latitudeI).toBe(11);
expect(n7.position?.longitudeI).toBe(22);
// error flagged
const err = newDB.getNodeError(7);
expect(err).toBeTruthy();
expect(String(err!.error)).toMatch(/MISMATCH|PK/i);
});
it("empty new key; drop new node", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(30);
oldDB.setNodeNum(77);
oldDB.addNode(makeNode(5, { user: { publicKey: keyOld, longName: "old-5" } }));
const newDB = st.addNodeDB(31);
newDB.addNode(makeNode(5, { user: { publicKey: new Uint8Array(), longName: "new-5" } }));
newDB.setNodeNum(77);
// node from old
const n5 = newDB.getNode(5)!;
expect(n5.user?.publicKey).toEqual(keyOld); // keep old PK
expect(n5.user?.longName).toBe("old-5");
// error not flagged; dropped silently
const err = newDB!.getNodeError(5);
expect(err).toBeUndefined();
});
it("old key empty, new key present, store new node", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(40);
oldDB.setNodeNum(1001);
oldDB.addNode(makeNode(8, { user: { longName: "old-8" } })); // no key
const newDB = st.addNodeDB(41);
newDB.addNode(
makeNode(8, {
user: { publicKey: keyNew, longName: "new-8" },
position: { altitude: 555 },
}),
);
newDB.setNodeNum(1001);
// node from new
const n8 = newDB.getNode(8)!;
expect(n8.user?.longName).toBe("new-8");
expect(n8.user?.publicKey).toEqual(keyNew);
expect(n8.position?.altitude).toBe(555);
// no error
const err = newDB.getNodeError(8);
expect(err).toBeFalsy();
});
it("unions nodeErrors: preserves old and new, respects existing-on-conflict", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(50);
oldDB.setNodeNum(2020);
oldDB.addNode(makeNode(1, { user: { longName: "old-1" } }));
oldDB.setNodeError(1, "OLD_ERR" as any);
const newDB = st.addNodeDB(51);
newDB.addNode(makeNode(1, { user: { longName: "new-1" } }));
newDB.addNode(makeNode(2, { user: { longName: "new-2" } }));
newDB.setNodeError(2, "NEW_ERR" as any);
// also set overlapping error
newDB.setNodeError(1, "SHOULD_NOT_OVERWRITE" as any);
newDB.setNodeNum(2020);
expect(newDB.getNodeError(1)!.error).toBe("OLD_ERR"); // old kept
expect(newDB.getNodeError(2)!.error).toBe("NEW_ERR"); // new added
});
it("removeAllNodes (optionally keeping my node) and removeAllNodeErrors persist across reload", async () => {
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const st = useNodeDBStore.getState();
const db = st.addNodeDB(1000);
db.setNodeNum(55);
db.addNode(makeNode(55, { user: { longName: "me" } }));
db.addNode(makeNode(56));
db.setNodeError(56, "ERR" as any);
db.removeAllNodes(true);
db.removeAllNodeErrors();
}
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const st = useNodeDBStore.getState();
const db = st.getNodeDB(1000)!;
expect(db.getNode(55)).toBeTruthy(); // kept me
expect(db.getNode(56)).toBeUndefined(); // cleared others
expect(db.getNodeError(56)).toBeUndefined(); // cleared errors
}
});
it("getMyNode works after merge establishes myNodeNum", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(1100);
oldDB.setNodeNum(4242);
oldDB.addNode(makeNode(4242));
const newDB = st.addNodeDB(1101);
newDB.setNodeNum(4242);
expect(newDB.getMyNode()?.num).toBe(4242);
});
});
describe("NodeDB deviceContext & debounce", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("useNodeDB resolves per-device DB and switches with deviceId", async () => {
const { useNodeDBStore, useNodeDB } = await freshStore();
// device 1
deviceIdForTests = 1;
const st = useNodeDBStore.getState();
const db1 = st.addNodeDB(1);
db1.addNode({ num: 10 } as any);
function Comp() {
const len = useNodeDB((db) => db.getNodesLength(), {
debounce: 0,
equality: (a, b) => a === b,
});
return <div data-testid="len">{len}</div>;
}
const { rerender } = render(<Comp />);
expect(screen.getByTestId("len").textContent).toBe("1");
// switch to device 2 and add nodes
deviceIdForTests = 2;
const db2 = st.addNodeDB(2);
db2.addNode({ num: 20 } as any);
db2.addNode({ num: 21 } as any);
db2.addNode({ num: 22 } as any);
// re-render so the hook re-subscribes with the new deviceId
await act(async () => {
rerender(<Comp />);
});
expect(screen.getByTestId("len").textContent).toBe("3");
});
it("useNodeDB selector re-renders only when the selected slice changes", async () => {
const { useNodeDBStore, useNodeDB } = await freshStore();
deviceIdForTests = 1;
const st = useNodeDBStore.getState();
const db = st.addNodeDB(1);
let renders = 0;
function Comp() {
const len = useNodeDB((d) => d.getNodesLength(), {
debounce: 0,
equality: (a, b) => a === b,
});
renders++;
return <div data-testid="len">{len}</div>;
}
render(<Comp />);
expect(screen.getByTestId("len").textContent).toBe("0");
expect(renders).toBe(1);
// mutate something unrelated to length
db.setNodeError(999, "X" as any);
await act(() => Promise.resolve());
expect(screen.getByTestId("len").textContent).toBe("0");
expect(renders).toBe(1); // no re-render
// now actually change the slice
db.addNode({ num: 1 } as any);
await act(() => Promise.resolve());
expect(screen.getByTestId("len").textContent).toBe("1");
expect(renders).toBe(2);
});
it("useNodeDB debounce coalesces rapid updates", async () => {
vi.useFakeTimers();
const { useNodeDBStore, useNodeDB } = await freshStore();
deviceIdForTests = 1;
const st = useNodeDBStore.getState();
const db = st.addNodeDB(1);
let renders = 0;
function Comp() {
const len = useNodeDB((d) => d.getNodesLength(), {
debounce: 50,
equality: (a, b) => a === b,
});
renders++;
return <div data-testid="len">{len}</div>;
}
render(<Comp />);
// burst of updates within the debounce window
db.addNode({ num: 1 } as any);
db.addNode({ num: 2 } as any);
db.addNode({ num: 3 } as any);
await act(() => {
vi.advanceTimersByTime(49);
});
expect(renders).toBe(1); // not yet
await act(() => {
vi.advanceTimersByTime(2);
});
expect(screen.getByTestId("len").textContent).toBe("3");
expect(renders).toBe(2); // single coalesced re-render
vi.useRealTimers();
});
});
@@ -0,0 +1,95 @@
import type { NodeErrorType } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { fromByteArray } from "base64-js";
export function equalKey(a?: Uint8Array | null, b?: Uint8Array | null): boolean {
if (!a || !b) {
return false;
}
if (a === b) {
return true;
}
const len = a.byteLength;
if (len !== b.byteLength) {
return false;
}
for (let i = 0; i < len; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// Validates a new incoming node against existing nodes.
// If valid, returns a node to store, else returns undefined.
export function validateIncomingNode(
newNode: Protobuf.Mesh.NodeInfo,
setNodeError: (nodeNum: number, error: NodeErrorType) => void,
getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean) => Protobuf.Mesh.NodeInfo[],
): Protobuf.Mesh.NodeInfo | undefined {
const num = newNode.num;
const existingNodes = getNodes((node) => node.num === num);
if (existingNodes.length === 0) {
// No existing node with this node number.
// Check if the new node's public key (if present and not empty)
// is already claimed by another existing node.
if (newNode.user?.publicKey !== undefined && newNode.user?.publicKey.length > 0) {
const nodesWithSameKey = getNodes((node) => node.user?.publicKey === newNode.user?.publicKey);
if (nodesWithSameKey.length > 0) {
// This is a potential impersonation attempt.
console.warn(
`Node ${num} rejected: Public key already claimed by another node. Key:`,
fromByteArray(newNode.user?.publicKey ?? new Uint8Array()),
);
setNodeError(num, "DUPLICATE_PKI");
return undefined; // drop newNode entirely
}
}
return newNode; // No conflicts, accept newNode
} else if (existingNodes.length === 1) {
// One existing node with this node number.
const oldNode = existingNodes[0];
if (!oldNode) {
return undefined;
}
// A public key is considered matching if the incoming key equals
// the existing key, OR if the existing key is empty.
const isKeyMatchingOrExistingEmpty =
equalKey(oldNode.user?.publicKey, newNode.user?.publicKey) ||
oldNode.user?.publicKey === undefined ||
oldNode.user?.publicKey.length === 0;
if (isKeyMatchingOrExistingEmpty) {
// Keys match or existing key was empty: trust the incoming node data completely.
// This allows for legitimate updates to user info and other fields.
return newNode;
} else if (newNode.user?.publicKey !== undefined && newNode.user?.publicKey.length > 0) {
console.warn(
`Node ${num} rejected: existing key does not match incoming key. Old key:`,
fromByteArray(oldNode.user?.publicKey ?? new Uint8Array()),
"New key:",
fromByteArray(newNode.user?.publicKey ?? new Uint8Array()),
);
// Keys do not match and existing key was not empty: potential impersonation attempt.
setNodeError(num, "MISMATCH_PKI");
return oldNode; // drop newNode fields and return old
} else {
// Incoming node has no public key: ignore the new node entirely.
console.warn(`Node ${num} rejected: incoming node has no public key, but existing does.`);
return oldNode; // drop newNode fields and return old
}
} else {
// Multiple existing nodes with the same node number
// This should never happen, but if it does, we drop the new node entirely.
console.warn(`Node ${num} rejected: Multiple existing nodes with this node number.`);
setNodeError(num, "DUPLICATE_PKI");
return undefined; // drop newNode entirely
}
}
@@ -0,0 +1,16 @@
import type { Protobuf } from "@meshtastic/core";
type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI" | "DUPLICATE_PKI";
type NodeError = {
node: number;
error: NodeErrorType;
};
type ProcessPacketParams = {
from: number;
snr: number;
time: number;
};
export type { NodeError, ProcessPacketParams, NodeErrorType };
@@ -0,0 +1,40 @@
import type React from "react";
import { createContext, useContext, useMemo, useState } from "react";
interface SidebarContextProps {
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
toggleSidebar: () => void;
}
const SidebarContext = createContext<SidebarContextProps | undefined>(undefined);
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
const toggleSidebar = useMemo(
() => () => {
setIsCollapsed((prev) => !prev);
},
[],
);
const value = useMemo(
() => ({
isCollapsed,
setIsCollapsed,
toggleSidebar,
}),
[isCollapsed, toggleSidebar],
);
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
};
export const useSidebar = (): SidebarContextProps => {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
};
@@ -0,0 +1,98 @@
import { useDeviceContext } from "@core/hooks/useDeviceContext";
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import type { StoreApi, UseBoundStore } from "zustand";
import { shallow } from "zustand/shallow";
type GenericEqualityFn<T> = (a: T, b: T) => boolean;
type DebounceOpts<T> = {
debounce?: number; // 0/undefined = no debounce
equality?: GenericEqualityFn<T>; // default: shallow
fireImmediately?: boolean; // default: true
};
type StoreWithSelector<S> = UseBoundStore<StoreApi<S>> & {
getState(): S;
subscribe: <U>(
selector: (state: S) => U,
listener: (next: U, prev: U) => void,
options?: { equalityFn?: GenericEqualityFn<U>; fireImmediately?: boolean },
) => () => void;
};
export function bindStoreToDevice<S, DB>(
store: StoreWithSelector<S>,
resolveDB: (state: S, deviceId: number) => DB,
) {
// Overloads:
function useBound(): DB;
function useBound<T>(selector: (db: DB) => T, opts?: DebounceOpts<T>): T;
// Implementation:
function useBound<T>(selector?: (db: DB) => T, opts?: DebounceOpts<T>): DB | T {
const { deviceId } = useDeviceContext();
// Build the store-level selector
const storeSelector = useCallback(
(state: S) => {
const db = resolveDB(state, deviceId);
return selector ? selector(db) : db;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[deviceId, selector],
);
type Selected = ReturnType<typeof storeSelector>;
const wait = opts?.debounce ?? 0;
const fireImmediately = opts?.fireImmediately ?? true;
const equality: GenericEqualityFn<Selected> =
(opts?.equality as GenericEqualityFn<Selected>) ??
(shallow as unknown as GenericEqualityFn<Selected>);
const snapRef = useRef<Selected>(storeSelector(store.getState()));
snapRef.current = storeSelector(store.getState()); // this ensures rerenders with a new selector (new deviceId) see the right initial value
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const subscribe = useMemo(() => {
return (onChange: () => void) => {
const unsubscribe = store.subscribe(
storeSelector,
(next: Selected, prev: Selected) => {
const emit = () => {
snapRef.current = next;
onChange();
};
if (equality(next, prev)) {
return;
}
if (wait > 0) {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(emit, wait);
} else {
emit();
}
},
{ equalityFn: equality, fireImmediately },
);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
unsubscribe();
};
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storeSelector, equality, wait, fireImmediately]);
const getSnapshot = () => snapRef.current;
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
return useBound;
}
@@ -0,0 +1,21 @@
export function evictOldestEntries<T>(arr: T[], maxSize: number): void;
export function evictOldestEntries<K, V>(map: Map<K, V>, maxSize: number): void;
export function evictOldestEntries<T, K, V>(collection: T[] | Map<K, V>, maxSize: number): void {
if (Array.isArray(collection)) {
// Trim array from the front (assuming oldest entries are at the start)
while (collection.length > maxSize) {
collection.shift();
}
} else if (collection instanceof Map) {
// Trim map by insertion order
while (collection.size > maxSize) {
const firstKey = collection.keys().next().value;
if (firstKey !== undefined) {
collection.delete(firstKey);
} else {
break;
}
}
}
}
@@ -0,0 +1,158 @@
import * as idb from "idb-keyval";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStorage } from "./indexDB.ts";
type PersistStorage<T> = ReturnType<typeof createStorage<T>>;
describe("indexDB.ts persistence (steps 1–5)", () => {
let store: PersistStorage<any>;
beforeEach(() => {
vi.restoreAllMocks();
store = createStorage<any>();
});
async function roundTrip(obj: any) {
const setSpy = vi.spyOn(idb, "set").mockResolvedValue();
const getSpy = vi.spyOn(idb, "get");
await store.setItem("rt", obj);
const storedStr = (setSpy.mock.calls[0] as any[])[1] as string;
getSpy.mockResolvedValue(storedStr);
return await store.getItem("rt");
}
// Basic methods
it("getItem returns null when idb-keyval.get yields undefined", async () => {
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined);
const res = await store.getItem("missing-key");
expect(getSpy).toHaveBeenCalledWith("missing-key");
expect(res).toBeNull();
});
it("setItem writes a string via idb-keyval.set", async () => {
const setSpy = vi.spyOn(idb, "set").mockResolvedValue();
const payload = { state: { a: 1 }, version: 0 };
await store.setItem("k1", payload);
expect(setSpy).toHaveBeenCalledTimes(1);
const [, value] = setSpy.mock.calls[0]!;
expect(typeof value).toBe("string");
// sanity: it should be JSON
expect(() => JSON.parse(value as string)).not.toThrow();
});
it("removeItem calls idb-keyval.del and getItem returns null afterwards", async () => {
const delSpy = vi.spyOn(idb, "del").mockResolvedValue();
await store.removeItem("k2");
expect(delSpy).toHaveBeenCalledWith("k2");
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined);
const res = await store.getItem("k2");
expect(getSpy).toHaveBeenCalledWith("k2");
expect(res).toBeNull();
});
// Map
it("round-trips an empty Map", async () => {
const out = await roundTrip({ state: { m: new Map() }, version: 0 });
expect(out?.state.m instanceof Map).toBe(true);
expect(out?.state.m.size).toBe(0);
});
it("round-trips a Map<number,string> and preserves numeric key semantics", async () => {
const m = new Map<number, string>([[1, "a"]]);
const out = await roundTrip({ state: { m }, version: 0 });
const m2 = out!.state.m as Map<number, string>;
expect(m2 instanceof Map).toBe(true);
expect(m2.get(1)).toBe("a");
});
it("round-trips nested Map inside arrays/objects", async () => {
const payload = {
state: {
list: [new Map<number, number>([[2, 3]])],
obj: { inner: new Map<string, number>([["k", 7]]) },
},
version: 0,
};
const out = await roundTrip(payload);
expect(out!.state.list[0] instanceof Map).toBe(true);
expect((out!.state.list[0] as Map<number, number>).get(2)).toBe(3);
expect(out!.state.obj.inner instanceof Map).toBe(true);
expect((out!.state.obj.inner as Map<string, number>).get("k")).toBe(7);
});
// Uint8Array
it("round-trips a Uint8Array (simple)", async () => {
const u8 = new Uint8Array([1, 2, 255]);
const out = await roundTrip({ state: { u8 }, version: 0 });
const u2 = out!.state.u8 as Uint8Array;
expect(u2 instanceof Uint8Array).toBe(true);
expect(Array.from(u2)).toEqual([1, 2, 255]);
});
it("round-trips a Uint8Array view with non-zero byteOffset", async () => {
const buf = new Uint8Array([0, 9, 8, 7, 6, 5, 4, 3]).buffer;
const view = new Uint8Array(buf, 2, 4); // [8,7,6,5]
const out = await roundTrip({ state: { view }, version: 0 });
const v2 = out!.state.view as Uint8Array;
expect(v2 instanceof Uint8Array).toBe(true);
expect(Array.from(v2)).toEqual([8, 7, 6, 5]);
// ensure it's a standalone buffer now, without offset
expect(v2.byteOffset).toBe(0);
});
// Mixed & nested structures
it("round-trips Map values containing objects with nested Uint8Array", async () => {
const inner = { key: new Uint8Array([7, 8]) };
const m = new Map<number, { key: Uint8Array }>([[42, inner]]);
const out = await roundTrip({ state: { m }, version: 0 });
const m2 = out!.state.m as Map<number, { key: Uint8Array }>;
expect(m2 instanceof Map).toBe(true);
const got = m2.get(42)!;
expect(got.key instanceof Uint8Array).toBe(true);
expect(Array.from(got.key)).toEqual([7, 8]);
});
it("round-trips deep nesting (array → object → map → u8)", async () => {
const payload = {
state: {
arr: [
{
obj: {
m: new Map<string, Uint8Array>([["k", new Uint8Array([9])]]),
},
},
],
},
version: 0,
};
const out = await roundTrip(payload);
const m2 = out!.state.arr[0].obj.m as Map<string, Uint8Array>;
expect(m2 instanceof Map).toBe(true);
const u = m2.get("k")!;
expect(u instanceof Uint8Array).toBe(true);
expect(Array.from(u)).toEqual([9]);
});
it("does not alter plain objects/arrays", async () => {
const payload = { state: { a: 1, b: [2, 3], c: { d: 4 } }, version: 0 };
const out = await roundTrip(payload);
expect(out).toEqual(payload);
});
it("revives envelope-looking objects", async () => {
// Current implementation will treat any {__datatype:"Map",value:[...]} as an envelope.
const forged = JSON.stringify({
state: { m: { __datatype: "Map", value: [[1, "x"]] } },
version: 0,
});
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(forged);
const out = await store.getItem("forged");
expect(getSpy).toHaveBeenCalled();
const m2 = out!.state.m as Map<number, string>;
expect(m2 instanceof Map).toBe(true);
expect(m2.get(1)).toBe("x");
});
});
+119
View File
@@ -0,0 +1,119 @@
import { del, get, set } from "idb-keyval";
import type { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
export const zustandIndexDBStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
return (await get(name)) || null;
},
setItem: async (name: string, value: string): Promise<void> => {
await set(name, value);
},
removeItem: async (name: string): Promise<void> => {
await del(name);
},
};
type AnyRecord = Record<string, unknown>;
type Envelope<Tag extends string = string, V = unknown> = {
__datatype: Tag;
value: V;
};
interface Handler<T, Tag extends string, Encoded> {
tag: Tag;
test(val: unknown): val is T;
serialize(val: T): Encoded;
revive(encoded: Encoded): T;
}
function isObject(x: unknown): x is AnyRecord {
return typeof x === "object" && x !== null;
}
function isEnvelope(x: unknown): x is Envelope {
return isObject(x) && typeof x.__datatype === "string" && "value" in x;
}
// Map handler
type SerializedMap<K = unknown, V = unknown> = Array<[K, V]>;
const mapHandler: Handler<Map<unknown, unknown>, "Map", SerializedMap> = {
tag: "Map",
test: (val): val is Map<unknown, unknown> => val instanceof Map,
serialize: (map) => Array.from(map.entries()),
revive: (pairs) => new Map(pairs),
};
// Uint8Array handler
const uint8ArrayHandler: Handler<Uint8Array, "Uint8Array", number[]> = {
tag: "Uint8Array",
test: (val): val is Uint8Array => val instanceof Uint8Array,
serialize: (uint8array) => Array.from(uint8array),
revive: (arr) => new Uint8Array(arr),
};
const defaultHandlers = [mapHandler, uint8ArrayHandler] as const;
function makeJson<H extends Handler<unknown, string, unknown>>(handlers: readonly H[]) {
const byTag = new Map<H["tag"], H>();
for (const handler of handlers) {
byTag.set(handler.tag, handler);
}
const replacer = (_: string, value: unknown): unknown => {
for (const handler of handlers) {
if (handler.test(value)) {
const encoded = handler.serialize(value as never);
const envelope: Envelope<typeof handler.tag, typeof encoded> = {
__datatype: handler.tag,
value: encoded,
};
return envelope;
}
}
return value;
};
const reviver = (_: string, value: unknown): unknown => {
if (isEnvelope(value)) {
const handler = byTag.get(value.__datatype);
if (handler) {
return handler.revive((value as Envelope<H["tag"], unknown>).value as never);
}
}
return value;
};
return { replacer, reviver };
}
export function createStorage<T, H extends Handler<unknown, string, unknown> = never>(
extraHandlers: readonly H[] = [] as const,
): PersistStorage<T> {
const { replacer, reviver } = makeJson([...defaultHandlers, ...extraHandlers]);
return {
getItem: async (name): Promise<StorageValue<T> | null> => {
const str = await zustandIndexDBStorage.getItem(name);
if (!str) {
return null;
}
try {
const parsed = JSON.parse(str, reviver) as StorageValue<T>;
return parsed;
} catch (error) {
console.error(`Error parsing persisted state (${name}):`, error);
return null;
}
},
setItem: async (name, newValue: StorageValue<T>): Promise<void> => {
try {
const str = JSON.stringify(newValue, replacer);
await zustandIndexDBStorage.setItem(name, str);
} catch (error) {
console.error(`Error stringifying or setting persisted state (${name}):`, error);
}
},
removeItem: async (name): Promise<void> => {
await zustandIndexDBStorage.removeItem(name);
},
};
}
+153
View File
@@ -0,0 +1,153 @@
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
import { useNewNodeNum } from "@core/hooks/useNewNodeNum";
import { type Device, type MessageStore, MessageType, type NodeDB } from "@core/stores";
import { type MeshDevice, Protobuf } from "@meshtastic/core";
export const subscribeAll = (
device: Device,
connection: MeshDevice,
messageStore: MessageStore,
nodeDB: NodeDB,
) => {
let myNodeNum = 0;
connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => {
device.addMetadata(metadataPacket.from, metadataPacket.data);
});
connection.events.onRoutingPacket.subscribe((routingPacket) => {
switch (routingPacket.data.variant.case) {
case "errorReason": {
if (routingPacket.data.variant.value === Protobuf.Mesh.Routing_Error.NONE) {
return;
}
console.info(`Routing Error: ${routingPacket.data.variant.value}`);
break;
}
case "routeReply": {
console.info(`Route Reply: ${routingPacket.data.variant.value}`);
break;
}
case "routeRequest": {
console.info(`Route Request: ${routingPacket.data.variant.value}`);
break;
}
}
});
connection.events.onTelemetryPacket.subscribe((telemetryPacket) => {
// Fold live device-metrics telemetry into the node so battery / channel
// utilisation / voltage stay current between NodeInfo broadcasts.
if (telemetryPacket.data.variant.case === "deviceMetrics") {
nodeDB.addDeviceMetrics({
...telemetryPacket,
data: telemetryPacket.data.variant.value,
});
}
});
connection.events.onDeviceStatus.subscribe((status) => {
device.setStatus(status);
});
connection.events.onWaypointPacket.subscribe((waypoint) => {
const { data, channel, from, rxTime } = waypoint;
device.addWaypoint(data, channel, from, rxTime);
});
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
useNewNodeNum(device.id, nodeInfo);
myNodeNum = nodeInfo.myNodeNum;
});
connection.events.onUserPacket.subscribe((user) => {
nodeDB.addUser(user);
});
connection.events.onPositionPacket.subscribe((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) => {
nodeDB.addNode(nodeInfo);
});
connection.events.onChannelPacket.subscribe((channel) => {
device.addChannel(channel);
});
connection.events.onConfigPacket.subscribe((config) => {
device.setConfig(config);
});
connection.events.onModuleConfigPacket.subscribe((moduleConfig) => {
device.setModuleConfig(moduleConfig);
});
connection.events.onMessagePacket.subscribe((messagePacket) => {
// incoming and outgoing messages are handled by this event listener
const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
const message = dto.toMessage();
messageStore.saveMessage(message);
if (message.type === MessageType.Direct) {
if (message.to === myNodeNum) {
device.incrementUnread(messagePacket.from);
}
} else if (message.type === MessageType.Broadcast) {
if (message.from !== myNodeNum) {
device.incrementUnread(message.channel);
}
}
});
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
device.addTraceRoute({
...traceRoutePacket,
});
});
connection.events.onPendingSettingsChange.subscribe((state) => {
device.setPendingSettingsChanges(state);
});
connection.events.onMeshPacket.subscribe((meshPacket) => {
nodeDB.processPacket({
from: meshPacket.from,
snr: meshPacket.rxSnr,
time: meshPacket.rxTime,
});
});
connection.events.onClientNotificationPacket.subscribe((clientNotificationPacket) => {
device.addClientNotification(clientNotificationPacket);
device.setDialogOpen("clientNotification", true);
});
connection.events.onNeighborInfoPacket.subscribe((neighborInfo) => {
device.addNeighborInfo(neighborInfo.from, neighborInfo.data);
});
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) {
case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
break;
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
nodeDB.setNodeError(routingPacket.from, routingPacket?.data?.variant?.value);
device.setDialogOpen("refreshKeys", true);
break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
nodeDB.setNodeError(routingPacket.from, routingPacket?.data?.variant?.value);
device.setDialogOpen("refreshKeys", true);
break;
default: {
break;
}
}
}
});
};
+12
View File
@@ -0,0 +1,12 @@
export interface EnumLike {
[key: number]: string | number;
}
export const bitwiseEncode = (enumValues: number[]): number => {
return enumValues.reduce((acc, curr) => acc | curr, 0);
};
export const bitwiseDecode = (value: number, decodeEnum: EnumLike): number[] => {
const enumValues = Object.keys(decodeEnum).map(Number).filter(Boolean);
return enumValues.map((b) => value & b).filter(Boolean);
};
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { getColorFromNodeNum, hexToRgb, isLightColor, type RGBColor, rgbToHex } from "./color.ts";
describe("hexToRgb", () => {
it.each([
[0x000000, { r: 0, g: 0, b: 0 }],
[0xffffff, { r: 255, g: 255, b: 255 }],
[0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
[0xff8000, { r: 255, g: 128, b: 0 }],
])("parses 0x%s correctly", (hex, expected) => {
expect(hexToRgb(hex)).toEqual(expected);
});
});
describe("rgbToHex", () => {
it.each<[RGBColor, number]>([
[{ r: 0, g: 0, b: 0 }, 0x000000],
[{ r: 255, g: 255, b: 255 }, 0xffffff],
[{ r: 0x12, g: 0x34, b: 0x56 }, 0x123456],
[{ r: 255, g: 128, b: 0 }, 0xff8000],
])("packs %j into 0x%s", (rgb, expected) => {
expect(rgbToHex(rgb)).toBe(expected);
});
it("rounds component values before packing", () => {
expect(rgbToHex({ r: 12.2, g: 12.8, b: 99.5 })).toBe((12 << 16) | (13 << 8) | 100);
});
});
describe("hexToRgb ⟷ rgbToHex round-trip", () => {
it("is identity for representative values (masked to 24-bit)", () => {
const samples = [0, 1, 0x7fffff, 0x800000, 0xffffff, 0x123456, 0x00ff00];
for (const hex of samples) {
const rgb = hexToRgb(hex);
expect(rgbToHex(rgb)).toBe(hex & 0xffffff);
}
});
it("holds for random 24-bit values", () => {
for (let i = 0; i < 100; i++) {
const hex = Math.floor(Math.random() * 0x1000000); // 0..0xFFFFFF
expect(rgbToHex(hexToRgb(hex))).toBe(hex);
}
});
});
describe("isLightColor", () => {
it("detects obvious extremes", () => {
expect(isLightColor({ r: 255, g: 255, b: 255 })).toBe(true); // white
expect(isLightColor({ r: 0, g: 0, b: 0 })).toBe(false); // black
});
it("respects the 127.5 threshold at boundary", () => {
// mid-gray 127 → false, 128 → true (given the formula and 127.5 threshold)
expect(isLightColor({ r: 127, g: 127, b: 127 })).toBe(false);
expect(isLightColor({ r: 128, g: 128, b: 128 })).toBe(true);
});
});
describe("getColorFromNodeNum", () => {
it.each([
[0x000000, { r: 0, g: 0, b: 0 }],
[0xffffff, { r: 255, g: 255, b: 255 }],
[0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
])("extracts RGB from lower 24 bits of %s", (nodeNum, expected) => {
expect(getColorFromNodeNum(nodeNum)).toEqual(expected);
});
it("matches hexToRgb when masking to 24 bits", () => {
const nodeNums = [1127947528, 42, 999999, 0xfeef12, 0xfeedface, -123456];
for (const n of nodeNums) {
// JS bitwise ops use signed 32-bit, so mask the lower 24 bits for comparison.
const masked = n & 0xffffff;
expect(getColorFromNodeNum(n)).toEqual(hexToRgb(masked));
}
});
it("always yields components within 0..255", () => {
const color = getColorFromNodeNum(Math.floor(Math.random() * 2 ** 31));
for (const v of Object.values(color)) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(255);
}
});
});
+26
View File
@@ -0,0 +1,26 @@
export interface RGBColor {
r: number;
g: number;
b: number;
}
export const hexToRgb = (hex: number): RGBColor => ({
r: (hex & 0xff0000) >> 16,
g: (hex & 0x00ff00) >> 8,
b: hex & 0x0000ff,
});
export const rgbToHex = (c: RGBColor): number =>
(Math.round(c.r) << 16) | (Math.round(c.g) << 8) | Math.round(c.b);
export const isLightColor = (c: RGBColor): boolean =>
(c.r * 299 + c.g * 587 + c.b * 114) / 1000 > 127.5;
export const getColorFromNodeNum = (nodeNum: number): RGBColor => {
// Extract RGB values directly from nodeNum (treated as hex color)
const r = (nodeNum & 0xff0000) >> 16;
const g = (nodeNum & 0x00ff00) >> 8;
const b = nodeNum & 0x0000ff;
return { r, g, b };
};
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { debounce } from "./debounce.ts";
describe("debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("delays executing the callback until after wait time has elapsed", () => {
const mockCallback = vi.fn();
const debouncedFunction = debounce(mockCallback, 500);
debouncedFunction();
expect(mockCallback).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(mockCallback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it("only executes the callback once if called multiple times within wait period", () => {
const mockCallback = vi.fn();
const debouncedFunction = debounce(mockCallback, 500);
debouncedFunction();
debouncedFunction();
debouncedFunction();
vi.advanceTimersByTime(500);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it("resets the timer when called again during wait period", () => {
const mockCallback = vi.fn();
const debouncedFunction = debounce(mockCallback, 500);
debouncedFunction();
vi.advanceTimersByTime(300);
debouncedFunction();
vi.advanceTimersByTime(300);
expect(mockCallback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it("passes arguments to the callback function", () => {
const mockCallback = vi.fn();
const debouncedFunction = debounce(mockCallback, 500);
debouncedFunction("test", 123);
vi.advanceTimersByTime(500);
expect(mockCallback).toHaveBeenCalledWith("test", 123);
});
});
+13
View File
@@ -0,0 +1,13 @@
type Callback<Args extends unknown[]> = (...args: Args) => void;
export function debounce<Args extends unknown[]>(
callback: Callback<Args>,
wait: number,
): Callback<Args> {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => callback(...args), wait);
};
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { deepCompareConfig } from "./deepCompareConfig.ts";
describe("deepCompareConfig", () => {
it("returns true for identical primitives", () => {
expect(deepCompareConfig(5, 5)).toBe(true);
expect(deepCompareConfig("foo", "foo")).toBe(true);
expect(deepCompareConfig(true, true)).toBe(true);
});
it("returns false for different primitives", () => {
expect(deepCompareConfig(5, 6)).toBe(false);
expect(deepCompareConfig("foo", "bar")).toBe(false);
expect(deepCompareConfig(true, false)).toBe(false);
});
it("handles nulls correctly", () => {
expect(deepCompareConfig(null, null)).toBe(true);
expect(deepCompareConfig(null, undefined)).toBe(false);
expect(deepCompareConfig(null, {})).toBe(false);
});
it("allows undefined in working when allowUndefined is true", () => {
expect(deepCompareConfig({ a: 1 }, { a: undefined }, true)).toBe(true);
expect(deepCompareConfig([1, 2, 3], [1, undefined, 3], true)).toBe(true);
});
it("rejects undefined in working when allowUndefined is false", () => {
expect(deepCompareConfig({ a: 1 }, { a: undefined }, false)).toBe(false);
});
it("compares arrays deeply", () => {
expect(deepCompareConfig([1, [2, 3]], [1, [2, 3]])).toBe(true);
expect(deepCompareConfig([1, [2, 3]], [1, [2, 4]])).toBe(false);
});
it("compares objects deeply", () => {
const existing = { x: 10, y: { z: 20 } };
const workingEqual = { x: 10, y: { z: 20 } };
const workingDiff = { x: 10, y: { z: 21 } };
expect(deepCompareConfig(existing, workingEqual)).toBe(true);
expect(deepCompareConfig(existing, workingDiff)).toBe(false);
});
it("ignores $typeName key in existing", () => {
const existing = { $typeName: "Test", a: 1 };
const working = { a: 1 };
expect(deepCompareConfig(existing, working)).toBe(true);
});
it("fails when working has extra keys", () => {
expect(deepCompareConfig({ a: 1 }, { a: 1, b: 2 })).toBe(false);
});
it("allows working arrays to be shorter if allowUndefined is true", () => {
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], true)).toBe(true);
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], false)).toBe(false);
});
it("compares Uint8Array strictly: equal bytes -> true", () => {
const a = new Uint8Array([1, 2, 3]);
const b = new Uint8Array([1, 2, 3]);
expect(deepCompareConfig(a, b)).toBe(true);
});
it("compares Uint8Array strictly: different bytes -> false", () => {
const a = new Uint8Array([1, 2, 3]);
const b = new Uint8Array([1, 2, 4]);
expect(deepCompareConfig(a, b)).toBe(false);
});
it("Uint8Array vs undefined is false even when allowUndefined is true", () => {
const a = new Uint8Array([1, 2, 3]);
expect(deepCompareConfig(a, undefined, true)).toBe(false);
expect(deepCompareConfig(undefined, a, true)).toBe(false);
});
it("nested Uint8Array fields must match exactly", () => {
const existing = { data: new Uint8Array([9, 8, 7]) };
const workingEqual = { data: new Uint8Array([9, 8, 7]) };
const workingDiff = { data: new Uint8Array([9, 8, 6]) };
const workingUndef = { data: undefined as unknown };
expect(deepCompareConfig(existing, workingEqual)).toBe(true);
expect(deepCompareConfig(existing, workingDiff)).toBe(false);
// still false even with allowUndefined
expect(deepCompareConfig(existing, workingUndef, true)).toBe(false);
});
it("arrays containing Uint8Array: element must match exactly", () => {
const a = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
const b = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
const c = [new Uint8Array([1, 2]), new Uint8Array([3, 9])];
expect(deepCompareConfig(a, b)).toBe(true);
expect(deepCompareConfig(a, c)).toBe(false);
});
it("shorter working array with missing Uint8Array element -> false even with allowUndefined", () => {
const existing = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
const workingShort = [new Uint8Array([1, 2])]; // missing the second byte array
expect(deepCompareConfig(existing, workingShort, true)).toBe(false);
});
});
@@ -0,0 +1,73 @@
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isUint8Array(v: unknown): v is Uint8Array {
return v instanceof Uint8Array;
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) {
return false;
}
for (let i = 0; i < a.byteLength; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
export function deepCompareConfig(a: unknown, b: unknown, allowUndefined = false): boolean {
if (a === b) {
return true;
}
if (isUint8Array(a) || isUint8Array(b)) {
return isUint8Array(a) && isUint8Array(b) && bytesEqual(a, b);
}
if (allowUndefined && (a === undefined || b === undefined)) {
return true;
}
if (typeof a !== typeof b || a === null || b === null) {
return false;
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length && !allowUndefined) {
return false;
}
const longestLength = Math.max(a.length, b.length);
for (let i = 0; i < longestLength; i++) {
if (!deepCompareConfig(a[i], b[i], allowUndefined)) {
return false;
}
}
return true;
}
if (isObject(a) && isObject(b)) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
const allKeys = new Set([...aKeys, ...bKeys]);
for (const key of allKeys) {
if (key === "$typeName") {
continue;
}
const aValue = a[key];
const bValue = b[key];
if (!deepCompareConfig(aValue, bValue, allowUndefined)) {
return false;
}
}
return true;
}
return false;
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { dotPaths } from "./dotPath.ts";
describe("dotPaths", () => {
it("returns flat keys for a simple object", () => {
const obj = { a: 1, b: 2, c: 3 };
expect(dotPaths(obj)).toEqual(["a", "b", "c"]);
});
it("returns dot notation keys for nested objects", () => {
const obj = { a: { b: { c: 1 } }, d: 2 };
expect(dotPaths(obj)).toEqual(["a.b.c", "d"]);
});
it("handles arrays at the root", () => {
const arr = [{ x: 1 }, { y: 2 }];
expect(dotPaths(arr)).toEqual(["0.x", "1.y"]);
});
it("handles arrays nested in objects", () => {
const obj = { a: [{ b: 1 }, { c: 2 }], d: 3 };
expect(dotPaths(obj)).toEqual(["a.0.b", "a.1.c", "d"]);
});
it("handles objects nested in arrays", () => {
const arr = [{ a: { b: 1 } }, { c: 2 }];
expect(dotPaths(arr)).toEqual(["0.a.b", "1.c"]);
});
it("handles primitive values in arrays", () => {
const arr = [1, { a: 2 }, 3];
expect(dotPaths(arr)).toEqual(["0", "1.a", "2"]);
});
it("handles empty objects and arrays", () => {
expect(dotPaths({})).toEqual([]);
expect(dotPaths([])).toEqual([]);
});
it("handles mixed nested structures", () => {
const obj = {
a: [{ b: 1, c: [2, 3] }, { d: { e: 4 } }],
f: 5,
};
expect(dotPaths(obj)).toEqual(["a.0.b", "a.0.c.0", "a.0.c.1", "a.1.d.e", "f"]);
});
it("handles prefix argument", () => {
const obj = { a: { b: 1 } };
expect(dotPaths(obj, "root.")).toEqual(["root.a.b"]);
});
it("skips null and undefined values", () => {
const obj = { a: null, b: undefined, c: { d: 1 } };
expect(dotPaths(obj)).toEqual(["a", "b", "c.d"]);
});
});
+12
View File
@@ -0,0 +1,12 @@
export type DotPath = { [key: string]: unknown } | unknown[];
export const dotPaths = <T extends DotPath>(obj: T, prefix = ""): string[] => {
if (Array.isArray(obj)) {
return obj.flatMap((v, i) =>
v && typeof v === "object" ? dotPaths(v as DotPath, `${prefix}${i}.`) : [`${prefix}${i}`],
);
}
return Object.entries(obj).flatMap(([k, v]) =>
v && typeof v === "object" ? dotPaths(v as DotPath, `${prefix}${k}.`) : [`${prefix}${k}`],
);
};
+71
View File
@@ -0,0 +1,71 @@
import { eventBus } from "@core/utils/eventBus.ts";
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("EventBus", () => {
beforeEach(() => {
// Reset event listeners before each test
eventBus.offAll();
});
it("should register an event listener and trigger it on emit", () => {
const mockCallback = vi.fn();
eventBus.on("dialog:unsafeRoles", mockCallback);
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
expect(mockCallback).toHaveBeenCalledWith({ action: "confirm" });
});
it("should remove an event listener with off", () => {
const mockCallback = vi.fn();
eventBus.on("dialog:unsafeRoles", mockCallback);
eventBus.off("dialog:unsafeRoles", mockCallback);
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
expect(mockCallback).not.toHaveBeenCalled();
});
it("should return an unsubscribe function from on", () => {
const mockCallback = vi.fn();
const unsubscribe = eventBus.on("dialog:unsafeRoles", mockCallback);
unsubscribe();
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
expect(mockCallback).not.toHaveBeenCalled();
});
it("should allow multiple listeners for the same event", () => {
const mockCallback1 = vi.fn();
const mockCallback2 = vi.fn();
eventBus.on("dialog:unsafeRoles", mockCallback1);
eventBus.on("dialog:unsafeRoles", mockCallback2);
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
expect(mockCallback1).toHaveBeenCalledWith({ action: "confirm" });
expect(mockCallback2).toHaveBeenCalledWith({ action: "confirm" });
});
it("should only remove the specific listener when off is called", () => {
const mockCallback1 = vi.fn();
const mockCallback2 = vi.fn();
eventBus.on("dialog:unsafeRoles", mockCallback1);
eventBus.on("dialog:unsafeRoles", mockCallback2);
eventBus.off("dialog:unsafeRoles", mockCallback1);
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
expect(mockCallback1).not.toHaveBeenCalled();
expect(mockCallback2).toHaveBeenCalledWith({ action: "dismiss" });
});
it("should not fail when calling off on a non-existent listener", () => {
const mockCallback = vi.fn();
eventBus.off("dialog:unsafeRoles", mockCallback);
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
expect(mockCallback).not.toHaveBeenCalled(); // No error should occur
});
});
+55
View File
@@ -0,0 +1,55 @@
export type EventMap = {
"dialog:unsafeRoles": {
action: "confirm" | "dismiss";
};
};
export type EventName = keyof EventMap;
export type EventCallback<T extends EventName> = (data: EventMap[T]) => void;
class EventBus {
private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {};
public on<T extends EventName>(event: T, callback: EventCallback<T>): () => void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]?.push(callback);
return () => {
this.off(event, callback);
};
}
public off<T extends EventName>(event: T, callback: EventCallback<T>): void {
if (!this.listeners[event]) {
return;
}
const callbackIndex = this.listeners[event]?.indexOf(callback);
if (callbackIndex !== undefined && callbackIndex > -1) {
this.listeners[event]?.splice(callbackIndex, 1);
}
}
public offAll<T extends EventName>(event?: T): void {
if (event) {
this.listeners[event] = [];
} else {
this.listeners = {};
}
}
public emit<T extends EventName>(event: T, data: EventMap[T]): void {
if (!this.listeners[event]) {
return;
}
this.listeners[event]?.forEach((callback) => {
callback(data);
});
}
}
export const eventBus = new EventBus();
+80
View File
@@ -0,0 +1,80 @@
import { bbox, lineString } from "@turf/turf";
export type LngLat = [number, number];
export type Mercator = [number, number];
export type Bounds = [[number, number], [number, number]];
const INT_DEG = 1e7;
const EARTH_RADIUS = 6378137;
export const toLngLat = (position?: { latitudeI?: number; longitudeI?: number }): LngLat => [
(position?.longitudeI ?? 0) / INT_DEG,
(position?.latitudeI ?? 0) / INT_DEG,
];
export const hasPos = (position?: { latitudeI?: number; longitudeI?: number }) =>
Number.isFinite(position?.latitudeI) &&
Number.isFinite(position?.longitudeI) &&
!(position?.latitudeI === 0 && position?.longitudeI === 0);
export const boundsFromLngLat = (coords: LngLat[]): Bounds | undefined => {
if (coords.length === 0) {
return undefined;
}
const turfCoords = coords.map(([lng, lat]) => [lat, lng]);
const [minLat, minLng, maxLat, maxLng] = bbox(lineString(turfCoords));
return [
[minLng, minLat],
[maxLng, maxLat],
];
};
const deg2rad = (d: number) => (d * Math.PI) / 180;
const rad2deg = (r: number) => (r * 180) / Math.PI;
export function lngLatToMercator([lng, lat]: LngLat): Mercator {
return [
EARTH_RADIUS * deg2rad(lng),
EARTH_RADIUS * Math.log(Math.tan(Math.PI / 4 + deg2rad(lat) / 2)),
];
}
export function mercatorToLngLat([x, y]: Mercator): LngLat {
return [
rad2deg(x / EARTH_RADIUS),
rad2deg(2 * Math.atan(Math.exp(y / EARTH_RADIUS)) - Math.PI / 2),
];
}
export function distanceMeters([lng1, lat1]: LngLat, [lng2, lat2]: LngLat) {
const phi1 = deg2rad(lat1),
phi2 = deg2rad(lat2);
const x = deg2rad(lng2 - lng1) * Math.cos((phi1 + phi2) * 0.5);
const y = phi2 - phi1;
return EARTH_RADIUS * Math.hypot(x, y);
}
export function precisionBitsToMeters(precisionBits: number): number {
const M_PER_DEG_EQ = (2 * Math.PI * EARTH_RADIUS) / 360; // ≈ 111_319.490793 m/deg
const stepInt = 2 ** (32 - precisionBits);
const stepDegrees = stepInt / INT_DEG;
return Math.round(0.5 * stepDegrees * M_PER_DEG_EQ);
}
export function bearingDegrees(from: LngLat, to: LngLat): number {
const [lambda1deg, phi1deg] = from;
const [lambda2deg, phi2deg] = to;
const phi1 = deg2rad(phi1deg);
const phi2 = deg2rad(phi2deg);
const deltaLambda = deg2rad(lambda2deg - lambda1deg);
const y = Math.sin(deltaLambda) * Math.cos(phi2);
const x =
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
return (rad2deg(Math.atan2(y, x)) + 360) % 360;
}
+86
View File
@@ -0,0 +1,86 @@
interface RepoIdentifier {
user: string;
repo: string;
}
interface GithubIssueUrlOptions extends Partial<RepoIdentifier> {
repoUrl?: string;
body?: string;
title?: string;
labels?: string[];
template?: string;
assignee?: string;
projects?: string[];
logs?: string;
version?: number;
}
type ValidatedOptions = {
repoUrl: string;
} & Omit<GithubIssueUrlOptions, "repoUrl" | "user" | "repo">;
const VALID_PARAMS = [
"body",
"title",
"labels",
"template",
"assignee",
"projects",
"version",
"logs",
] as const;
/**
* Generates a URL for creating a new GitHub issue
* @param options Configuration options for the GitHub issue URL
* @returns A formatted URL string for creating a new GitHub issue
* @throws {Error} If repository information is missing or invalid
* @throws {TypeError} If labels or projects are not arrays when provided
*/
export default function newGithubIssueUrl(options: GithubIssueUrlOptions = {}): string {
const validatedOptions = validateOptions(options);
const url = new URL(`${validatedOptions.repoUrl}/issues/new`);
for (const key of VALID_PARAMS) {
const value = validatedOptions[key];
if (value === undefined) {
continue;
}
if ((key === "labels" || key === "projects") && Array.isArray(value)) {
url.searchParams.set(key, value.join(","));
continue;
}
url.searchParams.set(key, String(value));
}
return url.toString();
}
function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions {
const repoUrl =
options.repoUrl ??
(options.user && options.repo
? `https://github.com/${options.user}/${options.repo}`
: undefined);
if (!repoUrl) {
throw new Error(
"You need to specify either the `repoUrl` option or both the `user` and `repo` options",
);
}
for (const key of ["labels", "projects"] as const) {
const value = options[key];
if (value !== undefined && !Array.isArray(value)) {
throw new TypeError(`The \`${key}\` option should be an array`);
}
}
return {
...options,
repoUrl,
};
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { convertIntToIpAddress, convertIpAddressToInt } from "./ip.ts";
describe("IP Address Conversion Functions", () => {
describe("convertIntToIpAddress", () => {
it("converts 0 to 0.0.0.0", () => {
expect(convertIntToIpAddress(0)).toBe("0.0.0.0");
});
it("converts 16_777_343 to 127.0.0.1", () => {
expect(convertIntToIpAddress(16_777_343)).toBe("127.0.0.1");
});
it("converts 16_820_416 to 192.168.0.1", () => {
expect(convertIntToIpAddress(16_820_416)).toBe("192.168.0.1");
});
it("converts 4_294_967_295 to 255.255.255.255", () => {
expect(convertIntToIpAddress(4_294_967_295)).toBe("255.255.255.255");
});
});
describe("convertIpAddressToInt", () => {
it("converts 0.0.0.0 to 0", () => {
expect(convertIpAddressToInt("0.0.0.0")).toBe(0);
});
it("converts 127.0.0.1 to 16_777_343", () => {
expect(convertIpAddressToInt("127.0.0.1")).toBe(16_777_343);
});
it("converts 192.168.0.1 to 16_820_416", () => {
expect(convertIpAddressToInt("192.168.0.1")).toBe(16_820_416);
});
it("converts 255.255.255.255 to 4_294_967_295", () => {
expect(convertIpAddressToInt("255.255.255.255")).toBe(4_294_967_295);
});
it("handles non-standard formats", () => {
expect(convertIpAddressToInt("1.2.3.4")).toBe(67_305_985);
});
it("handles invalid IP addresses gracefully", () => {
expect(convertIpAddressToInt("300.1.2.3")).not.toBeNull();
expect(typeof convertIpAddressToInt("300.1.2.3")).toBe("number");
});
});
describe("bidirectional conversion", () => {
it("can convert back and forth", () => {
const testIps = ["0.0.0.0", "127.0.0.1", "192.168.1.1", "10.0.0.1", "255.255.255.255"];
for (const ip of testIps) {
const int = convertIpAddressToInt(ip);
expect(int).not.toBeNull();
if (int !== null && typeof int === "number") {
const convertedBack = convertIntToIpAddress(int);
expect(convertedBack).toBe(ip);
}
}
});
});
});
+17
View File
@@ -0,0 +1,17 @@
export function convertIntToIpAddress(int: number): string {
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff}`;
}
export function convertIpAddressToInt(ip: string): number | undefined {
if (!ip) {
return undefined;
}
return (
ip
.split(".")
.reverse()
.reduce((ipnum, octet) => {
return (ipnum << 8) + Number.parseInt(octet, 10);
}, 0) >>> 0
);
}
+44
View File
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { randId } from "./randId.ts";
describe("randId", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns a number", () => {
const result = randId();
expect(typeof result).toBe("number");
});
it("returns an integer", () => {
const result = randId();
expect(Number.isInteger(result)).toBe(true);
});
it("uses Math.random to generate the number", () => {
const mockRandom = vi.spyOn(Math, "random").mockReturnValue(0.5);
const result = randId();
expect(mockRandom).toHaveBeenCalled();
expect(result).toBe(Math.floor(0.5 * 1e9));
});
it("returns a value between 0 and 1e9 (exclusive)", () => {
const result = randId();
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThan(1e9);
});
it("returns different values on subsequent calls", () => {
vi.spyOn(Math, "random").mockRestore();
const results = new Set();
for (let i = 0; i < 100; i++) {
results.add(randId());
}
expect(results.size).toBeGreaterThan(95);
});
});
+3
View File
@@ -0,0 +1,3 @@
export const randId = () => {
return Math.floor(Math.random() * 1e9);
};
+25
View File
@@ -0,0 +1,25 @@
export const SNR_THRESHOLD = {
GOOD: -7,
FAIR: -15,
};
export const RSSI_THRESHOLD = {
GOOD: -115,
FAIR: -126,
};
export const LINE_COLOR = {
GOOD: "#00ff00",
FAIR: "#ffe600",
BAD: "#f7931a",
};
export const getSignalColor = (snr: number, rssi?: number): string => {
if (snr > SNR_THRESHOLD.GOOD && (rssi == null || rssi > RSSI_THRESHOLD.GOOD)) {
return LINE_COLOR.GOOD;
}
if (snr > SNR_THRESHOLD.FAIR && (rssi == null || rssi > RSSI_THRESHOLD.FAIR)) {
return LINE_COLOR.FAIR;
}
return LINE_COLOR.BAD;
};
+17
View File
@@ -0,0 +1,17 @@
export function intlSort<T extends PropertyKey>(
arr: T[],
locale: Intl.Locale,
order: "asc" | "desc" = "asc",
): T[] {
const collator = new Intl.Collator(locale, { sensitivity: "base" });
return arr.sort((a, b) => {
const stringA = String(a);
const stringB = String(b);
if (order === "asc") {
return collator.compare(stringA, stringB);
}
return collator.compare(stringB, stringA);
});
}
+69
View File
@@ -0,0 +1,69 @@
interface PluralForms {
one: string;
other: string;
[key: string]: string;
}
interface FormatOptions {
locale?: string;
pluralRules?: Intl.PluralRulesOptions;
numberFormat?: Intl.NumberFormatOptions;
}
export function formatQuantity(value: number, forms: PluralForms, options: FormatOptions = {}) {
const {
locale = "en-US",
pluralRules: pluralOptions = { type: "cardinal" },
numberFormat: numberOptions = {},
} = options;
const pluralRules = new Intl.PluralRules(locale, pluralOptions);
const numberFormat = new Intl.NumberFormat(locale, numberOptions);
const pluralCategory = pluralRules.select(value);
const word = forms[pluralCategory];
return `${numberFormat.format(value)} ${word}`;
}
export interface LengthValidationResult {
isValid: boolean;
currentLength: number | null;
}
export function validateMaxByteLength(
value: string | null | undefined,
maxByteLength: number,
): LengthValidationResult {
// Ensure maxByteLength is valid
if (typeof maxByteLength !== "number" || !Number.isInteger(maxByteLength) || maxByteLength < 0) {
console.warn("validateMaxByteLength: maxByteLength must be a non-negative integer.");
return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
}
// Handle null or undefined input values
if (value === null || value === undefined) {
return { isValid: false, currentLength: null };
}
// Check for TextEncoder availability
if (typeof TextEncoder === "undefined") {
console.error("validateMaxByteLength: TextEncoder API is not available in this environment.");
return { isValid: false, currentLength: null }; // Cannot determine byte length
}
try {
// Encode the string to UTF-8 bytes and get the length
const encoder = new TextEncoder();
const currentLength = encoder.encode(value).length;
// Perform the byte length check
const isValid = currentLength <= maxByteLength;
// Return the result object
return { isValid, currentLength };
} catch (error) {
// Handle potential errors during encoding
console.error("validateMaxByteLength: Error encoding string:", error);
return { isValid: false, currentLength: null }; // Encoding failed
}
}
+17
View File
@@ -0,0 +1,17 @@
import { x25519 } from "@noble/curves/ed25519";
export function getX25519PrivateKey(): Uint8Array {
const key = x25519.utils.randomPrivateKey();
// scalar clamping for curve25519, according to
// https://www.rfc-editor.org/rfc/rfc7748#section-5
key[0] &= 248;
key[31] &= 127;
key[31] |= 64;
return key;
}
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
return x25519.getPublicKey(privateKey);
}