diff --git a/packages/web/public/i18n/locales/en/deviceConfig.json b/packages/web/public/i18n/locales/en/config.json
similarity index 91%
rename from packages/web/public/i18n/locales/en/deviceConfig.json
rename to packages/web/public/i18n/locales/en/config.json
index 193c5494..ca59a850 100644
--- a/packages/web/public/i18n/locales/en/deviceConfig.json
+++ b/packages/web/public/i18n/locales/en/config.json
@@ -1,6 +1,8 @@
{
"page": {
- "title": "Configuration",
+ "title": "Settings",
+ "tabUser": "User",
+ "tabChannels": "Channels",
"tabBluetooth": "Bluetooth",
"tabDevice": "Device",
"tabDisplay": "Display",
@@ -11,7 +13,7 @@
"tabSecurity": "Security"
},
"sidebar": {
- "label": "Modules"
+ "label": "Configuration"
},
"device": {
"title": "Device Settings",
@@ -424,5 +426,33 @@
"description": "Settings for Logging",
"label": "Logging Settings"
}
+ },
+ "user": {
+ "title": "User Settings",
+ "description": "Configure your device name and identity settings",
+ "longName": {
+ "label": "Long Name",
+ "description": "Your full display name (1-40 characters)",
+ "validation": {
+ "min": "Long name must be at least 1 character",
+ "max": "Long name must be at most 40 characters"
+ }
+ },
+ "shortName": {
+ "label": "Short Name",
+ "description": "Your abbreviated name (2-4 characters)",
+ "validation": {
+ "min": "Short name must be at least 2 characters",
+ "max": "Short name must be at most 4 characters"
+ }
+ },
+ "isUnmessageable": {
+ "label": "Unmessageable",
+ "description": "Used to identify unmonitored or infrastructure nodes so that messaging is not available to nodes that will never respond."
+ },
+ "isLicensed": {
+ "label": "Licensed amateur radio (HAM)",
+ "description": "Enable if you are a licensed amateur radio operator, enabling this option disables encryption and is not compatible with the default Meshtastic network."
+ }
}
}
diff --git a/packages/web/public/i18n/locales/en/ui.json b/packages/web/public/i18n/locales/en/ui.json
index 694e8982..18ea8092 100644
--- a/packages/web/public/i18n/locales/en/ui.json
+++ b/packages/web/public/i18n/locales/en/ui.json
@@ -3,11 +3,11 @@
"title": "Navigation",
"messages": "Messages",
"map": "Map",
- "config": "Config",
+ "settings": "Settings",
"channels": "Channels",
"radioConfig": "Radio Config",
+ "deviceConfig": "Device Config",
"moduleConfig": "Module Config",
- "channelConfig": "Channel Config",
"nodes": "Nodes"
},
"app": {
@@ -27,13 +27,7 @@
"title": "Firmware",
"version": "v{{version}}",
"buildDate": "Build date: {{date}}"
- },
- "deviceName": {
- "title": "Device Name",
- "changeName": "Change Device Name",
- "placeholder": "Enter device name"
- },
- "editDeviceName": "Edit device name"
+ }
}
},
"batteryStatus": {
diff --git a/packages/web/src/components/DeviceInfoPanel.tsx b/packages/web/src/components/DeviceInfoPanel.tsx
index 6480cb39..7b781d4c 100644
--- a/packages/web/src/components/DeviceInfoPanel.tsx
+++ b/packages/web/src/components/DeviceInfoPanel.tsx
@@ -4,7 +4,6 @@ import {
Languages,
type LucideIcon,
Palette,
- PenLine,
Search as SearchIcon,
ZapIcon,
} from "lucide-react";
@@ -53,7 +52,6 @@ export const DeviceInfoPanel = ({
firmwareVersion,
user,
isCollapsed,
- setDialogOpen,
setCommandPaletteOpen,
disableHover = false,
}: DeviceInfoPanelProps) => {
@@ -91,12 +89,6 @@ export const DeviceInfoPanel = ({
icon: Palette,
render: () => ,
},
- {
- id: "changeName",
- label: t("sidebar.deviceInfo.deviceName.changeName"),
- icon: PenLine,
- onClick: setDialogOpen,
- },
{
id: "commandMenu",
label: t("page.title", { ns: "commandPalette" }),
diff --git a/packages/web/src/components/Dialog/ImportDialog.tsx b/packages/web/src/components/Dialog/ImportDialog.tsx
index 7b2fb8fd..71affa79 100644
--- a/packages/web/src/components/Dialog/ImportDialog.tsx
+++ b/packages/web/src/components/Dialog/ImportDialog.tsx
@@ -33,7 +33,7 @@ export interface ImportDialogProps {
}
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
- const { config, channels } = useDevice();
+ const { setChange, channels, config } = useDevice();
const { t } = useTranslation("dialog");
const [importDialogInput, setImportDialogInput] = useState("");
const [channelSet, setChannelSet] = useState();
@@ -41,8 +41,6 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const [updateConfig, setUpdateConfig] = useState(true);
const [importIndex, setImportIndex] = useState([]);
- const { setWorkingChannelConfig, setWorkingConfig } = useDevice();
-
useEffect(() => {
// the channel information is contained in the URL's fragment, which will be present after a
// non-URL encoded `#`.
@@ -106,7 +104,11 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
true,
)
) {
- setWorkingChannelConfig(payload);
+ setChange(
+ { type: "channel", index: importIndex[index] ?? 0 },
+ payload,
+ channels.get(importIndex[index] ?? 0),
+ );
}
},
);
@@ -118,14 +120,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
};
if (!deepCompareConfig(config.lora, payload, true)) {
- setWorkingConfig(
- create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "lora",
- value: payload,
- },
- }),
- );
+ setChange({ type: "config", variant: "lora" }, payload, config.lora);
}
}
// Reset state after import
diff --git a/packages/web/src/components/Form/FormMultiSelect.tsx b/packages/web/src/components/Form/FormMultiSelect.tsx
index aa999439..6558a325 100644
--- a/packages/web/src/components/Form/FormMultiSelect.tsx
+++ b/packages/web/src/components/Form/FormMultiSelect.tsx
@@ -25,7 +25,7 @@ export function MultiSelectInput({
isDirty,
invalid,
}: GenericFormElementProps>) {
- const { t } = useTranslation("deviceConfig");
+ const { t } = useTranslation("config");
const { enumValue, className, ...remainingProperties } = field.properties;
const isNewConfigStructure =
diff --git a/packages/web/src/components/LanguageSwitcher.tsx b/packages/web/src/components/LanguageSwitcher.tsx
index 7f4d2376..1ab94e0d 100644
--- a/packages/web/src/components/LanguageSwitcher.tsx
+++ b/packages/web/src/components/LanguageSwitcher.tsx
@@ -1,4 +1,4 @@
-import { type LangCode, supportedLanguages } from "@app/i18n-config.ts";
+import type { LangCode } from "@app/i18n-config.ts";
import useLang from "@core/hooks/useLang.ts";
import { cn } from "@core/utils/cn.ts";
import { Check, Languages } from "lucide-react";
diff --git a/packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx b/packages/web/src/components/PageComponents/Channels/Channel.tsx
similarity index 95%
rename from packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx
rename to packages/web/src/components/PageComponents/Channels/Channel.tsx
index 305dbacd..dedff891 100644
--- a/packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx
+++ b/packages/web/src/components/PageComponents/Channels/Channel.tsx
@@ -24,12 +24,7 @@ export interface SettingsPanelProps {
}
export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
- const {
- config,
- setWorkingChannelConfig,
- getWorkingChannelConfig,
- removeWorkingChannelConfig,
- } = useDevice();
+ const { config, setChange, getChange, removeChange } = useDevice();
const { t } = useTranslation(["channels", "ui", "dialog"]);
const defaultConfig = channel;
@@ -51,7 +46,11 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
},
};
- const effectiveConfig = getWorkingChannelConfig(channel.index) ?? channel;
+ const workingChannel = getChange({
+ type: "channel",
+ index: channel.index,
+ }) as Protobuf.Channel.Channel | undefined;
+ const effectiveConfig = workingChannel ?? channel;
const formValues = {
...effectiveConfig,
...{
@@ -111,7 +110,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
return;
}
- const payload = {
+ const payload = create(Protobuf.Channel.ChannelSchema, {
...data,
settings: {
...data.settings,
@@ -121,14 +120,14 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
positionPrecision: data.settings.moduleSettings.positionPrecision,
}),
},
- };
+ });
if (deepCompareConfig(channel, payload, true)) {
- removeWorkingChannelConfig(channel.index);
+ removeChange({ type: "channel", index: channel.index });
return;
}
- setWorkingChannelConfig(create(Protobuf.Channel.ChannelSchema, payload));
+ setChange({ type: "channel", index: channel.index }, payload, channel);
};
const preSharedKeyRegenerate = async () => {
diff --git a/packages/web/src/pages/Config/ChannelConfig.tsx b/packages/web/src/components/PageComponents/Channels/Channels.tsx
similarity index 89%
rename from packages/web/src/pages/Config/ChannelConfig.tsx
rename to packages/web/src/components/PageComponents/Channels/Channels.tsx
index 7ec004fd..ad8edfd1 100644
--- a/packages/web/src/pages/Config/ChannelConfig.tsx
+++ b/packages/web/src/components/PageComponents/Channels/Channels.tsx
@@ -1,4 +1,4 @@
-import { Channel } from "@app/components/PageComponents/ChannelConfig/Channel";
+import { Channel } from "@app/components/PageComponents/Channels/Channel";
import { Button } from "@components/UI/Button.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
@@ -30,8 +30,8 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) => {
});
};
-export const ChannelConfig = ({ onFormInit }: ConfigProps) => {
- const { channels, getWorkingChannelConfig, setDialogOpen } = useDevice();
+export const Channels = ({ onFormInit }: ConfigProps) => {
+ const { channels, hasChannelChange, setDialogOpen } = useDevice();
const { t } = useTranslation("channels");
const allChannels = Array.from(channels.values());
@@ -40,10 +40,10 @@ export const ChannelConfig = ({ onFormInit }: ConfigProps) => {
new Map(
allChannels.map((channel) => [
channel.index,
- getWorkingChannelConfig(channel.index),
+ hasChannelChange(channel.index),
]),
),
- [allChannels, getWorkingChannelConfig],
+ [allChannels, hasChannelChange],
);
return (
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx b/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
index 5ae258dd..108b3796 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
@@ -3,14 +3,12 @@ import {
type AmbientLightingValidation,
AmbientLightingValidationSchema,
} from "@app/validation/moduleConfig/ambientLighting.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
-import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface AmbientLightingModuleConfigProps {
@@ -21,27 +19,20 @@ export const AmbientLighting = ({
onFormInit,
}: AmbientLightingModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "ambientLighting" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AmbientLightingValidation) => {
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
- removeWorkingModuleConfig("ambientLighting");
+ removeChange({ type: "moduleConfig", variant: "ambientLighting" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "ambientLighting",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "ambientLighting" },
+ data,
+ moduleConfig.ambientLighting,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx
index eb7068f2..06738323 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx
@@ -3,7 +3,6 @@ import {
type AudioValidation,
AudioValidationSchema,
} from "@app/validation/moduleConfig/audio.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
@@ -19,27 +18,20 @@ interface AudioModuleConfigProps {
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "audio" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AudioValidation) => {
if (deepCompareConfig(moduleConfig.audio, data, true)) {
- removeWorkingModuleConfig("audio");
+ removeChange({ type: "moduleConfig", variant: "audio" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "audio",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "audio" },
+ data,
+ moduleConfig.audio,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx b/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx
index 711c1657..402fbdfe 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx
@@ -3,7 +3,6 @@ import {
type CannedMessageValidation,
CannedMessageValidationSchema,
} from "@app/validation/moduleConfig/cannedMessage.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
@@ -22,27 +21,20 @@ export const CannedMessage = ({
}: CannedMessageModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "cannedMessage" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: CannedMessageValidation) => {
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
- removeWorkingModuleConfig("cannedMessage");
+ removeChange({ type: "moduleConfig", variant: "cannedMessage" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "cannedMessage",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "cannedMessage" },
+ data,
+ moduleConfig.cannedMessage,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx b/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
index 5d041884..79d18847 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
@@ -3,7 +3,6 @@ import {
type DetectionSensorValidation,
DetectionSensorValidationSchema,
} from "@app/validation/moduleConfig/detectionSensor.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
@@ -22,27 +21,20 @@ export const DetectionSensor = ({
}: DetectionSensorModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "detectionSensor" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: DetectionSensorValidation) => {
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
- removeWorkingModuleConfig("detectionSensor");
+ removeChange({ type: "moduleConfig", variant: "detectionSensor" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "detectionSensor",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "detectionSensor" },
+ data,
+ moduleConfig.detectionSensor,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx b/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
index 9bbb6178..e316ac78 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
@@ -3,14 +3,12 @@ import {
type ExternalNotificationValidation,
ExternalNotificationValidationSchema,
} from "@app/validation/moduleConfig/externalNotification.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
-import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface ExternalNotificationModuleConfigProps {
@@ -22,27 +20,20 @@ export const ExternalNotification = ({
}: ExternalNotificationModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "externalNotification" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: ExternalNotificationValidation) => {
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
- removeWorkingModuleConfig("externalNotification");
+ removeChange({ type: "moduleConfig", variant: "externalNotification" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "externalNotification",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "externalNotification" },
+ data,
+ moduleConfig.externalNotification,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx b/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx
index c505a34a..c65c7aa8 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx
@@ -23,9 +23,9 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
const {
config,
moduleConfig,
- setWorkingModuleConfig,
+ setChange,
getEffectiveModuleConfig,
- removeWorkingModuleConfig,
+ removeChange,
} = useDevice();
const { t } = useTranslation("moduleConfig");
@@ -39,17 +39,14 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
};
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
- removeWorkingModuleConfig("mqtt");
+ removeChange({ type: "moduleConfig", variant: "mqtt" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "mqtt",
- value: payload,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "mqtt" },
+ payload,
+ moduleConfig.mqtt,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx b/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
index 2e4f189f..1b134cbc 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
@@ -3,14 +3,12 @@ import {
type NeighborInfoValidation,
NeighborInfoValidationSchema,
} from "@app/validation/moduleConfig/neighborInfo.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
-import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface NeighborInfoModuleConfigProps {
@@ -20,27 +18,20 @@ interface NeighborInfoModuleConfigProps {
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "neighborInfo" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: NeighborInfoValidation) => {
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
- removeWorkingModuleConfig("neighborInfo");
+ removeChange({ type: "moduleConfig", variant: "neighborInfo" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "neighborInfo",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "neighborInfo" },
+ data,
+ moduleConfig.neighborInfo,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx
index d878c32a..13d636b0 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx
@@ -3,14 +3,12 @@ import {
type PaxcounterValidation,
PaxcounterValidationSchema,
} from "@app/validation/moduleConfig/paxcounter.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
-import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface PaxcounterModuleConfigProps {
@@ -20,27 +18,20 @@ interface PaxcounterModuleConfigProps {
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "paxcounter" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: PaxcounterValidation) => {
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
- removeWorkingModuleConfig("paxcounter");
+ removeChange({ type: "moduleConfig", variant: "paxcounter" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "paxcounter",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "paxcounter" },
+ data,
+ moduleConfig.paxcounter,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx b/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx
index 73c08fab..a360492e 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx
@@ -3,14 +3,12 @@ import {
type RangeTestValidation,
RangeTestValidationSchema,
} from "@app/validation/moduleConfig/rangeTest.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
-import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface RangeTestModuleConfigProps {
@@ -20,28 +18,21 @@ interface RangeTestModuleConfigProps {
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "rangeTest" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: RangeTestValidation) => {
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
- removeWorkingModuleConfig("rangeTest");
+ removeChange({ type: "moduleConfig", variant: "rangeTest" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "rangeTest",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "rangeTest" },
+ data,
+ moduleConfig.rangeTest,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx
index 70843430..96de4bc6 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx
@@ -3,7 +3,6 @@ import {
type SerialValidation,
SerialValidationSchema,
} from "@app/validation/moduleConfig/serial.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
@@ -20,27 +19,20 @@ interface SerialModuleConfigProps {
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "serial" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: SerialValidation) => {
if (deepCompareConfig(moduleConfig.serial, data, true)) {
- removeWorkingModuleConfig("serial");
+ removeChange({ type: "moduleConfig", variant: "serial" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "serial",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "serial" },
+ data,
+ moduleConfig.serial,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx b/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx
index eebb84e7..658362df 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx
@@ -3,14 +3,12 @@ import {
type StoreForwardValidation,
StoreForwardValidationSchema,
} from "@app/validation/moduleConfig/storeForward.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
-import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface StoreForwardModuleConfigProps {
@@ -20,27 +18,20 @@ interface StoreForwardModuleConfigProps {
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "storeForward" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: StoreForwardValidation) => {
if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
- removeWorkingModuleConfig("storeForward");
+ removeChange({ type: "moduleConfig", variant: "storeForward" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "storeForward",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "storeForward" },
+ data,
+ moduleConfig.storeForward,
);
};
diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx
index 782c5022..c6d1384a 100644
--- a/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx
+++ b/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx
@@ -3,14 +3,12 @@ import {
type TelemetryValidation,
TelemetryValidationSchema,
} from "@app/validation/moduleConfig/telemetry.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
-import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface TelemetryModuleConfigProps {
@@ -20,27 +18,20 @@ interface TelemetryModuleConfigProps {
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "telemetry" });
- const {
- moduleConfig,
- setWorkingModuleConfig,
- getEffectiveModuleConfig,
- removeWorkingModuleConfig,
- } = useDevice();
+ const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
+ useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: TelemetryValidation) => {
if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
- removeWorkingModuleConfig("telemetry");
+ removeChange({ type: "moduleConfig", variant: "telemetry" });
return;
}
- setWorkingModuleConfig(
- create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "telemetry",
- value: data,
- },
- }),
+ setChange(
+ { type: "moduleConfig", variant: "telemetry" },
+ data,
+ moduleConfig.telemetry,
);
};
diff --git a/packages/web/src/components/PageComponents/Config/Bluetooth.tsx b/packages/web/src/components/PageComponents/Settings/Bluetooth.tsx
similarity index 84%
rename from packages/web/src/components/PageComponents/Config/Bluetooth.tsx
rename to packages/web/src/components/PageComponents/Settings/Bluetooth.tsx
index ee283cdc..423197c9 100644
--- a/packages/web/src/components/PageComponents/Config/Bluetooth.tsx
+++ b/packages/web/src/components/PageComponents/Settings/Bluetooth.tsx
@@ -3,7 +3,6 @@ import {
type BluetoothValidation,
BluetoothValidationSchema,
} from "@app/validation/config/bluetooth.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
@@ -19,24 +18,16 @@ interface BluetoothConfigProps {
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
useWaitForConfig({ configCase: "bluetooth" });
- const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
- useDevice();
- const { t } = useTranslation("deviceConfig");
+ const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
+ const { t } = useTranslation("config");
const onSubmit = (data: BluetoothValidation) => {
if (deepCompareConfig(config.bluetooth, data, true)) {
- removeWorkingConfig("bluetooth");
+ removeChange({ type: "config", variant: "bluetooth" });
return;
}
- setWorkingConfig(
- create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "bluetooth",
- value: data,
- },
- }),
- );
+ setChange({ type: "config", variant: "bluetooth" }, data, config.bluetooth);
};
return (
diff --git a/packages/web/src/components/PageComponents/Config/Device/index.tsx b/packages/web/src/components/PageComponents/Settings/Device/index.tsx
similarity index 91%
rename from packages/web/src/components/PageComponents/Config/Device/index.tsx
rename to packages/web/src/components/PageComponents/Settings/Device/index.tsx
index 96afeeed..063eb682 100644
--- a/packages/web/src/components/PageComponents/Config/Device/index.tsx
+++ b/packages/web/src/components/PageComponents/Settings/Device/index.tsx
@@ -3,7 +3,6 @@ import {
type DeviceValidation,
DeviceValidationSchema,
} from "@app/validation/config/device.ts";
-import { create } from "@bufbuild/protobuf";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import {
DynamicForm,
@@ -20,25 +19,17 @@ interface DeviceConfigProps {
export const Device = ({ onFormInit }: DeviceConfigProps) => {
useWaitForConfig({ configCase: "device" });
- const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
- useDevice();
- const { t } = useTranslation("deviceConfig");
+ const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
+ const { t } = useTranslation("config");
const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => {
if (deepCompareConfig(config.device, data, true)) {
- removeWorkingConfig("device");
+ removeChange({ type: "config", variant: "device" });
return;
}
- setWorkingConfig(
- create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "device",
- value: data,
- },
- }),
- );
+ setChange({ type: "config", variant: "device" }, data, config.device);
};
return (
diff --git a/packages/web/src/components/PageComponents/Config/Display.tsx b/packages/web/src/components/PageComponents/Settings/Display.tsx
similarity index 91%
rename from packages/web/src/components/PageComponents/Config/Display.tsx
rename to packages/web/src/components/PageComponents/Settings/Display.tsx
index f8e1ce36..bd8f6c81 100644
--- a/packages/web/src/components/PageComponents/Config/Display.tsx
+++ b/packages/web/src/components/PageComponents/Settings/Display.tsx
@@ -3,7 +3,6 @@ import {
type DisplayValidation,
DisplayValidationSchema,
} from "@app/validation/config/display.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
@@ -18,24 +17,16 @@ interface DisplayConfigProps {
}
export const Display = ({ onFormInit }: DisplayConfigProps) => {
useWaitForConfig({ configCase: "display" });
- const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
- useDevice();
- const { t } = useTranslation("deviceConfig");
+ const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
+ const { t } = useTranslation("config");
const onSubmit = (data: DisplayValidation) => {
if (deepCompareConfig(config.display, data, true)) {
- removeWorkingConfig("display");
+ removeChange({ type: "config", variant: "display" });
return;
}
- setWorkingConfig(
- create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "display",
- value: data,
- },
- }),
- );
+ setChange({ type: "config", variant: "display" }, data, config.display);
};
return (
diff --git a/packages/web/src/components/PageComponents/Config/LoRa.tsx b/packages/web/src/components/PageComponents/Settings/LoRa.tsx
similarity index 94%
rename from packages/web/src/components/PageComponents/Config/LoRa.tsx
rename to packages/web/src/components/PageComponents/Settings/LoRa.tsx
index cfa7c612..844085df 100644
--- a/packages/web/src/components/PageComponents/Config/LoRa.tsx
+++ b/packages/web/src/components/PageComponents/Settings/LoRa.tsx
@@ -3,7 +3,6 @@ import {
type LoRaValidation,
LoRaValidationSchema,
} from "@app/validation/config/lora.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
@@ -19,24 +18,16 @@ interface LoRaConfigProps {
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
useWaitForConfig({ configCase: "lora" });
- const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
- useDevice();
- const { t } = useTranslation("deviceConfig");
+ const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
+ const { t } = useTranslation("config");
const onSubmit = (data: LoRaValidation) => {
if (deepCompareConfig(config.lora, data, true)) {
- removeWorkingConfig("lora");
+ removeChange({ type: "config", variant: "lora" });
return;
}
- setWorkingConfig(
- create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "lora",
- value: data,
- },
- }),
- );
+ setChange({ type: "config", variant: "lora" }, data, config.lora);
};
return (
diff --git a/packages/web/src/components/PageComponents/Config/Network/index.tsx b/packages/web/src/components/PageComponents/Settings/Network/index.tsx
similarity index 95%
rename from packages/web/src/components/PageComponents/Config/Network/index.tsx
rename to packages/web/src/components/PageComponents/Settings/Network/index.tsx
index b999d5dc..6b5bb63e 100644
--- a/packages/web/src/components/PageComponents/Config/Network/index.tsx
+++ b/packages/web/src/components/PageComponents/Settings/Network/index.tsx
@@ -23,9 +23,8 @@ interface NetworkConfigProps {
export const Network = ({ onFormInit }: NetworkConfigProps) => {
useWaitForConfig({ configCase: "network" });
- const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
- useDevice();
- const { t } = useTranslation("deviceConfig");
+ const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
+ const { t } = useTranslation("config");
const networkConfig = getEffectiveConfig("network");
@@ -44,18 +43,11 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
};
if (deepCompareConfig(config.network, payload, true)) {
- removeWorkingConfig("network");
+ removeChange({ type: "config", variant: "network" });
return;
}
- setWorkingConfig(
- create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "network",
- value: payload,
- },
- }),
- );
+ setChange({ type: "config", variant: "network" }, payload, config.network);
};
return (
diff --git a/packages/web/src/components/PageComponents/Config/Position.tsx b/packages/web/src/components/PageComponents/Settings/Position.tsx
similarity index 91%
rename from packages/web/src/components/PageComponents/Config/Position.tsx
rename to packages/web/src/components/PageComponents/Settings/Position.tsx
index c52bc668..74e0a92e 100644
--- a/packages/web/src/components/PageComponents/Config/Position.tsx
+++ b/packages/web/src/components/PageComponents/Settings/Position.tsx
@@ -3,7 +3,6 @@ import {
type PositionValidation,
PositionValidationSchema,
} from "@app/validation/config/position.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
@@ -24,26 +23,23 @@ interface PositionConfigProps {
export const Position = ({ onFormInit }: PositionConfigProps) => {
useWaitForConfig({ configCase: "position" });
- const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
- useDevice();
+ const { setChange, config, getEffectiveConfig, removeChange } = useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
getEffectiveConfig("position")?.positionFlags ?? 0,
);
- const { t } = useTranslation("deviceConfig");
+ const { t } = useTranslation("config");
const onSubmit = (data: PositionValidation) => {
- if (deepCompareConfig(config.position, data, true)) {
- removeWorkingConfig("position");
+ const payload = { ...data, positionFlags: flagsValue };
+ if (deepCompareConfig(config.position, payload, true)) {
+ removeChange({ type: "config", variant: "position" });
return;
}
- return setWorkingConfig(
- create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "position",
- value: { ...data, positionFlags: flagsValue },
- },
- }),
+ return setChange(
+ { type: "config", variant: "position" },
+ payload,
+ config.position,
);
};
diff --git a/packages/web/src/components/PageComponents/Config/Power.tsx b/packages/web/src/components/PageComponents/Settings/Power.tsx
similarity index 89%
rename from packages/web/src/components/PageComponents/Config/Power.tsx
rename to packages/web/src/components/PageComponents/Settings/Power.tsx
index 323c1ec2..dcad23d9 100644
--- a/packages/web/src/components/PageComponents/Config/Power.tsx
+++ b/packages/web/src/components/PageComponents/Settings/Power.tsx
@@ -3,14 +3,12 @@ import {
type PowerValidation,
PowerValidationSchema,
} from "@app/validation/config/power.ts";
-import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
-import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface PowerConfigProps {
@@ -19,24 +17,16 @@ interface PowerConfigProps {
export const Power = ({ onFormInit }: PowerConfigProps) => {
useWaitForConfig({ configCase: "power" });
- const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
- useDevice();
- const { t } = useTranslation("deviceConfig");
+ const { setChange, config, getEffectiveConfig, removeChange } = useDevice();
+ const { t } = useTranslation("config");
const onSubmit = (data: PowerValidation) => {
if (deepCompareConfig(config.power, data, true)) {
- removeWorkingConfig("power");
+ removeChange({ type: "config", variant: "power" });
return;
}
- setWorkingConfig(
- create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "power",
- value: data,
- },
- }),
- );
+ setChange({ type: "config", variant: "power" }, data, config.power);
};
return (
diff --git a/packages/web/src/components/PageComponents/Config/Security/Security.tsx b/packages/web/src/components/PageComponents/Settings/Security/Security.tsx
similarity index 95%
rename from packages/web/src/components/PageComponents/Config/Security/Security.tsx
rename to packages/web/src/components/PageComponents/Settings/Security/Security.tsx
index 21d620ea..a3e08e2c 100644
--- a/packages/web/src/components/PageComponents/Config/Security/Security.tsx
+++ b/packages/web/src/components/PageComponents/Settings/Security/Security.tsx
@@ -4,7 +4,6 @@ import {
type RawSecurity,
RawSecuritySchema,
} from "@app/validation/config/security.ts";
-import { create } from "@bufbuild/protobuf";
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
@@ -15,7 +14,6 @@ import {
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
-import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { type DefaultValues, useForm } from "react-hook-form";
@@ -27,15 +25,10 @@ interface SecurityConfigProps {
export const Security = ({ onFormInit }: SecurityConfigProps) => {
useWaitForConfig({ configCase: "security" });
- const {
- config,
- setWorkingConfig,
- setDialogOpen,
- getEffectiveConfig,
- removeWorkingConfig,
- } = useDevice();
+ const { config, setChange, setDialogOpen, getEffectiveConfig, removeChange } =
+ useDevice();
- const { t } = useTranslation("deviceConfig");
+ const { t } = useTranslation("config");
const defaultConfig = config.security;
const defaultValues = {
@@ -103,17 +96,14 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
};
if (deepCompareConfig(config.security, payload, true)) {
- removeWorkingConfig("security");
+ removeChange({ type: "config", variant: "security" });
return;
}
- setWorkingConfig(
- create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "security",
- value: payload,
- },
- }),
+ setChange(
+ { type: "config", variant: "security" },
+ payload,
+ config.security,
);
};
diff --git a/packages/web/src/components/PageComponents/Settings/User.tsx b/packages/web/src/components/PageComponents/Settings/User.tsx
new file mode 100644
index 00000000..c4f094f1
--- /dev/null
+++ b/packages/web/src/components/PageComponents/Settings/User.tsx
@@ -0,0 +1,111 @@
+import {
+ type UserValidation,
+ UserValidationSchema,
+} from "@app/validation/config/user.ts";
+import { create } from "@bufbuild/protobuf";
+import {
+ DynamicForm,
+ type DynamicFormFormInit,
+} from "@components/Form/DynamicForm.tsx";
+import { useDevice, useNodeDB } from "@core/stores";
+import { Protobuf } from "@meshtastic/core";
+import { useTranslation } from "react-i18next";
+
+interface UserConfigProps {
+ onFormInit: DynamicFormFormInit;
+}
+
+export const User = ({ onFormInit }: UserConfigProps) => {
+ const { hardware, getChange, connection } = useDevice();
+ const { getNode } = useNodeDB();
+ const { t } = useTranslation("config");
+
+ const myNode = getNode(hardware.myNodeNum);
+ const defaultUser = myNode?.user ?? {
+ id: "",
+ longName: "",
+ shortName: "",
+ isLicensed: false,
+ };
+
+ // Get working copy from change registry
+ const workingUser = getChange({ type: "user" }) as
+ | Protobuf.Mesh.User
+ | undefined;
+
+ const effectiveUser = workingUser ?? defaultUser;
+
+ const onSubmit = (data: UserValidation) => {
+ connection?.setOwner(
+ create(Protobuf.Mesh.UserSchema, {
+ ...data,
+ }),
+ );
+ };
+
+ return (
+
+ onSubmit={onSubmit}
+ onFormInit={onFormInit}
+ validationSchema={UserValidationSchema}
+ defaultValues={{
+ longName: defaultUser.longName,
+ shortName: defaultUser.shortName,
+ isLicensed: defaultUser.isLicensed,
+ isUnmessageable: false,
+ }}
+ values={{
+ longName: effectiveUser.longName,
+ shortName: effectiveUser.shortName,
+ isLicensed: effectiveUser.isLicensed,
+ isUnmessageable: false,
+ }}
+ fieldGroups={[
+ {
+ label: t("user.title"),
+ description: t("user.description"),
+ fields: [
+ {
+ type: "text",
+ name: "longName",
+ label: t("user.longName.label"),
+ description: t("user.longName.description"),
+ properties: {
+ fieldLength: {
+ min: 1,
+ max: 40,
+ showCharacterCount: true,
+ },
+ },
+ },
+ {
+ type: "text",
+ name: "shortName",
+ label: t("user.shortName.label"),
+ description: t("user.shortName.description"),
+ properties: {
+ fieldLength: {
+ min: 2,
+ max: 4,
+ showCharacterCount: true,
+ },
+ },
+ },
+ {
+ type: "toggle",
+ name: "isUnmessageable",
+ label: t("user.isUnmessageable.label"),
+ description: t("user.isUnmessageable.description"),
+ },
+ {
+ type: "toggle",
+ name: "isLicensed",
+ label: t("user.isLicensed.label"),
+ description: t("user.isLicensed.description"),
+ },
+ ],
+ },
+ ]}
+ />
+ );
+};
diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx
index 8cc117d8..5f551d93 100644
--- a/packages/web/src/components/Sidebar.tsx
+++ b/packages/web/src/components/Sidebar.tsx
@@ -108,9 +108,9 @@ export const Sidebar = ({ children }: SidebarProps) => {
},
{ name: t("navigation.map"), icon: MapIcon, page: "map" },
{
- name: t("navigation.config"),
+ name: t("navigation.settings"),
icon: SettingsIcon,
- page: "config",
+ page: "settings",
},
{
name: `${t("navigation.nodes")} (${displayedNodeCount})`,
diff --git a/packages/web/src/components/UI/Sidebar/SidebarSection.tsx b/packages/web/src/components/UI/Sidebar/SidebarSection.tsx
index f0b52d4b..e43c46bb 100644
--- a/packages/web/src/components/UI/Sidebar/SidebarSection.tsx
+++ b/packages/web/src/components/UI/Sidebar/SidebarSection.tsx
@@ -21,7 +21,7 @@ export const SidebarSection = ({
as="h3"
className={cn(
"mb-2",
- "uppercase tracking-wider text-md",
+ "capitalize tracking-wider text-sm",
"transition-all duration-300 ease-in-out",
"whitespace-nowrap overflow-hidden",
isCollapsed
diff --git a/packages/web/src/core/stores/deviceStore/changeRegistry.ts b/packages/web/src/core/stores/deviceStore/changeRegistry.ts
new file mode 100644
index 00000000..76de523e
--- /dev/null
+++ b/packages/web/src/core/stores/deviceStore/changeRegistry.ts
@@ -0,0 +1,220 @@
+import type { Types } from "@meshtastic/core";
+
+// Config type discriminators
+export type ValidConfigType =
+ | "device"
+ | "position"
+ | "power"
+ | "network"
+ | "display"
+ | "lora"
+ | "bluetooth"
+ | "security";
+
+export type ValidModuleConfigType =
+ | "mqtt"
+ | "serial"
+ | "externalNotification"
+ | "storeForward"
+ | "rangeTest"
+ | "telemetry"
+ | "cannedMessage"
+ | "audio"
+ | "neighborInfo"
+ | "ambientLighting"
+ | "detectionSensor"
+ | "paxcounter";
+
+// Unified config change key type
+export type ConfigChangeKey =
+ | { type: "config"; variant: ValidConfigType }
+ | { type: "moduleConfig"; variant: ValidModuleConfigType }
+ | { type: "channel"; index: Types.ChannelNumber }
+ | { type: "user" };
+
+// Serialized key for Map storage
+export type ConfigChangeKeyString = string;
+
+// Registry entry
+export interface ChangeEntry {
+ key: ConfigChangeKey;
+ value: unknown;
+ timestamp: number;
+ originalValue?: unknown;
+}
+
+// The unified registry
+export interface ChangeRegistry {
+ changes: Map;
+}
+
+/**
+ * Convert structured key to string for Map lookup
+ */
+export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
+ switch (key.type) {
+ case "config":
+ return `config:${key.variant}`;
+ case "moduleConfig":
+ return `moduleConfig:${key.variant}`;
+ case "channel":
+ return `channel:${key.index}`;
+ case "user":
+ return "user";
+ }
+}
+
+/**
+ * Reverse operation for type-safe retrieval
+ */
+export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
+ const [type, variant] = keyStr.split(":");
+
+ switch (type) {
+ case "config":
+ return { type: "config", variant: variant as ValidConfigType };
+ case "moduleConfig":
+ return {
+ type: "moduleConfig",
+ variant: variant as ValidModuleConfigType,
+ };
+ case "channel":
+ return {
+ type: "channel",
+ index: Number(variant) as Types.ChannelNumber,
+ };
+ case "user":
+ return { type: "user" };
+ default:
+ throw new Error(`Unknown key type: ${type}`);
+ }
+}
+
+/**
+ * Create an empty change registry
+ */
+export function createChangeRegistry(): ChangeRegistry {
+ return {
+ changes: new Map(),
+ };
+}
+
+/**
+ * Check if a config variant has changes
+ */
+export function hasConfigChange(
+ registry: ChangeRegistry,
+ variant: ValidConfigType,
+): boolean {
+ return registry.changes.has(serializeKey({ type: "config", variant }));
+}
+
+/**
+ * Check if a module config variant has changes
+ */
+export function hasModuleConfigChange(
+ registry: ChangeRegistry,
+ variant: ValidModuleConfigType,
+): boolean {
+ return registry.changes.has(serializeKey({ type: "moduleConfig", variant }));
+}
+
+/**
+ * Check if a channel has changes
+ */
+export function hasChannelChange(
+ registry: ChangeRegistry,
+ index: Types.ChannelNumber,
+): boolean {
+ return registry.changes.has(serializeKey({ type: "channel", index }));
+}
+
+/**
+ * Check if user config has changes
+ */
+export function hasUserChange(registry: ChangeRegistry): boolean {
+ return registry.changes.has(serializeKey({ type: "user" }));
+}
+
+/**
+ * Get count of config changes
+ */
+export function getConfigChangeCount(registry: ChangeRegistry): number {
+ let count = 0;
+ for (const keyStr of registry.changes.keys()) {
+ const key = deserializeKey(keyStr);
+ if (key.type === "config") {
+ count++;
+ }
+ }
+ return count;
+}
+
+/**
+ * Get count of module config changes
+ */
+export function getModuleConfigChangeCount(registry: ChangeRegistry): number {
+ let count = 0;
+ for (const keyStr of registry.changes.keys()) {
+ const key = deserializeKey(keyStr);
+ if (key.type === "moduleConfig") {
+ count++;
+ }
+ }
+ return count;
+}
+
+/**
+ * Get count of channel changes
+ */
+export function getChannelChangeCount(registry: ChangeRegistry): number {
+ let count = 0;
+ for (const keyStr of registry.changes.keys()) {
+ const key = deserializeKey(keyStr);
+ if (key.type === "channel") {
+ count++;
+ }
+ }
+ return count;
+}
+
+/**
+ * Get all config changes as an array
+ */
+export function getAllConfigChanges(registry: ChangeRegistry): ChangeEntry[] {
+ const changes: ChangeEntry[] = [];
+ for (const entry of registry.changes.values()) {
+ if (entry.key.type === "config") {
+ changes.push(entry);
+ }
+ }
+ return changes;
+}
+
+/**
+ * Get all module config changes as an array
+ */
+export function getAllModuleConfigChanges(
+ registry: ChangeRegistry,
+): ChangeEntry[] {
+ const changes: ChangeEntry[] = [];
+ for (const entry of registry.changes.values()) {
+ if (entry.key.type === "moduleConfig") {
+ changes.push(entry);
+ }
+ }
+ return changes;
+}
+
+/**
+ * Get all channel changes as an array
+ */
+export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
+ const changes: ChangeEntry[] = [];
+ for (const entry of registry.changes.values()) {
+ if (entry.key.type === "channel") {
+ changes.push(entry);
+ }
+ }
+ return changes;
+}
diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
index d3fd2255..eb67f7bf 100644
--- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
+++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
@@ -19,9 +19,7 @@ export const mockDeviceStore: Device = {
channels: new Map(),
config: {} as Protobuf.LocalOnly.LocalConfig,
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
- workingConfig: [],
- workingModuleConfig: [],
- workingChannelConfig: [],
+ changeRegistry: { changes: new Map() },
hardware: {} as Protobuf.Mesh.MyNodeInfo,
metadata: new Map(),
traceroutes: new Map(),
@@ -56,17 +54,8 @@ export const mockDeviceStore: Device = {
setStatus: vi.fn(),
setConfig: vi.fn(),
setModuleConfig: vi.fn(),
- setWorkingConfig: vi.fn(),
- setWorkingModuleConfig: vi.fn(),
- getWorkingConfig: vi.fn(),
- getWorkingModuleConfig: vi.fn(),
- removeWorkingConfig: vi.fn(),
- removeWorkingModuleConfig: vi.fn(),
getEffectiveConfig: vi.fn(),
getEffectiveModuleConfig: vi.fn(),
- setWorkingChannelConfig: vi.fn(),
- getWorkingChannelConfig: vi.fn(),
- removeWorkingChannelConfig: vi.fn(),
setHardware: vi.fn(),
setActiveNode: vi.fn(),
setPendingSettingsChanges: vi.fn(),
@@ -90,4 +79,21 @@ export const mockDeviceStore: Device = {
getUnreadCount: vi.fn().mockReturnValue(0),
getNeighborInfo: vi.fn(),
addNeighborInfo: vi.fn(),
+
+ // New unified change tracking methods
+ setChange: vi.fn(),
+ removeChange: vi.fn(),
+ hasChange: vi.fn().mockReturnValue(false),
+ getChange: vi.fn(),
+ clearAllChanges: vi.fn(),
+ hasConfigChange: vi.fn().mockReturnValue(false),
+ hasModuleConfigChange: vi.fn().mockReturnValue(false),
+ hasChannelChange: vi.fn().mockReturnValue(false),
+ hasUserChange: vi.fn().mockReturnValue(false),
+ getConfigChangeCount: vi.fn().mockReturnValue(0),
+ getModuleConfigChangeCount: vi.fn().mockReturnValue(0),
+ getChannelChangeCount: vi.fn().mockReturnValue(0),
+ getAllConfigChanges: vi.fn().mockReturnValue([]),
+ getAllModuleConfigChanges: vi.fn().mockReturnValue([]),
+ getAllChannelChanges: vi.fn().mockReturnValue([]),
};
diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.test.ts b/packages/web/src/core/stores/deviceStore/deviceStore.test.ts
index 8348b2b0..f7644bdd 100644
--- a/packages/web/src/core/stores/deviceStore/deviceStore.test.ts
+++ b/packages/web/src/core/stores/deviceStore/deviceStore.test.ts
@@ -52,25 +52,7 @@ function makeChannel(index: number) {
function makeWaypoint(id: number, expire?: number) {
return create(Protobuf.Mesh.WaypointSchema, { id, expire });
}
-function makeConfig(fields: Record) {
- return create(Protobuf.Config.ConfigSchema, {
- payloadVariant: {
- case: "device",
- value: create(Protobuf.Config.Config_DeviceConfigSchema, fields),
- },
- });
-}
-function makeModuleConfig(fields: Record) {
- return create(Protobuf.ModuleConfig.ModuleConfigSchema, {
- payloadVariant: {
- case: "mqtt",
- value: create(
- Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema,
- fields,
- ),
- },
- });
-}
+
function makeAdminMessage(fields: Record) {
return create(Protobuf.Admin.AdminMessageSchema, fields);
}
@@ -114,13 +96,13 @@ describe("DeviceStore – basic map ops & retention", () => {
});
});
-describe("DeviceStore – working/effective config API", () => {
+describe("DeviceStore – change registry API", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
- it("setWorkingConfig/getWorkingConfig replaces by variant and getEffectiveConfig merges base + working", async () => {
+ it("setChange/hasChange/getChange for config and getEffectiveConfig merges base + working", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(42);
@@ -138,14 +120,17 @@ describe("DeviceStore – working/effective config API", () => {
);
// working deviceConfig.role = ROUTER
- device.setWorkingConfig(
- makeConfig({
- role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
- }),
- );
+ const routerConfig = create(Protobuf.Config.Config_DeviceConfigSchema, {
+ role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
+ });
+ device.setChange({ type: "config", variant: "device" }, routerConfig);
- // expect working deviceConfig.role = ROUTER
- const working = device.getWorkingConfig("device");
+ // expect change is tracked
+ expect(device.hasConfigChange("device")).toBe(true);
+ const working = device.getChange({
+ type: "config",
+ variant: "device",
+ }) as Protobuf.Config.Config_DeviceConfig;
expect(working?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER);
// expect effective deviceConfig.role = ROUTER
@@ -154,30 +139,27 @@ describe("DeviceStore – working/effective config API", () => {
Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
);
- // remove working, effective should equal base
- device.removeWorkingConfig("device");
- expect(device.getWorkingConfig("device")).toBeUndefined();
+ // remove change, effective should equal base
+ device.removeChange({ type: "config", variant: "device" });
+ expect(device.hasConfigChange("device")).toBe(false);
expect(device.getEffectiveConfig("device")?.role).toBe(
Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
);
// add multiple, then clear all
- device.setWorkingConfig(makeConfig({}));
- device.setWorkingConfig(
- makeConfig({
- deviceRole: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
- }),
- );
- device.removeWorkingConfig(); // clears all
- expect(device.getWorkingConfig("device")).toBeUndefined();
+ device.setChange({ type: "config", variant: "device" }, routerConfig);
+ device.setChange({ type: "config", variant: "position" }, {});
+ device.clearAllChanges();
+ expect(device.hasConfigChange("device")).toBe(false);
+ expect(device.hasConfigChange("position")).toBe(false);
});
- it("setWorkingModuleConfig/getWorkingModuleConfig and getEffectiveModuleConfig", async () => {
+ it("setChange/hasChange for moduleConfig and getEffectiveModuleConfig", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(7);
- // base moduleConfig.mqtt empty; add working mqtt host
+ // base moduleConfig.mqtt with base address
device.setModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@@ -188,58 +170,82 @@ describe("DeviceStore – working/effective config API", () => {
},
}),
);
- device.setWorkingModuleConfig(
- makeModuleConfig({ address: "mqtt://working" }),
+
+ // working mqtt config
+ const workingMqtt = create(
+ Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema,
+ { address: "mqtt://working" },
+ );
+ device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt);
+
+ expect(device.hasModuleConfigChange("mqtt")).toBe(true);
+ const mqtt = device.getChange({
+ type: "moduleConfig",
+ variant: "mqtt",
+ }) as Protobuf.ModuleConfig.ModuleConfig_MQTTConfig;
+ expect(mqtt?.address).toBe("mqtt://working");
+
+ // effective should return working value
+ expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe(
+ "mqtt://working",
);
- const mqtt = device.getWorkingModuleConfig("mqtt");
- expect(mqtt?.address).toBe("mqtt://working");
- expect(mqtt?.address).toBe("mqtt://working");
-
- device.removeWorkingModuleConfig("mqtt");
- expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined();
+ // remove change
+ device.removeChange({ type: "moduleConfig", variant: "mqtt" });
+ expect(device.hasModuleConfigChange("mqtt")).toBe(false);
expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe(
"mqtt://base",
);
// Clear all
- device.setWorkingModuleConfig(makeModuleConfig({ address: "x" }));
- device.setWorkingModuleConfig(makeModuleConfig({ address: "y" }));
- device.removeWorkingModuleConfig();
- expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined();
+ device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt);
+ device.clearAllChanges();
+ expect(device.hasModuleConfigChange("mqtt")).toBe(false);
});
- it("channel working config add/update/remove/get", async () => {
+ it("channel change tracking add/update/remove/get", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(9);
- device.setWorkingChannelConfig(makeChannel(0));
- device.setWorkingChannelConfig(
- create(Protobuf.Channel.ChannelSchema, {
- index: 1,
- settings: { name: "one" },
- }),
- );
- expect(device.getWorkingChannelConfig(0)?.index).toBe(0);
- expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("one");
+ const channel0 = makeChannel(0);
+ const channel1 = create(Protobuf.Channel.ChannelSchema, {
+ index: 1,
+ settings: { name: "one" },
+ });
+
+ device.setChange({ type: "channel", index: 0 }, channel0);
+ device.setChange({ type: "channel", index: 1 }, channel1);
+
+ expect(device.hasChannelChange(0)).toBe(true);
+ expect(device.hasChannelChange(1)).toBe(true);
+ const ch0 = device.getChange({ type: "channel", index: 0 }) as
+ | Protobuf.Channel.Channel
+ | undefined;
+ expect(ch0?.index).toBe(0);
+ const ch1 = device.getChange({ type: "channel", index: 1 }) as
+ | Protobuf.Channel.Channel
+ | undefined;
+ expect(ch1?.settings?.name).toBe("one");
// update channel 1
- device.setWorkingChannelConfig(
- create(Protobuf.Channel.ChannelSchema, {
- index: 1,
- settings: { name: "uno" },
- }),
- );
- expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("uno");
+ const channel1Updated = create(Protobuf.Channel.ChannelSchema, {
+ index: 1,
+ settings: { name: "uno" },
+ });
+ device.setChange({ type: "channel", index: 1 }, channel1Updated);
+ const ch1Updated = device.getChange({ type: "channel", index: 1 }) as
+ | Protobuf.Channel.Channel
+ | undefined;
+ expect(ch1Updated?.settings?.name).toBe("uno");
// remove specific
- device.removeWorkingChannelConfig(1);
- expect(device.getWorkingChannelConfig(1)).toBeUndefined();
+ device.removeChange({ type: "channel", index: 1 });
+ expect(device.hasChannelChange(1)).toBe(false);
// remove all
- device.removeWorkingChannelConfig();
- expect(device.getWorkingChannelConfig(0)).toBeUndefined();
+ device.clearAllChanges();
+ expect(device.hasChannelChange(0)).toBe(false);
});
});
@@ -349,7 +355,8 @@ describe("DeviceStore – traceroutes & waypoints retention + merge on setHardwa
// Old device with myNodeNum=777 and some waypoints (one expired)
const oldDevice = state.addDevice(1);
- oldDevice.connection = { sendWaypoint: vi.fn() } as any;
+ const mockSendWaypoint = vi.fn();
+ oldDevice.addConnection({ sendWaypoint: mockSendWaypoint } as any);
oldDevice.setHardware(makeHardware(777));
oldDevice.addWaypoint(
@@ -393,10 +400,10 @@ describe("DeviceStore – traceroutes & waypoints retention + merge on setHardwa
// Remove waypoint
oldDevice.removeWaypoint(102, false);
- expect(oldDevice.connection?.sendWaypoint).not.toHaveBeenCalled();
+ expect(mockSendWaypoint).not.toHaveBeenCalled();
await oldDevice.removeWaypoint(101, true); // toMesh=true
- expect(oldDevice.connection?.sendWaypoint).toHaveBeenCalled();
+ expect(mockSendWaypoint).toHaveBeenCalled();
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(98);
diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts
index f57f7f92..55b10935 100644
--- a/packages/web/src/core/stores/deviceStore/index.ts
+++ b/packages/web/src/core/stores/deviceStore/index.ts
@@ -10,6 +10,21 @@ import {
persist,
subscribeWithSelector,
} from "zustand/middleware";
+import type { ChangeRegistry, ConfigChangeKey } from "./changeRegistry.ts";
+import {
+ createChangeRegistry,
+ getAllChannelChanges,
+ getAllConfigChanges,
+ getAllModuleConfigChanges,
+ getChannelChangeCount,
+ getConfigChangeCount,
+ getModuleConfigChangeCount,
+ hasChannelChange,
+ hasConfigChange,
+ hasModuleConfigChange,
+ hasUserChange,
+ serializeKey,
+} from "./changeRegistry.ts";
import type {
Dialogs,
DialogVariant,
@@ -42,9 +57,7 @@ export interface Device extends DeviceData {
channels: Map;
config: Protobuf.LocalOnly.LocalConfig;
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
- workingConfig: Protobuf.Config.Config[];
- workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
- workingChannelConfig: Protobuf.Channel.Channel[];
+ changeRegistry: ChangeRegistry; // Unified change tracking
hardware: Protobuf.Mesh.MyNodeInfo;
metadata: Map;
connection?: MeshDevice;
@@ -58,27 +71,12 @@ export interface Device extends DeviceData {
setStatus: (status: Types.DeviceStatusEnum) => void;
setConfig: (config: Protobuf.Config.Config) => void;
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
- setWorkingConfig: (config: Protobuf.Config.Config) => void;
- setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
- getWorkingConfig(
- payloadVariant: K,
- ): Protobuf.LocalOnly.LocalConfig[K] | undefined;
- getWorkingModuleConfig(
- payloadVariant: K,
- ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
- removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
- removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
getEffectiveConfig(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined;
getEffectiveModuleConfig(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
- setWorkingChannelConfig: (channelNum: Protobuf.Channel.Channel) => void;
- getWorkingChannelConfig: (
- index: Types.ChannelNumber,
- ) => Protobuf.Channel.Channel | undefined;
- removeWorkingChannelConfig: (channelNum?: Types.ChannelNumber) => void;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void;
@@ -116,6 +114,27 @@ export interface Device extends DeviceData {
neighborInfo: Protobuf.Mesh.NeighborInfo,
) => void;
getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined;
+
+ // New unified change tracking methods
+ setChange: (
+ key: ConfigChangeKey,
+ value: unknown,
+ originalValue?: unknown,
+ ) => void;
+ removeChange: (key: ConfigChangeKey) => void;
+ hasChange: (key: ConfigChangeKey) => boolean;
+ getChange: (key: ConfigChangeKey) => unknown | undefined;
+ clearAllChanges: () => void;
+ hasConfigChange: (variant: ValidConfigType) => boolean;
+ hasModuleConfigChange: (variant: ValidModuleConfigType) => boolean;
+ hasChannelChange: (index: Types.ChannelNumber) => boolean;
+ hasUserChange: () => boolean;
+ getConfigChangeCount: () => number;
+ getModuleConfigChangeCount: () => number;
+ getChannelChangeCount: () => number;
+ getAllConfigChanges: () => Protobuf.Config.Config[];
+ getAllModuleConfigChanges: () => Protobuf.ModuleConfig.ModuleConfig[];
+ getAllChannelChanges: () => Protobuf.Channel.Channel[];
}
export interface deviceState {
@@ -157,9 +176,7 @@ function deviceFactory(
channels: new Map(),
config: create(Protobuf.LocalOnly.LocalConfigSchema),
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
- workingConfig: [],
- workingModuleConfig: [],
- workingChannelConfig: [],
+ changeRegistry: createChangeRegistry(),
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
metadata: new Map(),
connection: undefined,
@@ -302,130 +319,6 @@ function deviceFactory(
}),
);
},
- setWorkingConfig: (config: Protobuf.Config.Config) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const index = device.workingConfig.findIndex(
- (wc) => wc.payloadVariant.case === config.payloadVariant.case,
- );
-
- if (index !== -1) {
- device.workingConfig[index] = config;
- } else {
- device.workingConfig.push(config);
- }
- }),
- );
- },
- setWorkingModuleConfig: (
- moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
- ) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const index = device.workingModuleConfig.findIndex(
- (wmc) =>
- wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
- );
-
- if (index !== -1) {
- device.workingModuleConfig[index] = moduleConfig;
- } else {
- device.workingModuleConfig.push(moduleConfig);
- }
- }),
- );
- },
-
- getWorkingConfig(payloadVariant: K) {
- const device = get().devices.get(id);
- if (!device) {
- return;
- }
-
- const workingConfig = device.workingConfig.find(
- (c) => c.payloadVariant.case === payloadVariant,
- );
-
- if (
- workingConfig?.payloadVariant.case === "deviceUi" ||
- workingConfig?.payloadVariant.case === "sessionkey"
- ) {
- return;
- }
-
- return workingConfig?.payloadVariant
- .value as Protobuf.LocalOnly.LocalConfig[K];
- },
- getWorkingModuleConfig(
- payloadVariant: K,
- ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined {
- const device = get().devices.get(id);
- if (!device) {
- return;
- }
-
- return device.workingModuleConfig.find(
- (c) => c.payloadVariant.case === payloadVariant,
- )?.payloadVariant.value as Protobuf.LocalOnly.LocalModuleConfig[K];
- },
-
- removeWorkingConfig: (payloadVariant?: ValidConfigType) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
-
- if (!payloadVariant) {
- device.workingConfig = [];
- return;
- }
-
- const index = device.workingConfig.findIndex(
- (wc: Protobuf.Config.Config) =>
- wc.payloadVariant.case === payloadVariant,
- );
-
- if (index !== -1) {
- device.workingConfig.splice(index, 1);
- }
- }),
- );
- },
- removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
-
- if (!payloadVariant) {
- device.workingModuleConfig = [];
- return;
- }
-
- const index = device.workingModuleConfig.findIndex(
- (wc: Protobuf.ModuleConfig.ModuleConfig) =>
- wc.payloadVariant.case === payloadVariant,
- );
-
- if (index !== -1) {
- device.workingModuleConfig.splice(index, 1);
- }
- }),
- );
- },
-
getEffectiveConfig(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined {
@@ -437,11 +330,13 @@ function deviceFactory(
return;
}
+ const workingValue = device.changeRegistry.changes.get(
+ serializeKey({ type: "config", variant: payloadVariant }),
+ )?.value as Protobuf.LocalOnly.LocalConfig[K] | undefined;
+
return {
...device.config[payloadVariant],
- ...device.workingConfig.find(
- (c) => c.payloadVariant.case === payloadVariant,
- )?.payloadVariant.value,
+ ...workingValue,
};
},
getEffectiveModuleConfig(
@@ -452,69 +347,16 @@ function deviceFactory(
return;
}
+ const workingValue = device.changeRegistry.changes.get(
+ serializeKey({ type: "moduleConfig", variant: payloadVariant }),
+ )?.value as Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
+
return {
...device.moduleConfig[payloadVariant],
- ...device.workingModuleConfig.find(
- (c) => c.payloadVariant.case === payloadVariant,
- )?.payloadVariant.value,
+ ...workingValue,
};
},
- setWorkingChannelConfig: (config: Protobuf.Channel.Channel) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const index = device.workingChannelConfig.findIndex(
- (wcc) => wcc.index === config.index,
- );
-
- if (index !== -1) {
- device.workingChannelConfig[index] = config;
- } else {
- device.workingChannelConfig.push(config);
- }
- }),
- );
- },
- getWorkingChannelConfig: (channelNum: Types.ChannelNumber) => {
- const device = get().devices.get(id);
- if (!device) {
- return;
- }
-
- const workingChannelConfig = device.workingChannelConfig.find(
- (c) => c.index === channelNum,
- );
-
- return workingChannelConfig;
- },
- removeWorkingChannelConfig: (channelNum?: Types.ChannelNumber) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
-
- if (channelNum === undefined) {
- device.workingChannelConfig = [];
- return;
- }
-
- const index = device.workingChannelConfig.findIndex(
- (wcc: Protobuf.Channel.Channel) => wcc.index === channelNum,
- );
-
- if (index !== -1) {
- device.workingChannelConfig.splice(index, 1);
- }
- }),
- );
- },
-
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set(
produce((draft) => {
@@ -855,6 +697,195 @@ function deviceFactory(
}
return device.neighborInfo.get(nodeNum);
},
+
+ // New unified change tracking methods
+ setChange: (
+ key: ConfigChangeKey,
+ value: unknown,
+ originalValue?: unknown,
+ ) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ const keyStr = serializeKey(key);
+ device.changeRegistry.changes.set(keyStr, {
+ key,
+ value,
+ originalValue,
+ timestamp: Date.now(),
+ });
+ }),
+ );
+ },
+
+ removeChange: (key: ConfigChangeKey) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ device.changeRegistry.changes.delete(serializeKey(key));
+ }),
+ );
+ },
+
+ hasChange: (key: ConfigChangeKey) => {
+ const device = get().devices.get(id);
+ return device?.changeRegistry.changes.has(serializeKey(key)) ?? false;
+ },
+
+ getChange: (key: ConfigChangeKey) => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ return device.changeRegistry.changes.get(serializeKey(key))?.value;
+ },
+
+ clearAllChanges: () => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ device.changeRegistry.changes.clear();
+ }),
+ );
+ },
+
+ hasConfigChange: (variant: ValidConfigType) => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return false;
+ }
+
+ return hasConfigChange(device.changeRegistry, variant);
+ },
+
+ hasModuleConfigChange: (variant: ValidModuleConfigType) => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return false;
+ }
+
+ return hasModuleConfigChange(device.changeRegistry, variant);
+ },
+
+ hasChannelChange: (index: Types.ChannelNumber) => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return false;
+ }
+
+ return hasChannelChange(device.changeRegistry, index);
+ },
+
+ hasUserChange: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return false;
+ }
+
+ return hasUserChange(device.changeRegistry);
+ },
+
+ getConfigChangeCount: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return 0;
+ }
+
+ return getConfigChangeCount(device.changeRegistry);
+ },
+
+ getModuleConfigChangeCount: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return 0;
+ }
+
+ return getModuleConfigChangeCount(device.changeRegistry);
+ },
+
+ getChannelChangeCount: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return 0;
+ }
+
+ return getChannelChangeCount(device.changeRegistry);
+ },
+
+ getAllConfigChanges: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return [];
+ }
+
+ const changes = getAllConfigChanges(device.changeRegistry);
+ return changes
+ .map((entry) => {
+ if (entry.key.type !== "config") {
+ return null;
+ }
+ if (!entry.value) {
+ return null;
+ }
+ return create(Protobuf.Config.ConfigSchema, {
+ payloadVariant: {
+ case: entry.key.variant,
+ value: entry.value,
+ },
+ });
+ })
+ .filter((c): c is Protobuf.Config.Config => c !== null);
+ },
+
+ getAllModuleConfigChanges: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return [];
+ }
+
+ const changes = getAllModuleConfigChanges(device.changeRegistry);
+ return changes
+ .map((entry) => {
+ if (entry.key.type !== "moduleConfig") {
+ return null;
+ }
+ if (!entry.value) {
+ return null;
+ }
+ return create(Protobuf.ModuleConfig.ModuleConfigSchema, {
+ payloadVariant: {
+ case: entry.key.variant,
+ value: entry.value,
+ },
+ });
+ })
+ .filter((c): c is Protobuf.ModuleConfig.ModuleConfig => c !== null);
+ },
+
+ getAllChannelChanges: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return [];
+ }
+
+ const changes = getAllChannelChanges(device.changeRegistry);
+ return changes
+ .map((entry) => entry.value as Protobuf.Channel.Channel)
+ .filter((c): c is Protobuf.Channel.Channel => c !== undefined);
+ },
};
}
diff --git a/packages/web/src/core/stores/deviceStore/types.ts b/packages/web/src/core/stores/deviceStore/types.ts
index 08726372..c6df2a32 100644
--- a/packages/web/src/core/stores/deviceStore/types.ts
+++ b/packages/web/src/core/stores/deviceStore/types.ts
@@ -1,4 +1,8 @@
import type { Protobuf } from "@meshtastic/core";
+import type {
+ ValidConfigType,
+ ValidModuleConfigType,
+} from "./changeRegistry.ts";
interface Dialogs {
import: boolean;
@@ -22,16 +26,7 @@ interface Dialogs {
type DialogVariant = keyof Dialogs;
-type ValidConfigType = Exclude<
- Protobuf.Config.Config["payloadVariant"]["case"],
- "deviceUi" | "sessionkey" | undefined
->;
-type ValidModuleConfigType = Exclude<
- Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
- undefined
->;
-
-type Page = "messages" | "map" | "config" | "channels" | "nodes";
+type Page = "messages" | "map" | "settings" | "channels" | "nodes";
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
metadata: {
diff --git a/packages/web/src/i18n-config.ts b/packages/web/src/i18n-config.ts
index 8b8de390..dc8cde12 100644
--- a/packages/web/src/i18n-config.ts
+++ b/packages/web/src/i18n-config.ts
@@ -52,7 +52,7 @@ i18next
"channels",
"commandPalette",
"common",
- "deviceConfig",
+ "config",
"moduleConfig",
"dashboard",
"dialog",
diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx
index 83186a24..e2ed979c 100644
--- a/packages/web/src/pages/Messages.tsx
+++ b/packages/web/src/pages/Messages.tsx
@@ -19,7 +19,6 @@ import {
import { cn } from "@core/utils/cn.ts";
import { randId } from "@core/utils/randId.ts";
import { Protobuf, Types } from "@meshtastic/core";
-import { getChannelName } from "@pages/Config/ChannelConfig.tsx";
import { useNavigate, useParams } from "@tanstack/react-router";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import {
@@ -30,6 +29,7 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
+import { getChannelName } from "../components/PageComponents/Channels/Channels.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
diff --git a/packages/web/src/pages/Config/DeviceConfig.tsx b/packages/web/src/pages/Settings/DeviceConfig.tsx
similarity index 68%
rename from packages/web/src/pages/Config/DeviceConfig.tsx
rename to packages/web/src/pages/Settings/DeviceConfig.tsx
index ead1d4f5..034864e7 100644
--- a/packages/web/src/pages/Config/DeviceConfig.tsx
+++ b/packages/web/src/pages/Settings/DeviceConfig.tsx
@@ -1,11 +1,10 @@
-import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
-import { Device } from "@components/PageComponents/Config/Device/index.tsx";
-import { Display } from "@components/PageComponents/Config/Display.tsx";
-import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
-import { Network } from "@components/PageComponents/Config/Network/index.tsx";
-import { Position } from "@components/PageComponents/Config/Position.tsx";
-import { Power } from "@components/PageComponents/Config/Power.tsx";
-import { Security } from "@components/PageComponents/Config/Security/Security.tsx";
+import { Bluetooth } from "@components/PageComponents/Settings/Bluetooth.tsx";
+import { Device } from "@components/PageComponents/Settings/Device/index.tsx";
+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,
@@ -23,21 +22,25 @@ interface ConfigProps {
}
type TabItem = {
- case: ValidConfigType;
+ case: ValidConfigType | "user";
label: string;
element: ComponentType;
count?: number;
};
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
- const { getWorkingConfig } = useDevice();
- const { t } = useTranslation("deviceConfig");
+ const { hasConfigChange, hasUserChange } = useDevice();
+ const { t } = useTranslation("config");
const tabs: TabItem[] = [
+ {
+ case: "user",
+ label: t("page.tabUser"),
+ element: User,
+ },
{
case: "device",
label: t("page.tabDevice"),
element: Device,
- count: 0,
},
{
case: "position",
@@ -59,30 +62,28 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
label: t("page.tabDisplay"),
element: Display,
},
- {
- case: "lora",
- label: t("page.tabLora"),
- element: LoRa,
- },
{
case: "bluetooth",
label: t("page.tabBluetooth"),
element: Bluetooth,
},
- {
- case: "security",
- label: t("page.tabSecurity"),
- element: Security,
- },
] as const;
const flags = useMemo(
- () => new Map(tabs.map((tab) => [tab.case, getWorkingConfig(tab.case)])),
- [tabs, getWorkingConfig],
+ () =>
+ new Map(
+ tabs.map((tab) => [
+ tab.case,
+ tab.case === "user"
+ ? hasUserChange()
+ : hasConfigChange(tab.case as ValidConfigType),
+ ]),
+ ),
+ [tabs, hasConfigChange, hasUserChange],
);
return (
-
+
{tabs.map((tab) => (
{
- const { getWorkingModuleConfig } = useDevice();
+ const { hasModuleConfigChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const tabs: TabItem[] = [
{
@@ -97,8 +97,8 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
const flags = useMemo(
() =>
- new Map(tabs.map((tab) => [tab.case, getWorkingModuleConfig(tab.case)])),
- [tabs, getWorkingModuleConfig],
+ new Map(tabs.map((tab) => [tab.case, hasModuleConfigChange(tab.case)])),
+ [tabs, hasModuleConfigChange],
);
return (
diff --git a/packages/web/src/pages/Settings/RadioConfig.tsx b/packages/web/src/pages/Settings/RadioConfig.tsx
new file mode 100644
index 00000000..ddcc52c0
--- /dev/null
+++ b/packages/web/src/pages/Settings/RadioConfig.tsx
@@ -0,0 +1,81 @@
+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 { useDevice, type ValidConfigType } from "@core/stores";
+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;
+ label: string;
+ element: ComponentType;
+ count?: number;
+};
+
+export const RadioConfig = ({ onFormInit }: ConfigProps) => {
+ const { hasConfigChange } = useDevice();
+ const { t } = useTranslation("config");
+ const tabs: TabItem[] = [
+ {
+ case: "lora",
+ label: t("page.tabLora"),
+ element: LoRa,
+ },
+ {
+ case: "channels",
+ label: t("page.tabChannels"),
+ element: Channels,
+ },
+ {
+ case: "security",
+ label: t("page.tabSecurity"),
+ element: Security,
+ },
+ ] as const;
+
+ const flags = useMemo(
+ () => new Map(tabs.map((tab) => [tab.case, hasConfigChange(tab.case)])),
+ [tabs, hasConfigChange],
+ );
+
+ return (
+
+
+ {tabs.map((tab) => (
+
+ {tab.label}
+ {flags.get(tab.case) && (
+
+
+
+
+ )}
+
+ ))}
+
+ {tabs.map((tab) => (
+
+ }>
+
+
+
+ ))}
+
+ );
+};
diff --git a/packages/web/src/pages/Config/index.tsx b/packages/web/src/pages/Settings/index.tsx
similarity index 64%
rename from packages/web/src/pages/Config/index.tsx
rename to packages/web/src/pages/Settings/index.tsx
index 7c0531de..ffd17614 100644
--- a/packages/web/src/pages/Config/index.tsx
+++ b/packages/web/src/pages/Settings/index.tsx
@@ -1,3 +1,4 @@
+import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
@@ -5,44 +6,84 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
-import { ChannelConfig } from "@pages/Config/ChannelConfig.tsx";
-import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
-import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
+import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
+import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
+import { useNavigate, useRouterState } from "@tanstack/react-router";
import {
- BoxesIcon,
LayersIcon,
+ RadioTowerIcon,
RefreshCwIcon,
+ RouterIcon,
SaveIcon,
SaveOff,
- SettingsIcon,
} from "lucide-react";
import { 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";
const ConfigPage = () => {
const {
- workingConfig,
- workingModuleConfig,
- workingChannelConfig,
+ getAllConfigChanges,
+ getAllModuleConfigChanges,
+ getAllChannelChanges,
connection,
- removeWorkingConfig,
- removeWorkingModuleConfig,
- removeWorkingChannelConfig,
+ clearAllChanges,
setConfig,
setModuleConfig,
addChannel,
+ getConfigChangeCount,
+ getModuleConfigChangeCount,
+ getChannelChangeCount,
} = useDevice();
- const [activeConfigSection, setActiveConfigSection] = useState<
- "device" | "module" | "channel"
- >("device");
const [isSaving, setIsSaving] = useState(false);
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
const unsubRef = useRef<(() => void) | null>(null);
const [formMethods, setFormMethods] = useState(null);
const { toast } = useToast();
- const { t } = useTranslation("deviceConfig");
+ const navigate = useNavigate();
+ const routerState = useRouterState();
+ const { t } = useTranslation("config");
+
+ const configChangeCount = getConfigChangeCount();
+ const moduleConfigChangeCount = getModuleConfigChangeCount();
+ const channelChangeCount = getChannelChangeCount();
+
+ const sections = useMemo(
+ () => [
+ {
+ key: "radio",
+ route: radioRoute,
+ label: t("navigation.radioConfig"),
+ icon: RadioTowerIcon,
+ changeCount: configChangeCount,
+ component: RadioConfig,
+ },
+ {
+ key: "device",
+ route: deviceRoute,
+ label: t("navigation.deviceConfig"),
+ icon: RouterIcon,
+ changeCount: moduleConfigChangeCount,
+ component: DeviceConfig,
+ },
+ {
+ key: "module",
+ route: moduleRoute,
+ label: t("navigation.moduleConfig"),
+ icon: LayersIcon,
+ changeCount: channelChangeCount,
+ component: ModuleConfig,
+ },
+ ],
+ [t, configChangeCount, moduleConfigChangeCount, channelChangeCount],
+ );
+
+ const activeSection =
+ sections.find((section) =>
+ routerState.location.pathname.includes(`/settings/${section.key}`),
+ ) ?? sections[0];
const onFormInit = useCallback(
(methods: UseFormReturn) => {
@@ -69,7 +110,6 @@ const ConfigPage = () => {
[],
);
- // Cleanup subscription on unmount
useEffect(() => {
return () => unsubRef.current?.();
}, []);
@@ -78,9 +118,12 @@ const ConfigPage = () => {
setIsSaving(true);
try {
- // Save all working channel configs first, doesn't require a commit/reboot
+ const channelChanges = getAllChannelChanges();
+ const configChanges = getAllConfigChanges();
+ const moduleConfigChanges = getAllModuleConfigChanges();
+
await Promise.all(
- workingChannelConfig.map((channel) =>
+ channelChanges.map((channel) =>
connection?.setChannel(channel).then(() => {
toast({
title: t("toast.savedChannel.title", {
@@ -93,7 +136,7 @@ const ConfigPage = () => {
);
await Promise.all(
- workingConfig.map((newConfig) =>
+ configChanges.map((newConfig) =>
connection?.setConfig(newConfig).then(() => {
toast({
title: t("toast.saveSuccess.title"),
@@ -106,7 +149,7 @@ const ConfigPage = () => {
);
await Promise.all(
- workingModuleConfig.map((newModuleConfig) =>
+ moduleConfigChanges.map((newModuleConfig) =>
connection?.setModuleConfig(newModuleConfig).then(() =>
toast({
title: t("toast.saveSuccess.title"),
@@ -118,23 +161,21 @@ const ConfigPage = () => {
),
);
- if (workingConfig.length > 0 || workingModuleConfig.length > 0) {
+ if (configChanges.length > 0 || moduleConfigChanges.length > 0) {
await connection?.commitEditSettings();
}
- workingChannelConfig.forEach((newChannel) => {
+ channelChanges.forEach((newChannel) => {
addChannel(newChannel);
});
- workingConfig.forEach((newConfig) => {
+ configChanges.forEach((newConfig) => {
setConfig(newConfig);
});
- workingModuleConfig.forEach((newModuleConfig) => {
+ moduleConfigChanges.forEach((newModuleConfig) => {
setModuleConfig(newModuleConfig);
});
- removeWorkingChannelConfig();
- removeWorkingConfig();
- removeWorkingModuleConfig();
+ clearAllChanges();
if (formMethods) {
formMethods.reset(formMethods.getValues(), {
@@ -144,7 +185,6 @@ const ConfigPage = () => {
keepValues: true,
});
- // Force RHF to re-validate and emit state
formMethods.trigger();
}
} catch (_error) {
@@ -162,77 +202,49 @@ const ConfigPage = () => {
}, [
toast,
t,
- workingConfig,
+ getAllConfigChanges,
connection,
- workingModuleConfig,
- workingChannelConfig,
+ getAllModuleConfigChanges,
+ getAllChannelChanges,
formMethods,
addChannel,
setConfig,
setModuleConfig,
- removeWorkingConfig,
- removeWorkingModuleConfig,
- removeWorkingChannelConfig,
+ clearAllChanges,
]);
const handleReset = useCallback(() => {
if (formMethods) {
formMethods.reset();
}
- removeWorkingChannelConfig();
- removeWorkingConfig();
- removeWorkingModuleConfig();
- }, [
- formMethods,
- removeWorkingConfig,
- removeWorkingModuleConfig,
- removeWorkingChannelConfig,
- ]);
+ clearAllChanges();
+ }, [formMethods, clearAllChanges]);
const leftSidebar = useMemo(
() => (
- setActiveConfigSection("device")}
- Icon={SettingsIcon}
- isDirty={workingConfig.length > 0}
- count={workingConfig.length}
- />
- setActiveConfigSection("module")}
- Icon={BoxesIcon}
- isDirty={workingModuleConfig.length > 0}
- count={workingModuleConfig.length}
- />
- setActiveConfigSection("channel")}
- Icon={LayersIcon}
- isDirty={workingChannelConfig.length > 0}
- count={workingChannelConfig.length}
- />
+ {sections.map((section) => (
+ navigate({ to: section.route.to })}
+ Icon={section.icon}
+ isDirty={section.changeCount > 0}
+ count={section.changeCount}
+ />
+ ))}
),
- [
- activeConfigSection,
- workingConfig,
- workingModuleConfig,
- workingChannelConfig,
- t,
- ],
+ [sections, activeSection?.key, navigate, t],
);
const hasDrafts =
- workingConfig.length > 0 ||
- workingModuleConfig.length > 0 ||
- workingChannelConfig.length > 0;
+ getConfigChangeCount() > 0 ||
+ getModuleConfigChangeCount() > 0 ||
+ getChannelChangeCount() > 0;
const hasPending = hasDrafts || rhfState.isDirty;
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
@@ -291,26 +303,16 @@ const ConfigPage = () => {
],
);
+ const ActiveComponent = activeSection?.component;
+
return (
- {activeConfigSection === "device" ? (
-
- ) : activeConfigSection === "module" ? (
-
- ) : (
-
- )}
+
);
};
diff --git a/packages/web/src/routes.tsx b/packages/web/src/routes.tsx
index 07125f82..11fb4edc 100644
--- a/packages/web/src/routes.tsx
+++ b/packages/web/src/routes.tsx
@@ -1,10 +1,10 @@
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import type { useAppStore, useMessageStore } from "@core/stores";
-import ConfigPage from "@pages/Config/index.tsx";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import MapPage from "@pages/Map/index.tsx";
import MessagesPage from "@pages/Messages.tsx";
import NodesPage from "@pages/Nodes/index.tsx";
+import ConfigPage from "@pages/Settings/index.tsx";
import {
createRootRouteWithContext,
createRoute,
@@ -109,9 +109,33 @@ export const mapWithParamsRoute = createRoute({
// }),
});
-const configRoute = createRoute({
+export const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
- path: "/config",
+ 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,
+});
+
+export const moduleRoute = createRoute({
+ getParentRoute: () => settingsRoute,
+ path: "module",
component: ConfigPage,
});
@@ -133,7 +157,7 @@ const routeTree = rootRoute.addChildren([
messagesWithParamsRoute,
mapRoute,
mapWithParamsRoute,
- configRoute,
+ settingsRoute.addChildren([radioRoute, deviceRoute, moduleRoute]),
nodesRoute,
dialogWithParamsRoute,
]);
diff --git a/packages/web/src/tests/setup.ts b/packages/web/src/tests/setup.ts
index 1b36f7d8..8d8b72f4 100644
--- a/packages/web/src/tests/setup.ts
+++ b/packages/web/src/tests/setup.ts
@@ -12,10 +12,10 @@ import commandPaletteEN from "@public/i18n/locales/en/commandPalette.json" with
import commonEN from "@public/i18n/locales/en/common.json" with {
type: "json",
};
-import dashboardEN from "@public/i18n/locales/en/dashboard.json" with {
+import configEN from "@public/i18n/locales/en/config.json" with {
type: "json",
};
-import deviceConfigEN from "@public/i18n/locales/en/deviceConfig.json" with {
+import dashboardEN from "@public/i18n/locales/en/dashboard.json" with {
type: "json",
};
import dialogEN from "@public/i18n/locales/en/dialog.json" with {
@@ -53,7 +53,7 @@ const appNamespaces = [
"channels",
"commandPalette",
"common",
- "deviceConfig",
+ "config",
"moduleConfig",
"dashboard",
"dialog",
@@ -76,7 +76,7 @@ i18n.use(initReactI18next).init({
channels: channelsEN,
commandPalette: commandPaletteEN,
common: commonEN,
- deviceConfig: deviceConfigEN,
+ config: configEN,
moduleConfig: moduleConfigEN,
dashboard: dashboardEN,
dialog: dialogEN,
diff --git a/packages/web/src/validation/config/user.ts b/packages/web/src/validation/config/user.ts
new file mode 100644
index 00000000..db5109a7
--- /dev/null
+++ b/packages/web/src/validation/config/user.ts
@@ -0,0 +1,17 @@
+import { t } from "i18next";
+import { z } from "zod/v4";
+
+export const UserValidationSchema = z.object({
+ longName: z
+ .string()
+ .min(1, t("deviceName.validation.longNameMin"))
+ .max(40, t("deviceName.validation.longNameMax")),
+ shortName: z
+ .string()
+ .min(2, t("deviceName.validation.shortNameMin"))
+ .max(4, t("deviceName.validation.shortNameMax")),
+ isUnmessageable: z.boolean().default(false),
+ isLicensed: z.boolean().default(false),
+});
+
+export type UserValidation = z.infer;