+
+
+
+
+
-
),
});
- return () => {
- if (!toastShownRef.current) {
- dismiss();
- }
- };
+ return () => dismiss();
}, [
enabled,
message,
onAccept,
- reminderInDays,
- suppressReminder,
- toast,
- reminderCookie,
+
]);
-}
+};
\ No newline at end of file
diff --git a/src/core/hooks/useLocalStorage.test.ts b/src/core/hooks/useLocalStorage.test.ts
new file mode 100644
index 00000000..8d621a57
--- /dev/null
+++ b/src/core/hooks/useLocalStorage.test.ts
@@ -0,0 +1,52 @@
+import { renderHook, act } from '@testing-library/react'
+import useLocalStorage from './useLocalStorage'
+import { beforeEach, describe, expect, it } from "vitest";
+
+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')
+ })
+})
diff --git a/src/core/hooks/usePinnedItems.test.ts b/src/core/hooks/usePinnedItems.test.ts
new file mode 100644
index 00000000..d761fba1
--- /dev/null
+++ b/src/core/hooks/usePinnedItems.test.ts
@@ -0,0 +1,65 @@
+import { renderHook, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { usePinnedItems } from "./usePinnedItems.ts";
+
+const mockSetPinnedItems = vi.fn();
+const mockUseLocalStorage = vi.fn();
+
+vi.mock("@core/hooks/useLocalStorage.ts", () => ({
+ default: (...args: any[]) => mockUseLocalStorage(...args),
+}));
+
+describe("usePinnedItems", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns default pinnedItems and togglePinnedItem", () => {
+ mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]);
+
+ const { result } = renderHook(() =>
+ usePinnedItems({ storageName: "test-storage" })
+ );
+
+ expect(result.current.pinnedItems).toEqual([]);
+ expect(typeof result.current.togglePinnedItem).toBe("function");
+ });
+
+ it("adds an item if it's not already pinned", () => {
+ mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]);
+
+ const { result } = renderHook(() =>
+ usePinnedItems({ storageName: "test-storage" })
+ );
+
+ act(() => {
+ result.current.togglePinnedItem("item2");
+ });
+
+ expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
+
+ const updater = mockSetPinnedItems.mock.calls[0][0];
+ const updated = updater(["item1"]);
+
+ expect(updated).toEqual(["item1", "item2"]);
+ });
+
+ it("removes an item if it's already pinned", () => {
+ mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]);
+
+ const { result } = renderHook(() =>
+ usePinnedItems({ storageName: "test-storage" })
+ );
+
+ act(() => {
+ result.current.togglePinnedItem("item1");
+ });
+
+ expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
+
+ const updater = mockSetPinnedItems.mock.calls[0][0];
+ const updated = updater(["item1", "item2"]);
+
+ expect(updated).toEqual(["item2"]);
+ });
+});
diff --git a/src/core/hooks/usePinnedItems.ts b/src/core/hooks/usePinnedItems.ts
new file mode 100644
index 00000000..cd80a17c
--- /dev/null
+++ b/src/core/hooks/usePinnedItems.ts
@@ -0,0 +1,19 @@
+import useLocalStorage from "@core/hooks/useLocalStorage.ts";
+import { useCallback } from "react";
+
+export function usePinnedItems({ storageName }: { storageName: string }) {
+ const [pinnedItems, setPinnedItems] = useLocalStorage
(storageName, []);
+
+ const togglePinnedItem = useCallback((label: string) => {
+ setPinnedItems((prev) =>
+ prev.includes(label)
+ ? prev.filter((g) => g !== label)
+ : [...prev, label]
+ );
+ }, []);
+
+ return {
+ pinnedItems,
+ togglePinnedItem,
+ };
+}
diff --git a/src/core/hooks/useToast.test.tsx b/src/core/hooks/useToast.test.tsx
new file mode 100644
index 00000000..9125da75
--- /dev/null
+++ b/src/core/hooks/useToast.test.tsx
@@ -0,0 +1,81 @@
+import { renderHook, act } from '@testing-library/react'
+import { useToast } from "@core/hooks/useToast.ts"
+import { Button } from '@components/UI/Button.tsx'
+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:
+ })
+ 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()
+ })
+
+})
diff --git a/src/core/hooks/useToast.ts b/src/core/hooks/useToast.ts
index 3269eee9..d728537f 100644
--- a/src/core/hooks/useToast.ts
+++ b/src/core/hooks/useToast.ts
@@ -155,7 +155,7 @@ function toast({ delay = 0, ...props }: Toast) {
...props,
id,
open: true,
- onOpenChange: (open) => {
+ onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},
diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts
index 266ff9c8..c6e74551 100644
--- a/src/core/stores/deviceStore.ts
+++ b/src/core/stores/deviceStore.ts
@@ -23,6 +23,7 @@ export type DialogVariant =
| "QR"
| "shutdown"
| "reboot"
+ | "rebootOTA"
| "deviceName"
| "nodeRemoval"
| "pkiBackup"
@@ -73,6 +74,7 @@ export interface Device {
QR: boolean;
shutdown: boolean;
reboot: boolean;
+ rebootOTA: boolean;
deviceName: boolean;
nodeRemoval: boolean;
pkiBackup: boolean;
@@ -175,6 +177,7 @@ export const useDeviceStore = createStore((set, get) => ({
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
+ rebootOTA: false,
},
pendingSettingsChanges: false,
messageDraft: "",
diff --git a/src/core/utils/ip.ts b/src/core/utils/ip.ts
index 0dbc5ca5..70ac97f6 100644
--- a/src/core/utils/ip.ts
+++ b/src/core/utils/ip.ts
@@ -1,10 +1,12 @@
export function convertIntToIpAddress(int: number): string {
- return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${
- (int >> 24) & 0xff
- }`;
+ return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff
+ }`;
}
-export function convertIpAddressToInt(ip: string): number | null {
+export function convertIpAddressToInt(ip: string): number | undefined {
+ if (!ip) {
+ return undefined;
+ }
return (
ip
.split(".")
diff --git a/src/pages/Config/DeviceConfig.tsx b/src/pages/Config/DeviceConfig.tsx
index 45ae5f19..b97e8640 100644
--- a/src/pages/Config/DeviceConfig.tsx
+++ b/src/pages/Config/DeviceConfig.tsx
@@ -2,7 +2,7 @@ import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
import { Device } from "../../components/PageComponents/Config/Device/index.tsx";
import { Display } from "@components/PageComponents/Config/Display.tsx";
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
-import { Network } from "@components/PageComponents/Config/Network.tsx";
+import { Network } from "../../components/PageComponents/Config/Network/index.tsx";
import { Position } from "@components/PageComponents/Config/Position.tsx";
import { Power } from "@components/PageComponents/Config/Power.tsx";
import { Security } from "../../components/PageComponents/Config/Security/Security.tsx";
@@ -31,7 +31,6 @@ export const DeviceConfig = () => {
{
label: "Network",
element: Network,
- // disabled: !metadata.get(0)?.hasWifi,
},
{
label: "Display",
diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx
index 06d8fb12..389bf0d0 100644
--- a/src/pages/Nodes.tsx
+++ b/src/pages/Nodes.tsx
@@ -19,12 +19,17 @@ export interface DeleteNoteDialogProps {
onOpenChange: (open: boolean) => void;
}
-function shortNameFromNode(node: ReturnType["nodes"][number]): string {
- const shortNameOfNode = node.user?.shortName ?? (node.user?.macaddr
- ? `${base16
- .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
- .toLowerCase()}`
- : `${numberToHexUnpadded(node.num).slice(-4)}`);
+function shortNameFromNode(
+ node: ReturnType["nodes"][number],
+): string {
+ const shortNameOfNode = node.user?.shortName ??
+ (node.user?.macaddr
+ ? `${
+ base16
+ .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
+ .toLowerCase()
+ }`
+ : `${numberToHexUnpadded(node.num).slice(-4)}`);
return String(shortNameOfNode);
}
@@ -70,7 +75,6 @@ const NodesPage = (): JSX.Element => {
};
}, [connection]);
-
const handleLocation = useCallback(
(location: Types.PacketMetadata) => {
if (location.to.valueOf() !== hardware.myNodeNum) return;
@@ -97,12 +101,12 @@ const NodesPage = (): JSX.Element => {
headings={[
{ title: "", type: "blank", sortable: false },
{ title: "Long Name", type: "normal", sortable: true },
- { title: "Model", type: "normal", sortable: true },
- { title: "MAC Address", type: "normal", sortable: true },
+ { title: "Connection", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
- { title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false },
- { title: "Connection", type: "normal", sortable: true },
+ { title: "SNR", type: "normal", sortable: true },
+ { title: "Model", type: "normal", sortable: true },
+ { title: "MAC Address", type: "normal", sortable: true },
]}
rows={filteredNodes.map((node) => [
@@ -111,55 +115,55 @@ const NodesPage = (): JSX.Element => {
setSelectedNode(node)}
- onKeyUp={(evt)=>{ evt.key === "Enter" && setSelectedNode(node) }}
+ onKeyUp={(evt) => {
+ evt.key === "Enter" && setSelectedNode(node);
+ }}
className="cursor-pointer underline"
tabIndex={0}
role="button"
>
{node.user?.longName ??
(node.user?.macaddr
- ? `Meshtastic ${base16
- .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
- .toLowerCase()}`
+ ? `Meshtastic ${
+ base16
+ .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
+ .toLowerCase()
+ }`
: `!${numberToHexUnpadded(node.num)}`)}
,
-
-
- {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
- ,
-
- {base16
- .stringify(node.user?.macaddr ?? [])
- .match(/.{1,2}/g)
- ?.join(":") ?? "UNK"}
+
+ {node.lastHeard !== 0
+ ? node.viaMqtt === false && node.hopsAway === 0
+ ? "Direct"
+ : `${node.hopsAway?.toString()} ${
+ node.hopsAway > 1 ? "hops" : "hop"
+ } away`
+ : "-"}
+ {node.viaMqtt === true ? ", via MQTT" : ""}
,
- {node.lastHeard === 0 ? (
- Never
- ) : (
-
- )}
+ {node.lastHeard === 0
+ ? Never
+ : }
+ ,
+
+ {node.user?.publicKey && node.user?.publicKey.length > 0
+ ?
+ : }
,
{node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw
,
-
- {node.user?.publicKey && node.user?.publicKey.length > 0 ? (
-
- ) : (
-
- )}
+
+ {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
,
-
- {node.lastHeard !== 0
- ? node.viaMqtt === false && node.hopsAway === 0
- ? "Direct"
- : `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop"
- } away`
- : "-"}
- {node.viaMqtt === true ? ", via MQTT" : ""}
+
+ {base16
+ .stringify(node.user?.macaddr ?? [])
+ .match(/.{1,2}/g)
+ ?.join(":") ?? "UNK"}
,
])}
/>
diff --git a/src/validation/config/network.ts b/src/validation/config/network.ts
index f37796cc..9268128b 100644
--- a/src/validation/config/network.ts
+++ b/src/validation/config/network.ts
@@ -1,61 +1,27 @@
-import type { Message } from "@bufbuild/protobuf";
+import { z } from "zod";
import { Protobuf } from "@meshtastic/core";
-import {
- IsBoolean,
- IsEnum,
- IsIP,
- IsOptional,
- IsString,
- Length,
-} from "class-validator";
-export class NetworkValidation
- implements
- Omit {
- @IsBoolean()
- wifiEnabled: boolean;
+const AddressModeEnum = z.nativeEnum(Protobuf.Config.Config_NetworkConfig_AddressMode);
+const ProtocolFlagsEnum = z.nativeEnum(Protobuf.Config.Config_NetworkConfig_ProtocolFlags);
+
+export const NetworkValidationIpV4ConfigSchema = z.object({
+ ip: z.string().ip(),
+ gateway: z.string().ip(),
+ subnet: z.string().ip(),
+ dns: z.string().ip(),
+});
+
+export const NetworkValidationSchema = z.object({
+ wifiEnabled: z.boolean(),
+ wifiSsid: z.string().min(0).max(33).optional(),
+ wifiPsk: z.string().min(0).max(64).optional(),
+ ntpServer: z.string().min(2).max(30),
+ ethEnabled: z.boolean(),
+ addressMode: AddressModeEnum,
+ ipv4Config: NetworkValidationIpV4ConfigSchema.optional(),
+ enabledProtocols: ProtocolFlagsEnum,
+ rsyslogServer: z.string(),
+});
+
+export type NetworkValidation = z.infer;
- @Length(1, 33)
- @IsOptional({})
- wifiSsid: string;
-
- @Length(8, 64)
- @IsOptional()
- wifiPsk: string;
-
- @Length(2, 30)
- ntpServer: string;
-
- @IsBoolean()
- ethEnabled: boolean;
-
- @IsEnum(Protobuf.Config.Config_NetworkConfig_AddressMode)
- addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode;
-
- ipv4Config: NetworkValidationIpV4Config;
-
- @IsString()
- rsyslogServer: string;
-}
-
-export class NetworkValidationIpV4Config implements
- Omit<
- Protobuf.Config.Config_NetworkConfig_IpV4Config,
- keyof Message | "ip" | "gateway" | "subnet" | "dns"
- > {
- @IsIP()
- @IsOptional()
- ip: string;
-
- @IsIP()
- @IsOptional()
- gateway: string;
-
- @IsIP()
- @IsOptional()
- subnet: string;
-
- @IsIP()
- @IsOptional()
- dns: string;
-}
diff --git a/src/validation/validate.ts b/src/validation/validate.ts
new file mode 100644
index 00000000..0969a1de
--- /dev/null
+++ b/src/validation/validate.ts
@@ -0,0 +1,13 @@
+import { ZodError, ZodSchema } from "zod";
+
+export function validateSchema(
+ schema: ZodSchema,
+ data: unknown
+): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
+ const result = schema.safeParse(data);
+ if (result.success) {
+ return { success: true, data: result.data };
+ } else {
+ return { success: false, errors: result.error.issues };
+ }
+}
\ No newline at end of file
diff --git a/vitest.config.ts b/vitest.config.ts
index d4542ae3..cbf600bc 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -9,9 +9,9 @@ export default defineConfig({
resolve: {
alias: {
'@app': path.resolve(process.cwd(), './src'),
+ '@core': path.resolve(process.cwd(), './src/core'),
'@pages': path.resolve(process.cwd(), './src/pages'),
'@components': path.resolve(process.cwd(), './src/components'),
- '@core': path.resolve(process.cwd(), './src/core'),
'@layouts': path.resolve(process.cwd(), './src/layouts'),
},
},