-
- {t("security.lockdown.status")}:
-
- {t(`security.lockdown.state.${stateKey}`)}
-
-
- {unlocked && status.bootsRemaining > 0 && (
-
- {t("security.lockdown.bootsRemaining", {
- count: status.bootsRemaining,
- })}
-
- )}
- {unlocked && status.validUntilEpoch > 0 && (
+
+
+
- {t("security.lockdown.expires", {
- date: new Date(status.validUntilEpoch * 1000).toLocaleString(),
- })}
+
+ {t("security.lockdown.status")}:{" "}
+
+
+ {t(`security.lockdown.state.${stateKey}`)}
+
- )}
-
+ {unlocked && status.bootsRemaining > 0 && (
+
+ {t("security.lockdown.bootsRemaining", {
+ count: status.bootsRemaining,
+ })}
+
+ )}
+ {unlocked && status.validUntilEpoch > 0 && (
+
+ {t("security.lockdown.expires", {
+ date: new Date(status.validUntilEpoch * 1000).toLocaleString(),
+ })}
+
+ )}
+
- {unlocked && (
-
-
-
- {t("security.lockdown.lockNowDescription")}
-
-
-
-
{t("security.lockdown.disableDescription")}
+ {unlocked && (
+
+
+
+ {t("security.lockdown.lockNowDescription")}
+
+
+
+ {t("security.lockdown.disableDescription")}
+
-
- )}
+ )}
+
{
report(client.device.disableLockdown(passphrase))
}
/>
-
+
);
};
diff --git a/apps/web/src/components/PageComponents/Settings/Security/Security.test.tsx b/apps/web/src/components/PageComponents/Settings/Security/Security.test.tsx
new file mode 100644
index 000000000..0c130c83b
--- /dev/null
+++ b/apps/web/src/components/PageComponents/Settings/Security/Security.test.tsx
@@ -0,0 +1,88 @@
+import { create } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/sdk";
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Security } from "./Security.tsx";
+
+const security = create(Protobuf.Config.Config_SecurityConfigSchema, {
+ privateKey: new Uint8Array(32),
+ publicKey: new Uint8Array(32),
+ adminChannelEnabled: true,
+});
+let metadata: { hasXeddsa: boolean } | undefined;
+
+vi.mock("@app/core/hooks/useWaitForConfig", () => ({
+ useWaitForConfig: () => {},
+}));
+
+vi.mock("@core/stores", () => ({
+ useDevice: () => ({
+ config: { security },
+ getEffectiveConfig: () => security,
+ }),
+}));
+
+vi.mock("@core/hooks/useDeviceCapabilities.ts", () => ({
+ useDeviceCapabilities: () => ({ metadata }),
+}));
+
+vi.mock("@core/hooks/useLockdown.ts", () => ({
+ useLockdown: () => ({ client: undefined, status: undefined }),
+}));
+
+vi.mock("@meshtastic/sdk-react", () => ({
+ useConfigEditor: () => ({
+ radio: { value: {} },
+ setRadioSection: vi.fn(),
+ }),
+ useSignal: (signal: { value: unknown }) => signal.value,
+}));
+
+describe("Security", () => {
+ beforeEach(() => {
+ metadata = { hasXeddsa: true };
+ });
+
+ it("orders the groups as Android does, without the legacy admin channel", () => {
+ render(
);
+ const headings = screen
+ .getAllByRole("heading", { level: 4 })
+ .map((h) => h.textContent);
+ expect(headings).toEqual([
+ "Packet authenticity",
+ "Direct Message Key",
+ "Admin Keys",
+ "Logs",
+ "Administration",
+ ]);
+ expect(screen.queryByText("Legacy Admin channel")).toBeNull();
+ });
+
+ it("enables the protection level when the radio reports XEdDSA", () => {
+ render(
);
+ expect(
+ screen.getByRole("combobox", { name: "Protection level" }),
+ ).toBeEnabled();
+ });
+
+ it("disables it when the radio does not support it", () => {
+ metadata = { hasXeddsa: false };
+ render(
);
+ expect(
+ screen.getByRole("combobox", { name: "Protection level" }),
+ ).toBeDisabled();
+ expect(
+ screen.getByText(
+ "This device does not support packet signature verification.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("disables it until the radio's metadata arrives", () => {
+ metadata = undefined;
+ render(
);
+ expect(
+ screen.getByRole("combobox", { name: "Protection level" }),
+ ).toBeDisabled();
+ });
+});
diff --git a/apps/web/src/components/PageComponents/Settings/Security/Security.tsx b/apps/web/src/components/PageComponents/Settings/Security/Security.tsx
index 7f47313f9..a70834262 100644
--- a/apps/web/src/components/PageComponents/Settings/Security/Security.tsx
+++ b/apps/web/src/components/PageComponents/Settings/Security/Security.tsx
@@ -11,8 +11,8 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
+import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts";
import { useDevice } from "@core/stores";
-import { LockdownSection } from "./LockdownSection.tsx";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
@@ -20,6 +20,7 @@ import { fromByteArray, toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { type DefaultValues, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
+import { LockdownSection } from "./LockdownSection.tsx";
interface SecurityConfigProps {
onFormInit: DynamicFormFormInit
;
@@ -57,6 +58,7 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
| undefined);
const { t } = useTranslation("config");
+ const { metadata } = useDeviceCapabilities();
const formMethods = useForm({
mode: "onChange",
@@ -145,10 +147,40 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
propMethods={formMethods}
onSubmit={onSubmit}
fieldGroups={[
+ {
+ label: t("security.packetSignaturesCard.label"),
+ description: t("security.packetSignaturesCard.description"),
+ fields: [
+ {
+ type: "select",
+ name: "packetSignaturePolicy",
+ label: t("security.packetSignaturePolicy.label"),
+ description:
+ metadata?.hasXeddsa === false
+ ? t("security.packetSignaturePolicy.unsupported")
+ : t("security.packetSignaturePolicy.description"),
+ // Only a radio that reports XEdDSA signing can apply the policy.
+ disabled: metadata?.hasXeddsa !== true,
+ properties: {
+ enumValue: packetSignaturePolicyOptions,
+ },
+ },
+ ],
+ },
{
label: t("security.directMessageKey.label"),
description: t("security.directMessageKey.description"),
fields: [
+ {
+ type: "text",
+ name: "publicKey",
+ label: t("security.publicKey.label"),
+ disabled: true,
+ description: t("security.publicKey.description"),
+ properties: {
+ showCopyButton: true,
+ },
+ },
{
type: "passwordGenerator",
id: "pskInput",
@@ -173,16 +205,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
showPasswordToggle: true,
},
},
- {
- type: "text",
- name: "publicKey",
- label: t("security.publicKey.label"),
- disabled: true,
- description: t("security.publicKey.description"),
- properties: {
- showCopyButton: true,
- },
- },
],
},
{
@@ -198,9 +220,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
bits,
devicePSKBitCount: 32,
actionButtons: [],
- disabledBy: [
- { fieldName: "adminChannelEnabled", invert: true },
- ],
properties: { showCopyButton: true },
},
{
@@ -212,9 +231,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
bits,
devicePSKBitCount: 32,
actionButtons: [],
- disabledBy: [
- { fieldName: "adminChannelEnabled", invert: true },
- ],
properties: { showCopyButton: true },
},
{
@@ -226,28 +242,10 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
bits,
devicePSKBitCount: 32,
actionButtons: [],
- disabledBy: [
- { fieldName: "adminChannelEnabled", invert: true },
- ],
properties: { showCopyButton: true },
},
],
},
- {
- label: t("security.packetSignaturesCard.label"),
- description: t("security.packetSignaturesCard.description"),
- fields: [
- {
- type: "select",
- name: "packetSignaturePolicy",
- label: t("security.packetSignaturePolicy.label"),
- description: t("security.packetSignaturePolicy.description"),
- properties: {
- enumValue: packetSignaturePolicyOptions,
- },
- },
- ],
- },
{
label: t("security.logsCard.label"),
description: t("security.logsCard.description"),
@@ -282,17 +280,11 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
setValue("isManaged", false);
},
},
- {
- type: "toggle",
- name: "adminChannelEnabled",
- label: t("security.adminChannelEnabled.label"),
- description: t("security.adminChannelEnabled.description"),
- },
],
+ footer: ,
},
]}
/>
-
({
+ useMyNodeAsProto: () => ({ user }),
+}));
+
+vi.mock("@core/stores", () => ({
+ useDevice: () => ({
+ moduleConfig: { statusmessage },
+ getEffectiveModuleConfig: () => statusmessage,
+ }),
+}));
+
+vi.mock("@core/hooks/useDeviceCapabilities.ts", () => ({
+ useDeviceCapabilities: () => ({
+ metadata: { firmwareVersion },
+ supportsFirmware28: false,
+ excludedModules,
+ }),
+}));
+
+vi.mock("@meshtastic/sdk-react", () => ({
+ useConfigEditor: () => ({
+ owner: { value: undefined },
+ modules: { value: {} },
+ setBaselineOwner: vi.fn(),
+ setOwner,
+ setModuleSection,
+ }),
+ useSignal: (signal: { value: unknown }) => signal.value,
+}));
+
+describe("User", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ firmwareVersion = "2.7.20";
+ excludedModules = 0;
+ statusmessage = create(
+ Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfigSchema,
+ { nodeStatus: "On the hill" },
+ );
+ });
+
+ it("shows the status message after Short Name from firmware 2.7.20", () => {
+ render();
+ const labels = screen
+ .getAllByText(
+ /^(Node ID|Long Name|Short Name|Status Message|Hardware model|Unmessageable|Licensed amateur radio \(Ham\))$/,
+ )
+ .map((el) => el.textContent);
+ expect(labels).toEqual([
+ "Node ID",
+ "Long Name",
+ "Short Name",
+ "Status Message",
+ "Hardware model",
+ "Unmessageable",
+ "Licensed amateur radio (Ham)",
+ ]);
+ expect(screen.getByLabelText("Status Message")).toHaveValue("On the hill");
+ });
+
+ it("hides the status message on older firmware", () => {
+ firmwareVersion = "2.7.19";
+ render();
+ expect(screen.queryByText("Status Message")).toBeNull();
+ });
+
+ it("hides the status message when the build leaves the module out", () => {
+ excludedModules = Excluded.STATUSMESSAGE_CONFIG;
+ render();
+ expect(screen.queryByText("Status Message")).toBeNull();
+ });
+
+ it("waits for the module's config before showing the status message", () => {
+ statusmessage = undefined;
+ render();
+ expect(screen.queryByText("Status Message")).toBeNull();
+ });
+
+ it("stages the owner, keeping its other fields, and the status", async () => {
+ render();
+ fireEvent.change(screen.getByLabelText("Status Message"), {
+ target: { value: "Back at base" },
+ });
+
+ await waitFor(() => expect(setModuleSection).toHaveBeenCalled());
+ expect(setModuleSection).toHaveBeenLastCalledWith(
+ "statusmessage",
+ expect.objectContaining({ nodeStatus: "Back at base" }),
+ );
+ expect(setOwner).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ id: "!abcd1234",
+ longName: "Base Station",
+ role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
+ }),
+ );
+ });
+});
diff --git a/apps/web/src/components/PageComponents/Settings/User.tsx b/apps/web/src/components/PageComponents/Settings/User.tsx
index 1f03e8a17..7a8a2f1d2 100644
--- a/apps/web/src/components/PageComponents/Settings/User.tsx
+++ b/apps/web/src/components/PageComponents/Settings/User.tsx
@@ -1,4 +1,5 @@
import {
+ NODE_STATUS_MAX_BYTES,
type UserValidation,
UserValidationSchema,
} from "@app/validation/config/user.ts";
@@ -7,8 +8,11 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
+import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts";
import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
-import { Protobuf } from "@meshtastic/sdk";
+import { useDevice } from "@core/stores";
+import { isModuleExcluded } from "@core/utils/excludedModules.ts";
+import { firmwareAtLeast, Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
@@ -23,11 +27,41 @@ const EMPTY_OWNER_SIGNAL = {
subscribe: () => () => {},
} as const;
+const EMPTY_MODULES_SIGNAL = {
+ value: {} as {
+ statusmessage?: Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfig;
+ },
+ peek: () =>
+ ({}) as {
+ statusmessage?: Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfig;
+ },
+ subscribe: () => () => {},
+} as const;
+
export const User = ({ onFormInit }: UserConfigProps) => {
const { t } = useTranslation("config");
const editor = useConfigEditor();
const myNode = useMyNodeAsProto();
const owner = useSignal(editor?.owner ?? EMPTY_OWNER_SIGNAL);
+ const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
+ const { moduleConfig, getEffectiveModuleConfig } = useDevice();
+ const { metadata, supportsFirmware28, excludedModules } =
+ useDeviceCapabilities();
+
+ // The Status Message module's one field lives here, as on Android: from
+ // firmware 2.7.20, unless the build leaves the module out. It also waits
+ // for the module's config, so an edit is never shown but not staged.
+ const supportsStatusMessage =
+ (supportsFirmware28 ||
+ firmwareAtLeast(metadata?.firmwareVersion ?? "", "2.7.20")) &&
+ !isModuleExcluded(excludedModules, "statusmessage");
+ const showStatusMessage =
+ supportsStatusMessage && moduleConfig.statusmessage !== undefined;
+ const baselineStatus = moduleConfig.statusmessage?.nodeStatus ?? "";
+ const effectiveStatus =
+ modules.statusmessage?.nodeStatus ??
+ getEffectiveModuleConfig("statusmessage")?.nodeStatus ??
+ baselineStatus;
// Seed the editor's baseline when the device's user info first arrives.
// setBaselineOwner is a no-op when the working copy is dirty, so user
@@ -59,6 +93,16 @@ export const User = ({ onFormInit }: UserConfigProps) => {
isLicensed: data.isLicensed,
});
editor.setOwner(merged);
+ // Only stage the status once the device has sent the module's config,
+ // so a node without it never gets a new, dirty section.
+ if (showStatusMessage) {
+ editor.setModuleSection(
+ "statusmessage",
+ create(Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfigSchema, {
+ nodeStatus: data.nodeStatus,
+ }),
+ );
+ }
};
const hwName =
@@ -77,6 +121,7 @@ export const User = ({ onFormInit }: UserConfigProps) => {
shortName: baselineUser.shortName,
isLicensed: baselineUser.isLicensed,
isUnmessageable: baselineUser.isUnmessagable ?? false,
+ nodeStatus: baselineStatus,
}}
values={{
nodeId: effectiveUser.id,
@@ -85,6 +130,7 @@ export const User = ({ onFormInit }: UserConfigProps) => {
shortName: effectiveUser.shortName,
isLicensed: effectiveUser.isLicensed,
isUnmessageable: effectiveUser.isUnmessagable ?? false,
+ nodeStatus: effectiveStatus,
}}
fieldGroups={[
{
@@ -119,6 +165,22 @@ export const User = ({ onFormInit }: UserConfigProps) => {
fieldLength: { min: 1, max: 4, showCharacterCount: true },
},
},
+ ...(showStatusMessage
+ ? [
+ {
+ type: "text" as const,
+ name: "nodeStatus" as const,
+ label: t("user.statusMessage.label"),
+ description: t("user.statusMessage.description"),
+ properties: {
+ fieldLength: {
+ max: NODE_STATUS_MAX_BYTES,
+ showCharacterCount: true,
+ },
+ },
+ },
+ ]
+ : []),
{
type: "text",
name: "hardwareModel",
diff --git a/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx b/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx
new file mode 100644
index 000000000..287e5f60d
--- /dev/null
+++ b/apps/web/src/components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx
@@ -0,0 +1,125 @@
+import { DialogWrapper } from "@components/Dialog/DialogWrapper.tsx";
+import { Button } from "@components/UI/Button.tsx";
+import { Checkbox } from "@components/UI/Checkbox/index.tsx";
+import {
+ PROFILE_FIELDS,
+ type ProfileField,
+ type ProfileSelection,
+} from "@core/utils/profileExport.ts";
+import { TriangleAlertIcon } from "lucide-react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export interface ProfileFieldsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ mode: "export" | "import";
+ /** Which parts exist; the others are shown disabled. */
+ available: ProfileSelection;
+ /** The starting selection; the private key should start unchecked. */
+ initial: ProfileSelection;
+ onConfirm: (selection: ProfileSelection) => void;
+}
+
+/** Fields that exist in the file but are never applied on import. */
+const NOT_IMPORTED: readonly ProfileField[] = ["isLicensed"];
+
+/**
+ * Android's EditDeviceProfileDialog: pick which parts of a profile to export
+ * or import. The private key is opt-in and carries a warning.
+ */
+export const ProfileFieldsDialog = ({
+ open,
+ onOpenChange,
+ mode,
+ available,
+ initial,
+ onConfirm,
+}: ProfileFieldsDialogProps) => {
+ const { t } = useTranslation("ui");
+ // Mount the dialog per profile: the selection starts from `initial` once.
+ const [selection, setSelection] = useState(initial);
+
+ const toggle = (field: ProfileField, checked: boolean) =>
+ setSelection((current) => ({ ...current, [field]: checked }));
+
+ // A row the user can't select (unavailable, not imported, or the private
+ // key without the configuration) doesn't count, even if still ticked.
+ const isEnabled = (field: ProfileField) =>
+ available[field] &&
+ !(mode === "import" && NOT_IMPORTED.includes(field)) &&
+ !(field === "privateKey" && !selection.config);
+
+ const anySelected = PROFILE_FIELDS.some(
+ (field) => selection[field] && isEnabled(field),
+ );
+
+ return (
+
+
+
+ >
+ }
+ >
+
+ {PROFILE_FIELDS.map((field) => {
+ const notApplied = mode === "import" && NOT_IMPORTED.includes(field);
+ // The key lives in the configuration's security section.
+ const enabled = isEnabled(field);
+ return (
+
+
toggle(field, checked)}
+ >
+ {t(`settingsPages.backup.fields.${field}`)}
+ {notApplied && available[field] && (
+
+ ({t("settingsPages.backup.notApplied")})
+
+ )}
+
+ {field === "privateKey" && available.privateKey && (
+
+
+ {mode === "import"
+ ? t("settingsPages.backup.privateKeyImportWarning")
+ : t("settingsPages.backup.privateKeyWarning")}
+
+ )}
+ {field === "privateKey" &&
+ mode === "export" &&
+ available.privateKey &&
+ !(enabled && selection.privateKey) && (
+
+ {t("settingsPages.backup.privateKeyExcludedNote")}
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/apps/web/src/components/PageComponents/SettingsPages/SettingsSection.tsx b/apps/web/src/components/PageComponents/SettingsPages/SettingsSection.tsx
new file mode 100644
index 000000000..dde7ad342
--- /dev/null
+++ b/apps/web/src/components/PageComponents/SettingsPages/SettingsSection.tsx
@@ -0,0 +1,74 @@
+import { Label } from "@components/UI/Label.tsx";
+import { Heading } from "@components/UI/Typography/Heading.tsx";
+import { Subtle } from "@components/UI/Typography/Subtle.tsx";
+import { cn } from "@core/utils/cn.ts";
+import type { ReactNode } from "react";
+
+/** The stack of groups on a settings page, spaced like DynamicForm. */
+export const SettingsSections = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+interface SettingsGroupProps {
+ title: string;
+ description?: ReactNode;
+ children?: ReactNode;
+}
+
+/** A titled group of rows, matching a DynamicForm field group. */
+export const SettingsGroup = ({
+ title,
+ description,
+ children,
+}: SettingsGroupProps) => (
+
+
+
+ {title}
+
+ {description && {description}}
+
+ {children}
+
+);
+
+interface SettingsRowProps {
+ label: string;
+ /** Id of the control, so the label focuses it. */
+ htmlFor?: string;
+ description?: ReactNode;
+ destructive?: boolean;
+ children?: ReactNode;
+}
+
+/** A label, description and control, laid out like FieldWrapper. */
+export const SettingsRow = ({
+ label,
+ htmlFor,
+ description,
+ destructive,
+ children,
+}: SettingsRowProps) => (
+
+
+
+
+ {description && (
+
+ {description}
+
+ )}
+ {children && (
+
+ {children}
+
+ )}
+
+
+
+);
diff --git a/apps/web/src/components/PageComponents/SettingsPages/useDeviceConnected.ts b/apps/web/src/components/PageComponents/SettingsPages/useDeviceConnected.ts
new file mode 100644
index 000000000..b4237d57c
--- /dev/null
+++ b/apps/web/src/components/PageComponents/SettingsPages/useDeviceConnected.ts
@@ -0,0 +1,7 @@
+import { useDevice } from "@core/stores";
+
+/** True once the active node is connected and configured. */
+export function useDeviceConnected(): boolean {
+ const { connection, connectionPhase } = useDevice();
+ return connection !== undefined && connectionPhase === "configured";
+}
diff --git a/apps/web/src/components/RegionSetupReminder.tsx b/apps/web/src/components/RegionSetupReminder.tsx
index 593ea47f5..35952c079 100644
--- a/apps/web/src/components/RegionSetupReminder.tsx
+++ b/apps/web/src/components/RegionSetupReminder.tsx
@@ -45,7 +45,7 @@ export const RegionSetupReminder = (): null => {
className="w-full"
onClick={() => {
dismiss();
- navigate({ to: "/settings/radio" });
+ navigate({ to: "/settings/lora" });
}}
>
{t("regionSetup.cta", { defaultValue: "Set region" })}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 908bf518a..c5c8cbf03 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -191,7 +191,9 @@ export const Sidebar = ({ children }: SidebarProps) => {
navigate({ to: `/${link.page}` });
}
}}
- active={link.page === pathname}
+ active={
+ pathname === link.page || pathname.startsWith(`${link.page}/`)
+ }
disabled={myNode === undefined}
/>
);
diff --git a/apps/web/src/components/UI/Sidebar/SidebarButton.tsx b/apps/web/src/components/UI/Sidebar/SidebarButton.tsx
index ea1f8d1e9..2b0843357 100644
--- a/apps/web/src/components/UI/Sidebar/SidebarButton.tsx
+++ b/apps/web/src/components/UI/Sidebar/SidebarButton.tsx
@@ -37,6 +37,9 @@ export const SidebarButton = ({
size="sm"
className={cn(
"flex w-full items-center text-wrap",
+ // Buttons sit 4px apart, so an offset ring spills onto the next one.
+ // Ring only for keyboard focus, drawn inside the button.
+ "focus:ring-0 focus:ring-offset-0 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-slate-400",
isButtonCollapsed
? "justify-center gap-0 px-2 h-9"
: "justify-start gap-2 min-h-9",
diff --git a/apps/web/src/core/hooks/useTheme.ts b/apps/web/src/core/hooks/useTheme.ts
index 8fb37d4f0..fddaea192 100644
--- a/apps/web/src/core/hooks/useTheme.ts
+++ b/apps/web/src/core/hooks/useTheme.ts
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
+const THEME_CHANGE_EVENT = "meshtastic:theme-change";
+
export function useTheme() {
const getSystemTheme = () =>
globalThis.matchMedia("(prefers-color-scheme: dark)").matches
@@ -35,9 +37,18 @@ export function useTheme() {
return () => media.removeEventListener("change", updateTheme);
}, [preference, getStoredPreference]);
+ // Several components use this hook (App, the sidebar panel, App
+ // settings); keep every instance on the same preference.
+ useEffect(() => {
+ const sync = () => setPreference(getStoredPreference());
+ globalThis.addEventListener(THEME_CHANGE_EVENT, sync);
+ return () => globalThis.removeEventListener(THEME_CHANGE_EVENT, sync);
+ }, [getStoredPreference]);
+
const setPreferenceValue = (newPreference: Theme) => {
localStorage.setItem("theme", newPreference);
setPreference(newPreference);
+ globalThis.dispatchEvent(new Event(THEME_CHANGE_EVENT));
};
return { theme, preference, setPreference: setPreferenceValue };
diff --git a/apps/web/src/core/utils/cleanNodes.test.ts b/apps/web/src/core/utils/cleanNodes.test.ts
new file mode 100644
index 000000000..c2b027e2d
--- /dev/null
+++ b/apps/web/src/core/utils/cleanNodes.test.ts
@@ -0,0 +1,56 @@
+import { create } from "@bufbuild/protobuf";
+import { type Node, Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import { minDays, nodesToClean } from "./cleanNodes.ts";
+
+const DAY = 24 * 60 * 60;
+const NOW = 1_800_000_000;
+
+const node = (num: number, overrides: Partial = {}): Node => ({
+ num,
+ isFavorite: false,
+ isIgnored: false,
+ user: create(Protobuf.Mesh.UserSchema, { longName: `Node ${num}` }),
+ lastHeard: NOW - 30 * DAY,
+ ...overrides,
+});
+
+describe("nodesToClean", () => {
+ const criteria = { olderThanDays: 14, onlyUnknown: false, nowSeconds: NOW };
+
+ it("selects nodes last heard before the cut-off", () => {
+ const nodes = [
+ node(1),
+ node(2, { lastHeard: NOW - 3 * DAY }),
+ node(3, { lastHeard: undefined }),
+ ];
+ expect(nodesToClean(nodes, criteria).map((n) => n.num)).toEqual([1, 3]);
+ });
+
+ it("keeps our own node, favourites and ignored nodes", () => {
+ const nodes = [
+ node(1),
+ node(2, { isFavorite: true }),
+ node(3, { isIgnored: true }),
+ ];
+ expect(
+ nodesToClean(nodes, { ...criteria, myNodeNum: 1 }).map((n) => n.num),
+ ).toEqual([]);
+ });
+
+ it("can limit to unknown nodes", () => {
+ const nodes = [
+ node(1),
+ node(2, { user: undefined }),
+ node(3, { user: create(Protobuf.Mesh.UserSchema, {}) }),
+ ];
+ expect(
+ nodesToClean(nodes, { ...criteria, onlyUnknown: true }).map((n) => n.num),
+ ).toEqual([2, 3]);
+ });
+
+ it("lets unknown-only cleaning go down to 0 days", () => {
+ expect(minDays(true)).toBe(0);
+ expect(minDays(false)).toBe(7);
+ });
+});
diff --git a/apps/web/src/core/utils/cleanNodes.ts b/apps/web/src/core/utils/cleanNodes.ts
new file mode 100644
index 000000000..cf62c35e4
--- /dev/null
+++ b/apps/web/src/core/utils/cleanNodes.ts
@@ -0,0 +1,43 @@
+import type { Node } from "@meshtastic/sdk";
+
+export const MIN_DAYS_KNOWN = 7;
+export const MIN_DAYS_UNKNOWN = 0;
+export const MAX_DAYS = 365;
+
+const SECONDS_PER_DAY = 24 * 60 * 60;
+
+export interface CleanNodesCriteria {
+ olderThanDays: number;
+ /** Only nodes that never sent their user info (no long name). */
+ onlyUnknown: boolean;
+ nowSeconds: number;
+ myNodeNum?: number;
+}
+
+export const isUnknownNode = (node: Node): boolean => !node.user?.longName;
+
+/**
+ * The nodes Android's "Clean node database" would remove: last heard before
+ * the cut-off (never heard counts as old), optionally only unknown ones. Our
+ * own node, favourites and ignored nodes are always kept, since removing an
+ * ignored node would forget that it's ignored.
+ */
+export function nodesToClean(
+ nodes: Iterable,
+ criteria: CleanNodesCriteria,
+): Node[] {
+ const cutoff =
+ criteria.nowSeconds - Math.floor(criteria.olderThanDays) * SECONDS_PER_DAY;
+ return [...nodes].filter(
+ (node) =>
+ node.num !== criteria.myNodeNum &&
+ !node.isFavorite &&
+ !node.isIgnored &&
+ (node.lastHeard ?? 0) < cutoff &&
+ (!criteria.onlyUnknown || isUnknownNode(node)),
+ );
+}
+
+/** The slider's lower bound: unknown-only cleaning may go down to 0 days. */
+export const minDays = (onlyUnknown: boolean): number =>
+ onlyUnknown ? MIN_DAYS_UNKNOWN : MIN_DAYS_KNOWN;
diff --git a/apps/web/src/core/utils/logRecords.test.ts b/apps/web/src/core/utils/logRecords.test.ts
new file mode 100644
index 000000000..c199bc1ce
--- /dev/null
+++ b/apps/web/src/core/utils/logRecords.test.ts
@@ -0,0 +1,47 @@
+import { create, type MessageInitShape } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import {
+ type DeviceLogEntry,
+ filterLogEntries,
+ formatLogEntry,
+} from "./logRecords.ts";
+
+const entry = (
+ id: number,
+ init: MessageInitShape,
+): DeviceLogEntry => ({
+ id,
+ receivedAt: Date.UTC(2026, 0, 2, 3, 4, 5),
+ record: create(Protobuf.Mesh.LogRecordSchema, init),
+});
+
+describe("log records", () => {
+ it("formats with the node's time when set", () => {
+ expect(
+ formatLogEntry(
+ entry(1, {
+ time: Date.UTC(2026, 0, 1) / 1000,
+ level: Protobuf.Mesh.LogRecord_Level.WARNING,
+ source: "Router",
+ message: "queue full",
+ }),
+ ),
+ ).toBe("2026-01-01T00:00:00.000Z [WARNING] Router: queue full");
+ });
+
+ it("falls back to the arrival time", () => {
+ expect(formatLogEntry(entry(1, { message: "hi" }))).toBe(
+ "2026-01-02T03:04:05.000Z [UNSET] hi",
+ );
+ });
+
+ it("filters case-insensitively", () => {
+ const entries = [
+ entry(1, { message: "GPS lock" }),
+ entry(2, { message: "radio idle" }),
+ ];
+ expect(filterLogEntries(entries, "gps").map((e) => e.id)).toEqual([1]);
+ expect(filterLogEntries(entries, " ")).toHaveLength(2);
+ });
+});
diff --git a/apps/web/src/core/utils/logRecords.ts b/apps/web/src/core/utils/logRecords.ts
new file mode 100644
index 000000000..d97e7d658
--- /dev/null
+++ b/apps/web/src/core/utils/logRecords.ts
@@ -0,0 +1,35 @@
+import { Protobuf } from "@meshtastic/sdk";
+
+/** A device log record as kept by the debug panel. */
+export interface DeviceLogEntry {
+ id: number;
+ /** When the record arrived, in ms; used when the node sent no time. */
+ receivedAt: number;
+ record: Protobuf.Mesh.LogRecord;
+}
+
+export const logLevelName = (level: Protobuf.Mesh.LogRecord_Level): string =>
+ Protobuf.Mesh.LogRecord_Level[level] ?? String(level);
+
+/** The record's time: the node's clock if it set one, else arrival time. */
+export const logEntryTime = (entry: DeviceLogEntry): Date =>
+ new Date(entry.record.time > 0 ? entry.record.time * 1000 : entry.receivedAt);
+
+/** One line of text, as shown and exported. */
+export function formatLogEntry(entry: DeviceLogEntry): string {
+ const { level, source, message } = entry.record;
+ const origin = source ? ` ${source}:` : "";
+ return `${logEntryTime(entry).toISOString()} [${logLevelName(level)}]${origin} ${message}`;
+}
+
+/** Case-insensitive match on the formatted line. */
+export function filterLogEntries(
+ entries: readonly DeviceLogEntry[],
+ query: string,
+): DeviceLogEntry[] {
+ const needle = query.trim().toLowerCase();
+ if (!needle) return [...entries];
+ return entries.filter((entry) =>
+ formatLogEntry(entry).toLowerCase().includes(needle),
+ );
+}
diff --git a/apps/web/src/core/utils/profileExport.test.ts b/apps/web/src/core/utils/profileExport.test.ts
new file mode 100644
index 000000000..4b2fec4d7
--- /dev/null
+++ b/apps/web/src/core/utils/profileExport.test.ts
@@ -0,0 +1,273 @@
+import { create } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/sdk";
+import { Result } from "better-result";
+import { describe, expect, it } from "vitest";
+import {
+ buildDeviceProfile,
+ decodeDeviceProfile,
+ defaultSelection,
+ deviceProfileFileName,
+ encodeDeviceProfile,
+ planProfileImport,
+ type ProfileSource,
+ profileContents,
+ sanitizeExportNameSegment,
+ selectProfileFields,
+} from "./profileExport.ts";
+
+const PRIVATE_KEY = new Uint8Array(32).fill(7);
+const PUBLIC_KEY = new Uint8Array(32).fill(9);
+
+const makeSource = (overrides: Partial = {}): ProfileSource => ({
+ owner: create(Protobuf.Mesh.UserSchema, {
+ id: "!abcd1234",
+ longName: "Roof Node",
+ shortName: "ROOF",
+ isLicensed: false,
+ }),
+ radio: {
+ device: create(Protobuf.Config.Config_DeviceConfigSchema, {
+ role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
+ }),
+ position: create(Protobuf.Config.Config_PositionConfigSchema, {
+ fixedPosition: true,
+ }),
+ lora: create(Protobuf.Config.Config_LoRaConfigSchema, {
+ region: Protobuf.Config.Config_LoRaConfig_RegionCode.EU_868,
+ }),
+ security: create(Protobuf.Config.Config_SecurityConfigSchema, {
+ privateKey: PRIVATE_KEY,
+ publicKey: PUBLIC_KEY,
+ serialEnabled: true,
+ }),
+ },
+ modules: {
+ mqtt: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, {
+ enabled: true,
+ address: "mqtt.example.org",
+ }),
+ },
+ channels: [
+ create(Protobuf.Channel.ChannelSchema, {
+ index: 0,
+ role: Protobuf.Channel.Channel_Role.PRIMARY,
+ settings: create(Protobuf.Channel.ChannelSettingsSchema, {
+ name: "Main",
+ psk: new Uint8Array([1]),
+ }),
+ }),
+ create(Protobuf.Channel.ChannelSchema, {
+ index: 1,
+ role: Protobuf.Channel.Channel_Role.DISABLED,
+ }),
+ ],
+ ownPosition: create(Protobuf.Mesh.PositionSchema, {
+ latitudeI: 515000000,
+ longitudeI: -1000000,
+ altitude: 42,
+ }),
+ ...overrides,
+});
+
+describe("profile export", () => {
+ it("leaves the private and public key out by default", () => {
+ const profile = buildDeviceProfile(makeSource());
+ const selection = defaultSelection(profile);
+ expect(selection.privateKey).toBe(false);
+
+ const exported = selectProfileFields(profile, selection);
+ expect(exported.config?.security?.privateKey).toHaveLength(0);
+ expect(exported.config?.security?.publicKey).toHaveLength(0);
+ // The rest of the security section is kept.
+ expect(exported.config?.security?.serialEnabled).toBe(true);
+ });
+
+ it("keeps the private key only when chosen", () => {
+ const profile = buildDeviceProfile(makeSource());
+ const exported = selectProfileFields(profile, {
+ ...defaultSelection(profile),
+ privateKey: true,
+ });
+ expect(exported.config?.security?.privateKey).toEqual(PRIVATE_KEY);
+ });
+
+ it("round-trips through the binary format", () => {
+ const profile = buildDeviceProfile(makeSource());
+ const exported = selectProfileFields(profile, defaultSelection(profile));
+ const decoded = decodeDeviceProfile(encodeDeviceProfile(exported));
+ expect(Result.isOk(decoded)).toBe(true);
+ if (!Result.isOk(decoded)) return;
+ const back = decoded.value;
+ expect(back.longName).toBe("Roof Node");
+ expect(back.shortName).toBe("ROOF");
+ expect(back.config?.device?.role).toBe(
+ Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
+ );
+ expect(back.moduleConfig?.mqtt?.address).toBe("mqtt.example.org");
+ expect(back.fixedPosition?.latitudeI).toBe(515000000);
+ expect(back.channelUrl).toMatch(/^https:\/\/meshtastic\.org\/e\/#/);
+ expect(back.config?.security?.privateKey).toHaveLength(0);
+ expect(profileContents(back).privateKey).toBe(false);
+ });
+
+ it("drops parts that are unselected", () => {
+ const profile = buildDeviceProfile(makeSource());
+ const exported = selectProfileFields(profile, {
+ ...defaultSelection(profile),
+ longName: false,
+ moduleConfig: false,
+ channelUrl: false,
+ });
+ expect(exported.longName).toBeUndefined();
+ expect(exported.moduleConfig).toBeUndefined();
+ expect(exported.channelUrl).toBeUndefined();
+ expect(exported.shortName).toBe("ROOF");
+ });
+
+ it("exports no fixed position when it is off", () => {
+ const source = makeSource();
+ const profile = buildDeviceProfile({
+ ...source,
+ radio: {
+ ...source.radio,
+ position: create(Protobuf.Config.Config_PositionConfigSchema, {}),
+ },
+ });
+ expect(profile.fixedPosition).toBeUndefined();
+ });
+
+ it("rejects bytes that aren't a profile", () => {
+ expect(
+ Result.isError(decodeDeviceProfile(new Uint8Array([0xff, 0xff, 0xff]))),
+ ).toBe(true);
+ expect(Result.isError(decodeDeviceProfile(new Uint8Array()))).toBe(true);
+ });
+});
+
+describe("export file name", () => {
+ const date = new Date(2026, 9, 7);
+
+ it("follows Android's pattern", () => {
+ expect(deviceProfileFileName("Roof // Node", "ROOF", date)).toBe(
+ "Meshtastic_Roof_Node_20261007_nodeConfig.cfg",
+ );
+ });
+
+ it("falls back to the short name, then to node", () => {
+ expect(deviceProfileFileName("📡📡", "AB", date)).toBe(
+ "Meshtastic_AB_20261007_nodeConfig.cfg",
+ );
+ expect(deviceProfileFileName(undefined, "🙂", date)).toBe(
+ "Meshtastic_node_20261007_nodeConfig.cfg",
+ );
+ });
+
+ it("keeps letters of any script", () => {
+ expect(sanitizeExportNameSegment("Küche")).toBe("Küche");
+ expect(sanitizeExportNameSegment("東京")).toBe("東京");
+ expect(sanitizeExportNameSegment(" a/b\\c ")).toBe("a_b_c");
+ expect(sanitizeExportNameSegment("x".repeat(60))).toHaveLength(48);
+ });
+});
+
+describe("profile import", () => {
+ const currentSecurity = create(Protobuf.Config.Config_SecurityConfigSchema, {
+ privateKey: new Uint8Array(32).fill(1),
+ publicKey: new Uint8Array(32).fill(2),
+ });
+ const owner = create(Protobuf.Mesh.UserSchema, {
+ id: "!00000001",
+ longName: "Old",
+ shortName: "OLD",
+ hwModel: Protobuf.Mesh.HardwareModel.RAK4631,
+ });
+
+ const exportedProfile = () => {
+ const profile = buildDeviceProfile(makeSource());
+ return selectProfileFields(profile, defaultSelection(profile));
+ };
+
+ it("keeps the node's own keys when the file has none", () => {
+ const plan = planProfileImport(exportedProfile(), {
+ owner,
+ security: currentSecurity,
+ activeChannelIndexes: [0],
+ });
+ expect(Result.isOk(plan)).toBe(true);
+ if (!Result.isOk(plan)) return;
+ expect(plan.value.radio.security?.privateKey).toEqual(
+ currentSecurity.privateKey,
+ );
+ expect(plan.value.radio.security?.publicKey).toEqual(
+ currentSecurity.publicKey,
+ );
+ expect(plan.value.radio.security?.serialEnabled).toBe(true);
+ });
+
+ it("skips security rather than send empty keys", () => {
+ const plan = planProfileImport(exportedProfile(), {
+ owner,
+ activeChannelIndexes: [],
+ });
+ if (!Result.isOk(plan)) throw plan.error;
+ expect(plan.value.radio.security).toBeUndefined();
+ expect(plan.value.skipped).toContain("security");
+ });
+
+ it("merges names into the current owner", () => {
+ const plan = planProfileImport(exportedProfile(), {
+ owner,
+ activeChannelIndexes: [],
+ });
+ if (!Result.isOk(plan)) throw plan.error;
+ expect(plan.value.owner?.longName).toBe("Roof Node");
+ expect(plan.value.owner?.id).toBe("!00000001");
+ expect(plan.value.owner?.hwModel).toBe(Protobuf.Mesh.HardwareModel.RAK4631);
+ expect(plan.value.skipped).toContain("isLicensed");
+ });
+
+ it("replaces the channel table and disables leftover channels", () => {
+ const plan = planProfileImport(exportedProfile(), {
+ owner,
+ activeChannelIndexes: [0, 1, 3],
+ });
+ if (!Result.isOk(plan)) throw plan.error;
+ const byIndex = new Map(plan.value.channels.map((ch) => [ch.index, ch]));
+ expect(byIndex.get(0)?.role).toBe(Protobuf.Channel.Channel_Role.PRIMARY);
+ expect(byIndex.get(0)?.settings?.name).toBe("Main");
+ expect(byIndex.get(1)?.role).toBe(Protobuf.Channel.Channel_Role.DISABLED);
+ expect(byIndex.get(3)?.role).toBe(Protobuf.Channel.Channel_Role.DISABLED);
+ expect(byIndex.has(2)).toBe(false);
+ expect(plan.value.radio.lora?.region).toBe(
+ Protobuf.Config.Config_LoRaConfig_RegionCode.EU_868,
+ );
+ });
+
+ it("stages modules and the fixed position, but not traffic management", () => {
+ const profile = exportedProfile();
+ if (!profile.moduleConfig) throw new Error("expected module config");
+ profile.moduleConfig.trafficManagement = create(
+ Protobuf.ModuleConfig.ModuleConfig_TrafficManagementConfigSchema,
+ {},
+ );
+ const plan = planProfileImport(profile, {
+ owner,
+ activeChannelIndexes: [],
+ });
+ if (!Result.isOk(plan)) throw plan.error;
+ expect(plan.value.modules.mqtt?.enabled).toBe(true);
+ expect(plan.value.modules.trafficManagement).toBeUndefined();
+ expect(plan.value.skipped).toContain("trafficManagement");
+ expect(plan.value.fixedPosition?.altitude).toBe(42);
+ });
+
+ it("fails on a bad channel URL", () => {
+ const profile = exportedProfile();
+ profile.channelUrl = "https://example.com/not-a-channel";
+ expect(
+ Result.isError(
+ planProfileImport(profile, { owner, activeChannelIndexes: [] }),
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/apps/web/src/core/utils/profileExport.ts b/apps/web/src/core/utils/profileExport.ts
new file mode 100644
index 000000000..1ac71a197
--- /dev/null
+++ b/apps/web/src/core/utils/profileExport.ts
@@ -0,0 +1,470 @@
+import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
+import { channelSetFromUrl, channelSetToUrl } from "@core/utils/channelUrl.ts";
+import { Protobuf } from "@meshtastic/sdk";
+import { Result } from "better-result";
+
+/**
+ * Device profile (`.cfg`) import and export, the binary
+ * `ClientOnly.DeviceProfile` format shared with the Android, Apple and Python
+ * clients. Everything here is pure: the page reads the editor and applies the
+ * import plan, so this file needs no device to test.
+ */
+
+type LocalConfig = Protobuf.LocalOnly.LocalConfig;
+type LocalModuleConfig = Protobuf.LocalOnly.LocalModuleConfig;
+type DeviceProfile = Protobuf.ClientOnly.DeviceProfile;
+
+/** The parts of a profile the user can include or leave out. */
+export const PROFILE_FIELDS = [
+ "longName",
+ "shortName",
+ "channelUrl",
+ "config",
+ "moduleConfig",
+ "fixedPosition",
+ "isUnmessagable",
+ "isLicensed",
+ "privateKey",
+] as const;
+
+export type ProfileField = (typeof PROFILE_FIELDS)[number];
+export type ProfileSelection = Record;
+
+/** LocalConfig sections, as named in both LocalConfig and the editor. */
+export const PROFILE_RADIO_SECTIONS = [
+ "device",
+ "position",
+ "power",
+ "network",
+ "display",
+ "lora",
+ "bluetooth",
+ "security",
+] as const satisfies readonly (keyof LocalConfig)[];
+
+/** LocalModuleConfig sections, as named in both LocalModuleConfig and the editor. */
+export const PROFILE_MODULE_SECTIONS = [
+ "mqtt",
+ "serial",
+ "externalNotification",
+ "storeForward",
+ "rangeTest",
+ "telemetry",
+ "cannedMessage",
+ "audio",
+ "remoteHardware",
+ "neighborInfo",
+ "ambientLighting",
+ "detectionSensor",
+ "paxcounter",
+ "statusmessage",
+ "trafficManagement",
+ "tak",
+ "meshBeacon",
+] as const satisfies readonly (keyof LocalModuleConfig)[];
+
+export type ProfileRadioSection = (typeof PROFILE_RADIO_SECTIONS)[number];
+export type ProfileModuleSection = (typeof PROFILE_MODULE_SECTIONS)[number];
+
+/**
+ * Not installed on import, like Android: a node built without the module
+ * exports zeroes, which would turn off this node's position dedup.
+ */
+const MODULE_SECTIONS_NOT_INSTALLED: readonly ProfileModuleSection[] = [
+ "trafficManagement",
+];
+
+const MAX_CHANNELS = 8;
+
+/** What the export reads from the connected node. */
+export interface ProfileSource {
+ owner?: Protobuf.Mesh.User;
+ radio: Partial>;
+ modules: Partial>;
+ /** Channels by index; disabled ones are left out of the URL. */
+ channels: Iterable;
+ /** The node's own position; exported only when fixed position is on. */
+ ownPosition?: Protobuf.Mesh.Position;
+}
+
+/** The channel share URL for the enabled channels, primary first. */
+export function channelUrlFromChannels(
+ channels: Iterable,
+ loraConfig: Protobuf.Config.Config_LoRaConfig | undefined,
+): string | undefined {
+ const settings = [...channels]
+ .filter((ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED)
+ .sort((a, b) => a.index - b.index)
+ .map((ch) => ch.settings)
+ .filter((s): s is Protobuf.Channel.ChannelSettings => s !== undefined);
+ if (settings.length === 0) return undefined;
+ const channelSet = create(Protobuf.AppOnly.ChannelSetSchema, {
+ settings,
+ loraConfig,
+ });
+ return channelSetToUrl(channelSet);
+}
+
+/** The full profile of the node, private key included. Filter before saving. */
+export function buildDeviceProfile(source: ProfileSource): DeviceProfile {
+ const config = create(Protobuf.LocalOnly.LocalConfigSchema, {});
+ let hasConfig = false;
+ for (const key of PROFILE_RADIO_SECTIONS) {
+ const value = source.radio[key];
+ if (value) {
+ (config as Record)[key] = value;
+ hasConfig = true;
+ }
+ }
+
+ const moduleConfig = create(Protobuf.LocalOnly.LocalModuleConfigSchema, {});
+ let hasModules = false;
+ for (const key of PROFILE_MODULE_SECTIONS) {
+ const value = source.modules[key];
+ if (value) {
+ (moduleConfig as Record)[key] = value;
+ hasModules = true;
+ }
+ }
+
+ const position = source.ownPosition;
+ const fixedPosition =
+ source.radio.position?.fixedPosition &&
+ position?.latitudeI !== undefined &&
+ position.longitudeI !== undefined &&
+ (position.latitudeI !== 0 || position.longitudeI !== 0)
+ ? create(Protobuf.Mesh.PositionSchema, {
+ latitudeI: position.latitudeI,
+ longitudeI: position.longitudeI,
+ altitude: position.altitude,
+ })
+ : undefined;
+
+ const owner = source.owner;
+ return create(Protobuf.ClientOnly.DeviceProfileSchema, {
+ longName: owner?.longName || undefined,
+ shortName: owner?.shortName || undefined,
+ isUnmessagable: owner ? (owner.isUnmessagable ?? false) : undefined,
+ isLicensed: owner ? owner.isLicensed : undefined,
+ channelUrl: channelUrlFromChannels(source.channels, source.radio.lora),
+ config: hasConfig ? config : undefined,
+ moduleConfig: hasModules ? moduleConfig : undefined,
+ fixedPosition,
+ });
+}
+
+const hasPrivateKey = (profile: DeviceProfile): boolean =>
+ (profile.config?.security?.privateKey.length ?? 0) > 0;
+
+/** Which parts the profile holds; the rest can't be selected. */
+export function profileContents(profile: DeviceProfile): ProfileSelection {
+ return {
+ longName: profile.longName !== undefined,
+ shortName: profile.shortName !== undefined,
+ channelUrl: profile.channelUrl !== undefined,
+ config: profile.config !== undefined,
+ moduleConfig: profile.moduleConfig !== undefined,
+ fixedPosition: profile.fixedPosition !== undefined,
+ isUnmessagable: profile.isUnmessagable !== undefined,
+ isLicensed: profile.isLicensed !== undefined,
+ privateKey: hasPrivateKey(profile),
+ };
+}
+
+/** Everything the profile holds, except the private key. */
+export function defaultSelection(profile: DeviceProfile): ProfileSelection {
+ return { ...profileContents(profile), privateKey: false };
+}
+
+/**
+ * A copy of the profile with only the selected parts. The private key, and
+ * the public key that pairs with it, are kept only when explicitly selected.
+ */
+export function selectProfileFields(
+ profile: DeviceProfile,
+ selection: ProfileSelection,
+): DeviceProfile {
+ const copy = create(Protobuf.ClientOnly.DeviceProfileSchema, {
+ longName: selection.longName ? profile.longName : undefined,
+ shortName: selection.shortName ? profile.shortName : undefined,
+ channelUrl: selection.channelUrl ? profile.channelUrl : undefined,
+ moduleConfig: selection.moduleConfig ? profile.moduleConfig : undefined,
+ fixedPosition: selection.fixedPosition ? profile.fixedPosition : undefined,
+ isUnmessagable: selection.isUnmessagable
+ ? profile.isUnmessagable
+ : undefined,
+ isLicensed: selection.isLicensed ? profile.isLicensed : undefined,
+ ringtone: profile.ringtone,
+ cannedMessages: profile.cannedMessages,
+ });
+
+ if (selection.config && profile.config) {
+ const security = profile.config.security;
+ copy.config = create(Protobuf.LocalOnly.LocalConfigSchema, {
+ ...profile.config,
+ security:
+ security && !(selection.privateKey && hasPrivateKey(profile))
+ ? create(Protobuf.Config.Config_SecurityConfigSchema, {
+ ...security,
+ privateKey: new Uint8Array(),
+ publicKey: new Uint8Array(),
+ })
+ : security,
+ });
+ }
+ return copy;
+}
+
+export function encodeDeviceProfile(profile: DeviceProfile): Uint8Array {
+ return toBinary(Protobuf.ClientOnly.DeviceProfileSchema, profile);
+}
+
+export class ProfileDecodeError extends Error {
+ constructor(message: string, options?: { cause?: unknown }) {
+ super(message, options);
+ this.name = "ProfileDecodeError";
+ }
+}
+
+/** Parses a `.cfg` file. Fails on bytes that aren't a non-empty profile. */
+export function decodeDeviceProfile(
+ bytes: Uint8Array,
+): Result {
+ let profile: DeviceProfile;
+ try {
+ profile = fromBinary(Protobuf.ClientOnly.DeviceProfileSchema, bytes);
+ } catch (cause) {
+ return Result.err(
+ new ProfileDecodeError("Not a device profile", { cause }),
+ );
+ }
+ const contents = profileContents(profile);
+ if (!Object.values(contents).some(Boolean)) {
+ return Result.err(new ProfileDecodeError("The profile is empty"));
+ }
+ return Result.ok(profile);
+}
+
+const FALLBACK_NODE_NAME = "node";
+const MAX_NAME_LENGTH = 48;
+const PICTOGRAPHIC_RANGES: readonly [number, number][] = [
+ [0x1d000, 0x1d1ff],
+ [0x1f000, 0x1faff],
+ [0x1fb00, 0x1fbff],
+];
+
+const isNameChar = (char: string): boolean => {
+ const codePoint = char.codePointAt(0) ?? 0;
+ if (
+ PICTOGRAPHIC_RANGES.some(([lo, hi]) => codePoint >= lo && codePoint <= hi)
+ )
+ return false;
+ return /^[\p{L}\p{N}_-]$/u.test(char);
+};
+
+/**
+ * Reduces a node name to something safe in a file name, like Android: letters
+ * and digits of any script stay, each run of anything else becomes one `_`.
+ * Undefined when nothing usable is left (an all-emoji name, say).
+ */
+export function sanitizeExportNameSegment(
+ name: string | undefined,
+): string | undefined {
+ if (!name) return undefined;
+ let out = "";
+ for (const char of name) {
+ if (isNameChar(char)) out += char;
+ else if (out.length > 0 && !out.endsWith("_")) out += "_";
+ }
+ const capped = Array.from(out).slice(0, MAX_NAME_LENGTH).join("");
+ const trimmed = capped.replace(/_+$/, "");
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
+const pad = (n: number) => String(n).padStart(2, "0");
+
+/** `yyyyMMdd` in local time. */
+export const dateStamp = (date: Date): string =>
+ `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
+
+/**
+ * Android's export name: `Meshtastic___nodeConfig.cfg`, where
+ * the name is the long name, else the short name, else `node`.
+ */
+export function deviceProfileFileName(
+ longName: string | undefined,
+ shortName: string | undefined,
+ date: Date,
+): string {
+ const name =
+ sanitizeExportNameSegment(longName) ??
+ sanitizeExportNameSegment(shortName) ??
+ FALLBACK_NODE_NAME;
+ return `Meshtastic_${name}_${dateStamp(date)}_nodeConfig.cfg`;
+}
+
+/** What the import reads from the connected node. */
+export interface ImportTarget {
+ owner?: Protobuf.Mesh.User;
+ /** The node's current security config, to keep its keys. */
+ security?: Protobuf.Config.Config_SecurityConfig;
+ /** Indexes of the channels in use now. */
+ activeChannelIndexes: readonly number[];
+}
+
+export type ImportSkip =
+ | "owner"
+ | "isLicensed"
+ | "security"
+ | "trafficManagement"
+ | "ringtone"
+ | "cannedMessages";
+
+/** The edits an import stages; nothing is sent until the user saves. */
+export interface ImportPlan {
+ owner?: Protobuf.Mesh.User;
+ radio: { [K in ProfileRadioSection]?: NonNullable };
+ modules: { [K in ProfileModuleSection]?: NonNullable };
+ channels: Protobuf.Channel.Channel[];
+ fixedPosition?: Protobuf.Mesh.Position;
+ /** Parts of the file that are deliberately not applied. */
+ skipped: ImportSkip[];
+}
+
+export class ProfileImportError extends Error {
+ constructor(message: string, options?: { cause?: unknown }) {
+ super(message, options);
+ this.name = "ProfileImportError";
+ }
+}
+
+/**
+ * Turns an (already filtered) profile into editor changes, following
+ * Android's install: the channel URL replaces the channel table and its LoRa
+ * settings win over the config's; the licensed flag and traffic management
+ * are never installed. The node keeps its own key pair unless the profile
+ * carries a private key.
+ */
+export function planProfileImport(
+ profile: DeviceProfile,
+ target: ImportTarget,
+): Result {
+ const plan: ImportPlan = {
+ radio: {},
+ modules: {},
+ channels: [],
+ skipped: [],
+ };
+
+ let channelSet: Protobuf.AppOnly.ChannelSet | undefined;
+ if (profile.channelUrl) {
+ try {
+ channelSet = channelSetFromUrl(profile.channelUrl);
+ } catch (cause) {
+ return Result.err(
+ new ProfileImportError("Invalid channel URL in profile", { cause }),
+ );
+ }
+ if (
+ channelSet.settings.length === 0 ||
+ channelSet.settings.length > MAX_CHANNELS
+ ) {
+ return Result.err(
+ new ProfileImportError("Invalid channel set in profile"),
+ );
+ }
+ }
+
+ if (
+ profile.longName !== undefined ||
+ profile.shortName !== undefined ||
+ profile.isUnmessagable !== undefined
+ ) {
+ if (target.owner) {
+ plan.owner = create(Protobuf.Mesh.UserSchema, {
+ ...target.owner,
+ longName: profile.longName ?? target.owner.longName,
+ shortName: profile.shortName ?? target.owner.shortName,
+ isUnmessagable: profile.isUnmessagable ?? target.owner.isUnmessagable,
+ });
+ } else {
+ plan.skipped.push("owner");
+ }
+ }
+ if (profile.isLicensed !== undefined) plan.skipped.push("isLicensed");
+
+ const config = profile.config;
+ if (config) {
+ for (const key of PROFILE_RADIO_SECTIONS) {
+ if (key === "security") continue;
+ const value = config[key];
+ if (value) (plan.radio as Record)[key] = value;
+ }
+ const security = config.security;
+ if (security) {
+ if (security.privateKey.length > 0) {
+ plan.radio.security = security;
+ } else if (target.security) {
+ plan.radio.security = create(
+ Protobuf.Config.Config_SecurityConfigSchema,
+ {
+ ...security,
+ privateKey: target.security.privateKey,
+ publicKey: target.security.publicKey,
+ },
+ );
+ } else {
+ // Staging it now would send empty keys, and the node would make new ones.
+ plan.skipped.push("security");
+ }
+ }
+ }
+
+ const moduleConfig = profile.moduleConfig;
+ if (moduleConfig) {
+ for (const key of PROFILE_MODULE_SECTIONS) {
+ const value = moduleConfig[key];
+ if (!value) continue;
+ if (MODULE_SECTIONS_NOT_INSTALLED.includes(key)) {
+ plan.skipped.push("trafficManagement");
+ continue;
+ }
+ (plan.modules as Record)[key] = value;
+ }
+ }
+
+ if (channelSet) {
+ channelSet.settings.forEach((settings, index) => {
+ plan.channels.push(
+ create(Protobuf.Channel.ChannelSchema, {
+ index,
+ role:
+ index === 0
+ ? Protobuf.Channel.Channel_Role.PRIMARY
+ : Protobuf.Channel.Channel_Role.SECONDARY,
+ settings,
+ }),
+ );
+ });
+ for (const index of target.activeChannelIndexes) {
+ if (index >= channelSet.settings.length && index < MAX_CHANNELS) {
+ plan.channels.push(
+ create(Protobuf.Channel.ChannelSchema, {
+ index,
+ role: Protobuf.Channel.Channel_Role.DISABLED,
+ settings: create(Protobuf.Channel.ChannelSettingsSchema, {}),
+ }),
+ );
+ }
+ }
+ if (channelSet.loraConfig) {
+ plan.radio.lora = channelSet.loraConfig;
+ }
+ }
+
+ if (profile.fixedPosition) plan.fixedPosition = profile.fixedPosition;
+ if (profile.ringtone !== undefined) plan.skipped.push("ringtone");
+ if (profile.cannedMessages !== undefined) plan.skipped.push("cannedMessages");
+
+ return Result.ok(plan);
+}
diff --git a/apps/web/src/core/utils/stagedAdminKeys.ts b/apps/web/src/core/utils/stagedAdminKeys.ts
new file mode 100644
index 000000000..b43429d56
--- /dev/null
+++ b/apps/web/src/core/utils/stagedAdminKeys.ts
@@ -0,0 +1,6 @@
+/**
+ * Keys for admin messages staged in the config editor
+ * (`editor.stageAdminMessage`). One message is kept per key, so every place
+ * that stages the same message must use the same key.
+ */
+export const STAGED_FIXED_POSITION = "fixedPosition";
diff --git a/apps/web/src/pages/Settings/About.tsx b/apps/web/src/pages/Settings/About.tsx
new file mode 100644
index 000000000..b22f61b7c
--- /dev/null
+++ b/apps/web/src/pages/Settings/About.tsx
@@ -0,0 +1,97 @@
+import {
+ SettingsGroup,
+ SettingsRow,
+ SettingsSections,
+} from "@components/PageComponents/SettingsPages/SettingsSection.tsx";
+import { Subtle } from "@components/UI/Typography/Subtle.tsx";
+import { ExternalLinkIcon } from "lucide-react";
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx";
+
+const GITHUB_URL = "https://github.com/meshtastic/web";
+const WEBSITE_URL = "https://meshtastic.org";
+const DOCS_URL = "https://meshtastic.org/docs/";
+const LICENSE_URL = "https://www.gnu.org/licenses/gpl-3.0.html";
+const HARDWARE_URL = "https://meshtastic.org/docs/hardware/devices/";
+
+// Set at build time by vite.config.ts, as in the footer.
+const VERSION = String(import.meta.env.VITE_VERSION ?? "");
+const COMMIT = String(import.meta.env.VITE_COMMIT_HASH ?? "").slice(0, 7);
+
+const ExternalLink = ({
+ href,
+ children,
+}: {
+ href: string;
+ children: ReactNode;
+}) => (
+
+ {children}
+
+
+);
+
+/** Android's About screen. */
+export const About = (_props: ConfigProps) => {
+ const { t } = useTranslation("ui");
+ const version = [VERSION, COMMIT && `#${COMMIT}`].filter(Boolean).join(" ");
+
+ return (
+
+
+
+
+
+
+
+ github.com/meshtastic/web
+
+
+
+ {version || "-"}
+
+
+
+
+
+ meshtastic.org
+
+
+ meshtastic.org/docs
+
+
+
+ {t("settingsPages.about.licenseDescription")}
+
+
+
+
+
+
+ {t("settingsPages.about.devices")}
+
+
+
+
+ {t("settingsPages.about.copyright")}
+ {t("settingsPages.about.licenseNotice")}
+
+
+
+ );
+};
diff --git a/apps/web/src/pages/Settings/Administration.tsx b/apps/web/src/pages/Settings/Administration.tsx
new file mode 100644
index 000000000..e8884f95a
--- /dev/null
+++ b/apps/web/src/pages/Settings/Administration.tsx
@@ -0,0 +1,130 @@
+import { DialogWrapper } from "@components/Dialog/DialogWrapper.tsx";
+import {
+ SettingsGroup,
+ SettingsRow,
+ SettingsSections,
+} from "@components/PageComponents/SettingsPages/SettingsSection.tsx";
+import { useDeviceConnected } from "@components/PageComponents/SettingsPages/useDeviceConnected.ts";
+import { Button } from "@components/UI/Button.tsx";
+import { Subtle } from "@components/UI/Typography/Subtle.tsx";
+import { useToast } from "@core/hooks/useToast.ts";
+import { useDevice } from "@core/stores";
+import type { DialogVariant } from "@core/stores/deviceStore/types.ts";
+import {
+ FactoryIcon,
+ HardDriveUploadIcon,
+ type LucideIcon,
+ PowerIcon,
+ RefreshCwIcon,
+ TrashIcon,
+} from "lucide-react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx";
+
+type AdminAction = {
+ key:
+ | "reboot"
+ | "dfu"
+ | "shutdown"
+ | "factoryResetDevice"
+ | "factoryResetConfig"
+ | "resetNodeDb";
+ icon: LucideIcon;
+ destructive?: boolean;
+ /** Opens one of the existing device dialogs. */
+ dialog?: DialogVariant;
+};
+
+// Order follows Android. Set time is left out: the SDK sends it only during
+// the connect handshake and has no public call for it.
+const ACTIONS: AdminAction[] = [
+ { key: "reboot", icon: RefreshCwIcon, dialog: "reboot" },
+ // The web can't tell an nRF52 board apart (Android uses its hardware
+ // list), so DFU stays visible like in the command palette.
+ { key: "dfu", icon: HardDriveUploadIcon, destructive: true },
+ { key: "shutdown", icon: PowerIcon, dialog: "shutdown" },
+ {
+ key: "factoryResetDevice",
+ icon: FactoryIcon,
+ destructive: true,
+ dialog: "factoryResetDevice",
+ },
+ {
+ key: "factoryResetConfig",
+ icon: FactoryIcon,
+ destructive: true,
+ dialog: "factoryResetConfig",
+ },
+ {
+ key: "resetNodeDb",
+ icon: TrashIcon,
+ destructive: true,
+ dialog: "resetNodeDb",
+ },
+];
+
+/** Android's Administration screen: power and reset actions for the node. */
+export const Administration = (_props: ConfigProps) => {
+ const { t } = useTranslation("ui");
+ const { toast } = useToast();
+ const { setDialogOpen, connection } = useDevice();
+ const connected = useDeviceConnected();
+ const [dfuConfirmOpen, setDfuConfirmOpen] = useState(false);
+
+ const enterDfu = async () => {
+ try {
+ await connection?.enterDfuMode();
+ toast({ title: t("settingsPages.administration.dfu.sent") });
+ } catch {
+ toast({ title: t("settingsPages.administration.dfu.failed") });
+ }
+ };
+
+ return (
+
+
+
+ {!connected && (
+
+ {t("settingsPages.notConnected")}
+
+ )}
+ {ACTIONS.map(({ key, icon: Icon, destructive, dialog }) => (
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/pages/Settings/AppSettings.tsx b/apps/web/src/pages/Settings/AppSettings.tsx
new file mode 100644
index 000000000..6a2fa5fcb
--- /dev/null
+++ b/apps/web/src/pages/Settings/AppSettings.tsx
@@ -0,0 +1,41 @@
+import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
+import {
+ SettingsGroup,
+ SettingsRow,
+ SettingsSections,
+} from "@components/PageComponents/SettingsPages/SettingsSection.tsx";
+import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
+import { useTranslation } from "react-i18next";
+import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx";
+
+/**
+ * App preferences, stored in this browser. Android's Units setting is left
+ * out: the web has no shared distance/altitude formatter to apply it to.
+ */
+export const AppSettings = (_props: ConfigProps) => {
+ const { t } = useTranslation("ui");
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/pages/Settings/BackupRestore.tsx b/apps/web/src/pages/Settings/BackupRestore.tsx
new file mode 100644
index 000000000..041b7e976
--- /dev/null
+++ b/apps/web/src/pages/Settings/BackupRestore.tsx
@@ -0,0 +1,285 @@
+import { create } from "@bufbuild/protobuf";
+import { ProfileFieldsDialog } from "@components/PageComponents/SettingsPages/ProfileFieldsDialog.tsx";
+import {
+ SettingsGroup,
+ SettingsRow,
+ SettingsSections,
+} from "@components/PageComponents/SettingsPages/SettingsSection.tsx";
+import { useDeviceConnected } from "@components/PageComponents/SettingsPages/useDeviceConnected.ts";
+import { Button } from "@components/UI/Button.tsx";
+import { Subtle } from "@components/UI/Typography/Subtle.tsx";
+import { STAGED_FIXED_POSITION } from "@core/utils/stagedAdminKeys.ts";
+import { useToast } from "@core/hooks/useToast.ts";
+import {
+ buildDeviceProfile,
+ decodeDeviceProfile,
+ defaultSelection,
+ deviceProfileFileName,
+ encodeDeviceProfile,
+ type ImportPlan,
+ planProfileImport,
+ type ProfileSelection,
+ profileContents,
+ selectProfileFields,
+} from "@core/utils/profileExport.ts";
+import { type ConfigEditor, Protobuf } from "@meshtastic/sdk";
+import {
+ useActiveClient,
+ useConfigEditor,
+ useSignal,
+} from "@meshtastic/sdk-react";
+import { Result } from "better-result";
+import { DownloadIcon, UploadIcon } from "lucide-react";
+import { type ChangeEvent, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx";
+
+type DeviceProfile = Protobuf.ClientOnly.DeviceProfile;
+
+const EMPTY_BOOLEAN_SIGNAL = {
+ value: false,
+ peek: () => false,
+ subscribe: () => () => {},
+} as const;
+
+type PendingDialog =
+ | { mode: "export"; profile: DeviceProfile }
+ | { mode: "import"; profile: DeviceProfile };
+
+const downloadBytes = (bytes: Uint8Array, fileName: string) => {
+ const blob = new Blob([bytes as Uint8Array], {
+ type: "application/octet-stream",
+ });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.click();
+ // Revoke after the click has been handled.
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+};
+
+const applyPlan = (editor: ConfigEditor, plan: ImportPlan) => {
+ if (plan.owner) editor.setOwner(plan.owner);
+ for (const [key, value] of Object.entries(plan.radio)) {
+ editor.setRadioSection(
+ key as keyof ImportPlan["radio"],
+ value as NonNullable,
+ );
+ }
+ for (const [key, value] of Object.entries(plan.modules)) {
+ editor.setModuleSection(
+ key as keyof ImportPlan["modules"],
+ value as NonNullable,
+ );
+ }
+ for (const channel of plan.channels) editor.setChannel(channel);
+ if (plan.fixedPosition) {
+ // Same key as the Position page, so only one fixed position is sent;
+ // Reset drops it and a later edit or import replaces it.
+ editor.stageAdminMessage(
+ STAGED_FIXED_POSITION,
+ create(Protobuf.Admin.AdminMessageSchema, {
+ payloadVariant: { case: "setFixedPosition", value: plan.fixedPosition },
+ }),
+ );
+ }
+};
+
+/**
+ * Android's Import / Export configuration: a `.cfg` device profile, with a
+ * dialog to choose what goes in or out. Imports are staged in the editor and
+ * written only when the user presses Save.
+ */
+export const BackupRestore = (_props: ConfigProps) => {
+ const { t } = useTranslation("ui");
+ const { toast } = useToast();
+ const client = useActiveClient();
+ const editor = useConfigEditor();
+ const isDirty = useSignal(editor?.isDirty ?? EMPTY_BOOLEAN_SIGNAL);
+ const connected = useDeviceConnected();
+ const fileInputRef = useRef(null);
+ const [pending, setPending] = useState();
+
+ const myNode = () => {
+ const myNodeNum = client?.device.myNodeNum.peek();
+ return myNodeNum === undefined ? undefined : client?.nodes.byNum(myNodeNum);
+ };
+
+ const startExport = () => {
+ if (!editor) return;
+ const profile = buildDeviceProfile({
+ owner: editor.owner.peek() ?? myNode()?.user,
+ radio: editor.radio.peek(),
+ modules: editor.modules.peek(),
+ channels: editor.channels.peek().values(),
+ ownPosition: myNode()?.position,
+ });
+ setPending({ mode: "export", profile });
+ };
+
+ const finishExport = (
+ profile: DeviceProfile,
+ selection: ProfileSelection,
+ ) => {
+ try {
+ const selected = selectProfileFields(profile, selection);
+ downloadBytes(
+ encodeDeviceProfile(selected),
+ deviceProfileFileName(
+ selected.longName,
+ selected.shortName,
+ new Date(),
+ ),
+ );
+ } catch {
+ toast({ title: t("settingsPages.backup.export.failed") });
+ }
+ };
+
+ const onFileChosen = async (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+ const decoded = decodeDeviceProfile(
+ new Uint8Array(await file.arrayBuffer()),
+ );
+ if (Result.isError(decoded)) {
+ toast({
+ title: t("settingsPages.backup.import.failed"),
+ description: t("settingsPages.backup.import.invalidFile"),
+ });
+ return;
+ }
+ setPending({ mode: "import", profile: decoded.value });
+ };
+
+ const finishImport = (
+ profile: DeviceProfile,
+ selection: ProfileSelection,
+ ) => {
+ if (!editor) {
+ toast({ title: t("settingsPages.backup.import.notConnected") });
+ return;
+ }
+ // The User page loads the owner into the editor only when opened.
+ const nodeUser = myNode()?.user;
+ if (!editor.owner.peek() && nodeUser) editor.setBaselineOwner(nodeUser);
+
+ const plan = planProfileImport(selectProfileFields(profile, selection), {
+ owner: editor.owner.peek(),
+ security: editor.radio.peek().security,
+ activeChannelIndexes: [...editor.channels.peek().values()]
+ .filter((ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED)
+ .map((ch) => ch.index),
+ });
+ if (Result.isError(plan)) {
+ toast({
+ title: t("settingsPages.backup.import.failed"),
+ description: t("settingsPages.backup.import.invalidChannels"),
+ });
+ return;
+ }
+
+ applyPlan(editor, plan.value);
+ const skipped = [...new Set(plan.value.skipped)].map((key) =>
+ t(`settingsPages.backup.skipped.${key}`),
+ );
+ toast({
+ title: t("settingsPages.backup.import.staged"),
+ description: [
+ t("settingsPages.backup.import.stagedDescription"),
+ skipped.length > 0
+ ? `${t("settingsPages.backup.skipped.title")} ${skipped.join(", ")}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" "),
+ });
+ };
+
+ const available = useMemo(
+ () => (pending ? profileContents(pending.profile) : undefined),
+ [pending],
+ );
+ const initial = useMemo(
+ () => (pending ? defaultSelection(pending.profile) : undefined),
+ [pending],
+ );
+
+ return (
+
+
+
+ {!connected && (
+
+ {t("settingsPages.notConnected")}
+
+ )}
+
+
+ void onFileChosen(event)}
+ />
+
+
+ {t("settingsPages.backup.export.description")}
+ {isDirty && (
+
+ {t("settingsPages.backup.export.pendingChanges")}
+
+ )}
+ >
+ }
+ >
+
+
+
+
+
+ {pending && available && initial && (
+ {
+ if (!open) setPending(undefined);
+ }}
+ mode={pending.mode}
+ available={available}
+ initial={initial}
+ onConfirm={(selection) =>
+ pending.mode === "export"
+ ? finishExport(pending.profile, selection)
+ : finishImport(pending.profile, selection)
+ }
+ />
+ )}
+
+ );
+};
diff --git a/apps/web/src/pages/Settings/CleanNodeDb.tsx b/apps/web/src/pages/Settings/CleanNodeDb.tsx
new file mode 100644
index 000000000..3dc98f82f
--- /dev/null
+++ b/apps/web/src/pages/Settings/CleanNodeDb.tsx
@@ -0,0 +1,186 @@
+import { DialogWrapper } from "@components/Dialog/DialogWrapper.tsx";
+import {
+ SettingsGroup,
+ SettingsRow,
+ SettingsSections,
+} from "@components/PageComponents/SettingsPages/SettingsSection.tsx";
+import { useDeviceConnected } from "@components/PageComponents/SettingsPages/useDeviceConnected.ts";
+import { Button } from "@components/UI/Button.tsx";
+import { Slider } from "@components/UI/Slider.tsx";
+import { Switch } from "@components/UI/Switch.tsx";
+import { Subtle } from "@components/UI/Typography/Subtle.tsx";
+import { useToast } from "@core/hooks/useToast.ts";
+import {
+ MAX_DAYS,
+ MIN_DAYS_KNOWN,
+ minDays,
+ nodesToClean,
+} from "@core/utils/cleanNodes.ts";
+import type { Node } from "@meshtastic/sdk";
+import { useActiveClient, useSignal } from "@meshtastic/sdk-react";
+import { EraserIcon } from "lucide-react";
+import { useId, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx";
+
+const EMPTY_NODES: ReadonlyArray = [];
+const EMPTY_NODES_SIGNAL = {
+ value: EMPTY_NODES,
+ peek: () => EMPTY_NODES,
+ subscribe: () => () => {},
+} as const;
+const EMPTY_NUM_SIGNAL = {
+ value: undefined as number | undefined,
+ peek: () => undefined as number | undefined,
+ subscribe: () => () => {},
+} as const;
+
+const nodeLabel = (node: Node) =>
+ node.user?.longName ||
+ node.user?.shortName ||
+ `!${(node.num >>> 0).toString(16).padStart(8, "0")}`;
+
+/** Android's Clean node database screen. */
+export const CleanNodeDb = (_props: ConfigProps) => {
+ const { t } = useTranslation("ui");
+ const { toast } = useToast();
+ const client = useActiveClient();
+ const nodes = useSignal(client?.nodes.list ?? EMPTY_NODES_SIGNAL);
+ const myNodeNum = useSignal(client?.device.myNodeNum ?? EMPTY_NUM_SIGNAL);
+ const connected = useDeviceConnected();
+ const switchId = useId();
+ const sliderId = useId();
+
+ const [olderThanDays, setOlderThanDays] = useState(30);
+ const [onlyUnknown, setOnlyUnknown] = useState(false);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [cleaning, setCleaning] = useState(false);
+ // The cut-off is relative to when the page was opened.
+ const [nowSeconds] = useState(() => Math.floor(Date.now() / 1000));
+
+ const queued = useMemo(
+ () =>
+ nodesToClean(nodes, {
+ olderThanDays,
+ onlyUnknown,
+ nowSeconds,
+ myNodeNum,
+ }),
+ [nodes, olderThanDays, onlyUnknown, myNodeNum, nowSeconds],
+ );
+
+ const onOnlyUnknownChange = (checked: boolean) => {
+ setOnlyUnknown(checked);
+ if (!checked) setOlderThanDays((days) => Math.max(days, MIN_DAYS_KNOWN));
+ };
+
+ const cleanNow = async () => {
+ if (!client) return;
+ setCleaning(true);
+ let removed = 0;
+ let failed = 0;
+ // One at a time: each removal is an admin message to the node.
+ for (const node of queued) {
+ const result = await client.nodes.remove(node.num);
+ if (result.isOk()) removed++;
+ else failed++;
+ }
+ setCleaning(false);
+ toast({
+ title: t("settingsPages.cleanNodes.done", { count: removed }),
+ description:
+ failed > 0
+ ? t("settingsPages.cleanNodes.failed", { count: failed })
+ : undefined,
+ });
+ };
+
+ return (
+
+
+
+
+ {
+ if (days !== undefined) setOlderThanDays(days);
+ }}
+ />
+
+ {olderThanDays}
+
+
+
+
+
+
+
+
+ {queued.length === 0 ? (
+ {t("settingsPages.cleanNodes.none")}
+ ) : (
+
+ {queued.map((node) => (
+ -
+ {nodeLabel(node)}
+
+ ))}
+
+ )}
+ {!connected && (
+
+ {t("settingsPages.notConnected")}
+
+ )}
+
+
+
+
+ void cleanNow()}
+ />
+
+ );
+};
diff --git a/apps/web/src/pages/Settings/DebugPanel.tsx b/apps/web/src/pages/Settings/DebugPanel.tsx
new file mode 100644
index 000000000..793c7a27e
--- /dev/null
+++ b/apps/web/src/pages/Settings/DebugPanel.tsx
@@ -0,0 +1,154 @@
+import {
+ SettingsGroup,
+ SettingsSections,
+} from "@components/PageComponents/SettingsPages/SettingsSection.tsx";
+import { Button } from "@components/UI/Button.tsx";
+import { Input } from "@components/UI/Input.tsx";
+import { Subtle } from "@components/UI/Typography/Subtle.tsx";
+import { cn } from "@core/utils/cn.ts";
+import {
+ type DeviceLogEntry,
+ filterLogEntries,
+ formatLogEntry,
+} from "@core/utils/logRecords.ts";
+import { Protobuf } from "@meshtastic/sdk";
+import { useActiveClient } from "@meshtastic/sdk-react";
+import { useNavigate } from "@tanstack/react-router";
+import { DownloadIcon, ShieldIcon, Trash2Icon } from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx";
+
+/** Records kept in memory; older ones are dropped. */
+const MAX_ENTRIES = 2000;
+
+const levelClass = (level: Protobuf.Mesh.LogRecord_Level) => {
+ const Level = Protobuf.Mesh.LogRecord_Level;
+ if (level >= Level.ERROR) return "text-red-600 dark:text-red-400";
+ if (level === Level.WARNING) return "text-amber-600 dark:text-amber-400";
+ if (level === Level.DEBUG || level === Level.TRACE)
+ return "text-slate-500 dark:text-slate-400";
+ return "";
+};
+
+/**
+ * Android's Debug panel, for the device log records the node streams (the
+ * SDK's onLogRecord event). The SDK doesn't keep them, so the page collects
+ * them from when it's opened.
+ */
+export const DebugPanel = (_props: ConfigProps) => {
+ const { t } = useTranslation("ui");
+ const navigate = useNavigate();
+ const client = useActiveClient();
+ const [entries, setEntries] = useState([]);
+ const [query, setQuery] = useState("");
+ const nextId = useRef(0);
+
+ useEffect(() => {
+ if (!client) return;
+ return client.events.onLogRecord.subscribe((record) => {
+ const entry: DeviceLogEntry = {
+ id: nextId.current++,
+ receivedAt: Date.now(),
+ record,
+ };
+ setEntries((current) => [...current, entry].slice(-MAX_ENTRIES));
+ });
+ }, [client]);
+
+ const shown = useMemo(
+ () => filterLogEntries(entries, query),
+ [entries, query],
+ );
+
+ const exportLogs = () => {
+ const text = `${shown.map(formatLogEntry).join("\n")}\n`;
+ const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `Meshtastic_device_log_${new Date().toISOString().replace(/[:.]/g, "-")}.txt`;
+ anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+ };
+
+ return (
+
+
+
+
+
+
+
+ setQuery(event.target.value)}
+ />
+
+
+
+
+ {t("settingsPages.debug.count", {
+ shown: shown.length,
+ total: entries.length,
+ })}
+ {entries.length >= MAX_ENTRIES &&
+ ` ${t("settingsPages.debug.capped", { max: MAX_ENTRIES })}`}
+
+
+ {shown.length === 0 ? (
+
+ {entries.length === 0
+ ? t("settingsPages.debug.empty")
+ : t("settingsPages.debug.noMatch")}
+
+ ) : (
+ shown.map((entry) => (
+
+ {formatLogEntry(entry)}
+
+ ))
+ )}
+
+
+
+
+ );
+};
diff --git a/apps/web/src/pages/Settings/DeviceConfig.tsx b/apps/web/src/pages/Settings/DeviceConfig.tsx
index 0f7698340..1e6f889a9 100644
--- a/apps/web/src/pages/Settings/DeviceConfig.tsx
+++ b/apps/web/src/pages/Settings/DeviceConfig.tsx
@@ -4,30 +4,18 @@ import { Display } from "@components/PageComponents/Settings/Display.tsx";
import { Network } from "@components/PageComponents/Settings/Network/index.tsx";
import { Position } from "@components/PageComponents/Settings/Position.tsx";
import { Power } from "@components/PageComponents/Settings/Power.tsx";
-import { User } from "@components/PageComponents/Settings/User.tsx";
-import { Spinner } from "@components/UI/Spinner.tsx";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@components/UI/Tabs.tsx";
+import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts";
import type { ValidConfigType } from "@core/stores";
+import type { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
-import { type ComponentType, Suspense, useMemo } from "react";
-import type { UseFormReturn } from "react-hook-form";
+import { type ComponentType, useMemo } from "react";
import { useTranslation } from "react-i18next";
-
-interface ConfigProps {
- onFormInit: (methods: UseFormReturn) => void;
-}
-
-type TabItem = {
- case: ValidConfigType | "user";
- label: string;
- element: ComponentType;
- count?: number;
-};
+import { dirtyDeviceTabs } from "./dirtySections.ts";
+import {
+ type ConfigProps,
+ type SettingsTab,
+ SettingsTabs,
+} from "./SettingsTabs.tsx";
const EMPTY_DIRTY_RADIO_SIGNAL = {
value: [] as readonly string[],
@@ -35,101 +23,61 @@ const EMPTY_DIRTY_RADIO_SIGNAL = {
subscribe: () => () => {},
} as const;
-const EMPTY_OWNER_DIRTY_SIGNAL = {
- value: false,
- peek: () => false,
+const EMPTY_STAGED_SIGNAL = {
+ value: new Map() as ReadonlyMap,
+ peek: () => new Map() as ReadonlyMap,
subscribe: () => () => {},
} as const;
+type DeviceTab = {
+ key: ValidConfigType;
+ labelKey: string;
+ element: ComponentType;
+};
+
+/** Device configuration tabs, in the order of Android's deviceConfigRoutes. */
+export const DEVICE_TABS: DeviceTab[] = [
+ { key: "device", labelKey: "page.tabDevice", element: Device },
+ { key: "position", labelKey: "page.tabPosition", element: Position },
+ { key: "power", labelKey: "page.tabPower", element: Power },
+ { key: "network", labelKey: "page.tabNetwork", element: Network },
+ { key: "display", labelKey: "page.tabDisplay", element: Display },
+ { key: "bluetooth", labelKey: "page.tabBluetooth", element: Bluetooth },
+];
+
+/**
+ * Tabs this hardware supports: Network needs Wi-Fi or Ethernet, Bluetooth
+ * needs Bluetooth. Until metadata arrives every tab shows, as on Android.
+ */
+export function visibleDeviceTabs(
+ metadata: Protobuf.Mesh.DeviceMetadata | undefined,
+): DeviceTab[] {
+ return DEVICE_TABS.filter((tab) => {
+ if (!metadata) return true;
+ if (tab.key === "network") return metadata.hasWifi || metadata.hasEthernet;
+ if (tab.key === "bluetooth") return metadata.hasBluetooth;
+ return true;
+ });
+}
+
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
const editor = useConfigEditor();
const dirtyRadio = useSignal(
editor?.dirtyRadioSections ?? EMPTY_DIRTY_RADIO_SIGNAL,
);
- const isOwnerDirty = useSignal(
- editor?.isOwnerDirty ?? EMPTY_OWNER_DIRTY_SIGNAL,
- );
+ const staged = useSignal(editor?.stagedAdminMessages ?? EMPTY_STAGED_SIGNAL);
+ const { metadata } = useDeviceCapabilities();
const { t } = useTranslation("config");
- const tabs: TabItem[] = useMemo(
- () => [
- {
- case: "user",
- label: t("page.tabUser"),
- element: User,
- },
- {
- case: "device",
- label: t("page.tabDevice"),
- element: Device,
- },
- {
- case: "position",
- label: t("page.tabPosition"),
- element: Position,
- },
- {
- case: "power",
- label: t("page.tabPower"),
- element: Power,
- },
- {
- case: "network",
- label: t("page.tabNetwork"),
- element: Network,
- },
- {
- case: "display",
- label: t("page.tabDisplay"),
- element: Display,
- },
- {
- case: "bluetooth",
- label: t("page.tabBluetooth"),
- element: Bluetooth,
- },
- ],
- [t],
- );
- const flags = useMemo(
- () =>
- new Map(
- tabs.map((tab) => [
- tab.case,
- tab.case === "user"
- ? isOwnerDirty
- : dirtyRadio.includes(tab.case as ValidConfigType),
- ]),
- ),
- [tabs, dirtyRadio, isOwnerDirty],
- );
+ const tabs: SettingsTab[] = useMemo(() => {
+ const dirty = dirtyDeviceTabs(dirtyRadio, staged.keys());
+ return visibleDeviceTabs(metadata).map((tab) => ({
+ key: tab.key,
+ label: t(tab.labelKey),
+ element: tab.element,
+ isDirty: dirty.has(tab.key),
+ }));
+ }, [t, metadata, dirtyRadio, staged]);
- return (
-
-
- {tabs.map((tab) => (
-
- {tab.label}
- {flags.get(tab.case) && (
-
-
-
-
- )}
-
- ))}
-
- {tabs.map((tab) => (
-
- }>
-
-
-
- ))}
-
- );
+ return ;
};
diff --git a/apps/web/src/pages/Settings/ModuleConfig.tsx b/apps/web/src/pages/Settings/ModuleConfig.tsx
index 2fe81a079..13b65afbe 100644
--- a/apps/web/src/pages/Settings/ModuleConfig.tsx
+++ b/apps/web/src/pages/Settings/ModuleConfig.tsx
@@ -10,35 +10,22 @@ import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.t
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.tsx";
import { RemoteHardware } from "@components/PageComponents/ModuleConfig/RemoteHardware.tsx";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.tsx";
-import { StatusMessage } from "@components/PageComponents/ModuleConfig/StatusMessage.tsx";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.tsx";
import { Tak } from "@components/PageComponents/ModuleConfig/Tak.tsx";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx";
-import { Spinner } from "@components/UI/Spinner.tsx";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@components/UI/Tabs.tsx";
import { useDeviceCapabilities } from "@core/hooks/useDeviceCapabilities.ts";
-import type { ValidModuleConfigType } from "@core/stores";
+import { useDevice, type ValidModuleConfigType } from "@core/stores";
import { isModuleExcluded } from "@core/utils/excludedModules.ts";
+import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
-import { type ComponentType, Suspense, useMemo } from "react";
-import type { UseFormReturn } from "react-hook-form";
+import { type ComponentType, useMemo } from "react";
import { useTranslation } from "react-i18next";
-
-interface ConfigProps {
- onFormInit: (methods: UseFormReturn) => void;
-}
-
-type TabItem = {
- case: ValidModuleConfigType;
- label: string;
- element: ComponentType;
- count?: number;
-};
+import { dirtyModuleTabs } from "./dirtySections.ts";
+import {
+ type ConfigProps,
+ type SettingsTab,
+ SettingsTabs,
+} from "./SettingsTabs.tsx";
const EMPTY_DIRTY_MODULE_SIGNAL = {
value: [] as readonly string[],
@@ -46,134 +33,120 @@ const EMPTY_DIRTY_MODULE_SIGNAL = {
subscribe: () => () => {},
} as const;
+const EMPTY_STAGED_SIGNAL = {
+ value: new Map() as ReadonlyMap,
+ peek: () => new Map() as ReadonlyMap,
+ subscribe: () => () => {},
+} as const;
+
+const EMPTY_RADIO_SIGNAL = {
+ value: {} as { device?: Protobuf.Config.Config_DeviceConfig },
+ peek: () => ({}) as { device?: Protobuf.Config.Config_DeviceConfig },
+ subscribe: () => () => {},
+} as const;
+
+type ModuleTab = {
+ key: ValidModuleConfigType;
+ labelKey: string;
+ element: ComponentType;
+};
+
+/**
+ * Module configuration, in the order of Android's ModuleRoute. Status
+ * Message is not a tab: like Android, its one field lives on the User page.
+ */
+export const MODULE_TABS: ModuleTab[] = [
+ { key: "mqtt", labelKey: "page.tabMqtt", element: MQTT },
+ { key: "serial", labelKey: "page.tabSerial", element: Serial },
+ {
+ key: "externalNotification",
+ labelKey: "page.tabExternalNotification",
+ element: ExternalNotification,
+ },
+ {
+ key: "storeForward",
+ labelKey: "page.tabStoreAndForward",
+ element: StoreForward,
+ },
+ { key: "rangeTest", labelKey: "page.tabRangeTest", element: RangeTest },
+ { key: "telemetry", labelKey: "page.tabTelemetry", element: Telemetry },
+ {
+ key: "cannedMessage",
+ labelKey: "page.tabCannedMessage",
+ element: CannedMessage,
+ },
+ { key: "audio", labelKey: "page.tabAudio", element: Audio },
+ {
+ key: "remoteHardware",
+ labelKey: "page.tabRemoteHardware",
+ element: RemoteHardware,
+ },
+ {
+ key: "neighborInfo",
+ labelKey: "page.tabNeighborInfo",
+ element: NeighborInfo,
+ },
+ {
+ key: "ambientLighting",
+ labelKey: "page.tabAmbientLighting",
+ element: AmbientLighting,
+ },
+ {
+ key: "detectionSensor",
+ labelKey: "page.tabDetectionSensor",
+ element: DetectionSensor,
+ },
+ { key: "paxcounter", labelKey: "page.tabPaxcounter", element: Paxcounter },
+ { key: "tak", labelKey: "page.tabTak", element: Tak },
+ { key: "meshBeacon", labelKey: "page.tabMeshBeacon", element: MeshBeacon },
+];
+
+const TAK_ROLES: Protobuf.Config.Config_DeviceConfig_Role[] = [
+ Protobuf.Config.Config_DeviceConfig_Role.TAK,
+ Protobuf.Config.Config_DeviceConfig_Role.TAK_TRACKER,
+];
+
export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
const editor = useConfigEditor();
const dirtyModule = useSignal(
editor?.dirtyModuleSections ?? EMPTY_DIRTY_MODULE_SIGNAL,
);
- const { t } = useTranslation("moduleConfig");
- const { excludedModules } = useDeviceCapabilities();
- const allTabs: TabItem[] = useMemo(
- () => [
- {
- case: "mqtt",
- label: t("page.tabMqtt"),
- element: MQTT,
- },
- {
- case: "serial",
- label: t("page.tabSerial"),
- element: Serial,
- },
- {
- case: "externalNotification",
- label: t("page.tabExternalNotification"),
- element: ExternalNotification,
- },
- {
- case: "storeForward",
- label: t("page.tabStoreAndForward"),
- element: StoreForward,
- },
- {
- case: "rangeTest",
- label: t("page.tabRangeTest"),
- element: RangeTest,
- },
- {
- case: "telemetry",
- label: t("page.tabTelemetry"),
- element: Telemetry,
- },
- {
- case: "cannedMessage",
- label: t("page.tabCannedMessage"),
- element: CannedMessage,
- },
- {
- case: "audio",
- label: t("page.tabAudio"),
- element: Audio,
- },
- {
- case: "neighborInfo",
- label: t("page.tabNeighborInfo"),
- element: NeighborInfo,
- },
- {
- case: "ambientLighting",
- label: t("page.tabAmbientLighting"),
- element: AmbientLighting,
- },
- {
- case: "detectionSensor",
- label: t("page.tabDetectionSensor"),
- element: DetectionSensor,
- },
- {
- case: "paxcounter",
- label: t("page.tabPaxcounter"),
- element: Paxcounter,
- },
- {
- case: "remoteHardware",
- label: t("page.tabRemoteHardware"),
- element: RemoteHardware,
- },
- {
- case: "statusmessage",
- label: t("page.tabStatusMessage"),
- element: StatusMessage,
- },
- { case: "tak", label: t("page.tabTak"), element: Tak },
- {
- case: "meshBeacon",
- label: t("page.tabMeshBeacon"),
- element: MeshBeacon,
- },
- ],
- [t],
- );
- // Hide the modules this firmware build was compiled without.
- const tabs = useMemo(
- () => allTabs.filter((tab) => !isModuleExcluded(excludedModules, tab.case)),
- [allTabs, excludedModules],
+ const staged = useSignal(editor?.stagedAdminMessages ?? EMPTY_STAGED_SIGNAL);
+ const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);
+ const { getEffectiveConfig } = useDevice();
+ const dirtyTabs = useMemo(
+ () => dirtyModuleTabs(dirtyModule, staged.keys()),
+ [dirtyModule, staged],
);
+ const { t } = useTranslation("moduleConfig");
+ const { excludedModules, supportsFirmware28 } = useDeviceCapabilities();
- const flags = useMemo(
+ // Staged role first, so the TAK tab appears as soon as the role is set.
+ const role = (
+ radio.device ??
+ (getEffectiveConfig("device") as
+ | Protobuf.Config.Config_DeviceConfig
+ | undefined)
+ )?.role;
+ // Like Android, TAK needs firmware 2.8 and a TAK role.
+ const showTak =
+ supportsFirmware28 && role !== undefined && TAK_ROLES.includes(role);
+
+ // Hide the modules this firmware build was compiled without.
+ const tabs: SettingsTab[] = useMemo(
() =>
- new Map(tabs.map((tab) => [tab.case, dirtyModule.includes(tab.case)])),
- [tabs, dirtyModule],
+ MODULE_TABS.filter(
+ (tab) =>
+ !isModuleExcluded(excludedModules, tab.key) &&
+ (tab.key !== "tak" || showTak),
+ ).map((tab) => ({
+ key: tab.key,
+ label: t(tab.labelKey),
+ element: tab.element,
+ isDirty: dirtyTabs.has(tab.key),
+ })),
+ [t, excludedModules, dirtyTabs, showTak],
);
- return (
- // Keyed on the first tab so the default moves on when metadata arriving
- // later hides it.
-
-
- {tabs.map((tab) => (
-
- {tab.label}
- {flags.get(tab.case) && (
-
-
-
-
- )}
-
- ))}
-
- {tabs.map((tab) => (
-
- }>
-
-
-
- ))}
-
- );
+ return ;
};
diff --git a/apps/web/src/pages/Settings/RadioConfig.tsx b/apps/web/src/pages/Settings/RadioConfig.tsx
deleted file mode 100644
index 36a95ad79..000000000
--- a/apps/web/src/pages/Settings/RadioConfig.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import { Channels } from "@app/components/PageComponents/Channels/Channels";
-import { LoRa } from "@components/PageComponents/Settings/LoRa.tsx";
-import { Security } from "@components/PageComponents/Settings/Security/Security.tsx";
-import { Spinner } from "@components/UI/Spinner.tsx";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@components/UI/Tabs.tsx";
-import type { ValidConfigType } from "@core/stores";
-import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
-import { type ComponentType, Suspense, useMemo } from "react";
-import type { UseFormReturn } from "react-hook-form";
-import { useTranslation } from "react-i18next";
-
-interface ConfigProps {
- onFormInit: (methods: UseFormReturn) => void;
-}
-
-type TabItem = {
- case: ValidConfigType | "channels";
- label: string;
- element: ComponentType;
- count?: number;
-};
-
-const EMPTY_DIRTY_RADIO_SIGNAL = {
- value: [] as readonly string[],
- peek: () => [] as readonly string[],
- subscribe: () => () => {},
-} as const;
-
-const EMPTY_DIRTY_CHANNELS_SIGNAL = {
- value: [] as readonly number[],
- peek: () => [] as readonly number[],
- subscribe: () => () => {},
-} as const;
-
-export const RadioConfig = ({ onFormInit }: ConfigProps) => {
- const editor = useConfigEditor();
- const dirtyRadio = useSignal(
- editor?.dirtyRadioSections ?? EMPTY_DIRTY_RADIO_SIGNAL,
- );
- const dirtyChannels = useSignal(
- editor?.dirtyChannels ?? EMPTY_DIRTY_CHANNELS_SIGNAL,
- );
- const { t } = useTranslation("config");
- const tabs: TabItem[] = useMemo(
- () => [
- {
- case: "lora",
- label: t("page.tabLora"),
- element: LoRa,
- },
- {
- case: "channels",
- label: t("page.tabChannels"),
- element: Channels,
- },
- {
- case: "security",
- label: t("page.tabSecurity"),
- element: Security,
- },
- ],
- [t],
- );
-
- const flags = useMemo(
- () =>
- new Map(
- tabs.map((tab) => [
- tab.case,
- tab.case === "channels"
- ? dirtyChannels.length > 0
- : dirtyRadio.includes(tab.case),
- ]),
- ),
- [tabs, dirtyRadio, dirtyChannels],
- );
-
- return (
-
-
- {tabs.map((tab) => (
-
- {tab.label}
- {flags.get(tab.case) && (
-
-
-
-
- )}
-
- ))}
-
- {tabs.map((tab) => (
-
- }>
-
-
-
- ))}
-
- );
-};
diff --git a/apps/web/src/pages/Settings/SettingsTabs.tsx b/apps/web/src/pages/Settings/SettingsTabs.tsx
new file mode 100644
index 000000000..ca16e4e1c
--- /dev/null
+++ b/apps/web/src/pages/Settings/SettingsTabs.tsx
@@ -0,0 +1,80 @@
+import { Spinner } from "@components/UI/Spinner.tsx";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@components/UI/Tabs.tsx";
+import { useNavigate, useSearch } from "@tanstack/react-router";
+import { type ComponentType, type ReactNode, Suspense } from "react";
+import type { UseFormReturn } from "react-hook-form";
+
+export interface ConfigProps {
+ onFormInit: (methods: UseFormReturn) => void;
+}
+
+export interface SettingsTab {
+ /** Stable id, used in the `?tab=` search param. */
+ key: string;
+ label: string;
+ element: ComponentType;
+ isDirty?: boolean;
+}
+
+interface SettingsTabsProps extends ConfigProps {
+ tabs: SettingsTab[];
+}
+
+export const DirtyDot = () => (
+
+
+
+
+);
+
+/** Tabs whose selection lives in the URL (`?tab=`), so it can be linked. */
+export const SettingsTabs = ({ tabs, onFormInit }: SettingsTabsProps) => {
+ const navigate = useNavigate();
+ const search = useSearch({ strict: false }) as { tab?: string };
+ const active = tabs.find((tab) => tab.key === search.tab) ?? tabs[0];
+
+ if (!active) return null;
+
+ return (
+
+ navigate({ to: ".", search: { tab }, replace: true })
+ }
+ >
+
+ {tabs.map((tab) => (
+
+ {tab.label}
+ {tab.isDirty && }
+
+ ))}
+
+ {tabs.map((tab) => (
+
+ }>
+
+
+
+ ))}
+
+ );
+};
+
+/** Bordered card matching a tab panel, for pages that have no tabs. */
+export const SettingsCard = ({ children }: { children: ReactNode }) => (
+
+ }>
+ {children}
+
+
+);
diff --git a/apps/web/src/pages/Settings/dirtySections.test.ts b/apps/web/src/pages/Settings/dirtySections.test.ts
new file mode 100644
index 000000000..c61df01db
--- /dev/null
+++ b/apps/web/src/pages/Settings/dirtySections.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { dirtyDeviceTabs, dirtyModuleTabs } from "./dirtySections.ts";
+
+describe("dirtyModuleTabs", () => {
+ it("counts staged canned messages and ringtone under their modules", () => {
+ const dirty = dirtyModuleTabs(["mqtt"], ["cannedMessages", "ringtone"]);
+ expect([...dirty].sort()).toEqual([
+ "cannedMessage",
+ "externalNotification",
+ "mqtt",
+ ]);
+ });
+
+ it("leaves the status message to the User page", () => {
+ expect(dirtyModuleTabs(["statusmessage"], []).size).toBe(0);
+ });
+
+ it("counts a module once when both its section and a message are staged", () => {
+ expect(dirtyModuleTabs(["cannedMessage"], ["cannedMessages"]).size).toBe(1);
+ });
+});
+
+describe("dirtyDeviceTabs", () => {
+ it("keeps only device configuration sections", () => {
+ const dirty = dirtyDeviceTabs(["lora", "power", "security"], []);
+ expect([...dirty]).toEqual(["power"]);
+ });
+
+ it("counts a staged fixed position under Position", () => {
+ expect([...dirtyDeviceTabs([], ["fixedPosition"])]).toEqual(["position"]);
+ });
+});
diff --git a/apps/web/src/pages/Settings/dirtySections.ts b/apps/web/src/pages/Settings/dirtySections.ts
new file mode 100644
index 000000000..e446b0c07
--- /dev/null
+++ b/apps/web/src/pages/Settings/dirtySections.ts
@@ -0,0 +1,62 @@
+import { STAGED_FIXED_POSITION } from "@core/utils/stagedAdminKeys.ts";
+
+/**
+ * Admin messages staged in the config editor, by the settings tab they
+ * belong to. They are sent with Save like a config section, so they count
+ * as that tab's unsaved changes.
+ */
+const STAGED_KEY_MODULE: Record = {
+ cannedMessages: "cannedMessage",
+ ringtone: "externalNotification",
+};
+const STAGED_KEY_DEVICE: Record = {
+ [STAGED_FIXED_POSITION]: "position",
+};
+
+export const DEVICE_SECTIONS = [
+ "device",
+ "position",
+ "power",
+ "network",
+ "display",
+ "bluetooth",
+];
+
+function withStaged(
+ dirty: Set,
+ stagedKeys: Iterable,
+ mapping: Record,
+): Set {
+ for (const key of stagedKeys) {
+ const tab = mapping[key];
+ if (tab) dirty.add(tab);
+ }
+ return dirty;
+}
+
+/**
+ * Module tabs with unsaved changes. The status message is excluded because
+ * it is edited on the User page.
+ */
+export function dirtyModuleTabs(
+ dirtyModuleSections: readonly string[],
+ stagedKeys: Iterable,
+): Set {
+ return withStaged(
+ new Set(dirtyModuleSections.filter((key) => key !== "statusmessage")),
+ stagedKeys,
+ STAGED_KEY_MODULE,
+ );
+}
+
+/** Device configuration tabs with unsaved changes. */
+export function dirtyDeviceTabs(
+ dirtyRadioSections: readonly string[],
+ stagedKeys: Iterable,
+): Set {
+ return withStaged(
+ new Set(dirtyRadioSections.filter((key) => DEVICE_SECTIONS.includes(key))),
+ stagedKeys,
+ STAGED_KEY_DEVICE,
+ );
+}
diff --git a/apps/web/src/pages/Settings/index.tsx b/apps/web/src/pages/Settings/index.tsx
index 9a2081e09..a66be6552 100644
--- a/apps/web/src/pages/Settings/index.tsx
+++ b/apps/web/src/pages/Settings/index.tsx
@@ -1,4 +1,7 @@
-import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
+import { Channels } from "@components/PageComponents/Channels/Channels.tsx";
+import { LoRa } from "@components/PageComponents/Settings/LoRa.tsx";
+import { Security } from "@components/PageComponents/Settings/Security/Security.tsx";
+import { User } from "@components/PageComponents/Settings/User.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
@@ -6,21 +9,47 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { cn } from "@core/utils/cn.ts";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
+import { About } from "@pages/Settings/About.tsx";
+import { Administration } from "@pages/Settings/Administration.tsx";
+import { AppSettings } from "@pages/Settings/AppSettings.tsx";
+import { BackupRestore } from "@pages/Settings/BackupRestore.tsx";
+import { CleanNodeDb } from "@pages/Settings/CleanNodeDb.tsx";
+import { DebugPanel } from "@pages/Settings/DebugPanel.tsx";
import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import {
+ ArchiveRestoreIcon,
+ BookOpenIcon,
+ BugIcon,
+ EraserIcon,
+ HardDriveUploadIcon,
+ InfoIcon,
LayersIcon,
+ ListIcon,
+ type LucideIcon,
+ PaletteIcon,
RadioTowerIcon,
RefreshCwIcon,
RouterIcon,
SaveIcon,
SaveOff,
+ ShieldAlertIcon,
+ ShieldIcon,
+ UserIcon,
} from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ type ComponentType,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import { RadioConfig } from "./RadioConfig.tsx";
+import { dirtyDeviceTabs, dirtyModuleTabs } from "./dirtySections.ts";
+import { type ConfigProps, SettingsCard } from "./SettingsTabs.tsx";
const EMPTY_DIRTY_STRING_SIGNAL = {
value: [] as readonly string[],
@@ -32,16 +61,55 @@ const EMPTY_DIRTY_NUMBER_SIGNAL = {
peek: () => [] as readonly number[],
subscribe: () => () => {},
} as const;
+const EMPTY_STAGED_SIGNAL = {
+ value: new Map() as ReadonlyMap,
+ peek: () => new Map() as ReadonlyMap,
+ subscribe: () => () => {},
+} as const;
+const EMPTY_BOOLEAN_SIGNAL = {
+ value: false,
+ peek: () => false,
+ subscribe: () => () => {},
+} as const;
+
+const FIRMWARE_FLASHER_URL = "https://flasher.meshtastic.org";
+const DOCS_URL = "https://meshtastic.org/docs/";
+
+const carded =
+ (Form: ComponentType): ComponentType =>
+ ({ onFormInit }) => (
+
+
+
+ );
+
+const UserPage = carded(User);
+const LoRaPage = carded(LoRa);
+const ChannelsPage = carded(Channels);
+const SecurityPage = carded(Security);
+
+type SettingsSection = {
+ /** Path segment under /settings. */
+ key: string;
+ label: string;
+ icon: LucideIcon;
+ /** Opens in a new tab instead of rendering a page. */
+ href?: string;
+ component?: ComponentType;
+ /** Shows Save / Reset; true for pages that edit device config. */
+ edits?: boolean;
+ changeCount?: number;
+};
+
+type SettingsGroup = {
+ key: string;
+ label: string;
+ sections: SettingsSection[];
+};
const ConfigPage = () => {
const editor = useConfigEditor();
- const editorIsDirty = useSignal(
- editor?.isDirty ?? {
- value: false,
- peek: () => false,
- subscribe: () => () => {},
- },
- );
+ const editorIsDirty = useSignal(editor?.isDirty ?? EMPTY_BOOLEAN_SIGNAL);
const dirtyRadio = useSignal(
editor?.dirtyRadioSections ?? EMPTY_DIRTY_STRING_SIGNAL,
);
@@ -51,6 +119,8 @@ const ConfigPage = () => {
const dirtyChannels = useSignal(
editor?.dirtyChannels ?? EMPTY_DIRTY_NUMBER_SIGNAL,
);
+ const isOwnerDirty = useSignal(editor?.isOwnerDirty ?? EMPTY_BOOLEAN_SIGNAL);
+ const staged = useSignal(editor?.stagedAdminMessages ?? EMPTY_STAGED_SIGNAL);
const [isSaving, setIsSaving] = useState(false);
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
@@ -58,54 +128,159 @@ const ConfigPage = () => {
const [formMethods, setFormMethods] = useState(null);
const { toast } = useToast();
const navigate = useNavigate();
- const routerState = useRouterState();
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
const { t } = useTranslation("config");
- const configChangeCount = dirtyRadio.length;
- const moduleConfigChangeCount = dirtyModule.length;
- const channelChangeCount = dirtyChannels.length;
-
- const sections = useMemo(
- () => [
+ // Grouped like Android's Settings screen.
+ const groups: SettingsGroup[] = useMemo(() => {
+ const radioCount = (key: string) => (dirtyRadio.includes(key) ? 1 : 0);
+ // The status message is a module field that lives on the User page.
+ const statusDirty = dirtyModule.includes("statusmessage") ? 1 : 0;
+ return [
{
- key: "radio",
- route: radioRoute,
- label: t("navigation.radioConfig"),
- icon: RadioTowerIcon,
- changeCount: configChangeCount,
- component: RadioConfig,
+ key: "configuration",
+ label: t("sidebar.configuration"),
+ sections: [
+ {
+ key: "user",
+ label: t("navigation.user"),
+ icon: UserIcon,
+ component: UserPage,
+ edits: true,
+ changeCount: (isOwnerDirty ? 1 : 0) + statusDirty,
+ },
+ {
+ key: "lora",
+ label: t("navigation.lora"),
+ icon: RadioTowerIcon,
+ component: LoRaPage,
+ edits: true,
+ changeCount: radioCount("lora"),
+ },
+ {
+ key: "channels",
+ label: t("navigation.channels"),
+ icon: ListIcon,
+ component: ChannelsPage,
+ edits: true,
+ changeCount: dirtyChannels.length,
+ },
+ {
+ key: "security",
+ label: t("navigation.security"),
+ icon: ShieldIcon,
+ component: SecurityPage,
+ edits: true,
+ changeCount: radioCount("security"),
+ },
+ {
+ key: "device",
+ label: t("navigation.deviceConfiguration"),
+ icon: RouterIcon,
+ component: DeviceConfig,
+ edits: true,
+ changeCount: dirtyDeviceTabs(dirtyRadio, staged.keys()).size,
+ },
+ {
+ key: "module",
+ label: t("navigation.moduleConfiguration"),
+ icon: LayersIcon,
+ component: ModuleConfig,
+ edits: true,
+ changeCount: dirtyModuleTabs(dirtyModule, staged.keys()).size,
+ },
+ {
+ key: "administration",
+ label: t("navigation.administration"),
+ icon: ShieldAlertIcon,
+ component: Administration,
+ },
+ ],
},
{
- key: "device",
- route: deviceRoute,
- label: t("navigation.deviceConfig"),
- icon: RouterIcon,
- changeCount: moduleConfigChangeCount,
- component: DeviceConfig,
+ key: "backupRestore",
+ label: t("sidebar.backupRestore"),
+ sections: [
+ {
+ key: "backup",
+ label: t("navigation.deviceProfile"),
+ icon: ArchiveRestoreIcon,
+ component: BackupRestore,
+ },
+ ],
},
{
- key: "module",
- route: moduleRoute,
- label: t("navigation.moduleConfig"),
- icon: LayersIcon,
- changeCount: channelChangeCount,
- component: ModuleConfig,
+ key: "advanced",
+ label: t("sidebar.advanced"),
+ sections: [
+ {
+ key: "firmware",
+ label: t("navigation.firmwareUpdate"),
+ icon: HardDriveUploadIcon,
+ href: FIRMWARE_FLASHER_URL,
+ },
+ {
+ key: "clean-nodes",
+ label: t("navigation.cleanNodeDb"),
+ icon: EraserIcon,
+ component: CleanNodeDb,
+ },
+ {
+ key: "debug",
+ label: t("navigation.debugPanel"),
+ icon: BugIcon,
+ component: DebugPanel,
+ },
+ ],
},
- ],
- [t, configChangeCount, moduleConfigChangeCount, channelChangeCount],
- );
+ {
+ key: "app",
+ label: t("sidebar.app"),
+ sections: [
+ {
+ key: "app",
+ label: t("navigation.appSettings"),
+ icon: PaletteIcon,
+ component: AppSettings,
+ },
+ ],
+ },
+ {
+ key: "information",
+ label: t("sidebar.information"),
+ sections: [
+ {
+ key: "about",
+ label: t("navigation.about"),
+ icon: InfoIcon,
+ component: About,
+ },
+ {
+ key: "help",
+ label: t("navigation.help"),
+ icon: BookOpenIcon,
+ href: DOCS_URL,
+ },
+ ],
+ },
+ ];
+ }, [t, dirtyRadio, dirtyModule, dirtyChannels, isOwnerDirty, staged]);
+ const sections = useMemo(
+ () => groups.flatMap((group) => group.sections),
+ [groups],
+ );
+ const segment = pathname.split("/")[2] ?? "";
const activeSection =
- sections.find((section) =>
- routerState.location.pathname.includes(`/settings/${section.key}`),
- ) ?? sections[0];
+ sections.find((section) => section.component && section.key === segment) ??
+ sections[0];
const onFormInit = useCallback(
(methods: UseFormReturn) => {
setFormMethods(methods as UseFormReturn);
setRhfState({
- // Assume defailt on init, changes will be caught by subscription
+ // Assume default on init, changes will be caught by subscription
isDirty: false,
isValid: true,
});
@@ -169,25 +344,42 @@ const ConfigPage = () => {
editor?.reset();
}, [formMethods, editor]);
+ const openSection = useCallback(
+ (section: SettingsSection) => {
+ if (section.href) {
+ window.open(section.href, "_blank", "noopener,noreferrer");
+ return;
+ }
+ navigate({ to: `/settings/${section.key}` });
+ },
+ [navigate],
+ );
+
const leftSidebar = useMemo(
() => (
-
- {sections.map((section) => (
- navigate({ to: section.route.to })}
- Icon={section.icon}
- isDirty={section.changeCount > 0}
- count={section.changeCount}
- />
- ))}
-
+ {groups.map((group) => (
+
+ {group.sections.map((section) => (
+ openSection(section)}
+ Icon={section.icon}
+ isDirty={(section.changeCount ?? 0) > 0}
+ count={section.changeCount}
+ />
+ ))}
+
+ ))}
),
- [sections, activeSection?.key, navigate, t],
+ [groups, activeSection?.key, openSection],
);
const hasDrafts = editorIsDirty;
@@ -256,7 +448,8 @@ const ConfigPage = () => {
contentClassName="overflow-auto"
leftBar={leftSidebar}
label={activeSection?.label ?? ""}
- actions={actions}
+ // Pending edits stay visible from any page so they aren't forgotten.
+ actions={activeSection?.edits || hasPending ? actions : []}
>
{ActiveComponent && }
diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx
index 565f83f42..927aa1dae 100644
--- a/apps/web/src/routes.tsx
+++ b/apps/web/src/routes.tsx
@@ -112,30 +112,47 @@ export const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/settings",
component: ConfigPage,
- // beforeLoad: () => {
- // throw redirect({
- // to: "/settings/radio",
- // replace: true,
- // });
- // },
});
-export const radioRoute = createRoute({
- getParentRoute: () => settingsRoute,
- path: "radio",
- component: ConfigPage,
-});
-
-export const deviceRoute = createRoute({
- getParentRoute: () => settingsRoute,
- path: "device",
- component: ConfigPage,
+const settingsTabSearchSchema = z.object({
+ tab: z.string().optional(),
});
-export const moduleRoute = createRoute({
+const settingsPage = (path: string) =>
+ createRoute({
+ getParentRoute: () => settingsRoute,
+ path,
+ component: ConfigPage,
+ });
+
+const settingsTabbedPage = (path: string) =>
+ createRoute({
+ getParentRoute: () => settingsRoute,
+ path,
+ component: ConfigPage,
+ validateSearch: (search) => settingsTabSearchSchema.parse(search),
+ });
+
+export const userRoute = settingsPage("user");
+export const loraRoute = settingsPage("lora");
+export const channelsRoute = settingsPage("channels");
+export const securityRoute = settingsPage("security");
+export const deviceRoute = settingsTabbedPage("device");
+export const moduleRoute = settingsTabbedPage("module");
+export const administrationRoute = settingsPage("administration");
+export const backupRestoreRoute = settingsPage("backup");
+export const cleanNodeDbRoute = settingsPage("clean-nodes");
+export const debugRoute = settingsPage("debug");
+export const appSettingsRoute = settingsPage("app");
+export const aboutRoute = settingsPage("about");
+
+// Radio Config used to group LoRa, Channels and Security.
+const legacyRadioRoute = createRoute({
getParentRoute: () => settingsRoute,
- path: "module",
- component: ConfigPage,
+ path: "radio",
+ beforeLoad: () => {
+ throw redirect({ to: "/settings/lora", replace: true });
+ },
});
const nodesRoute = createRoute({
@@ -162,7 +179,21 @@ export const routeTree = rootRoute.addChildren([
messagesWithParamsRoute,
mapRoute,
mapWithParamsRoute,
- settingsRoute.addChildren([radioRoute, deviceRoute, moduleRoute]),
+ settingsRoute.addChildren([
+ userRoute,
+ loraRoute,
+ channelsRoute,
+ securityRoute,
+ deviceRoute,
+ moduleRoute,
+ administrationRoute,
+ backupRestoreRoute,
+ cleanNodeDbRoute,
+ debugRoute,
+ appSettingsRoute,
+ aboutRoute,
+ legacyRadioRoute,
+ ]),
nodesRoute,
dialogWithParamsRoute,
connectionsRoute,
diff --git a/apps/web/src/validation/channel.test.ts b/apps/web/src/validation/channel.test.ts
index 3b99c0ea9..941a64db5 100644
--- a/apps/web/src/validation/channel.test.ts
+++ b/apps/web/src/validation/channel.test.ts
@@ -2,8 +2,6 @@ import { fromByteArray } from "base64-js";
import { describe, expect, it } from "vitest";
import { makeChannelSchema } from "./channel.ts";
-const mockRole = 0;
-
function makeBase64OfLength(len: number): string {
return fromByteArray(new Uint8Array(len));
}
@@ -12,146 +10,76 @@ describe("makeChannelSchema", () => {
const allowedBytes = 16;
const schema = makeChannelSchema(allowedBytes);
- const validBase64 = makeBase64OfLength(allowedBytes);
-
- const validSettings = {
- channelNum: 3,
- psk: validBase64,
+ const valid = {
name: "TestName",
- id: 3,
+ psk: makeBase64OfLength(allowedBytes),
uplinkEnabled: true,
downlinkEnabled: false,
- moduleSettings: { positionPrecision: 10 },
+ positionPrecision: 10,
};
- it("accepts valid channel object", () => {
- const result = schema.safeParse({
- index: 0,
- settings: validSettings,
- role: mockRole,
- });
- expect(result.success).toBe(true);
- });
+ const issuePaths = (input: unknown) => {
+ const result = schema.safeParse(input);
+ return result.success ? [] : result.error.issues.map((i) => i.path[0]);
+ };
- it("keeps use_aead through validation", () => {
- const on = schema.safeParse({
- index: 0,
- settings: { ...validSettings, useAead: true },
- role: mockRole,
- });
- expect(on.success && on.data.settings.useAead).toBe(true);
+ it("accepts the dialog's fields", () => {
+ expect(schema.safeParse(valid).success).toBe(true);
+ });
- const absent = schema.safeParse({
- index: 0,
- settings: validSettings,
- role: mockRole,
- });
- expect(absent.success && absent.data.settings.useAead).toBe(false);
+ it("does not require fields the dialog does not render", () => {
+ // role, id, channelNum and useAead are copied from the existing channel.
+ const result = schema.safeParse(valid);
+ expect(result.success && Object.keys(result.data).sort()).toEqual([
+ "downlinkEnabled",
+ "name",
+ "positionPrecision",
+ "psk",
+ "uplinkEnabled",
+ ]);
});
it("rejects invalid base64 psk", () => {
- const result = schema.safeParse({
- index: 0,
- settings: { ...validSettings, psk: "not_base64!" },
- role: mockRole,
- });
- expect(result.success).toBe(false);
- if (!result.success) {
- expect(
- result.error.issues.some(
- (i) => i.path.includes("settings") && i.path.includes("psk"),
- ),
- ).toBe(true);
- }
+ expect(issuePaths({ ...valid, psk: "not_base64!" })).toContain("psk");
});
- it("rejects psk of wrong length", () => {
- const wrongLength = makeBase64OfLength(8);
- const result = schema.safeParse({
- index: 0,
- settings: { ...validSettings, psk: wrongLength },
- role: mockRole,
- });
- expect(result.success).toBe(false);
- if (!result.success) {
- expect(
- result.error.issues.some(
- (i) => i.path.includes("settings") && i.path.includes("psk"),
- ),
- ).toBe(true);
- }
+ it("rejects psk of the wrong length", () => {
+ expect(issuePaths({ ...valid, psk: makeBase64OfLength(8) })).toContain(
+ "psk",
+ );
});
- it("rejects name longer than 12 bytes", () => {
- const longName = "a".repeat(13);
- const result = schema.safeParse({
- index: 0,
- settings: { ...validSettings, name: longName },
- role: mockRole,
- });
- expect(result.success).toBe(false);
- if (!result.success) {
- expect(
- result.error.issues.some(
- (i) => i.path.includes("settings") && i.path.includes("name"),
- ),
- ).toBe(true);
- }
+ it("accepts an empty psk when 0 bytes are allowed", () => {
+ expect(makeChannelSchema(0).safeParse({ ...valid, psk: "" }).success).toBe(
+ true,
+ );
});
- it("rejects channelNum out of range", () => {
- const result = schema.safeParse({
- index: 0,
- settings: { ...validSettings, channelNum: 10 },
- role: mockRole,
- });
- expect(result.success).toBe(false);
- if (!result.success) {
- expect(
- result.error.issues.some(
- (i) => i.path.includes("settings") && i.path.includes("channelNum"),
- ),
- ).toBe(true);
- }
+ it("accepts a name of 11 bytes and rejects 12", () => {
+ expect(schema.safeParse({ ...valid, name: "a".repeat(11) }).success).toBe(
+ true,
+ );
+ expect(issuePaths({ ...valid, name: "a".repeat(12) })).toContain("name");
});
- it("rejects missing required fields", () => {
- const result = schema.safeParse({
- index: 0,
- settings: {},
- role: mockRole,
- });
- expect(result.success).toBe(false);
- if (!result.success) {
- expect(result.error.issues.length).toBeGreaterThan(0);
- }
+ it("counts bytes, not characters, in the name", () => {
+ // "é" is 2 bytes in UTF-8: 6 of them are 12 bytes.
+ expect(issuePaths({ ...valid, name: "é".repeat(6) })).toContain("name");
});
- it("accepts moduleSettings.positionPrecision as 0, 10-19, or 32", () => {
+ it("accepts positionPrecision 0, 10-19 or 32", () => {
for (const val of [0, 10, 15, 19, 32]) {
- const result = schema.safeParse({
- index: 0,
- settings: {
- ...validSettings,
- moduleSettings: { positionPrecision: val },
- },
- role: mockRole,
- });
- expect(result.success).toBe(true);
+ expect(
+ schema.safeParse({ ...valid, positionPrecision: val }).success,
+ ).toBe(true);
}
});
- it("rejects moduleSettings.positionPrecision out of range", () => {
+ it("rejects positionPrecision out of range", () => {
for (const val of [9, 20, 31, 33]) {
- const result = schema.safeParse({
- index: 0,
- settings: {
- ...validSettings,
- moduleSettings: { positionPrecision: val },
- },
- role: mockRole,
- });
- expect(result.success).toBe(false);
+ expect(
+ schema.safeParse({ ...valid, positionPrecision: val }).success,
+ ).toBe(false);
}
});
});
diff --git a/apps/web/src/validation/channel.ts b/apps/web/src/validation/channel.ts
index 1f96f1424..dde3dce22 100644
--- a/apps/web/src/validation/channel.ts
+++ b/apps/web/src/validation/channel.ts
@@ -1,43 +1,54 @@
import { validateMaxByteLength } from "@core/utils/string.ts";
-import { Protobuf } from "@meshtastic/sdk";
import { z } from "zod/v4";
import { makePskHelpers } from "./pskSchema.ts";
-const RoleEnum = z.enum(Protobuf.Channel.Channel_Role);
+/**
+ * Longest channel name in UTF-8 bytes. The protobuf allows 12 bytes, and one
+ * of those is the string terminator, so 11 are usable (as on Android).
+ */
+export const CHANNEL_NAME_MAX_BYTES = 11;
-const moduleSettingsSchema = z.object({
- positionPrecision: z.union([
- z.literal(0),
- z.coerce.number().int().min(10).max(19),
- z.literal(32),
- ]),
-});
+export const POSITION_PRECISION_OFF = 0;
+export const POSITION_PRECISION_PRECISE = 32;
+export const POSITION_PRECISION_MIN = 10;
+export const POSITION_PRECISION_MAX = 19;
+const positionPrecisionSchema = z.union([
+ z.literal(POSITION_PRECISION_OFF),
+ z.coerce
+ .number()
+ .int()
+ .min(POSITION_PRECISION_MIN)
+ .max(POSITION_PRECISION_MAX),
+ z.literal(POSITION_PRECISION_PRECISE),
+]);
+
+/**
+ * The fields the edit channel dialog renders. Everything else on the channel
+ * (role, id, useAead, moduleSettings.isMuted, ...) is copied from the existing
+ * channel when the edit is staged, so it is not validated here.
+ *
+ * @param allowedBytes the PSK length in bytes the user picked (0, 1, 16 or 32)
+ */
export function makeChannelSchema(allowedBytes: number) {
const { stringSchema } = makePskHelpers([allowedBytes]);
- const ChannelSettingsSchema = z.object({
- channelNum: z.coerce.number().int().min(0).max(7),
+ return z.object({
+ name: z
+ .string()
+ .refine(
+ (s) => validateMaxByteLength(s.trim(), CHANNEL_NAME_MAX_BYTES).isValid,
+ {
+ message: "formValidation.tooBig.bytes",
+ params: { maximum: CHANNEL_NAME_MAX_BYTES },
+ },
+ ),
psk: stringSchema(false),
- name: z.string().refine((s) => validateMaxByteLength(s, 12).isValid, {
- message: "formValidation.tooBig.bytes",
- params: { maximum: 12 },
- }),
- id: z.coerce.number().int(),
uplinkEnabled: z.boolean(),
downlinkEnabled: z.boolean(),
- // Experimental AES-CCM (firmware 2.8). Kept in the schema so saving the
- // form does not drop a value set elsewhere.
- useAead: z.boolean().default(false),
- moduleSettings: moduleSettingsSchema,
- });
-
- return z.object({
- index: z.coerce.number(),
- settings: ChannelSettingsSchema,
- role: RoleEnum,
+ positionPrecision: positionPrecisionSchema,
});
}
-const ChannelValidationSchema = makeChannelSchema(0); // generate a schema that doesn't validate PSK length, just structure, for type purposes
+const ChannelValidationSchema = makeChannelSchema(0); // structure only, for the type
export type ChannelValidation = z.infer;
diff --git a/apps/web/src/validation/config/bluetooth.test.ts b/apps/web/src/validation/config/bluetooth.test.ts
new file mode 100644
index 000000000..fdcba8551
--- /dev/null
+++ b/apps/web/src/validation/config/bluetooth.test.ts
@@ -0,0 +1,40 @@
+import { Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import { BluetoothValidationSchema } from "./bluetooth.ts";
+
+const { FIXED_PIN, RANDOM_PIN, NO_PIN } =
+ Protobuf.Config.Config_BluetoothConfig_PairingMode;
+
+describe("BluetoothValidationSchema", () => {
+ it.each([RANDOM_PIN, NO_PIN])("accepts PIN 0 in mode %i", (mode) => {
+ expect(
+ BluetoothValidationSchema.safeParse({ enabled: true, mode, fixedPin: 0 })
+ .success,
+ ).toBe(true);
+ });
+
+ it("accepts a six-digit fixed PIN", () => {
+ expect(
+ BluetoothValidationSchema.safeParse({
+ enabled: true,
+ mode: FIXED_PIN,
+ fixedPin: 123456,
+ }).success,
+ ).toBe(true);
+ });
+
+ it("rejects a short fixed PIN on the PIN field", () => {
+ const result = BluetoothValidationSchema.safeParse({
+ enabled: true,
+ mode: FIXED_PIN,
+ fixedPin: 0,
+ });
+ expect(result.success).toBe(false);
+ expect(result.error?.issues[0]).toMatchObject({
+ code: "too_small",
+ origin: "number",
+ minimum: 100000,
+ path: ["fixedPin"],
+ });
+ });
+});
diff --git a/apps/web/src/validation/config/bluetooth.ts b/apps/web/src/validation/config/bluetooth.ts
index 7ae02b236..5ce2b5eff 100644
--- a/apps/web/src/validation/config/bluetooth.ts
+++ b/apps/web/src/validation/config/bluetooth.ts
@@ -5,10 +5,30 @@ const PairingModeEnum = z.enum(
Protobuf.Config.Config_BluetoothConfig_PairingMode,
);
-export const BluetoothValidationSchema = z.object({
- enabled: z.boolean(),
- mode: PairingModeEnum,
- fixedPin: z.coerce.number().int().min(100000).max(999999),
-});
+const MIN_FIXED_PIN = 100000;
+
+export const BluetoothValidationSchema = z
+ .object({
+ enabled: z.boolean(),
+ mode: PairingModeEnum,
+ fixedPin: z.coerce.number().int().min(0).max(999999),
+ })
+ .superRefine((config, ctx) => {
+ // Radios that never used a fixed PIN report 0.
+ if (
+ config.mode ===
+ Protobuf.Config.Config_BluetoothConfig_PairingMode.FIXED_PIN &&
+ config.fixedPin < MIN_FIXED_PIN
+ ) {
+ ctx.addIssue({
+ code: "too_small",
+ origin: "number",
+ minimum: MIN_FIXED_PIN,
+ inclusive: true,
+ input: config.fixedPin,
+ path: ["fixedPin"],
+ });
+ }
+ });
export type BluetoothValidation = z.infer;
diff --git a/apps/web/src/validation/config/device.test.ts b/apps/web/src/validation/config/device.test.ts
new file mode 100644
index 000000000..c1b0bf6e6
--- /dev/null
+++ b/apps/web/src/validation/config/device.test.ts
@@ -0,0 +1,32 @@
+import { create } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import { toDeviceConfig, toDeviceFormValues } from "./device.ts";
+
+describe("Device toggles shown as on Android", () => {
+ it("shows the inverse of disableTripleClick and ledHeartbeatDisabled", () => {
+ const values = toDeviceFormValues(
+ create(Protobuf.Config.Config_DeviceConfigSchema, {
+ disableTripleClick: true,
+ ledHeartbeatDisabled: false,
+ }),
+ );
+ expect(values.tripleClickAdHocPing).toBe(false);
+ expect(values.ledHeartbeat).toBe(true);
+ });
+
+ it("writes the proto fields back, without the form-only names", () => {
+ const values = toDeviceFormValues(
+ create(Protobuf.Config.Config_DeviceConfigSchema),
+ );
+ const cfg = toDeviceConfig({
+ ...values,
+ tripleClickAdHocPing: false,
+ ledHeartbeat: false,
+ });
+ expect(cfg.disableTripleClick).toBe(true);
+ expect(cfg.ledHeartbeatDisabled).toBe(true);
+ expect(cfg).not.toHaveProperty("tripleClickAdHocPing");
+ expect(cfg).not.toHaveProperty("ledHeartbeat");
+ });
+});
diff --git a/apps/web/src/validation/config/device.ts b/apps/web/src/validation/config/device.ts
index 4428916f3..2ecade3cb 100644
--- a/apps/web/src/validation/config/device.ts
+++ b/apps/web/src/validation/config/device.ts
@@ -15,9 +15,35 @@ export const DeviceValidationSchema = z.object({
nodeInfoBroadcastSecs: z.coerce.number().int().min(0),
doubleTapAsButtonPress: z.boolean(),
isManaged: z.boolean(),
- disableTripleClick: z.boolean(),
- ledHeartbeatDisabled: z.boolean(),
+ // Shown the way Android shows them, as the inverse of the proto fields
+ // disableTripleClick and ledHeartbeatDisabled.
+ tripleClickAdHocPing: z.boolean(),
+ ledHeartbeat: z.boolean(),
tzdef: z.string().max(65),
});
export type DeviceValidation = z.infer;
+
+/** The form's values for a DeviceConfig, with the inverted toggles. */
+export function toDeviceFormValues(
+ cfg: Protobuf.Config.Config_DeviceConfig | undefined,
+): DeviceValidation {
+ return {
+ ...cfg,
+ tripleClickAdHocPing: !cfg?.disableTripleClick,
+ ledHeartbeat: !cfg?.ledHeartbeatDisabled,
+ } as DeviceValidation;
+}
+
+/** The DeviceConfig fields to stage from the form. */
+export function toDeviceConfig({
+ tripleClickAdHocPing,
+ ledHeartbeat,
+ ...rest
+}: DeviceValidation) {
+ return {
+ ...rest,
+ disableTripleClick: !tripleClickAdHocPing,
+ ledHeartbeatDisabled: !ledHeartbeat,
+ };
+}
diff --git a/apps/web/src/validation/config/display.ts b/apps/web/src/validation/config/display.ts
index f28ece343..2dfb5a821 100644
--- a/apps/web/src/validation/config/display.ts
+++ b/apps/web/src/validation/config/display.ts
@@ -28,7 +28,6 @@ export const DisplayValidationSchema = z.object({
wakeOnTapOrMotion: z.boolean(),
compassOrientation: CompassOrientationEnum,
use12hClock: z.boolean(),
- enableMessageBubbles: z.boolean(),
});
export type DisplayValidation = z.infer;
diff --git a/apps/web/src/validation/config/lora.test.ts b/apps/web/src/validation/config/lora.test.ts
index 153e42b20..68da04a1e 100644
--- a/apps/web/src/validation/config/lora.test.ts
+++ b/apps/web/src/validation/config/lora.test.ts
@@ -1,5 +1,12 @@
import { Protobuf } from "@meshtastic/sdk";
import { describe, expect, it } from "vitest";
+import {
+ codingRateOverride,
+ codingRateOverrides,
+ hasPaFan,
+ LoRaValidationSchema,
+ normalizeCodingRate,
+} from "./lora.ts";
describe("LoRa modem presets", () => {
it("includes LONG_TURBO modem preset", () => {
@@ -11,3 +18,102 @@ describe("LoRa modem presets", () => {
).toBe(true);
});
});
+
+const Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset;
+const Hw = Protobuf.Mesh.HardwareModel;
+
+describe("coding rate override", () => {
+ it("offers only the rates above the preset's own", () => {
+ expect(codingRateOverrides(Preset.LONG_FAST)).toEqual([6, 7, 8]);
+ expect(codingRateOverrides(Preset.NARROW_FAST)).toEqual([7, 8]);
+ expect(codingRateOverrides(Preset.LONG_SLOW)).toEqual([]);
+ });
+
+ it("reads a stored rate as firmware applies it", () => {
+ expect(codingRateOverride(Preset.LONG_FAST, 7)).toBe(7);
+ // At or below the preset's own rate firmware ignores it.
+ expect(codingRateOverride(Preset.LONG_FAST, 5)).toBe(0);
+ expect(codingRateOverride(Preset.LONG_SLOW, 8)).toBe(0);
+ expect(codingRateOverride(Preset.MEDIUM_FAST, 0)).toBe(0);
+ });
+
+ it("normalizes the coding rate only while presets are on", () => {
+ expect(
+ normalizeCodingRate({
+ usePreset: true,
+ modemPreset: Preset.MEDIUM_FAST,
+ codingRate: 7,
+ }).codingRate,
+ ).toBe(7);
+ expect(
+ normalizeCodingRate({
+ usePreset: true,
+ modemPreset: Preset.LONG_FAST,
+ codingRate: 5,
+ }).codingRate,
+ ).toBe(0);
+ const manual = {
+ usePreset: false,
+ modemPreset: Preset.LONG_FAST,
+ codingRate: 5,
+ };
+ expect(normalizeCodingRate(manual)).toBe(manual);
+ });
+});
+
+describe("hasPaFan", () => {
+ it("shows the PA fan switch for boards with a fan or an unknown model", () => {
+ expect(hasPaFan(undefined)).toBe(true);
+ expect(hasPaFan(Hw.UNSET)).toBe(true);
+ expect(hasPaFan(Hw.BETAFPV_2400_TX)).toBe(true);
+ expect(hasPaFan(Hw.RADIOMASTER_900_BANDIT_NANO)).toBe(true);
+ expect(hasPaFan(Hw.RADIOMASTER_900_BANDIT)).toBe(true);
+ });
+
+ it("hides it for other boards", () => {
+ expect(hasPaFan(Hw.HELTEC_V3)).toBe(false);
+ expect(hasPaFan(Hw.TBEAM)).toBe(false);
+ });
+});
+
+describe("LoRaValidationSchema", () => {
+ const base = {
+ usePreset: true,
+ modemPreset: Preset.LONG_FAST,
+ bandwidth: 0,
+ spreadFactor: 0,
+ codingRate: 0,
+ region: Protobuf.Config.Config_LoRaConfig_RegionCode.US,
+ hopLimit: 3,
+ txEnabled: true,
+ txPower: 0,
+ channelNum: 0,
+ overrideDutyCycle: false,
+ sx126xRxBoostedGain: false,
+ overrideFrequency: 0,
+ ignoreIncoming: [],
+ ignoreMqtt: false,
+ configOkToMqtt: false,
+ };
+
+ it("drops the fields the page no longer shows, keeping the device's values", () => {
+ const result = LoRaValidationSchema.safeParse({
+ ...base,
+ frequencyOffset: 12,
+ femLnaMode: 1,
+ serialHalOnly: true,
+ });
+ expect(result.success).toBe(true);
+ expect(result.data).not.toHaveProperty("frequencyOffset");
+ expect(result.data).not.toHaveProperty("femLnaMode");
+ expect(result.data).not.toHaveProperty("serialHalOnly");
+ });
+
+ it("keeps paFanDisabled, defaulting to false", () => {
+ expect(LoRaValidationSchema.parse(base).paFanDisabled).toBe(false);
+ expect(
+ LoRaValidationSchema.parse({ ...base, paFanDisabled: true })
+ .paFanDisabled,
+ ).toBe(true);
+ });
+});
diff --git a/apps/web/src/validation/config/lora.ts b/apps/web/src/validation/config/lora.ts
index b64cbf4eb..43407a3aa 100644
--- a/apps/web/src/validation/config/lora.ts
+++ b/apps/web/src/validation/config/lora.ts
@@ -8,7 +8,6 @@ import { z } from "zod/v4";
const ModemPresetEnum = z.enum(Protobuf.Config.Config_LoRaConfig_ModemPreset);
const RegionCodeEnum = z.enum(Protobuf.Config.Config_LoRaConfig_RegionCode);
-const FemLnaModeEnum = z.enum(Protobuf.Config.Config_LoRaConfig_FEM_LNA_Mode);
export const LoRaValidationSchema = z.object({
usePreset: z.boolean(),
@@ -16,7 +15,6 @@ export const LoRaValidationSchema = z.object({
bandwidth: z.coerce.number().int(),
spreadFactor: z.coerce.number().int().max(12),
codingRate: z.coerce.number().int().min(0).max(10),
- frequencyOffset: z.coerce.number().int(),
region: RegionCodeEnum,
hopLimit: z.coerce.number().int().min(0).max(7),
txEnabled: z.boolean(),
@@ -33,12 +31,116 @@ export const LoRaValidationSchema = z.object({
ignoreIncoming: z.coerce.number().array(),
ignoreMqtt: z.boolean(),
configOkToMqtt: z.boolean(),
- femLnaMode: FemLnaModeEnum,
- serialHalOnly: z.boolean(),
+ paFanDisabled: z.boolean().default(false),
});
export type LoRaValidation = z.infer;
+const Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset;
+const HardwareModel = Protobuf.Mesh.HardwareModel;
+
+/** The highest LoRa coding rate, 4/8. */
+export const MAX_CODING_RATE = 8;
+
+/**
+ * The coding-rate denominator each modem preset uses (5 to 8 for 4/5 to
+ * 4/8), from the firmware's modemPresetToParams as Android lists it.
+ */
+const PRESET_CODING_RATES: Partial<
+ Record
+> = {
+ [Preset.VERY_LONG_SLOW]: 8,
+ [Preset.LONG_TURBO]: 8,
+ [Preset.LONG_FAST]: 5,
+ [Preset.LONG_MODERATE]: 8,
+ [Preset.LONG_SLOW]: 8,
+ [Preset.MEDIUM_FAST]: 5,
+ [Preset.MEDIUM_SLOW]: 5,
+ [Preset.MEDIUM_TURBO]: 5,
+ [Preset.SHORT_FAST]: 5,
+ [Preset.SHORT_SLOW]: 5,
+ [Preset.SHORT_TURBO]: 5,
+ [Preset.LITE_FAST]: 5,
+ [Preset.LITE_SLOW]: 5,
+ [Preset.NARROW_FAST]: 6,
+ [Preset.NARROW_SLOW]: 6,
+ [Preset.TINY_FAST]: 5,
+ [Preset.TINY_SLOW]: 6,
+};
+
+/** The coding rate a preset uses on its own, or undefined if unknown. */
+export function presetCodingRate(
+ preset: Protobuf.Config.Config_LoRaConfig_ModemPreset,
+): number | undefined {
+ return PRESET_CODING_RATES[preset];
+}
+
+/**
+ * The coding rates `codingRate` can raise a preset to while usePreset is on
+ * (firmware 2.7.18+). Firmware only applies a rate above the preset's own, so
+ * only those are offered, and a preset already at 4/8 has none.
+ */
+export function codingRateOverrides(
+ preset: Protobuf.Config.Config_LoRaConfig_ModemPreset,
+): number[] {
+ const own = presetCodingRate(preset);
+ if (own === undefined) return [];
+ const rates: number[] = [];
+ for (let rate = own + 1; rate <= MAX_CODING_RATE; rate++) rates.push(rate);
+ return rates;
+}
+
+/**
+ * The stored codingRate as firmware applies it over a preset: the override
+ * while it raises the preset's rate, otherwise 0 ("preset default").
+ */
+export function codingRateOverride(
+ preset: Protobuf.Config.Config_LoRaConfig_ModemPreset,
+ stored: number,
+): number {
+ return codingRateOverrides(preset).includes(stored) ? stored : 0;
+}
+
+/**
+ * With usePreset on, keeps codingRate only while it raises the preset's own
+ * rate and stores 0 otherwise, so a value firmware ignores never looks like
+ * it applies. Manual settings and unknown presets are returned unchanged.
+ */
+export function normalizeCodingRate<
+ T extends {
+ usePreset?: boolean;
+ modemPreset?: Protobuf.Config.Config_LoRaConfig_ModemPreset;
+ codingRate?: number;
+ },
+>(cfg: T): T {
+ if (
+ !cfg.usePreset ||
+ cfg.modemPreset === undefined ||
+ presetCodingRate(cfg.modemPreset) === undefined
+ ) {
+ return cfg;
+ }
+ const codingRate = codingRateOverride(cfg.modemPreset, cfg.codingRate ?? 0);
+ return codingRate === cfg.codingRate ? cfg : { ...cfg, codingRate };
+}
+
+/**
+ * Boards with a PA fan that paFanDisabled controls. Like Android, the switch
+ * is also shown while the hardware model is unknown.
+ */
+const PA_FAN_MODELS = new Set([
+ HardwareModel.UNSET,
+ HardwareModel.BETAFPV_2400_TX,
+ HardwareModel.RADIOMASTER_900_BANDIT_NANO,
+ HardwareModel.RADIOMASTER_900_BANDIT,
+]);
+
+export function hasPaFan(
+ hwModel: Protobuf.Mesh.HardwareModel | undefined,
+): boolean {
+ return hwModel === undefined || PA_FAN_MODELS.has(hwModel);
+}
+
/**
* LoRaValidationSchema plus the device's own limits: the presets legal in the
* selected region (firmware 2.8 region_presets) and the presets and regions
diff --git a/apps/web/src/validation/config/network.test.ts b/apps/web/src/validation/config/network.test.ts
new file mode 100644
index 000000000..e2cc750c1
--- /dev/null
+++ b/apps/web/src/validation/config/network.test.ts
@@ -0,0 +1,23 @@
+import { Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import { hasUdpBroadcast, withUdpBroadcast } from "./network.ts";
+
+const UDP = Protobuf.Config.Config_NetworkConfig_ProtocolFlags.UDP_BROADCAST;
+
+describe("UDP broadcasting switch", () => {
+ it("reads the UDP_BROADCAST bit", () => {
+ expect(hasUdpBroadcast(undefined)).toBe(false);
+ expect(hasUdpBroadcast(0)).toBe(false);
+ expect(hasUdpBroadcast(UDP)).toBe(true);
+ expect(hasUdpBroadcast(UDP | 0b100)).toBe(true);
+ });
+
+ it("sets and clears only that bit", () => {
+ expect(withUdpBroadcast(0, true)).toBe(UDP);
+ expect(withUdpBroadcast(undefined, true)).toBe(UDP);
+ expect(withUdpBroadcast(UDP, false)).toBe(0);
+ // Other protocol bits are kept.
+ expect(withUdpBroadcast(0b100, true)).toBe(0b100 | UDP);
+ expect(withUdpBroadcast(0b100 | UDP, false)).toBe(0b100);
+ });
+});
diff --git a/apps/web/src/validation/config/network.ts b/apps/web/src/validation/config/network.ts
index 0f280f544..61a9a0859 100644
--- a/apps/web/src/validation/config/network.ts
+++ b/apps/web/src/validation/config/network.ts
@@ -4,9 +4,8 @@ import { z } from "zod/v4";
const AddressModeEnum = z.enum(
Protobuf.Config.Config_NetworkConfig_AddressMode,
);
-const ProtocolFlagsEnum = z.enum(
- Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
-);
+const UDP_BROADCAST =
+ Protobuf.Config.Config_NetworkConfig_ProtocolFlags.UDP_BROADCAST;
export const NetworkValidationIpV4ConfigSchema = z.object({
ip: z.ipv4(),
@@ -23,8 +22,26 @@ export const NetworkValidationSchema = z.object({
ethEnabled: z.boolean(),
addressMode: AddressModeEnum,
ipv4Config: NetworkValidationIpV4ConfigSchema,
- enabledProtocols: ProtocolFlagsEnum,
+ /** Form only: the UDP_BROADCAST bit of enabledProtocols, as a switch. */
+ udpBroadcast: z.boolean(),
rsyslogServer: z.string().max(33),
});
export type NetworkValidation = z.infer;
+
+/** Whether the enabledProtocols bitmask has UDP broadcasting on. */
+export function hasUdpBroadcast(enabledProtocols: number | undefined): boolean {
+ return ((enabledProtocols ?? 0) & UDP_BROADCAST) !== 0;
+}
+
+/**
+ * enabledProtocols with the UDP_BROADCAST bit set or cleared, keeping any
+ * other protocol bits the device has.
+ */
+export function withUdpBroadcast(
+ enabledProtocols: number | undefined,
+ enabled: boolean,
+): number {
+ const flags = enabledProtocols ?? 0;
+ return enabled ? flags | UDP_BROADCAST : flags & ~UDP_BROADCAST;
+}
diff --git a/apps/web/src/validation/config/power.test.ts b/apps/web/src/validation/config/power.test.ts
new file mode 100644
index 000000000..1edd3ea01
--- /dev/null
+++ b/apps/web/src/validation/config/power.test.ts
@@ -0,0 +1,27 @@
+import { create } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import {
+ type PowerValidation,
+ toPowerConfig,
+ toPowerFormValues,
+} from "./power.ts";
+
+const power = (adcMultiplierOverride: number) =>
+ create(Protobuf.Config.Config_PowerConfigSchema, { adcMultiplierOverride });
+
+describe("ADC multiplier override switch", () => {
+ it("is on while the ratio is above 0", () => {
+ expect(toPowerFormValues(power(0)).adcOverrideEnabled).toBe(false);
+ expect(toPowerFormValues(power(2.5)).adcOverrideEnabled).toBe(true);
+ expect(toPowerFormValues(undefined).adcOverrideEnabled).toBe(false);
+ });
+
+ it("writes the ratio while on and 0 when off, without the switch", () => {
+ const values = toPowerFormValues(power(2.5));
+ expect(toPowerConfig(values)).not.toHaveProperty("adcOverrideEnabled");
+ expect(toPowerConfig(values).adcMultiplierOverride).toBe(2.5);
+ const off: PowerValidation = { ...values, adcOverrideEnabled: false };
+ expect(toPowerConfig(off).adcMultiplierOverride).toBe(0);
+ });
+});
diff --git a/apps/web/src/validation/config/power.ts b/apps/web/src/validation/config/power.ts
index 4c99fd76d..f180a8235 100644
--- a/apps/web/src/validation/config/power.ts
+++ b/apps/web/src/validation/config/power.ts
@@ -1,8 +1,11 @@
+import type { Protobuf } from "@meshtastic/sdk";
import { z } from "zod/v4";
export const PowerValidationSchema = z.object({
isPowerSaving: z.boolean(),
onBatteryShutdownAfterSecs: z.coerce.number().int().min(0),
+ /** Form only: the "ADC multiplier override" switch, on while the ratio is above 0. */
+ adcOverrideEnabled: z.boolean(),
adcMultiplierOverride: z.coerce.number().min(0).max(4),
waitBluetoothSecs: z.coerce.number().int().min(0),
sdsSecs: z.coerce.number().int().min(0),
@@ -12,3 +15,27 @@ export const PowerValidationSchema = z.object({
});
export type PowerValidation = z.infer;
+
+/** The form's values for a PowerConfig: the override switch is derived from the ratio. */
+export function toPowerFormValues(
+ cfg: Protobuf.Config.Config_PowerConfig | undefined,
+): PowerValidation {
+ return {
+ ...cfg,
+ adcOverrideEnabled: (cfg?.adcMultiplierOverride ?? 0) > 0,
+ } as PowerValidation;
+}
+
+/**
+ * The PowerConfig fields to stage from the form. The switch is not a proto
+ * field; turning it off writes a ratio of 0, which disables the override.
+ */
+export function toPowerConfig({
+ adcOverrideEnabled,
+ ...rest
+}: PowerValidation): Omit {
+ return {
+ ...rest,
+ adcMultiplierOverride: adcOverrideEnabled ? rest.adcMultiplierOverride : 0,
+ };
+}
diff --git a/apps/web/src/validation/config/security.test.ts b/apps/web/src/validation/config/security.test.ts
index 4e84be16d..5ec3e1290 100644
--- a/apps/web/src/validation/config/security.test.ts
+++ b/apps/web/src/validation/config/security.test.ts
@@ -16,7 +16,6 @@ describe("RawSecuritySchema", () => {
it("accepts valid security config", () => {
const result = RawSecuritySchema.safeParse({
isManaged: false,
- adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
packetSignaturePolicy: COMPATIBLE,
@@ -30,7 +29,6 @@ describe("RawSecuritySchema", () => {
it("rejects if privateKey is invalid", () => {
const result = RawSecuritySchema.safeParse({
isManaged: false,
- adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
packetSignaturePolicy: COMPATIBLE,
@@ -49,7 +47,6 @@ describe("RawSecuritySchema", () => {
it("requires at least one adminKey if isManaged", () => {
const result = RawSecuritySchema.safeParse({
isManaged: true,
- adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
packetSignaturePolicy: COMPATIBLE,
@@ -70,6 +67,22 @@ describe("RawSecuritySchema", () => {
it("accepts if at least one adminKey is valid when isManaged", () => {
const result = RawSecuritySchema.safeParse({
isManaged: true,
+ debugLogApiEnabled: false,
+ serialEnabled: true,
+ packetSignaturePolicy: COMPATIBLE,
+ privateKey: validKey,
+ publicKey: validKey,
+ adminKey: [validKey, "", ""],
+ });
+ expect(result.success).toBe(true);
+ });
+});
+
+describe("adminChannelEnabled", () => {
+ it("is not part of the form, so a save keeps the device's value", () => {
+ const validKey = makeBase64OfLength(32);
+ const result = RawSecuritySchema.safeParse({
+ isManaged: false,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
@@ -79,13 +92,13 @@ describe("RawSecuritySchema", () => {
adminKey: [validKey, "", ""],
});
expect(result.success).toBe(true);
+ expect(result.data).not.toHaveProperty("adminChannelEnabled");
});
});
describe("packetSignaturePolicy", () => {
const base = {
isManaged: false,
- adminChannelEnabled: false,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: makeBase64OfLength(32),
@@ -119,7 +132,6 @@ describe("ParsedSecuritySchema", () => {
it("accepts valid parsed security config", () => {
const result = ParsedSecuritySchema.safeParse({
isManaged: false,
- adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
packetSignaturePolicy: COMPATIBLE,
@@ -133,7 +145,6 @@ describe("ParsedSecuritySchema", () => {
it("requires at least one adminKey if isManaged", () => {
const result = ParsedSecuritySchema.safeParse({
isManaged: true,
- adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
packetSignaturePolicy: COMPATIBLE,
diff --git a/apps/web/src/validation/config/security.ts b/apps/web/src/validation/config/security.ts
index 6310e0f49..0a0a02aad 100644
--- a/apps/web/src/validation/config/security.ts
+++ b/apps/web/src/validation/config/security.ts
@@ -16,7 +16,6 @@ function makeSecuritySchema(
return z
.object({
isManaged: z.boolean(),
- adminChannelEnabled: z.boolean(),
debugLogApiEnabled: z.boolean(),
serialEnabled: z.boolean(),
packetSignaturePolicy: PacketSignaturePolicyEnum,
diff --git a/apps/web/src/validation/config/user.test.ts b/apps/web/src/validation/config/user.test.ts
index fcede8c41..359302c5a 100644
--- a/apps/web/src/validation/config/user.test.ts
+++ b/apps/web/src/validation/config/user.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
-import { LONG_NAME_MAX_BYTES, UserValidationSchema } from "./user.ts";
+import {
+ LONG_NAME_MAX_BYTES,
+ NODE_STATUS_MAX_BYTES,
+ UserValidationSchema,
+} from "./user.ts";
const parse = (longName: string) =>
UserValidationSchema.safeParse({
@@ -44,3 +48,31 @@ describe("UserValidationSchema longName", () => {
expect(parse("").success).toBe(false);
});
});
+
+describe("UserValidationSchema nodeStatus", () => {
+ const parseStatus = (nodeStatus?: string) =>
+ UserValidationSchema.safeParse({
+ longName: "Node",
+ shortName: "ABCD",
+ isUnmessageable: false,
+ isLicensed: false,
+ nodeStatus,
+ });
+
+ it("limits the status message to 80 bytes", () => {
+ expect(NODE_STATUS_MAX_BYTES).toBe(80);
+ expect(parseStatus("a".repeat(80)).success).toBe(true);
+ expect(parseStatus("a".repeat(81)).success).toBe(false);
+ // 21 emoji are 84 bytes.
+ const result = parseStatus("📡".repeat(21));
+ expect(result.success).toBe(false);
+ const issue = result.error?.issues.find((i) => i.path[0] === "nodeStatus");
+ expect(issue?.message).toBe("formValidation.tooBig.bytes");
+ });
+
+ it("defaults to an empty status when the radio has none", () => {
+ const result = parseStatus(undefined);
+ expect(result.success).toBe(true);
+ expect(result.data?.nodeStatus).toBe("");
+ });
+});
diff --git a/apps/web/src/validation/config/user.ts b/apps/web/src/validation/config/user.ts
index 49169b579..8b7335ab0 100644
--- a/apps/web/src/validation/config/user.ts
+++ b/apps/web/src/validation/config/user.ts
@@ -9,6 +9,12 @@ import { z } from "zod/v4";
*/
export const LONG_NAME_MAX_BYTES = 24;
+/**
+ * ModuleConfig.statusmessage.node_status, shown on the User page from
+ * firmware 2.7.20. Android limits it to 80 bytes of UTF-8.
+ */
+export const NODE_STATUS_MAX_BYTES = 80;
+
export const UserValidationSchema = z.object({
// Read-only display fields (preserved on submit).
nodeId: z.string().optional(),
@@ -33,6 +39,20 @@ export const UserValidationSchema = z.object({
.string()
.min(1, t("user.shortName.validation.min"))
.max(4, t("user.shortName.validation.max")),
+ // Status Message module's node_status; not part of the owner.
+ nodeStatus: z
+ .string()
+ .superRefine((value, ctx) => {
+ const { isValid } = validateMaxByteLength(value, NODE_STATUS_MAX_BYTES);
+ if (!isValid) {
+ ctx.addIssue({
+ code: "custom",
+ message: "formValidation.tooBig.bytes",
+ params: { maximum: NODE_STATUS_MAX_BYTES },
+ });
+ }
+ })
+ .default(""),
isUnmessageable: z.boolean().default(false),
isLicensed: z.boolean().default(false),
});
diff --git a/apps/web/src/validation/formSchemaCoverage.test.ts b/apps/web/src/validation/formSchemaCoverage.test.ts
new file mode 100644
index 000000000..8fd758873
--- /dev/null
+++ b/apps/web/src/validation/formSchemaCoverage.test.ts
@@ -0,0 +1,154 @@
+import { create, type DescMessage } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import { BluetoothValidationSchema } from "./config/bluetooth.ts";
+import { DeviceValidationSchema, toDeviceFormValues } from "./config/device.ts";
+import { DisplayValidationSchema } from "./config/display.ts";
+import { LoRaValidationSchema } from "./config/lora.ts";
+import { NetworkValidationSchema } from "./config/network.ts";
+import { PositionValidationSchema } from "./config/position.ts";
+import { PowerValidationSchema, toPowerFormValues } from "./config/power.ts";
+import { AmbientLightingValidationSchema } from "./moduleConfig/ambientLighting.ts";
+import { AudioValidationSchema } from "./moduleConfig/audio.ts";
+import { CannedMessageValidationSchema } from "./moduleConfig/cannedMessage.ts";
+import { DetectionSensorValidationSchema } from "./moduleConfig/detectionSensor.ts";
+import { ExternalNotificationValidationSchema } from "./moduleConfig/externalNotification.ts";
+import { MqttValidationSchema } from "./moduleConfig/mqtt.ts";
+import { NeighborInfoValidationSchema } from "./moduleConfig/neighborInfo.ts";
+import { PaxcounterValidationSchema } from "./moduleConfig/paxcounter.ts";
+import { RangeTestValidationSchema } from "./moduleConfig/rangeTest.ts";
+import { RemoteHardwareValidationSchema } from "./moduleConfig/remoteHardware.ts";
+import { SerialValidationSchema } from "./moduleConfig/serial.ts";
+import { StoreForwardValidationSchema } from "./moduleConfig/storeForward.ts";
+import { TakValidationSchema } from "./moduleConfig/tak.ts";
+import { TelemetryValidationSchema } from "./moduleConfig/telemetry.ts";
+
+type Schema = {
+ safeParse(value: unknown): {
+ success: boolean;
+ error?: { issues: { path: PropertyKey[]; message: string }[] };
+ };
+};
+
+const C = Protobuf.Config;
+const M = Protobuf.ModuleConfig;
+const fresh = (desc: DescMessage) => create(desc);
+
+/**
+ * A form auto-saves through its resolver, so a schema that rejects the
+ * radio's own config blocks every change on that page. Each case is what
+ * the page passes to its form for a factory-default radio.
+ */
+const cases: [string, Schema, unknown][] = [
+ [
+ "bluetooth",
+ BluetoothValidationSchema,
+ fresh(C.Config_BluetoothConfigSchema),
+ ],
+ [
+ "device",
+ DeviceValidationSchema,
+ toDeviceFormValues(create(C.Config_DeviceConfigSchema)),
+ ],
+ ["display", DisplayValidationSchema, fresh(C.Config_DisplayConfigSchema)],
+ ["lora", LoRaValidationSchema, fresh(C.Config_LoRaConfigSchema)],
+ [
+ "network",
+ NetworkValidationSchema,
+ {
+ ...fresh(C.Config_NetworkConfigSchema),
+ ipv4Config: {
+ ip: "0.0.0.0",
+ gateway: "0.0.0.0",
+ subnet: "0.0.0.0",
+ dns: "0.0.0.0",
+ },
+ udpBroadcast: false,
+ },
+ ],
+ [
+ "position",
+ PositionValidationSchema,
+ { ...fresh(C.Config_PositionConfigSchema), altitude: 0 },
+ ],
+ [
+ "power",
+ PowerValidationSchema,
+ toPowerFormValues(create(C.Config_PowerConfigSchema)),
+ ],
+ [
+ "ambientLighting",
+ AmbientLightingValidationSchema,
+ fresh(M.ModuleConfig_AmbientLightingConfigSchema),
+ ],
+ ["audio", AudioValidationSchema, fresh(M.ModuleConfig_AudioConfigSchema)],
+ [
+ "cannedMessage",
+ CannedMessageValidationSchema,
+ { ...fresh(M.ModuleConfig_CannedMessageConfigSchema), messages: "" },
+ ],
+ [
+ "detectionSensor",
+ DetectionSensorValidationSchema,
+ fresh(M.ModuleConfig_DetectionSensorConfigSchema),
+ ],
+ [
+ "externalNotification",
+ ExternalNotificationValidationSchema,
+ { ...fresh(M.ModuleConfig_ExternalNotificationConfigSchema), ringtone: "" },
+ ],
+ [
+ "mqtt",
+ MqttValidationSchema,
+ {
+ ...fresh(M.ModuleConfig_MQTTConfigSchema),
+ mapReportSettings: {
+ publishIntervalSecs: 3600,
+ positionPrecision: 13,
+ shouldReportLocation: false,
+ },
+ },
+ ],
+ [
+ "neighborInfo",
+ NeighborInfoValidationSchema,
+ fresh(M.ModuleConfig_NeighborInfoConfigSchema),
+ ],
+ [
+ "paxcounter",
+ PaxcounterValidationSchema,
+ fresh(M.ModuleConfig_PaxcounterConfigSchema),
+ ],
+ [
+ "rangeTest",
+ RangeTestValidationSchema,
+ fresh(M.ModuleConfig_RangeTestConfigSchema),
+ ],
+ [
+ "remoteHardware",
+ RemoteHardwareValidationSchema,
+ fresh(M.ModuleConfig_RemoteHardwareConfigSchema),
+ ],
+ ["serial", SerialValidationSchema, fresh(M.ModuleConfig_SerialConfigSchema)],
+ [
+ "storeForward",
+ StoreForwardValidationSchema,
+ fresh(M.ModuleConfig_StoreForwardConfigSchema),
+ ],
+ ["tak", TakValidationSchema, fresh(M.ModuleConfig_TAKConfigSchema)],
+ [
+ "telemetry",
+ TelemetryValidationSchema,
+ fresh(M.ModuleConfig_TelemetryConfigSchema),
+ ],
+];
+
+describe("settings forms accept a factory-default radio", () => {
+ it.each(cases)("%s", (_, schema, value) => {
+ const result = schema.safeParse(value);
+ const issues = result.error?.issues.map(
+ (issue) => `${issue.path.join(".")}: ${issue.message}`,
+ );
+ expect(issues ?? []).toEqual([]);
+ });
+});
diff --git a/apps/web/src/validation/moduleConfig/cannedMessage.ts b/apps/web/src/validation/moduleConfig/cannedMessage.ts
index d68c1beb4..697509d1d 100644
--- a/apps/web/src/validation/moduleConfig/cannedMessage.ts
+++ b/apps/web/src/validation/moduleConfig/cannedMessage.ts
@@ -1,4 +1,5 @@
-import { Protobuf } from "@meshtastic/sdk";
+import { validateMaxByteLength } from "@core/utils/string.ts";
+import { CANNED_MESSAGES_MAX_BYTES, Protobuf } from "@meshtastic/sdk";
import { z } from "zod/v4";
const InputEventCharEnum = z.enum(
@@ -17,6 +18,16 @@ export const CannedMessageValidationSchema = z.object({
enabled: z.boolean(),
allowInputSource: z.string().max(30),
sendBell: z.boolean(),
+ // Not part of the module config: sent with its own admin message.
+ messages: z
+ .string()
+ .refine(
+ (s) => validateMaxByteLength(s, CANNED_MESSAGES_MAX_BYTES).isValid,
+ {
+ message: "formValidation.tooBig.bytes",
+ params: { maximum: CANNED_MESSAGES_MAX_BYTES },
+ },
+ ),
});
export type CannedMessageValidation = z.infer<
diff --git a/apps/web/src/validation/moduleConfig/externalNotification.ts b/apps/web/src/validation/moduleConfig/externalNotification.ts
index b8769f0ea..0c2d7a503 100644
--- a/apps/web/src/validation/moduleConfig/externalNotification.ts
+++ b/apps/web/src/validation/moduleConfig/externalNotification.ts
@@ -1,3 +1,5 @@
+import { validateMaxByteLength } from "@core/utils/string.ts";
+import { RINGTONE_MAX_BYTES } from "@meshtastic/sdk";
import { z } from "zod/v4";
export const ExternalNotificationValidationSchema = z.object({
@@ -16,6 +18,13 @@ export const ExternalNotificationValidationSchema = z.object({
usePwm: z.boolean(),
nagTimeout: z.coerce.number().int().min(0),
useI2sAsBuzzer: z.boolean(),
+ // Not part of the module config: sent with its own admin message.
+ ringtone: z
+ .string()
+ .refine((s) => validateMaxByteLength(s, RINGTONE_MAX_BYTES).isValid, {
+ message: "formValidation.tooBig.bytes",
+ params: { maximum: RINGTONE_MAX_BYTES },
+ }),
});
export type ExternalNotificationValidation = z.infer<
diff --git a/apps/web/src/validation/moduleConfig/meshBeacon.test.ts b/apps/web/src/validation/moduleConfig/meshBeacon.test.ts
index 8cd07b26a..71e994f87 100644
--- a/apps/web/src/validation/moduleConfig/meshBeacon.test.ts
+++ b/apps/web/src/validation/moduleConfig/meshBeacon.test.ts
@@ -2,29 +2,33 @@ import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/sdk";
import { describe, expect, it } from "vitest";
import {
+ beaconBroadcastGate,
+ DEFAULT_TARGET,
fromMeshBeaconForm,
MESH_BEACON_MESSAGE_MAX_BYTES,
type MeshBeaconValidation,
MeshBeaconValidationSchema,
OFFER_CHANNEL_KEEP,
- OFFER_CHANNEL_NONE,
- OFFER_PRESET_UNSET,
+ selectTargetChannel,
+ TARGET_DEFAULT,
toMeshBeaconForm,
} from "./meshBeacon.ts";
const Flags = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_Flags;
const Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset;
const Region = Protobuf.Config.Config_LoRaConfig_RegionCode;
+const BeaconSchema = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema;
+const TargetSchema =
+ Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_BroadcastTargetSchema;
const valid: MeshBeaconValidation = {
+ region: Region.US,
listenEnabled: true,
broadcastEnabled: true,
- legacySplit: false,
broadcastMessage: "Join us",
broadcastIntervalSecs: 3600,
offerChannel: 0,
- broadcastOfferRegion: Region.US,
- broadcastOfferPreset: OFFER_PRESET_UNSET,
+ broadcastTargets: [{ ...DEFAULT_TARGET }],
};
const channel = (name: string, psk: number[]) =>
@@ -40,6 +44,17 @@ const club = channel(
);
const channels = [primary, club, undefined, undefined];
+const lora = create(Protobuf.Config.Config_LoRaConfigSchema, {
+ region: Region.US,
+ usePreset: true,
+ modemPreset: Preset.MEDIUM_FAST,
+});
+const customLora = create(Protobuf.Config.Config_LoRaConfigSchema, {
+ region: Region.US,
+ usePreset: false,
+ modemPreset: Preset.LONG_FAST,
+});
+
describe("MeshBeaconValidationSchema", () => {
it("accepts a valid config", () => {
expect(MeshBeaconValidationSchema.safeParse(valid).success).toBe(true);
@@ -81,159 +96,311 @@ describe("MeshBeaconValidationSchema", () => {
).toBe(false);
});
- it("accepts an unset preset or a real one, nothing else", () => {
- const parse = (broadcastOfferPreset: number) =>
- MeshBeaconValidationSchema.safeParse({ ...valid, broadcastOfferPreset })
- .success;
- expect(parse(OFFER_PRESET_UNSET)).toBe(true);
- expect(parse(Preset.MEDIUM_FAST)).toBe(true);
- expect(parse(999)).toBe(false);
- });
-
- it("limits the offered channel to the eight slots and the two sentinels", () => {
+ it("limits the offered channel to the eight slots and the keep sentinel", () => {
const parse = (offerChannel: number) =>
MeshBeaconValidationSchema.safeParse({ ...valid, offerChannel }).success;
expect(parse(OFFER_CHANNEL_KEEP)).toBe(true);
- expect(parse(OFFER_CHANNEL_NONE)).toBe(true);
+ expect(parse(0)).toBe(true);
expect(parse(7)).toBe(true);
+ expect(parse(-1)).toBe(false);
expect(parse(8)).toBe(false);
- expect(parse(-3)).toBe(false);
+ });
+
+ it("needs at least one target, each Default or a real channel and preset", () => {
+ const parse = (broadcastTargets: unknown) =>
+ MeshBeaconValidationSchema.safeParse({ ...valid, broadcastTargets })
+ .success;
+ expect(parse([])).toBe(false);
+ expect(parse([{ channelIndex: 3, preset: Preset.SHORT_FAST }])).toBe(true);
+ expect(parse([{ channelIndex: 8, preset: TARGET_DEFAULT }])).toBe(false);
+ expect(parse([{ channelIndex: TARGET_DEFAULT, preset: 999 }])).toBe(false);
});
});
describe("toMeshBeaconForm", () => {
- it("unpacks the flag bits", () => {
+ it("unpacks the flag bits and shows the radio's region", () => {
const form = toMeshBeaconForm(
- create(Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema, {
- flags: Flags.FLAG_LISTEN_ENABLED | Flags.FLAG_LEGACY_SPLIT,
- }),
+ create(BeaconSchema, { flags: Flags.FLAG_LISTEN_ENABLED }),
channels,
+ lora,
);
expect(form.listenEnabled).toBe(true);
expect(form.broadcastEnabled).toBe(false);
- expect(form.legacySplit).toBe(true);
+ expect(form.region).toBe(Region.US);
});
it("shows the one hour default for an interval never set", () => {
- expect(toMeshBeaconForm(undefined, channels).broadcastIntervalSecs).toBe(
- 3600,
- );
+ expect(
+ toMeshBeaconForm(undefined, channels, lora).broadcastIntervalSecs,
+ ).toBe(3600);
});
it("maps the offered channel back to the local slot it came from", () => {
- const config = create(
- Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema,
- { broadcastOfferChannel: channel("Club", Array.from(club.psk)) },
- );
- expect(toMeshBeaconForm(config, channels).offerChannel).toBe(1);
+ const config = create(BeaconSchema, {
+ broadcastOfferChannel: channel("Club", Array.from(club.psk)),
+ });
+ expect(toMeshBeaconForm(config, channels, lora).offerChannel).toBe(1);
});
it("keeps an offered channel that isn't in the channel table", () => {
- const config = create(
- Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema,
- { broadcastOfferChannel: channel("Elsewhere", [9]) },
- );
- expect(toMeshBeaconForm(config, channels).offerChannel).toBe(
+ const config = create(BeaconSchema, {
+ broadcastOfferChannel: channel("Elsewhere", [9]),
+ });
+ expect(toMeshBeaconForm(config, channels, lora).offerChannel).toBe(
OFFER_CHANNEL_KEEP,
);
});
- it("reports no offered channel and no preset when unset", () => {
- const form = toMeshBeaconForm(undefined, channels);
- expect(form.offerChannel).toBe(OFFER_CHANNEL_NONE);
- expect(form.broadcastOfferPreset).toBe(OFFER_PRESET_UNSET);
+ it("shows the primary for an unset offered channel, as a save fills in", () => {
+ expect(toMeshBeaconForm(undefined, channels, lora).offerChannel).toBe(0);
});
-});
-describe("fromMeshBeaconForm", () => {
- const base = create(
- Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema,
- {
- // A flag bit this client doesn't know about.
- flags: Flags.FLAG_BROADCAST_ENABLED | 64,
- broadcastOfferChannel: channel("Elsewhere", [9]),
+ it("seeds one Default target row for an empty list", () => {
+ expect(
+ toMeshBeaconForm(undefined, channels, lora).broadcastTargets,
+ ).toEqual([DEFAULT_TARGET]);
+ });
+
+ it("shows stored targets with Default for unset fields", () => {
+ const config = create(BeaconSchema, {
broadcastTargets: [
- create(
- Protobuf.ModuleConfig
- .ModuleConfig_MeshBeaconConfig_BroadcastTargetSchema,
- { channelIndex: 1 },
- ),
+ create(TargetSchema, { channelIndex: 1 }),
+ create(TargetSchema, { preset: Preset.SHORT_FAST }),
],
- },
- );
+ });
+ expect(toMeshBeaconForm(config, channels, lora).broadcastTargets).toEqual([
+ { channelIndex: 1, preset: TARGET_DEFAULT },
+ { channelIndex: TARGET_DEFAULT, preset: Preset.SHORT_FAST },
+ ]);
+ });
+});
+
+describe("fromMeshBeaconForm", () => {
+ const base = create(BeaconSchema, {
+ // The legacy-split bit and one this client doesn't know about.
+ flags: Flags.FLAG_BROADCAST_ENABLED | Flags.FLAG_LEGACY_SPLIT | 64,
+ broadcastMessage: "Stored",
+ broadcastIntervalSecs: 7200,
+ broadcastOfferChannel: channel("Elsewhere", [9]),
+ broadcastOfferRegion: Region.EU_868,
+ broadcastOfferPreset: Preset.LONG_SLOW,
+ broadcastTargets: [create(TargetSchema, { channelIndex: 1 })],
+ });
it("packs the flags and keeps bits it doesn't own", () => {
const config = fromMeshBeaconForm(
- { ...valid, broadcastEnabled: false, legacySplit: true },
+ { ...valid, broadcastEnabled: false },
base,
channels,
+ lora,
);
expect(config.flags).toBe(
Flags.FLAG_LISTEN_ENABLED | Flags.FLAG_LEGACY_SPLIT | 64,
);
});
- it("keeps broadcast targets, which the form doesn't edit", () => {
- const config = fromMeshBeaconForm(valid, base, channels);
- expect(config.broadcastTargets).toHaveLength(1);
- expect(config.broadcastTargets[0]?.channelIndex).toBe(1);
+ it("stamps the radio's region and preset over stored values", () => {
+ const config = fromMeshBeaconForm(valid, base, channels, lora);
+ expect(config.broadcastOfferRegion).toBe(Region.US);
+ expect(config.broadcastOfferPreset).toBe(Preset.MEDIUM_FAST);
+ expect(config.broadcastMessage).toBe("Join us");
+ expect(config.broadcastIntervalSecs).toBe(3600);
});
it("copies only the name and key of the chosen channel", () => {
- const config = fromMeshBeaconForm({ ...valid, offerChannel: 1 }, base, [
- primary,
- create(Protobuf.Channel.ChannelSettingsSchema, {
- ...club,
- uplinkEnabled: true,
- }),
- ]);
+ const config = fromMeshBeaconForm(
+ { ...valid, offerChannel: 1 },
+ base,
+ [
+ primary,
+ create(Protobuf.Channel.ChannelSettingsSchema, {
+ ...club,
+ uplinkEnabled: true,
+ }),
+ ],
+ lora,
+ );
expect(config.broadcastOfferChannel?.name).toBe("Club");
expect(config.broadcastOfferChannel?.psk).toEqual(club.psk);
expect(config.broadcastOfferChannel?.uplinkEnabled).toBe(false);
});
- it("keeps or clears the offered channel", () => {
+ it("keeps an offered channel the radio doesn't have", () => {
expect(
fromMeshBeaconForm(
{ ...valid, offerChannel: OFFER_CHANNEL_KEEP },
base,
channels,
+ lora,
).broadcastOfferChannel?.name,
).toBe("Elsewhere");
- expect(
- fromMeshBeaconForm(
- { ...valid, offerChannel: OFFER_CHANNEL_NONE },
- base,
- channels,
- ).broadcastOfferChannel,
- ).toBeUndefined();
});
- it("leaves the preset unset or sets it", () => {
+ it("falls back to the primary when the chosen slot is empty", () => {
+ const config = fromMeshBeaconForm(
+ { ...valid, offerChannel: 3 },
+ undefined,
+ channels,
+ lora,
+ );
+ expect(config.broadcastOfferChannel?.psk).toEqual(primary.psk);
+ });
+
+ it("keeps an empty list empty when its Default row is untouched", () => {
+ const empty = create(BeaconSchema, { flags: Flags.FLAG_BROADCAST_ENABLED });
expect(
- fromMeshBeaconForm(valid, base, channels).broadcastOfferPreset,
- ).toBeUndefined();
+ fromMeshBeaconForm(valid, empty, channels, lora).broadcastTargets,
+ ).toEqual([]);
+ });
+
+ it("keeps a stored Default target when another field changes", () => {
+ const withDefault = create(BeaconSchema, {
+ broadcastTargets: [create(TargetSchema, { region: Region.US })],
+ });
+ const form = toMeshBeaconForm(withDefault, channels, lora);
+ const config = fromMeshBeaconForm(
+ { ...form, listenEnabled: !form.listenEnabled },
+ withDefault,
+ channels,
+ lora,
+ );
+ expect(config.broadcastTargets).toHaveLength(1);
+ expect(config.broadcastTargets[0]?.channelIndex).toBeUndefined();
+ });
+
+ it("keeps fields it doesn't show on a target the user didn't change", () => {
+ const withSlot = create(BeaconSchema, {
+ broadcastTargets: [
+ create(TargetSchema, {
+ channelIndex: 1,
+ region: Region.EU_868,
+ frequencySlot: 3,
+ }),
+ ],
+ });
+ const form = toMeshBeaconForm(withSlot, channels, lora);
+ const [kept] = fromMeshBeaconForm(
+ form,
+ withSlot,
+ channels,
+ lora,
+ ).broadcastTargets;
+ expect(kept?.frequencySlot).toBe(3);
+ expect(kept?.region).toBe(Region.US);
+ });
+
+ it("saves targets with the radio's region and unset Defaults", () => {
+ const config = fromMeshBeaconForm(
+ {
+ ...valid,
+ broadcastTargets: [
+ { channelIndex: 1, preset: Preset.SHORT_FAST },
+ { ...DEFAULT_TARGET },
+ ],
+ },
+ base,
+ channels,
+ lora,
+ );
expect(
- fromMeshBeaconForm(
- { ...valid, broadcastOfferPreset: Preset.SHORT_FAST },
- base,
- channels,
- ).broadcastOfferPreset,
- ).toBe(Preset.SHORT_FAST);
+ config.broadcastTargets.map((t) => ({
+ region: t.region,
+ channelIndex: t.channelIndex,
+ preset: t.preset,
+ })),
+ ).toEqual([
+ { region: Region.US, channelIndex: 1, preset: Preset.SHORT_FAST },
+ { region: Region.US, channelIndex: undefined, preset: undefined },
+ ]);
});
- it("never sends the frequency slot firmware 2.8.1 doesn't know", () => {
- const withSlot = create(
- Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema,
- { broadcastOfferFrequencySlot: 5 },
+ it("keeps every broadcast field on custom LoRa settings", () => {
+ const config = fromMeshBeaconForm(
+ {
+ ...valid,
+ broadcastEnabled: false,
+ broadcastMessage: "Changed",
+ offerChannel: 1,
+ },
+ base,
+ channels,
+ customLora,
);
+ expect(config.flags & Flags.FLAG_BROADCAST_ENABLED).toBe(0);
+ expect(config.broadcastMessage).toBe("Stored");
+ expect(config.broadcastIntervalSecs).toBe(7200);
+ expect(config.broadcastOfferChannel?.name).toBe("Elsewhere");
+ expect(config.broadcastOfferRegion).toBe(Region.EU_868);
+ expect(config.broadcastOfferPreset).toBe(Preset.LONG_SLOW);
+ expect(config.broadcastTargets[0]?.channelIndex).toBe(1);
+ });
+
+ it("never sends the frequency slot firmware 2.8.1 doesn't know", () => {
+ const withSlot = create(BeaconSchema, { broadcastOfferFrequencySlot: 5 });
expect(
- fromMeshBeaconForm(valid, withSlot, channels).broadcastOfferFrequencySlot,
+ fromMeshBeaconForm(valid, withSlot, channels, lora)
+ .broadcastOfferFrequencySlot,
).toBeUndefined();
});
it("round-trips through the form", () => {
- const config = fromMeshBeaconForm(valid, undefined, channels);
- expect(toMeshBeaconForm(config, channels)).toEqual(valid);
+ const form = {
+ ...valid,
+ offerChannel: 1,
+ broadcastTargets: [{ channelIndex: 1, preset: Preset.SHORT_FAST }],
+ };
+ const config = fromMeshBeaconForm(form, undefined, channels, lora);
+ expect(toMeshBeaconForm(config, channels, lora)).toEqual(form);
+ });
+});
+
+describe("selectTargetChannel", () => {
+ it("preselects the radio's preset the first time a row gets a channel", () => {
+ expect(
+ selectTargetChannel({ ...DEFAULT_TARGET }, 1, Preset.MEDIUM_FAST),
+ ).toEqual({ channelIndex: 1, preset: Preset.MEDIUM_FAST });
+ });
+
+ it("keeps a preset the user chose", () => {
+ expect(
+ selectTargetChannel(
+ { channelIndex: 1, preset: Preset.SHORT_FAST },
+ 2,
+ Preset.MEDIUM_FAST,
+ ),
+ ).toEqual({ channelIndex: 2, preset: Preset.SHORT_FAST });
+ });
+
+ it("leaves the preset alone when Default is picked", () => {
+ expect(
+ selectTargetChannel(
+ { channelIndex: 1, preset: TARGET_DEFAULT },
+ TARGET_DEFAULT,
+ Preset.MEDIUM_FAST,
+ ),
+ ).toEqual(DEFAULT_TARGET);
+ });
+});
+
+describe("beaconBroadcastGate", () => {
+ it("shows and enables everything on a standard preset", () => {
+ expect(beaconBroadcastGate(true, false)).toEqual({
+ sectionsVisible: true,
+ sectionsEnabled: true,
+ toggleEnabled: true,
+ });
+ });
+
+ it("on custom settings, only lets a broadcasting radio turn it off", () => {
+ expect(beaconBroadcastGate(false, true)).toEqual({
+ sectionsVisible: true,
+ sectionsEnabled: false,
+ toggleEnabled: true,
+ });
+ expect(beaconBroadcastGate(false, false)).toEqual({
+ sectionsVisible: false,
+ sectionsEnabled: false,
+ toggleEnabled: false,
+ });
});
});
diff --git a/apps/web/src/validation/moduleConfig/meshBeacon.ts b/apps/web/src/validation/moduleConfig/meshBeacon.ts
index b1f5cfcfd..47ad6b1dc 100644
--- a/apps/web/src/validation/moduleConfig/meshBeacon.ts
+++ b/apps/web/src/validation/moduleConfig/meshBeacon.ts
@@ -4,13 +4,23 @@ import { Protobuf } from "@meshtastic/sdk";
import { z } from "zod/v4";
type MeshBeaconConfig = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig;
+type BroadcastTarget =
+ Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_BroadcastTarget;
type ChannelSettings = Protobuf.Channel.ChannelSettings;
+type LoRaConfig = Protobuf.Config.Config_LoRaConfig;
+type ModemPreset = Protobuf.Config.Config_LoRaConfig_ModemPreset;
+type RegionCode = Protobuf.Config.Config_LoRaConfig_RegionCode;
const Flags = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_Flags;
-const FLAG_MASK =
- Flags.FLAG_LISTEN_ENABLED |
- Flags.FLAG_BROADCAST_ENABLED |
- Flags.FLAG_LEGACY_SPLIT;
+const Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset;
+const Region = Protobuf.Config.Config_LoRaConfig_RegionCode;
+
+/**
+ * The flag bits the form edits. Like Android, the legacy-split bit isn't
+ * shown, so it is kept as the radio has it, along with any bit this client
+ * doesn't know.
+ */
+const FLAG_MASK = Flags.FLAG_LISTEN_ENABLED | Flags.FLAG_BROADCAST_ENABLED;
/**
* Firmware 2.8.1 was built from protobufs 8f97d66, where nanopb holds
@@ -20,21 +30,46 @@ const FLAG_MASK =
export const MESH_BEACON_MESSAGE_MAX_BYTES = 100;
/** The proto documents one hour as both the minimum and the default. */
export const MESH_BEACON_MIN_INTERVAL_SECS = 3600;
+const MAX_CHANNEL_INDEX = 7;
-/** Form value for "advertise no channel". */
-export const OFFER_CHANNEL_NONE = -1;
/**
* Form value for "keep the advertised channel as it is": the radio
* advertises a channel that isn't in its own channel table.
*/
export const OFFER_CHANNEL_KEEP = -2;
-/** Form value for "no preset advertised", the proto's unset optional. */
-export const OFFER_PRESET_UNSET = -1;
+/**
+ * Form value for a target's "Default" channel or preset, the proto's unset
+ * optional: firmware then uses its running preset, or the preset's default
+ * channel.
+ */
+export const TARGET_DEFAULT = -1;
+
+/**
+ * Presets a beacon may target when the radio sent no region preset map, as
+ * Android's CONSERVATIVE_BEACON_PRESETS: no newer-firmware presets and
+ * nothing wider than 250 kHz, so they fit any region's band.
+ */
+export const CONSERVATIVE_BEACON_PRESETS: readonly ModemPreset[] = [
+ Preset.LONG_FAST,
+ Preset.LONG_MODERATE,
+ Preset.MEDIUM_SLOW,
+ Preset.MEDIUM_FAST,
+ Preset.SHORT_SLOW,
+ Preset.SHORT_FAST,
+];
+
+const channelIndex = z.coerce.number().int().min(0).max(MAX_CHANNEL_INDEX);
+
+const BroadcastTargetSchema = z.object({
+ channelIndex: z.union([z.literal(TARGET_DEFAULT), channelIndex]),
+ preset: z.union([z.literal(TARGET_DEFAULT), z.enum(Preset)]),
+});
export const MeshBeaconValidationSchema = z.object({
+ /** Read-only: the beacon always uses the radio's own region. */
+ region: z.enum(Region),
listenEnabled: z.boolean(),
broadcastEnabled: z.boolean(),
- legacySplit: z.boolean(),
broadcastMessage: z
.string()
.refine(
@@ -48,28 +83,31 @@ export const MeshBeaconValidationSchema = z.object({
.number()
.int()
.min(MESH_BEACON_MIN_INTERVAL_SECS),
- offerChannel: z.coerce.number().int().min(OFFER_CHANNEL_KEEP).max(7),
- broadcastOfferRegion: z.enum(Protobuf.Config.Config_LoRaConfig_RegionCode),
- broadcastOfferPreset: z.union([
- z.literal(OFFER_PRESET_UNSET),
- z.enum(Protobuf.Config.Config_LoRaConfig_ModemPreset),
- ]),
+ offerChannel: z.union([z.literal(OFFER_CHANNEL_KEEP), channelIndex]),
+ broadcastTargets: z.array(BroadcastTargetSchema).min(1),
});
export type MeshBeaconValidation = z.infer;
+export type MeshBeaconTargetValidation = z.infer;
+
+export const DEFAULT_TARGET: MeshBeaconTargetValidation = {
+ channelIndex: TARGET_DEFAULT,
+ preset: TARGET_DEFAULT,
+};
const sameBytes = (a: Uint8Array, b: Uint8Array) =>
a.length === b.length && a.every((v, i) => v === b[i]);
/**
* The local channel the advertised channel was copied from, matched on
- * name and key, or OFFER_CHANNEL_KEEP when it isn't one of ours.
+ * name and key. An unset offer shows the primary, which is what a save
+ * fills in. One that isn't ours is OFFER_CHANNEL_KEEP.
*/
function offerChannelIndex(
offer: ChannelSettings | undefined,
channels: ReadonlyArray,
): number {
- if (!offer) return OFFER_CHANNEL_NONE;
+ if (!offer) return 0;
const index = channels.findIndex(
(c) =>
c !== undefined && c.name === offer.name && sameBytes(c.psk, offer.psk),
@@ -80,69 +118,186 @@ function offerChannelIndex(
/**
* The form's view of the config. `channels` is indexed by channel index,
* with undefined for disabled slots.
+ *
+ * Like Android, an empty target list shows as one "Default" row: firmware
+ * sends a single beacon on its running settings when the list is empty, and
+ * the row makes that visible.
*/
export function toMeshBeaconForm(
config: MeshBeaconConfig | undefined,
channels: ReadonlyArray,
+ lora: LoRaConfig | undefined,
): MeshBeaconValidation {
const flags = config?.flags ?? 0;
+ const targets = config?.broadcastTargets ?? [];
return {
+ region: lora?.region ?? Region.UNSET,
listenEnabled: (flags & Flags.FLAG_LISTEN_ENABLED) !== 0,
broadcastEnabled: (flags & Flags.FLAG_BROADCAST_ENABLED) !== 0,
- legacySplit: (flags & Flags.FLAG_LEGACY_SPLIT) !== 0,
broadcastMessage: config?.broadcastMessage ?? "",
// 0 is the proto3 default for a radio that never set it; show the
// documented default instead of a value the schema rejects.
broadcastIntervalSecs:
config?.broadcastIntervalSecs || MESH_BEACON_MIN_INTERVAL_SECS,
offerChannel: offerChannelIndex(config?.broadcastOfferChannel, channels),
- broadcastOfferRegion:
- config?.broadcastOfferRegion ??
- Protobuf.Config.Config_LoRaConfig_RegionCode.UNSET,
- broadcastOfferPreset: config?.broadcastOfferPreset ?? OFFER_PRESET_UNSET,
+ broadcastTargets:
+ targets.length > 0
+ ? targets.map((target) => ({
+ channelIndex: target.channelIndex ?? TARGET_DEFAULT,
+ preset: target.preset ?? TARGET_DEFAULT,
+ }))
+ : [{ ...DEFAULT_TARGET }],
};
}
+const isDefaultTarget = (target: MeshBeaconTargetValidation) =>
+ target.channelIndex === TARGET_DEFAULT && target.preset === TARGET_DEFAULT;
+
/**
- * Merge the form back onto the config. Fields the form doesn't show
- * (broadcast targets, flag bits it doesn't know) are kept as they are.
- * The advertised channel carries only the name and key a listener needs.
+ * Merge the form back onto the config, applying Android's save rules
+ * (MeshBeaconConfigPolicy.stampBeaconConfigForSave).
+ *
+ * On a standard modem preset, the beacon advertises the radio's own region
+ * and preset, never a user's choice, and every target uses the radio's
+ * region. An untouched offered channel becomes the primary; only its name
+ * and key are sent. A target row the user didn't change keeps the stored
+ * target, fields this form doesn't show included. The "Default" row shown
+ * for an empty list stays an empty list: firmware then sends one beacon on
+ * the primary channel, while a saved Default target would use the preset's
+ * default channel.
+ *
+ * On custom LoRa settings a preset means nothing, so only the listen and
+ * broadcast flags come from the form and every broadcast field stays as the
+ * radio has it.
+ *
+ * Unlike Android, the frequency slots are never sent: firmware 2.8.1 doesn't
+ * know them. Flag bits the form doesn't own are kept.
+ *
+ * `stored` is the radio's own config, which target rows are compared with;
+ * it defaults to `base`, the config being edited.
*/
export function fromMeshBeaconForm(
form: MeshBeaconValidation,
base: MeshBeaconConfig | undefined,
channels: ReadonlyArray,
+ lora: LoRaConfig | undefined,
+ stored: MeshBeaconConfig | undefined = base,
): MeshBeaconConfig {
- const bits =
- (form.listenEnabled ? Flags.FLAG_LISTEN_ENABLED : 0) |
- (form.broadcastEnabled ? Flags.FLAG_BROADCAST_ENABLED : 0) |
- (form.legacySplit ? Flags.FLAG_LEGACY_SPLIT : 0);
-
- let broadcastOfferChannel: ChannelSettings | undefined;
- if (form.offerChannel === OFFER_CHANNEL_KEEP) {
- broadcastOfferChannel = base?.broadcastOfferChannel;
- } else if (form.offerChannel !== OFFER_CHANNEL_NONE) {
- const source = channels[form.offerChannel];
- broadcastOfferChannel = source
- ? create(Protobuf.Channel.ChannelSettingsSchema, {
- name: source.name,
- psk: source.psk,
- })
- : undefined;
- }
-
const Schema = Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfigSchema;
const config = base ? clone(Schema, base) : create(Schema);
+ const bits =
+ (form.listenEnabled ? Flags.FLAG_LISTEN_ENABLED : 0) |
+ (form.broadcastEnabled ? Flags.FLAG_BROADCAST_ENABLED : 0);
config.flags = (config.flags & ~FLAG_MASK) | bits;
+
+ if (!lora?.usePreset) return config;
+
config.broadcastMessage = form.broadcastMessage;
config.broadcastIntervalSecs = form.broadcastIntervalSecs;
- config.broadcastOfferChannel = broadcastOfferChannel;
- config.broadcastOfferRegion = form.broadcastOfferRegion;
- config.broadcastOfferPreset =
- form.broadcastOfferPreset === OFFER_PRESET_UNSET
- ? undefined
- : form.broadcastOfferPreset;
+ config.broadcastOfferChannel = offerChannel(
+ form.offerChannel,
+ base,
+ channels,
+ );
+ config.broadcastOfferRegion = lora.region;
+ config.broadcastOfferPreset = lora.modemPreset;
// Added to the schema after firmware 2.8.1, which doesn't know it.
config.broadcastOfferFrequencySlot = undefined;
+
+ const storedTargets = stored?.broadcastTargets ?? [];
+ const [only] = form.broadcastTargets;
+ config.broadcastTargets =
+ storedTargets.length === 0 &&
+ form.broadcastTargets.length === 1 &&
+ only &&
+ isDefaultTarget(only)
+ ? []
+ : form.broadcastTargets.map((target, index) =>
+ toTarget(target, storedTargets[index], lora.region),
+ );
return config;
}
+
+function offerChannel(
+ choice: number,
+ base: MeshBeaconConfig | undefined,
+ channels: ReadonlyArray,
+): ChannelSettings | undefined {
+ if (choice === OFFER_CHANNEL_KEEP) return base?.broadcastOfferChannel;
+ const source = channels[choice] ?? channels[0];
+ if (!source) return base?.broadcastOfferChannel;
+ return create(Protobuf.Channel.ChannelSettingsSchema, {
+ name: source.name,
+ psk: source.psk,
+ });
+}
+
+function toTarget(
+ target: MeshBeaconTargetValidation,
+ stored: BroadcastTarget | undefined,
+ region: RegionCode,
+): BroadcastTarget {
+ const TargetSchema =
+ Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_BroadcastTargetSchema;
+ if (
+ stored &&
+ (stored.channelIndex ?? TARGET_DEFAULT) === target.channelIndex &&
+ (stored.preset ?? TARGET_DEFAULT) === target.preset
+ ) {
+ const kept = clone(TargetSchema, stored);
+ kept.region = region;
+ return kept;
+ }
+ return create(
+ Protobuf.ModuleConfig.ModuleConfig_MeshBeaconConfig_BroadcastTargetSchema,
+ {
+ region,
+ channelIndex:
+ target.channelIndex === TARGET_DEFAULT
+ ? undefined
+ : target.channelIndex,
+ preset: target.preset === TARGET_DEFAULT ? undefined : target.preset,
+ },
+ );
+}
+
+/**
+ * A channel pick on a target row (Android's selectBeaconTargetChannel): the
+ * first time a row gets a real channel, preselect the radio's preset, but
+ * never replace a preset the user chose.
+ */
+export function selectTargetChannel(
+ target: MeshBeaconTargetValidation,
+ channelIndex: number,
+ currentPreset: ModemPreset | undefined,
+): MeshBeaconTargetValidation {
+ const preset =
+ channelIndex !== TARGET_DEFAULT &&
+ target.preset === TARGET_DEFAULT &&
+ currentPreset !== undefined
+ ? currentPreset
+ : target.preset;
+ return { channelIndex, preset };
+}
+
+/**
+ * Whether the broadcast half of the editor shows and can be edited
+ * (Android's beaconBroadcastGate). It shows on a standard preset, or while
+ * the radio is still broadcasting so it can be turned off, and is editable
+ * only on a standard preset.
+ */
+export function beaconBroadcastGate(
+ usePreset: boolean,
+ storedBroadcastEnabled: boolean,
+): {
+ sectionsVisible: boolean;
+ sectionsEnabled: boolean;
+ toggleEnabled: boolean;
+} {
+ const visible = usePreset || storedBroadcastEnabled;
+ return {
+ sectionsVisible: visible,
+ sectionsEnabled: usePreset,
+ toggleEnabled: visible,
+ };
+}
diff --git a/apps/web/src/validation/moduleConfig/moduleMessages.test.ts b/apps/web/src/validation/moduleConfig/moduleMessages.test.ts
new file mode 100644
index 000000000..fbb27286e
--- /dev/null
+++ b/apps/web/src/validation/moduleConfig/moduleMessages.test.ts
@@ -0,0 +1,67 @@
+import { Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import { CannedMessageValidationSchema } from "./cannedMessage.ts";
+import { ExternalNotificationValidationSchema } from "./externalNotification.ts";
+
+const InputEvent =
+ Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar;
+
+const canned = {
+ rotary1Enabled: false,
+ inputbrokerPinA: 0,
+ inputbrokerPinB: 0,
+ inputbrokerPinPress: 0,
+ inputbrokerEventCw: InputEvent.NONE,
+ inputbrokerEventCcw: InputEvent.NONE,
+ inputbrokerEventPress: InputEvent.NONE,
+ updown1Enabled: false,
+ enabled: true,
+ allowInputSource: "",
+ sendBell: false,
+ messages: "Hi|Bye",
+};
+
+const notification = {
+ enabled: true,
+ outputMs: 0,
+ output: 0,
+ outputVibra: 0,
+ outputBuzzer: 0,
+ active: false,
+ alertMessage: false,
+ alertMessageVibra: false,
+ alertMessageBuzzer: false,
+ alertBell: false,
+ alertBellVibra: false,
+ alertBellBuzzer: false,
+ usePwm: false,
+ nagTimeout: 0,
+ useI2sAsBuzzer: false,
+ ringtone: "24:d=32,o=5,b=565:f6,p,f6",
+};
+
+describe("canned messages", () => {
+ it("allows 200 bytes of messages, counted in UTF-8", () => {
+ const parse = (messages: string) =>
+ CannedMessageValidationSchema.safeParse({ ...canned, messages });
+ expect(parse("a".repeat(200)).success).toBe(true);
+ const tooLong = parse("é".repeat(101));
+ expect(tooLong.success).toBe(false);
+ expect(tooLong.error?.issues[0]?.message).toBe(
+ "formValidation.tooBig.bytes",
+ );
+ });
+});
+
+describe("ringtone", () => {
+ it("allows 230 bytes", () => {
+ const parse = (ringtone: string) =>
+ ExternalNotificationValidationSchema.safeParse({
+ ...notification,
+ ringtone,
+ }).success;
+ expect(parse(notification.ringtone)).toBe(true);
+ expect(parse("a".repeat(230))).toBe(true);
+ expect(parse("a".repeat(231))).toBe(false);
+ });
+});
diff --git a/apps/web/src/validation/moduleConfig/remoteHardware.test.ts b/apps/web/src/validation/moduleConfig/remoteHardware.test.ts
new file mode 100644
index 000000000..a0000148d
--- /dev/null
+++ b/apps/web/src/validation/moduleConfig/remoteHardware.test.ts
@@ -0,0 +1,73 @@
+import { isMessage } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import {
+ REMOTE_HARDWARE_MAX_PINS,
+ type RemoteHardwareValidation,
+ RemoteHardwareValidationSchema,
+ toRemoteHardwareConfig,
+} from "./remoteHardware.ts";
+
+const PinType = Protobuf.ModuleConfig.RemoteHardwarePinType;
+
+const pin = (gpioPin: number, name = "Door") => ({
+ gpioPin,
+ name,
+ type: PinType.DIGITAL_READ,
+});
+
+const valid: RemoteHardwareValidation = {
+ enabled: true,
+ allowUndefinedPinAccess: false,
+ availablePins: [pin(12)],
+};
+
+describe("RemoteHardwareValidationSchema", () => {
+ it("accepts a valid config", () => {
+ expect(RemoteHardwareValidationSchema.safeParse(valid).success).toBe(true);
+ });
+
+ it("allows at most four pins", () => {
+ const parse = (count: number) =>
+ RemoteHardwareValidationSchema.safeParse({
+ ...valid,
+ availablePins: Array.from({ length: count }, (_, i) => pin(i)),
+ }).success;
+ expect(parse(REMOTE_HARDWARE_MAX_PINS)).toBe(true);
+ expect(parse(REMOTE_HARDWARE_MAX_PINS + 1)).toBe(false);
+ });
+
+ it("limits a pin name to 14 bytes", () => {
+ const parse = (name: string) =>
+ RemoteHardwareValidationSchema.safeParse({
+ ...valid,
+ availablePins: [pin(1, name)],
+ }).success;
+ expect(parse("a".repeat(14))).toBe(true);
+ expect(parse("é".repeat(8))).toBe(false);
+ });
+
+ it("coerces a typed pin number and rejects a negative one", () => {
+ const parse = (gpioPin: unknown) =>
+ RemoteHardwareValidationSchema.safeParse({
+ ...valid,
+ availablePins: [{ ...pin(0), gpioPin }],
+ });
+ expect(parse("7").data?.availablePins[0]?.gpioPin).toBe(7);
+ expect(parse(-1).success).toBe(false);
+ });
+});
+
+describe("toRemoteHardwareConfig", () => {
+ it("builds each pin as a protobuf message", () => {
+ const config = toRemoteHardwareConfig(valid);
+ const [first] = config.availablePins ?? [];
+ expect(
+ first && isMessage(first, Protobuf.ModuleConfig.RemoteHardwarePinSchema),
+ ).toBe(true);
+ expect(first?.gpioPin).toBe(12);
+ expect(first?.name).toBe("Door");
+ expect(first?.type).toBe(PinType.DIGITAL_READ);
+ expect(config.enabled).toBe(true);
+ });
+});
diff --git a/apps/web/src/validation/moduleConfig/remoteHardware.ts b/apps/web/src/validation/moduleConfig/remoteHardware.ts
index 51fb4fe6e..0727d6431 100644
--- a/apps/web/src/validation/moduleConfig/remoteHardware.ts
+++ b/apps/web/src/validation/moduleConfig/remoteHardware.ts
@@ -1,13 +1,65 @@
+import { create } from "@bufbuild/protobuf";
+import { validateMaxByteLength } from "@core/utils/string.ts";
+import { Protobuf } from "@meshtastic/sdk";
import { z } from "zod/v4";
+/** nanopb max_count for available_pins. */
+export const REMOTE_HARDWARE_MAX_PINS = 4;
+/** nanopb max_size 15 for a pin name, less the terminator. */
+export const REMOTE_HARDWARE_PIN_NAME_MAX_BYTES = 14;
+
+export const RemoteHardwarePinValidationSchema = z.object({
+ gpioPin: z.coerce.number().int().min(0),
+ name: z
+ .string()
+ .refine(
+ (s) =>
+ validateMaxByteLength(s, REMOTE_HARDWARE_PIN_NAME_MAX_BYTES).isValid,
+ {
+ message: "formValidation.tooBig.bytes",
+ params: { maximum: REMOTE_HARDWARE_PIN_NAME_MAX_BYTES },
+ },
+ ),
+ type: z.enum(Protobuf.ModuleConfig.RemoteHardwarePinType),
+});
+
export const RemoteHardwareValidationSchema = z.object({
enabled: z.boolean(),
allowUndefinedPinAccess: z.boolean(),
- // Preserved untouched: the form does not yet edit the available-pins list,
- // so round-trip it to avoid clearing pins configured elsewhere.
- availablePins: z.array(z.any()),
+ availablePins: z
+ .array(RemoteHardwarePinValidationSchema)
+ .max(REMOTE_HARDWARE_MAX_PINS),
});
+export type RemoteHardwarePinValidation = z.infer<
+ typeof RemoteHardwarePinValidationSchema
+>;
+
export type RemoteHardwareValidation = z.infer<
typeof RemoteHardwareValidationSchema
>;
+
+/** The config to stage, with each pin built as a proper protobuf message. */
+export function toRemoteHardwareConfig(
+ data: RemoteHardwareValidation,
+): Partial {
+ return {
+ enabled: data.enabled,
+ allowUndefinedPinAccess: data.allowUndefinedPinAccess,
+ availablePins: data.availablePins.map((pin) =>
+ createPin(pin.gpioPin, pin.name, pin.type),
+ ),
+ };
+}
+
+function createPin(
+ gpioPin: number,
+ name: string,
+ type: Protobuf.ModuleConfig.RemoteHardwarePinType,
+): Protobuf.ModuleConfig.RemoteHardwarePin {
+ return create(Protobuf.ModuleConfig.RemoteHardwarePinSchema, {
+ gpioPin,
+ name,
+ type,
+ });
+}
diff --git a/apps/web/src/validation/moduleConfig/statusMessage.ts b/apps/web/src/validation/moduleConfig/statusMessage.ts
deleted file mode 100644
index 1d6344147..000000000
--- a/apps/web/src/validation/moduleConfig/statusMessage.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { z } from "zod/v4";
-
-export const StatusMessageValidationSchema = z.object({
- nodeStatus: z.string().max(200),
-});
-
-export type StatusMessageValidation = z.infer<
- typeof StatusMessageValidationSchema
->;
diff --git a/packages/sdk-react/mod.ts b/packages/sdk-react/mod.ts
index 8aef97414..0d3d06ebf 100644
--- a/packages/sdk-react/mod.ts
+++ b/packages/sdk-react/mod.ts
@@ -39,6 +39,11 @@ export {
useModuleConfig,
} from "./src/hooks/useConfig.ts";
export { useConfigEditor } from "./src/hooks/useConfigEditor.ts";
+export {
+ useCannedMessages,
+ useRingtone,
+} from "./src/hooks/useModuleMessages.ts";
+export type { UseModuleMessagesResult } from "./src/hooks/useModuleMessages.ts";
export { useConnectionProgress } from "./src/hooks/useConnectionProgress.ts";
export {
useTotalUnread,
diff --git a/packages/sdk-react/src/hooks/useModuleMessages.ts b/packages/sdk-react/src/hooks/useModuleMessages.ts
new file mode 100644
index 000000000..96c7de8a7
--- /dev/null
+++ b/packages/sdk-react/src/hooks/useModuleMessages.ts
@@ -0,0 +1,134 @@
+import type { MeshClient, Protobuf, ReadonlySignal } from "@meshtastic/sdk";
+import { Result } from "better-result";
+import { useCallback, useContext, useEffect, useRef, useState } from "react";
+import { useOptionalMeshRegistry } from "../adapters/useMeshRegistry.ts";
+import { useSignal } from "../adapters/useSignal.ts";
+import { MeshContext } from "../provider/MeshContext.ts";
+
+/** How long to wait for the device to answer before giving up. */
+export const MODULE_MESSAGES_TIMEOUT_MS = 15_000;
+
+export interface UseModuleMessagesResult {
+ /** The device's value, or undefined until it has answered once. */
+ value: string | undefined;
+ /** A request is out and the device hasn't answered it yet. */
+ isLoading: boolean;
+ /** The request couldn't be sent, or the device didn't answer in time. */
+ error: Error | undefined;
+ /** Ask the device again. */
+ refresh: () => void;
+}
+
+const EMPTY_STRING_SIGNAL: ReadonlySignal = {
+ value: undefined,
+ peek: () => undefined,
+ subscribe: () => () => {},
+};
+
+const EMPTY_CLIENT_SIGNAL: ReadonlySignal = {
+ value: undefined,
+ peek: () => undefined,
+ subscribe: () => () => {},
+};
+
+type ResponseCase = Extract<
+ Protobuf.Admin.AdminMessage["payloadVariant"]["case"],
+ "getCannedMessageModuleMessagesResponse" | "getRingtoneResponse"
+>;
+
+interface Source {
+ signal: (client: MeshClient) => ReadonlySignal;
+ request: (client: MeshClient) => Promise>;
+ responseCase: ResponseCase;
+}
+
+/** The nearest MeshProvider client, else the registry's active one, if any. */
+function useOptionalClient(): MeshClient | undefined {
+ const direct = useContext(MeshContext);
+ const registry = useOptionalMeshRegistry();
+ const active = useSignal(registry?.active ?? EMPTY_CLIENT_SIGNAL);
+ return direct ?? active;
+}
+
+function useModuleMessages(source: Source): UseModuleMessagesResult {
+ const client = useOptionalClient();
+ const value = useSignal(client ? source.signal(client) : EMPTY_STRING_SIGNAL);
+ // Loading from the first render, since the request goes out on mount.
+ const [isLoading, setIsLoading] = useState(client !== undefined);
+ const [error, setError] = useState(undefined);
+ const timer = useRef | undefined>(undefined);
+ const sourceRef = useRef(source);
+
+ const stopWaiting = useCallback(() => {
+ clearTimeout(timer.current);
+ timer.current = undefined;
+ setIsLoading(false);
+ }, []);
+
+ // The answer may repeat the cached value, so wait on the packet rather
+ // than on the signal changing.
+ useEffect(() => {
+ if (!client) return;
+ return client.events.onAdminPacket.subscribe((packet) => {
+ if (packet.from !== client.myNodeNum) return;
+ if (packet.data.payloadVariant.case !== sourceRef.current.responseCase) {
+ return;
+ }
+ setError(undefined);
+ stopWaiting();
+ });
+ }, [client, stopWaiting]);
+
+ const refresh = useCallback(() => {
+ if (!client) return;
+ clearTimeout(timer.current);
+ setIsLoading(true);
+ setError(undefined);
+ timer.current = setTimeout(() => {
+ timer.current = undefined;
+ setIsLoading(false);
+ setError(new Error("The device did not answer"));
+ }, MODULE_MESSAGES_TIMEOUT_MS);
+ sourceRef.current.request(client).then((result) => {
+ if (Result.isError(result)) {
+ setError(result.error);
+ stopWaiting();
+ }
+ });
+ }, [client, stopWaiting]);
+
+ useEffect(() => {
+ refresh();
+ return () => clearTimeout(timer.current);
+ }, [refresh]);
+
+ return { value, isLoading, error, refresh };
+}
+
+const CANNED_MESSAGES: Source = {
+ signal: (client) => client.config.cannedMessages,
+ request: (client) => client.config.requestCannedMessages(),
+ responseCase: "getCannedMessageModuleMessagesResponse",
+};
+
+const RINGTONE: Source = {
+ signal: (client) => client.config.ringtone,
+ request: (client) => client.config.requestRingtone(),
+ responseCase: "getRingtoneResponse",
+};
+
+/**
+ * The connected device's `|`-separated canned message list. Asks the device
+ * for it on mount. Returns an undefined value when no client is active.
+ */
+export function useCannedMessages(): UseModuleMessagesResult {
+ return useModuleMessages(CANNED_MESSAGES);
+}
+
+/**
+ * The connected device's External Notification ringtone (RTTTL). Asks the
+ * device for it on mount. Returns an undefined value when no client is active.
+ */
+export function useRingtone(): UseModuleMessagesResult {
+ return useModuleMessages(RINGTONE);
+}
diff --git a/packages/sdk-react/tests/useModuleMessages.test.tsx b/packages/sdk-react/tests/useModuleMessages.test.tsx
new file mode 100644
index 000000000..267da7710
--- /dev/null
+++ b/packages/sdk-react/tests/useModuleMessages.test.tsx
@@ -0,0 +1,88 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { MeshClient, Protobuf } from "@meshtastic/sdk";
+import { createFakeTransport } from "@meshtastic/sdk/testing";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { MeshProvider, useCannedMessages, useRingtone } from "../mod.ts";
+import { MODULE_MESSAGES_TIMEOUT_MS } from "../src/hooks/useModuleMessages.ts";
+
+function setup() {
+ const handle = createFakeTransport();
+ const client = new MeshClient({ transport: handle.transport });
+ const sendPacket = vi.spyOn(client, "sendPacket").mockResolvedValue(1);
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ return { client, sendPacket, wrapper };
+}
+
+function answer(
+ client: MeshClient,
+ payloadVariant: Protobuf.Admin.AdminMessage["payloadVariant"],
+) {
+ client.events.onAdminPacket.dispatch({
+ id: 2,
+ rxTime: new Date(),
+ type: "direct",
+ from: client.myNodeNum,
+ to: client.myNodeNum,
+ channel: 0,
+ // Only the fields the SDK reads; avoids a protobuf runtime dependency.
+ data: {
+ payloadVariant,
+ sessionPasskey: new Uint8Array(),
+ } as unknown as Protobuf.Admin.AdminMessage,
+ });
+}
+
+describe("useCannedMessages / useRingtone", () => {
+ afterEach(() => vi.useRealTimers());
+
+ it("asks the device on mount and shows its answer", async () => {
+ const { client, sendPacket, wrapper } = setup();
+ const { result } = renderHook(() => useCannedMessages(), { wrapper });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.value).toBeUndefined();
+ expect(sendPacket).toHaveBeenCalledTimes(1);
+
+ act(() =>
+ answer(client, {
+ case: "getCannedMessageModuleMessagesResponse",
+ value: "Yes|No",
+ }),
+ );
+ await waitFor(() => expect(result.current.value).toBe("Yes|No"));
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeUndefined();
+ });
+
+ it("stops loading when the answer repeats the cached value", async () => {
+ const { client, wrapper } = setup();
+ answer(client, { case: "getRingtoneResponse", value: "tone" });
+ const { result } = renderHook(() => useRingtone(), { wrapper });
+ expect(result.current.value).toBe("tone");
+ expect(result.current.isLoading).toBe(true);
+
+ act(() => answer(client, { case: "getRingtoneResponse", value: "tone" }));
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ });
+
+ it("reports an error when the device doesn't answer", async () => {
+ vi.useFakeTimers();
+ const { wrapper } = setup();
+ const { result } = renderHook(() => useRingtone(), { wrapper });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(MODULE_MESSAGES_TIMEOUT_MS);
+ });
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeInstanceOf(Error);
+ });
+
+ it("reports an error when the request can't be sent", async () => {
+ const { sendPacket, wrapper } = setup();
+ sendPacket.mockRejectedValue(new Error("offline"));
+ const { result } = renderHook(() => useRingtone(), { wrapper });
+ await waitFor(() => expect(result.current.error?.message).toBe("offline"));
+ expect(result.current.isLoading).toBe(false);
+ });
+});
diff --git a/packages/sdk/mod.ts b/packages/sdk/mod.ts
index 722d09647..0b7f7116e 100644
--- a/packages/sdk/mod.ts
+++ b/packages/sdk/mod.ts
@@ -137,7 +137,12 @@ export {
isBeaconAlreadyJoined,
} from "./src/features/channels/index.ts";
-export { ConfigClient, ConfigEditor } from "./src/features/config/index.ts";
+export {
+ CANNED_MESSAGES_MAX_BYTES,
+ ConfigClient,
+ ConfigEditor,
+ RINGTONE_MAX_BYTES,
+} from "./src/features/config/index.ts";
export type {
ModuleConfig,
ModuleConfigSection,
diff --git a/packages/sdk/src/core/packet-codec/decodePacket.ts b/packages/sdk/src/core/packet-codec/decodePacket.ts
index 2317092eb..2e9903cb8 100644
--- a/packages/sdk/src/core/packet-codec/decodePacket.ts
+++ b/packages/sdk/src/core/packet-codec/decodePacket.ts
@@ -520,6 +520,14 @@ function handleDecodedPacket(
});
break;
}
+ case "getRingtoneResponse": {
+ // ConfigClient reads it from onAdminPacket above.
+ sink.log.debug(
+ Emitter[Emitter.HandleMeshPacket],
+ "🔔 Received ringtone response packet",
+ );
+ break;
+ }
default: {
sink.log.error(
Emitter[Emitter.HandleMeshPacket],
diff --git a/packages/sdk/src/features/config/ConfigClient.ts b/packages/sdk/src/features/config/ConfigClient.ts
index 37ea6572c..1b820e1b9 100644
--- a/packages/sdk/src/features/config/ConfigClient.ts
+++ b/packages/sdk/src/features/config/ConfigClient.ts
@@ -1,6 +1,6 @@
import * as Protobuf from "@meshtastic/protobufs";
import { computed } from "@preact/signals-core";
-import type { Result } from "better-result";
+import { Result } from "better-result";
import type { MeshClient } from "../../core/client/MeshClient.ts";
import {
type ReadonlySignal,
@@ -14,6 +14,13 @@ import {
setConfig,
setModuleConfig,
} from "./application/ConfigUseCases.ts";
+import {
+ requestCannedMessages,
+ requestRingtone,
+ setCannedMessages,
+ setRingtone,
+} from "./application/ModuleMessagesUseCases.ts";
+import { DeviceStatusEnum } from "../device/domain/DeviceStatus.ts";
import { ConfigEditor } from "./domain/ConfigEditor.ts";
import type { ModuleConfig } from "./domain/ModuleConfig.ts";
import type { RadioConfig } from "./domain/RadioConfig.ts";
@@ -36,13 +43,28 @@ export class ConfigClient {
* flash a "set region" prompt during the connect handshake.
*/
public readonly isRegionUnset: ReadonlySignal;
+ /**
+ * The device's `|`-separated canned message list, or undefined until
+ * {@link requestCannedMessages} has been answered.
+ */
+ public readonly cannedMessages: ReadonlySignal;
+ /**
+ * The External Notification ringtone (RTTTL), or undefined until
+ * {@link requestRingtone} has been answered.
+ */
+ public readonly ringtone: ReadonlySignal;
constructor(client: MeshClient) {
this.client = client;
this.store = createConfigStore();
this.radio = this.store.radio.read;
this.modules = this.store.modules.read;
- this.editor = new ConfigEditor(client);
+ this.cannedMessages = this.store.cannedMessages.read;
+ this.ringtone = this.store.ringtone.read;
+ this.editor = new ConfigEditor(client, {
+ onStagedAdminMessageCommitted: (message) =>
+ this.applySentAdminMessage(message.payloadVariant),
+ });
this.isRegionUnset = toReadonly(
computed(() => {
@@ -66,6 +88,63 @@ export class ConfigClient {
moduleConfig,
);
});
+ client.events.onAdminPacket.subscribe((packet) => {
+ // Only the local node's answers; a remote node's list is not ours.
+ if (packet.from !== client.myNodeNum) return;
+ const variant = packet.data.payloadVariant;
+ if (variant.case === "getCannedMessageModuleMessagesResponse") {
+ this.store.cannedMessages.write.value = variant.value;
+ } else if (variant.case === "getRingtoneResponse") {
+ this.store.ringtone.write.value = variant.value;
+ }
+ });
+ client.events.onDeviceStatus.subscribe((status) => {
+ if (status === DeviceStatusEnum.DeviceDisconnected) {
+ this.store.cannedMessages.write.value = undefined;
+ this.store.ringtone.write.value = undefined;
+ }
+ });
+ }
+
+ /** Ask the device for its canned message list; see {@link cannedMessages}. */
+ public requestCannedMessages(): Promise> {
+ return requestCannedMessages(this.client);
+ }
+
+ /** Set the canned message list (`|`-separated, at most 200 bytes). */
+ public async setCannedMessages(
+ messages: string,
+ ): Promise> {
+ const result = await setCannedMessages(this.client, messages);
+ if (Result.isOk(result)) {
+ this.store.cannedMessages.write.value = messages;
+ }
+ return result;
+ }
+
+ /** Ask the device for its ringtone; see {@link ringtone}. */
+ public requestRingtone(): Promise> {
+ return requestRingtone(this.client);
+ }
+
+ /** Set the External Notification ringtone (RTTTL, at most 230 bytes). */
+ public async setRingtone(ringtone: string): Promise> {
+ const result = await setRingtone(this.client, ringtone);
+ if (Result.isOk(result)) {
+ this.store.ringtone.write.value = ringtone;
+ }
+ return result;
+ }
+
+ /** Keep the cached values in step with sets sent through the editor. */
+ private applySentAdminMessage(
+ variant: Protobuf.Admin.AdminMessage["payloadVariant"],
+ ): void {
+ if (variant.case === "setCannedMessageModuleMessages") {
+ this.store.cannedMessages.write.value = variant.value;
+ } else if (variant.case === "setRingtoneMessage") {
+ this.store.ringtone.write.value = variant.value;
+ }
}
public beginEdit(): Promise> {
diff --git a/packages/sdk/src/features/config/ConfigEditor.test.ts b/packages/sdk/src/features/config/ConfigEditor.test.ts
index fbf7d7cef..09d5a4901 100644
--- a/packages/sdk/src/features/config/ConfigEditor.test.ts
+++ b/packages/sdk/src/features/config/ConfigEditor.test.ts
@@ -91,6 +91,25 @@ describe("ConfigEditor", () => {
expect(editor.radio.value.lora?.region).toBe(4);
});
+ it("reset() drops queued admin messages", () => {
+ const { transport } = createFakeTransport();
+ const client = new MeshClient({ transport });
+ const editor = client.config.editor;
+
+ editor.queueAdminMessage(
+ create(Protobuf.Admin.AdminMessageSchema, {
+ payloadVariant: {
+ case: "setFixedPosition",
+ value: create(Protobuf.Mesh.PositionSchema, { latitudeI: 1 }),
+ },
+ }),
+ );
+ expect(editor.isDirty.value).toBe(true);
+
+ editor.reset();
+ expect(editor.isDirty.value).toBe(false);
+ });
+
it("inbound baseline updates do not stomp pending working edits", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
@@ -111,6 +130,74 @@ describe("ConfigEditor", () => {
expect(editor.dirtyRadioSections.value).toEqual(["lora"]);
});
+ it("keeps radio fields a partial update leaves out", () => {
+ const { transport } = createFakeTransport();
+ const client = new MeshClient({ transport });
+ const editor = client.config.editor;
+
+ client.events.onConfigPacket.dispatch(
+ create(Protobuf.Config.ConfigSchema, {
+ payloadVariant: {
+ case: "lora",
+ value: create(Protobuf.Config.Config_LoRaConfigSchema, {
+ region: 4,
+ paFanDisabled: true,
+ ignoreIncoming: [1234],
+ }),
+ },
+ }),
+ );
+
+ // Form data after zod validation: only the fields the form renders.
+ editor.setRadioSection("lora", {
+ region: 7,
+ } as unknown as Protobuf.Config.Config_LoRaConfig);
+
+ const lora = editor.radio.value.lora;
+ expect(lora?.region).toBe(7);
+ expect(lora?.paFanDisabled).toBe(true);
+ expect(lora?.ignoreIncoming).toEqual([1234]);
+ expect(editor.dirtyRadioSections.value).toEqual(["lora"]);
+ });
+
+ it("keeps module fields a partial update leaves out", () => {
+ const { transport } = createFakeTransport();
+ const client = new MeshClient({ transport });
+ const editor = client.config.editor;
+
+ client.events.onModuleConfigPacket.dispatch(
+ create(Protobuf.ModuleConfig.ModuleConfigSchema, {
+ payloadVariant: {
+ case: "rangeTest",
+ value: create(
+ Protobuf.ModuleConfig.ModuleConfig_RangeTestConfigSchema,
+ { enabled: false, clearOnReboot: true },
+ ),
+ },
+ }),
+ );
+
+ editor.setModuleSection("rangeTest", {
+ enabled: true,
+ } as unknown as Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig);
+
+ expect(editor.modules.value.rangeTest?.enabled).toBe(true);
+ expect(editor.modules.value.rangeTest?.clearOnReboot).toBe(true);
+ });
+
+ it("a partial update equal to the baseline is not dirty", () => {
+ const { transport } = createFakeTransport();
+ const client = new MeshClient({ transport });
+ const editor = client.config.editor;
+
+ client.events.onConfigPacket.dispatch(loraPacket(4));
+ editor.setRadioSection("lora", {
+ region: 4,
+ } as unknown as Protobuf.Config.Config_LoRaConfig);
+
+ expect(editor.isDirty.value).toBe(false);
+ });
+
it("disconnect drops baseline and working", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
diff --git a/packages/sdk/src/features/config/ModuleMessages.test.ts b/packages/sdk/src/features/config/ModuleMessages.test.ts
new file mode 100644
index 000000000..c5ba09316
--- /dev/null
+++ b/packages/sdk/src/features/config/ModuleMessages.test.ts
@@ -0,0 +1,227 @@
+import { create, fromBinary } from "@bufbuild/protobuf";
+import * as Protobuf from "@meshtastic/protobufs";
+import { Result } from "better-result";
+import { describe, expect, it, vi } from "vitest";
+import { MeshClient } from "../../core/client/MeshClient.ts";
+import { createFakeTransport } from "../../core/testing/createFakeTransport.ts";
+import { DeviceStatusEnum } from "../../core/transport/Transport.ts";
+import {
+ CANNED_MESSAGES_MAX_BYTES,
+ RINGTONE_MAX_BYTES,
+ requestCannedMessages,
+ requestRingtone,
+ setCannedMessages,
+ setRingtone,
+} from "./application/ModuleMessagesUseCases.ts";
+
+function makeClient() {
+ const { transport } = createFakeTransport();
+ const client = new MeshClient({ transport });
+ const sendPacket = vi.spyOn(client, "sendPacket").mockResolvedValue(42);
+ const sentAdmin = () =>
+ sendPacket.mock.calls.map((call) =>
+ fromBinary(Protobuf.Admin.AdminMessageSchema, call[0] as Uint8Array),
+ );
+ return { client, sendPacket, sentAdmin };
+}
+
+function adminPacket(
+ from: number,
+ payloadVariant: Protobuf.Admin.AdminMessage["payloadVariant"],
+) {
+ return {
+ id: 1,
+ rxTime: new Date(),
+ type: "direct" as const,
+ from,
+ to: from,
+ channel: 0,
+ data: create(Protobuf.Admin.AdminMessageSchema, { payloadVariant }),
+ };
+}
+
+function admin(payloadVariant: Protobuf.Admin.AdminMessage["payloadVariant"]) {
+ return create(Protobuf.Admin.AdminMessageSchema, { payloadVariant });
+}
+
+describe("module message use-cases", () => {
+ it("requests the canned messages and the ringtone", async () => {
+ const { client, sentAdmin } = makeClient();
+ expect(Result.isOk(await requestCannedMessages(client))).toBe(true);
+ expect(Result.isOk(await requestRingtone(client))).toBe(true);
+ expect(sentAdmin().map((m) => m.payloadVariant)).toEqual([
+ { case: "getCannedMessageModuleMessagesRequest", value: true },
+ { case: "getRingtoneRequest", value: true },
+ ]);
+ });
+
+ it("sets the canned messages and the ringtone", async () => {
+ const { client, sentAdmin } = makeClient();
+ const result = await setCannedMessages(client, "Hi|Bye");
+ expect(Result.isOk(result) && result.value).toBe(42);
+ await setRingtone(client, "a:d=8,o=5,b=100:c");
+ expect(sentAdmin().map((m) => m.payloadVariant)).toEqual([
+ { case: "setCannedMessageModuleMessages", value: "Hi|Bye" },
+ { case: "setRingtoneMessage", value: "a:d=8,o=5,b=100:c" },
+ ]);
+ });
+
+ it("rejects values over the firmware byte limits without sending", async () => {
+ const { client, sendPacket } = makeClient();
+ const messages = await setCannedMessages(
+ client,
+ "é".repeat(CANNED_MESSAGES_MAX_BYTES / 2 + 1),
+ );
+ const ringtone = await setRingtone(
+ client,
+ "a".repeat(RINGTONE_MAX_BYTES + 1),
+ );
+ expect(Result.isError(messages)).toBe(true);
+ expect(Result.isError(ringtone)).toBe(true);
+ expect(sendPacket).not.toHaveBeenCalled();
+ });
+
+ it("returns an error when the send fails", async () => {
+ const { client, sendPacket } = makeClient();
+ sendPacket.mockRejectedValueOnce(new Error("offline"));
+ const result = await requestRingtone(client);
+ expect(Result.isError(result) && result.error.message).toBe("offline");
+ });
+});
+
+describe("ConfigClient canned messages and ringtone", () => {
+ it("stays undefined until the local node answers", () => {
+ const { client } = makeClient();
+ expect(client.config.cannedMessages.value).toBeUndefined();
+ expect(client.config.ringtone.value).toBeUndefined();
+
+ client.events.onAdminPacket.dispatch(
+ adminPacket(client.myNodeNum, {
+ case: "getCannedMessageModuleMessagesResponse",
+ value: "Yes|No",
+ }),
+ );
+ client.events.onAdminPacket.dispatch(
+ adminPacket(client.myNodeNum, {
+ case: "getRingtoneResponse",
+ value: "tone:d=4:c",
+ }),
+ );
+ expect(client.config.cannedMessages.value).toBe("Yes|No");
+ expect(client.config.ringtone.value).toBe("tone:d=4:c");
+ });
+
+ it("ignores answers from other nodes", () => {
+ const { client } = makeClient();
+ client.events.onAdminPacket.dispatch(
+ adminPacket(client.myNodeNum + 1, {
+ case: "getRingtoneResponse",
+ value: "remote",
+ }),
+ );
+ expect(client.config.ringtone.value).toBeUndefined();
+ });
+
+ it("forgets both on disconnect", () => {
+ const { client } = makeClient();
+ client.events.onAdminPacket.dispatch(
+ adminPacket(client.myNodeNum, {
+ case: "getRingtoneResponse",
+ value: "tone",
+ }),
+ );
+ client.events.onDeviceStatus.dispatch(DeviceStatusEnum.DeviceDisconnected);
+ expect(client.config.ringtone.value).toBeUndefined();
+ });
+
+ it("caches a value it set", async () => {
+ const { client } = makeClient();
+ await client.config.setCannedMessages("A|B");
+ await client.config.setRingtone("r");
+ expect(client.config.cannedMessages.value).toBe("A|B");
+ expect(client.config.ringtone.value).toBe("r");
+ });
+});
+
+describe("ConfigEditor.stageAdminMessage", () => {
+ it("keeps one message per key and marks the editor dirty", () => {
+ const { client } = makeClient();
+ const editor = client.config.editor;
+ editor.stageAdminMessage(
+ "cannedMessages",
+ admin({ case: "setCannedMessageModuleMessages", value: "a" }),
+ );
+ editor.stageAdminMessage(
+ "cannedMessages",
+ admin({ case: "setCannedMessageModuleMessages", value: "ab" }),
+ );
+ expect(editor.stagedAdminMessages.value.size).toBe(1);
+ expect(
+ editor.stagedAdminMessages.value.get("cannedMessages")?.payloadVariant
+ .value,
+ ).toBe("ab");
+ expect(editor.isDirty.value).toBe(true);
+
+ editor.stageAdminMessage("cannedMessages", undefined);
+ expect(editor.stagedAdminMessages.value.size).toBe(0);
+ expect(editor.isDirty.value).toBe(false);
+ });
+
+ it("drops staged messages on reset and disconnect", () => {
+ const { client } = makeClient();
+ const editor = client.config.editor;
+ const ringtone = admin({ case: "setRingtoneMessage", value: "r" });
+
+ editor.stageAdminMessage("ringtone", ringtone);
+ editor.reset();
+ expect(editor.stagedAdminMessages.value.size).toBe(0);
+ expect(editor.isDirty.value).toBe(false);
+
+ editor.stageAdminMessage("ringtone", ringtone);
+ client.events.onDeviceStatus.dispatch(DeviceStatusEnum.DeviceDisconnected);
+ expect(editor.stagedAdminMessages.value.size).toBe(0);
+ });
+
+ it("sends staged messages inside the edit and caches them on commit", async () => {
+ const { client, sentAdmin } = makeClient();
+ const editor = client.config.editor;
+ editor.stageAdminMessage(
+ "cannedMessages",
+ admin({ case: "setCannedMessageModuleMessages", value: "Hi|Bye" }),
+ );
+ editor.stageAdminMessage(
+ "ringtone",
+ admin({ case: "setRingtoneMessage", value: "tone" }),
+ );
+
+ const result = await editor.commit();
+ expect(Result.isOk(result)).toBe(true);
+ expect(sentAdmin().map((m) => m.payloadVariant.case)).toEqual([
+ "beginEditSettings",
+ "setCannedMessageModuleMessages",
+ "setRingtoneMessage",
+ "commitEditSettings",
+ ]);
+ expect(editor.stagedAdminMessages.value.size).toBe(0);
+ expect(editor.isDirty.value).toBe(false);
+ expect(client.config.cannedMessages.value).toBe("Hi|Bye");
+ expect(client.config.ringtone.value).toBe("tone");
+ });
+
+ it("keeps staged messages when the commit fails", async () => {
+ const { client, sendPacket } = makeClient();
+ const editor = client.config.editor;
+ editor.stageAdminMessage(
+ "ringtone",
+ admin({ case: "setRingtoneMessage", value: "tone" }),
+ );
+ sendPacket
+ .mockResolvedValueOnce(1)
+ .mockRejectedValueOnce(new Error("no ack"));
+
+ const result = await editor.commit();
+ expect(Result.isError(result)).toBe(true);
+ expect(editor.stagedAdminMessages.value.size).toBe(1);
+ expect(client.config.ringtone.value).toBeUndefined();
+ });
+});
diff --git a/packages/sdk/src/features/config/application/ModuleMessagesUseCases.ts b/packages/sdk/src/features/config/application/ModuleMessagesUseCases.ts
new file mode 100644
index 000000000..e247d93a6
--- /dev/null
+++ b/packages/sdk/src/features/config/application/ModuleMessagesUseCases.ts
@@ -0,0 +1,91 @@
+import type * as Protobuf from "@meshtastic/protobufs";
+import { Result } from "better-result";
+import type { MeshClient } from "../../../core/client/MeshClient.ts";
+import { sendAdminMessage } from "../../device/infrastructure/AdminMessageSender.ts";
+
+/**
+ * The canned message list and the External Notification ringtone are not part
+ * of ModuleConfig. Firmware keeps each one as a single string and reads and
+ * writes it through its own admin messages.
+ */
+
+/** nanopb max_size 201 for the `|`-separated canned message list. */
+export const CANNED_MESSAGES_MAX_BYTES = 200;
+/** nanopb max_size 231 for the RTTTL ringtone. */
+export const RINGTONE_MAX_BYTES = 230;
+
+const encoder = new TextEncoder();
+
+async function send(
+ client: MeshClient,
+ payloadVariant: Protobuf.Admin.AdminMessage["payloadVariant"],
+): Promise> {
+ try {
+ return Result.ok(await sendAdminMessage(client, payloadVariant));
+ } catch (e) {
+ return Result.err(e instanceof Error ? e : new Error(String(e)));
+ }
+}
+
+function tooLong(what: string, max: number): Error {
+ return new Error(`${what} is longer than ${max} bytes`);
+}
+
+/** Admin message that sets the canned message list. */
+export function cannedMessagesAdminMessage(
+ messages: string,
+): Protobuf.Admin.AdminMessage["payloadVariant"] {
+ return { case: "setCannedMessageModuleMessages", value: messages };
+}
+
+/** Admin message that sets the External Notification ringtone. */
+export function ringtoneAdminMessage(
+ ringtone: string,
+): Protobuf.Admin.AdminMessage["payloadVariant"] {
+ return { case: "setRingtoneMessage", value: ringtone };
+}
+
+/**
+ * Ask the device for its canned message list. The answer arrives as a
+ * `getCannedMessageModuleMessagesResponse` admin message.
+ */
+export function requestCannedMessages(
+ client: MeshClient,
+): Promise> {
+ return send(client, {
+ case: "getCannedMessageModuleMessagesRequest",
+ value: true,
+ });
+}
+
+export function setCannedMessages(
+ client: MeshClient,
+ messages: string,
+): Promise> {
+ if (encoder.encode(messages).length > CANNED_MESSAGES_MAX_BYTES) {
+ return Promise.resolve(
+ Result.err(tooLong("Canned messages", CANNED_MESSAGES_MAX_BYTES)),
+ );
+ }
+ return send(client, cannedMessagesAdminMessage(messages));
+}
+
+/**
+ * Ask the device for its ringtone. The answer arrives as a
+ * `getRingtoneResponse` admin message.
+ */
+export function requestRingtone(
+ client: MeshClient,
+): Promise> {
+ return send(client, { case: "getRingtoneRequest", value: true });
+}
+
+export function setRingtone(
+ client: MeshClient,
+ ringtone: string,
+): Promise> {
+ if (encoder.encode(ringtone).length > RINGTONE_MAX_BYTES) {
+ return Promise.resolve(Result.err(tooLong("Ringtone", RINGTONE_MAX_BYTES)));
+ }
+ return send(client, ringtoneAdminMessage(ringtone));
+}
diff --git a/packages/sdk/src/features/config/domain/ConfigEditor.ts b/packages/sdk/src/features/config/domain/ConfigEditor.ts
index f6b9b9815..38a0872b5 100644
--- a/packages/sdk/src/features/config/domain/ConfigEditor.ts
+++ b/packages/sdk/src/features/config/domain/ConfigEditor.ts
@@ -58,6 +58,9 @@ export class ConfigEditor {
private readonly queuedAdminMessages = signal<
readonly Protobuf.Admin.AdminMessage[]
>([]);
+ private readonly _stagedAdminMessages = signal<
+ ReadonlyMap
+ >(new Map());
private readonly _dirtyRadioSections = signal(
[],
);
@@ -92,9 +95,29 @@ export class ConfigEditor {
this._isOwnerDirty,
);
public readonly isDirty: ReadonlySignal = toReadonly(this._isDirty);
-
- constructor(client: MeshClient) {
+ /**
+ * Admin messages staged with {@link stageAdminMessage}, by key. Sent on the
+ * next commit; cleared by commit, reset and disconnect.
+ */
+ public readonly stagedAdminMessages: ReadonlySignal<
+ ReadonlyMap
+ > = toReadonly(this._stagedAdminMessages);
+
+ private readonly onStagedAdminMessageCommitted?: (
+ message: Protobuf.Admin.AdminMessage,
+ ) => void;
+
+ constructor(
+ client: MeshClient,
+ options: {
+ /** Called for each staged admin message after a successful commit. */
+ onStagedAdminMessageCommitted?: (
+ message: Protobuf.Admin.AdminMessage,
+ ) => void;
+ } = {},
+ ) {
this.client = client;
+ this.onStagedAdminMessageCommitted = options.onStagedAdminMessageCommitted;
client.events.onConfigPacket.subscribe((config) => {
const next = ConfigMapper.mergeRadio(this.baselineRadio.peek(), config);
@@ -158,6 +181,7 @@ export class ConfigEditor {
this.workingChannels.value = new Map();
this.workingOwner.value = undefined;
this.queuedAdminMessages.value = [];
+ this._stagedAdminMessages.value = new Map();
this._dirtyRadioSections.value = [];
this._dirtyModuleSections.value = [];
this._dirtyChannels.value = [];
@@ -199,19 +223,58 @@ export class ConfigEditor {
this.recomputeDirty();
}
+ /**
+ * Stage an admin message under `key`, replacing any message staged under
+ * the same key, or remove it with `undefined`. Unlike
+ * {@link queueAdminMessage}, a form can call this on every change and
+ * {@link reset} drops it. Used for settings that live outside Config and
+ * ModuleConfig, such as the canned message list and the ringtone.
+ */
+ public stageAdminMessage(
+ key: string,
+ message: Protobuf.Admin.AdminMessage | undefined,
+ ): void {
+ const current = this._stagedAdminMessages.peek();
+ if (message === undefined && !current.has(key)) return;
+ const next = new Map(current);
+ if (message === undefined) {
+ next.delete(key);
+ } else {
+ next.set(key, message);
+ }
+ this._stagedAdminMessages.value = next;
+ this.recomputeDirty();
+ }
+
+ /**
+ * Stage a radio config section. `value` is merged over the current working
+ * (or baseline) section, so fields a form doesn't render keep the device's
+ * value instead of being sent as protobuf defaults.
+ */
public setRadioSection(
key: K,
value: RadioConfig[K],
): void {
- this.workingRadio.value = { ...this.workingRadio.peek(), [key]: value };
+ const working = this.workingRadio.peek();
+ const current = working[key] ?? this.baselineRadio.peek()[key];
+ this.workingRadio.value = {
+ ...working,
+ [key]: current && value ? { ...current, ...value } : value,
+ };
this.recomputeDirty();
}
+ /** Stage a module config section, merged like {@link setRadioSection}. */
public setModuleSection(
key: K,
value: ModuleConfig[K],
): void {
- this.workingModules.value = { ...this.workingModules.peek(), [key]: value };
+ const working = this.workingModules.peek();
+ const current = working[key] ?? this.baselineModules.peek()[key];
+ this.workingModules.value = {
+ ...working,
+ [key]: current && value ? { ...current, ...value } : value,
+ };
this.recomputeDirty();
}
@@ -227,6 +290,8 @@ export class ConfigEditor {
this.workingModules.value = this.baselineModules.peek();
this.workingChannels.value = new Map(this.baselineChannels.peek());
this.workingOwner.value = this.baselineOwner.peek();
+ this._stagedAdminMessages.value = new Map();
+ this.queuedAdminMessages.value = [];
this.recomputeDirty();
}
@@ -285,6 +350,17 @@ export class ConfigEditor {
}
}
+ const staged = [...this._stagedAdminMessages.peek().values()];
+ for (const message of staged) {
+ const variant = message.payloadVariant;
+ if (!variant.case) continue;
+ try {
+ await sendAdminMessage(this.client, variant);
+ } catch (e) {
+ return Result.err(e instanceof Error ? e : new Error(String(e)));
+ }
+ }
+
const commitResult = await commitEditSettings(this.client);
if (Result.isError(commitResult)) return Result.err(commitResult.error);
@@ -293,12 +369,17 @@ export class ConfigEditor {
this.baselineChannels.value = new Map(this.workingChannels.peek());
this.baselineOwner.value = this.workingOwner.peek();
this.queuedAdminMessages.value = [];
+ this._stagedAdminMessages.value = new Map();
this._dirtyRadioSections.value = [];
this._dirtyModuleSections.value = [];
this._dirtyChannels.value = [];
this._isOwnerDirty.value = false;
this._isDirty.value = false;
+ for (const message of staged) {
+ this.onStagedAdminMessageCommitted?.(message);
+ }
+
return Result.ok(undefined);
}
@@ -356,7 +437,9 @@ export class ConfigEditor {
this.baselineOwner.peek(),
this.workingOwner.peek(),
);
- const hasQueuedAdmin = this.queuedAdminMessages.peek().length > 0;
+ const hasQueuedAdmin =
+ this.queuedAdminMessages.peek().length > 0 ||
+ this._stagedAdminMessages.peek().size > 0;
this._dirtyRadioSections.value = radioDirty;
this._dirtyModuleSections.value = moduleDirty;
diff --git a/packages/sdk/src/features/config/index.ts b/packages/sdk/src/features/config/index.ts
index 006af8537..8862dd546 100644
--- a/packages/sdk/src/features/config/index.ts
+++ b/packages/sdk/src/features/config/index.ts
@@ -1,5 +1,9 @@
export { ConfigClient } from "./ConfigClient.ts";
export { ConfigEditor } from "./domain/ConfigEditor.ts";
+export {
+ CANNED_MESSAGES_MAX_BYTES,
+ RINGTONE_MAX_BYTES,
+} from "./application/ModuleMessagesUseCases.ts";
export type { RadioConfig, RadioConfigSection } from "./domain/RadioConfig.ts";
export type {
ModuleConfig,
diff --git a/packages/sdk/src/features/config/state/configStore.ts b/packages/sdk/src/features/config/state/configStore.ts
index 02ec29603..e8ed65f3e 100644
--- a/packages/sdk/src/features/config/state/configStore.ts
+++ b/packages/sdk/src/features/config/state/configStore.ts
@@ -6,6 +6,8 @@ export function createConfigStore() {
return {
radio: createStore({}),
modules: createStore({}),
+ cannedMessages: createStore(undefined),
+ ringtone: createStore(undefined),
};
}