Feat(config): Align settings menu to match android/ios (#906)

* feat: aligned settings menu to match android/ios

* updated sidebar text size.

* Update packages/web/public/i18n/locales/en/config.json

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

* Update packages/web/public/i18n/locales/en/config.json

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

* Update packages/web/src/components/PageComponents/Settings/User.tsx

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

* Update packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx

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

* linting/formatting fixes

* fixed formatting issue

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dan Ditomaso
2025-10-24 13:31:22 -04:00
committed by GitHub
co-authored by Copilot
parent ff02b1455d
commit 0cf677c8d5
45 changed files with 1116 additions and 777 deletions
@@ -1,6 +1,8 @@
{ {
"page": { "page": {
"title": "Configuration", "title": "Settings",
"tabUser": "User",
"tabChannels": "Channels",
"tabBluetooth": "Bluetooth", "tabBluetooth": "Bluetooth",
"tabDevice": "Device", "tabDevice": "Device",
"tabDisplay": "Display", "tabDisplay": "Display",
@@ -11,7 +13,7 @@
"tabSecurity": "Security" "tabSecurity": "Security"
}, },
"sidebar": { "sidebar": {
"label": "Modules" "label": "Configuration"
}, },
"device": { "device": {
"title": "Device Settings", "title": "Device Settings",
@@ -424,5 +426,33 @@
"description": "Settings for Logging", "description": "Settings for Logging",
"label": "Logging Settings" "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."
}
} }
} }
+3 -9
View File
@@ -3,11 +3,11 @@
"title": "Navigation", "title": "Navigation",
"messages": "Messages", "messages": "Messages",
"map": "Map", "map": "Map",
"config": "Config", "settings": "Settings",
"channels": "Channels", "channels": "Channels",
"radioConfig": "Radio Config", "radioConfig": "Radio Config",
"deviceConfig": "Device Config",
"moduleConfig": "Module Config", "moduleConfig": "Module Config",
"channelConfig": "Channel Config",
"nodes": "Nodes" "nodes": "Nodes"
}, },
"app": { "app": {
@@ -27,13 +27,7 @@
"title": "Firmware", "title": "Firmware",
"version": "v{{version}}", "version": "v{{version}}",
"buildDate": "Build date: {{date}}" "buildDate": "Build date: {{date}}"
}, }
"deviceName": {
"title": "Device Name",
"changeName": "Change Device Name",
"placeholder": "Enter device name"
},
"editDeviceName": "Edit device name"
} }
}, },
"batteryStatus": { "batteryStatus": {
@@ -4,7 +4,6 @@ import {
Languages, Languages,
type LucideIcon, type LucideIcon,
Palette, Palette,
PenLine,
Search as SearchIcon, Search as SearchIcon,
ZapIcon, ZapIcon,
} from "lucide-react"; } from "lucide-react";
@@ -53,7 +52,6 @@ export const DeviceInfoPanel = ({
firmwareVersion, firmwareVersion,
user, user,
isCollapsed, isCollapsed,
setDialogOpen,
setCommandPaletteOpen, setCommandPaletteOpen,
disableHover = false, disableHover = false,
}: DeviceInfoPanelProps) => { }: DeviceInfoPanelProps) => {
@@ -91,12 +89,6 @@ export const DeviceInfoPanel = ({
icon: Palette, icon: Palette,
render: () => <ThemeSwitcher />, render: () => <ThemeSwitcher />,
}, },
{
id: "changeName",
label: t("sidebar.deviceInfo.deviceName.changeName"),
icon: PenLine,
onClick: setDialogOpen,
},
{ {
id: "commandMenu", id: "commandMenu",
label: t("page.title", { ns: "commandPalette" }), label: t("page.title", { ns: "commandPalette" }),
@@ -33,7 +33,7 @@ export interface ImportDialogProps {
} }
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const { config, channels } = useDevice(); const { setChange, channels, config } = useDevice();
const { t } = useTranslation("dialog"); const { t } = useTranslation("dialog");
const [importDialogInput, setImportDialogInput] = useState<string>(""); const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>(); const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
@@ -41,8 +41,6 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const [updateConfig, setUpdateConfig] = useState<boolean>(true); const [updateConfig, setUpdateConfig] = useState<boolean>(true);
const [importIndex, setImportIndex] = useState<number[]>([]); const [importIndex, setImportIndex] = useState<number[]>([]);
const { setWorkingChannelConfig, setWorkingConfig } = useDevice();
useEffect(() => { useEffect(() => {
// the channel information is contained in the URL's fragment, which will be present after a // the channel information is contained in the URL's fragment, which will be present after a
// non-URL encoded `#`. // non-URL encoded `#`.
@@ -106,7 +104,11 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
true, 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)) { if (!deepCompareConfig(config.lora, payload, true)) {
setWorkingConfig( setChange({ type: "config", variant: "lora" }, payload, config.lora);
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: payload,
},
}),
);
} }
} }
// Reset state after import // Reset state after import
@@ -25,7 +25,7 @@ export function MultiSelectInput<T extends FieldValues>({
isDirty, isDirty,
invalid, invalid,
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) { }: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("config");
const { enumValue, className, ...remainingProperties } = field.properties; const { enumValue, className, ...remainingProperties } = field.properties;
const isNewConfigStructure = const isNewConfigStructure =
@@ -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 useLang from "@core/hooks/useLang.ts";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Check, Languages } from "lucide-react"; import { Check, Languages } from "lucide-react";
@@ -24,12 +24,7 @@ export interface SettingsPanelProps {
} }
export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
const { const { config, setChange, getChange, removeChange } = useDevice();
config,
setWorkingChannelConfig,
getWorkingChannelConfig,
removeWorkingChannelConfig,
} = useDevice();
const { t } = useTranslation(["channels", "ui", "dialog"]); const { t } = useTranslation(["channels", "ui", "dialog"]);
const defaultConfig = channel; 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 = { const formValues = {
...effectiveConfig, ...effectiveConfig,
...{ ...{
@@ -111,7 +110,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
return; return;
} }
const payload = { const payload = create(Protobuf.Channel.ChannelSchema, {
...data, ...data,
settings: { settings: {
...data.settings, ...data.settings,
@@ -121,14 +120,14 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
positionPrecision: data.settings.moduleSettings.positionPrecision, positionPrecision: data.settings.moduleSettings.positionPrecision,
}), }),
}, },
}; });
if (deepCompareConfig(channel, payload, true)) { if (deepCompareConfig(channel, payload, true)) {
removeWorkingChannelConfig(channel.index); removeChange({ type: "channel", index: channel.index });
return; return;
} }
setWorkingChannelConfig(create(Protobuf.Channel.ChannelSchema, payload)); setChange({ type: "channel", index: channel.index }, payload, channel);
}; };
const preSharedKeyRegenerate = async () => { const preSharedKeyRegenerate = async () => {
@@ -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 { Button } from "@components/UI/Button.tsx";
import { Spinner } from "@components/UI/Spinner.tsx"; import { Spinner } from "@components/UI/Spinner.tsx";
import { import {
@@ -30,8 +30,8 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) => {
}); });
}; };
export const ChannelConfig = ({ onFormInit }: ConfigProps) => { export const Channels = ({ onFormInit }: ConfigProps) => {
const { channels, getWorkingChannelConfig, setDialogOpen } = useDevice(); const { channels, hasChannelChange, setDialogOpen } = useDevice();
const { t } = useTranslation("channels"); const { t } = useTranslation("channels");
const allChannels = Array.from(channels.values()); const allChannels = Array.from(channels.values());
@@ -40,10 +40,10 @@ export const ChannelConfig = ({ onFormInit }: ConfigProps) => {
new Map( new Map(
allChannels.map((channel) => [ allChannels.map((channel) => [
channel.index, channel.index,
getWorkingChannelConfig(channel.index), hasChannelChange(channel.index),
]), ]),
), ),
[allChannels, getWorkingChannelConfig], [allChannels, hasChannelChange],
); );
return ( return (
@@ -3,14 +3,12 @@ import {
type AmbientLightingValidation, type AmbientLightingValidation,
AmbientLightingValidationSchema, AmbientLightingValidationSchema,
} from "@app/validation/moduleConfig/ambientLighting.ts"; } from "@app/validation/moduleConfig/ambientLighting.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface AmbientLightingModuleConfigProps { interface AmbientLightingModuleConfigProps {
@@ -21,27 +19,20 @@ export const AmbientLighting = ({
onFormInit, onFormInit,
}: AmbientLightingModuleConfigProps) => { }: AmbientLightingModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "ambientLighting" }); useWaitForConfig({ moduleConfigCase: "ambientLighting" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AmbientLightingValidation) => { const onSubmit = (data: AmbientLightingValidation) => {
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) { if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
removeWorkingModuleConfig("ambientLighting"); removeChange({ type: "moduleConfig", variant: "ambientLighting" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "ambientLighting" },
payloadVariant: { data,
case: "ambientLighting", moduleConfig.ambientLighting,
value: data,
},
}),
); );
}; };
@@ -3,7 +3,6 @@ import {
type AudioValidation, type AudioValidation,
AudioValidationSchema, AudioValidationSchema,
} from "@app/validation/moduleConfig/audio.ts"; } from "@app/validation/moduleConfig/audio.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
@@ -19,27 +18,20 @@ interface AudioModuleConfigProps {
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => { export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "audio" }); useWaitForConfig({ moduleConfigCase: "audio" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AudioValidation) => { const onSubmit = (data: AudioValidation) => {
if (deepCompareConfig(moduleConfig.audio, data, true)) { if (deepCompareConfig(moduleConfig.audio, data, true)) {
removeWorkingModuleConfig("audio"); removeChange({ type: "moduleConfig", variant: "audio" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "audio" },
payloadVariant: { data,
case: "audio", moduleConfig.audio,
value: data,
},
}),
); );
}; };
@@ -3,7 +3,6 @@ import {
type CannedMessageValidation, type CannedMessageValidation,
CannedMessageValidationSchema, CannedMessageValidationSchema,
} from "@app/validation/moduleConfig/cannedMessage.ts"; } from "@app/validation/moduleConfig/cannedMessage.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
@@ -22,27 +21,20 @@ export const CannedMessage = ({
}: CannedMessageModuleConfigProps) => { }: CannedMessageModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "cannedMessage" }); useWaitForConfig({ moduleConfigCase: "cannedMessage" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: CannedMessageValidation) => { const onSubmit = (data: CannedMessageValidation) => {
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) { if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
removeWorkingModuleConfig("cannedMessage"); removeChange({ type: "moduleConfig", variant: "cannedMessage" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "cannedMessage" },
payloadVariant: { data,
case: "cannedMessage", moduleConfig.cannedMessage,
value: data,
},
}),
); );
}; };
@@ -3,7 +3,6 @@ import {
type DetectionSensorValidation, type DetectionSensorValidation,
DetectionSensorValidationSchema, DetectionSensorValidationSchema,
} from "@app/validation/moduleConfig/detectionSensor.ts"; } from "@app/validation/moduleConfig/detectionSensor.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
@@ -22,27 +21,20 @@ export const DetectionSensor = ({
}: DetectionSensorModuleConfigProps) => { }: DetectionSensorModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "detectionSensor" }); useWaitForConfig({ moduleConfigCase: "detectionSensor" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: DetectionSensorValidation) => { const onSubmit = (data: DetectionSensorValidation) => {
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) { if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
removeWorkingModuleConfig("detectionSensor"); removeChange({ type: "moduleConfig", variant: "detectionSensor" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "detectionSensor" },
payloadVariant: { data,
case: "detectionSensor", moduleConfig.detectionSensor,
value: data,
},
}),
); );
}; };
@@ -3,14 +3,12 @@ import {
type ExternalNotificationValidation, type ExternalNotificationValidation,
ExternalNotificationValidationSchema, ExternalNotificationValidationSchema,
} from "@app/validation/moduleConfig/externalNotification.ts"; } from "@app/validation/moduleConfig/externalNotification.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface ExternalNotificationModuleConfigProps { interface ExternalNotificationModuleConfigProps {
@@ -22,27 +20,20 @@ export const ExternalNotification = ({
}: ExternalNotificationModuleConfigProps) => { }: ExternalNotificationModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "externalNotification" }); useWaitForConfig({ moduleConfigCase: "externalNotification" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: ExternalNotificationValidation) => { const onSubmit = (data: ExternalNotificationValidation) => {
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) { if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
removeWorkingModuleConfig("externalNotification"); removeChange({ type: "moduleConfig", variant: "externalNotification" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "externalNotification" },
payloadVariant: { data,
case: "externalNotification", moduleConfig.externalNotification,
value: data,
},
}),
); );
}; };
@@ -23,9 +23,9 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
const { const {
config, config,
moduleConfig, moduleConfig,
setWorkingModuleConfig, setChange,
getEffectiveModuleConfig, getEffectiveModuleConfig,
removeWorkingModuleConfig, removeChange,
} = useDevice(); } = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
@@ -39,17 +39,14 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
}; };
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) { if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
removeWorkingModuleConfig("mqtt"); removeChange({ type: "moduleConfig", variant: "mqtt" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "mqtt" },
payloadVariant: { payload,
case: "mqtt", moduleConfig.mqtt,
value: payload,
},
}),
); );
}; };
@@ -3,14 +3,12 @@ import {
type NeighborInfoValidation, type NeighborInfoValidation,
NeighborInfoValidationSchema, NeighborInfoValidationSchema,
} from "@app/validation/moduleConfig/neighborInfo.ts"; } from "@app/validation/moduleConfig/neighborInfo.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface NeighborInfoModuleConfigProps { interface NeighborInfoModuleConfigProps {
@@ -20,27 +18,20 @@ interface NeighborInfoModuleConfigProps {
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => { export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "neighborInfo" }); useWaitForConfig({ moduleConfigCase: "neighborInfo" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: NeighborInfoValidation) => { const onSubmit = (data: NeighborInfoValidation) => {
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) { if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
removeWorkingModuleConfig("neighborInfo"); removeChange({ type: "moduleConfig", variant: "neighborInfo" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "neighborInfo" },
payloadVariant: { data,
case: "neighborInfo", moduleConfig.neighborInfo,
value: data,
},
}),
); );
}; };
@@ -3,14 +3,12 @@ import {
type PaxcounterValidation, type PaxcounterValidation,
PaxcounterValidationSchema, PaxcounterValidationSchema,
} from "@app/validation/moduleConfig/paxcounter.ts"; } from "@app/validation/moduleConfig/paxcounter.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface PaxcounterModuleConfigProps { interface PaxcounterModuleConfigProps {
@@ -20,27 +18,20 @@ interface PaxcounterModuleConfigProps {
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => { export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "paxcounter" }); useWaitForConfig({ moduleConfigCase: "paxcounter" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: PaxcounterValidation) => { const onSubmit = (data: PaxcounterValidation) => {
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) { if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
removeWorkingModuleConfig("paxcounter"); removeChange({ type: "moduleConfig", variant: "paxcounter" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "paxcounter" },
payloadVariant: { data,
case: "paxcounter", moduleConfig.paxcounter,
value: data,
},
}),
); );
}; };
@@ -3,14 +3,12 @@ import {
type RangeTestValidation, type RangeTestValidation,
RangeTestValidationSchema, RangeTestValidationSchema,
} from "@app/validation/moduleConfig/rangeTest.ts"; } from "@app/validation/moduleConfig/rangeTest.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface RangeTestModuleConfigProps { interface RangeTestModuleConfigProps {
@@ -20,28 +18,21 @@ interface RangeTestModuleConfigProps {
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => { export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "rangeTest" }); useWaitForConfig({ moduleConfigCase: "rangeTest" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: RangeTestValidation) => { const onSubmit = (data: RangeTestValidation) => {
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) { if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
removeWorkingModuleConfig("rangeTest"); removeChange({ type: "moduleConfig", variant: "rangeTest" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "rangeTest" },
payloadVariant: { data,
case: "rangeTest", moduleConfig.rangeTest,
value: data,
},
}),
); );
}; };
@@ -3,7 +3,6 @@ import {
type SerialValidation, type SerialValidation,
SerialValidationSchema, SerialValidationSchema,
} from "@app/validation/moduleConfig/serial.ts"; } from "@app/validation/moduleConfig/serial.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
@@ -20,27 +19,20 @@ interface SerialModuleConfigProps {
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => { export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "serial" }); useWaitForConfig({ moduleConfigCase: "serial" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: SerialValidation) => { const onSubmit = (data: SerialValidation) => {
if (deepCompareConfig(moduleConfig.serial, data, true)) { if (deepCompareConfig(moduleConfig.serial, data, true)) {
removeWorkingModuleConfig("serial"); removeChange({ type: "moduleConfig", variant: "serial" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "serial" },
payloadVariant: { data,
case: "serial", moduleConfig.serial,
value: data,
},
}),
); );
}; };
@@ -3,14 +3,12 @@ import {
type StoreForwardValidation, type StoreForwardValidation,
StoreForwardValidationSchema, StoreForwardValidationSchema,
} from "@app/validation/moduleConfig/storeForward.ts"; } from "@app/validation/moduleConfig/storeForward.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface StoreForwardModuleConfigProps { interface StoreForwardModuleConfigProps {
@@ -20,27 +18,20 @@ interface StoreForwardModuleConfigProps {
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => { export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "storeForward" }); useWaitForConfig({ moduleConfigCase: "storeForward" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: StoreForwardValidation) => { const onSubmit = (data: StoreForwardValidation) => {
if (deepCompareConfig(moduleConfig.storeForward, data, true)) { if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
removeWorkingModuleConfig("storeForward"); removeChange({ type: "moduleConfig", variant: "storeForward" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "storeForward" },
payloadVariant: { data,
case: "storeForward", moduleConfig.storeForward,
value: data,
},
}),
); );
}; };
@@ -3,14 +3,12 @@ import {
type TelemetryValidation, type TelemetryValidation,
TelemetryValidationSchema, TelemetryValidationSchema,
} from "@app/validation/moduleConfig/telemetry.ts"; } from "@app/validation/moduleConfig/telemetry.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface TelemetryModuleConfigProps { interface TelemetryModuleConfigProps {
@@ -20,27 +18,20 @@ interface TelemetryModuleConfigProps {
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => { export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "telemetry" }); useWaitForConfig({ moduleConfigCase: "telemetry" });
const { const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } =
moduleConfig, useDevice();
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const onSubmit = (data: TelemetryValidation) => { const onSubmit = (data: TelemetryValidation) => {
if (deepCompareConfig(moduleConfig.telemetry, data, true)) { if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
removeWorkingModuleConfig("telemetry"); removeChange({ type: "moduleConfig", variant: "telemetry" });
return; return;
} }
setWorkingModuleConfig( setChange(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { { type: "moduleConfig", variant: "telemetry" },
payloadVariant: { data,
case: "telemetry", moduleConfig.telemetry,
value: data,
},
}),
); );
}; };
@@ -3,7 +3,6 @@ import {
type BluetoothValidation, type BluetoothValidation,
BluetoothValidationSchema, BluetoothValidationSchema,
} from "@app/validation/config/bluetooth.ts"; } from "@app/validation/config/bluetooth.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
@@ -19,24 +18,16 @@ interface BluetoothConfigProps {
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => { export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
useWaitForConfig({ configCase: "bluetooth" }); useWaitForConfig({ configCase: "bluetooth" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
useDevice(); const { t } = useTranslation("config");
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: BluetoothValidation) => { const onSubmit = (data: BluetoothValidation) => {
if (deepCompareConfig(config.bluetooth, data, true)) { if (deepCompareConfig(config.bluetooth, data, true)) {
removeWorkingConfig("bluetooth"); removeChange({ type: "config", variant: "bluetooth" });
return; return;
} }
setWorkingConfig( setChange({ type: "config", variant: "bluetooth" }, data, config.bluetooth);
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "bluetooth",
value: data,
},
}),
);
}; };
return ( return (
@@ -3,7 +3,6 @@ import {
type DeviceValidation, type DeviceValidation,
DeviceValidationSchema, DeviceValidationSchema,
} from "@app/validation/config/device.ts"; } from "@app/validation/config/device.ts";
import { create } from "@bufbuild/protobuf";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { import {
DynamicForm, DynamicForm,
@@ -20,25 +19,17 @@ interface DeviceConfigProps {
export const Device = ({ onFormInit }: DeviceConfigProps) => { export const Device = ({ onFormInit }: DeviceConfigProps) => {
useWaitForConfig({ configCase: "device" }); useWaitForConfig({ configCase: "device" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
useDevice(); const { t } = useTranslation("config");
const { t } = useTranslation("deviceConfig");
const { validateRoleSelection } = useUnsafeRolesDialog(); const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => { const onSubmit = (data: DeviceValidation) => {
if (deepCompareConfig(config.device, data, true)) { if (deepCompareConfig(config.device, data, true)) {
removeWorkingConfig("device"); removeChange({ type: "config", variant: "device" });
return; return;
} }
setWorkingConfig( setChange({ type: "config", variant: "device" }, data, config.device);
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "device",
value: data,
},
}),
);
}; };
return ( return (
@@ -3,7 +3,6 @@ import {
type DisplayValidation, type DisplayValidation,
DisplayValidationSchema, DisplayValidationSchema,
} from "@app/validation/config/display.ts"; } from "@app/validation/config/display.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
@@ -18,24 +17,16 @@ interface DisplayConfigProps {
} }
export const Display = ({ onFormInit }: DisplayConfigProps) => { export const Display = ({ onFormInit }: DisplayConfigProps) => {
useWaitForConfig({ configCase: "display" }); useWaitForConfig({ configCase: "display" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
useDevice(); const { t } = useTranslation("config");
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: DisplayValidation) => { const onSubmit = (data: DisplayValidation) => {
if (deepCompareConfig(config.display, data, true)) { if (deepCompareConfig(config.display, data, true)) {
removeWorkingConfig("display"); removeChange({ type: "config", variant: "display" });
return; return;
} }
setWorkingConfig( setChange({ type: "config", variant: "display" }, data, config.display);
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "display",
value: data,
},
}),
);
}; };
return ( return (
@@ -3,7 +3,6 @@ import {
type LoRaValidation, type LoRaValidation,
LoRaValidationSchema, LoRaValidationSchema,
} from "@app/validation/config/lora.ts"; } from "@app/validation/config/lora.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
@@ -19,24 +18,16 @@ interface LoRaConfigProps {
export const LoRa = ({ onFormInit }: LoRaConfigProps) => { export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
useWaitForConfig({ configCase: "lora" }); useWaitForConfig({ configCase: "lora" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
useDevice(); const { t } = useTranslation("config");
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: LoRaValidation) => { const onSubmit = (data: LoRaValidation) => {
if (deepCompareConfig(config.lora, data, true)) { if (deepCompareConfig(config.lora, data, true)) {
removeWorkingConfig("lora"); removeChange({ type: "config", variant: "lora" });
return; return;
} }
setWorkingConfig( setChange({ type: "config", variant: "lora" }, data, config.lora);
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: data,
},
}),
);
}; };
return ( return (
@@ -23,9 +23,8 @@ interface NetworkConfigProps {
export const Network = ({ onFormInit }: NetworkConfigProps) => { export const Network = ({ onFormInit }: NetworkConfigProps) => {
useWaitForConfig({ configCase: "network" }); useWaitForConfig({ configCase: "network" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
useDevice(); const { t } = useTranslation("config");
const { t } = useTranslation("deviceConfig");
const networkConfig = getEffectiveConfig("network"); const networkConfig = getEffectiveConfig("network");
@@ -44,18 +43,11 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
}; };
if (deepCompareConfig(config.network, payload, true)) { if (deepCompareConfig(config.network, payload, true)) {
removeWorkingConfig("network"); removeChange({ type: "config", variant: "network" });
return; return;
} }
setWorkingConfig( setChange({ type: "config", variant: "network" }, payload, config.network);
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "network",
value: payload,
},
}),
);
}; };
return ( return (
<DynamicForm<NetworkValidation> <DynamicForm<NetworkValidation>
@@ -3,7 +3,6 @@ import {
type PositionValidation, type PositionValidation,
PositionValidationSchema, PositionValidationSchema,
} from "@app/validation/config/position.ts"; } from "@app/validation/config/position.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
@@ -24,26 +23,23 @@ interface PositionConfigProps {
export const Position = ({ onFormInit }: PositionConfigProps) => { export const Position = ({ onFormInit }: PositionConfigProps) => {
useWaitForConfig({ configCase: "position" }); useWaitForConfig({ configCase: "position" });
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } = const { setChange, config, getEffectiveConfig, removeChange } = useDevice();
useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
getEffectiveConfig("position")?.positionFlags ?? 0, getEffectiveConfig("position")?.positionFlags ?? 0,
); );
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("config");
const onSubmit = (data: PositionValidation) => { const onSubmit = (data: PositionValidation) => {
if (deepCompareConfig(config.position, data, true)) { const payload = { ...data, positionFlags: flagsValue };
removeWorkingConfig("position"); if (deepCompareConfig(config.position, payload, true)) {
removeChange({ type: "config", variant: "position" });
return; return;
} }
return setWorkingConfig( return setChange(
create(Protobuf.Config.ConfigSchema, { { type: "config", variant: "position" },
payloadVariant: { payload,
case: "position", config.position,
value: { ...data, positionFlags: flagsValue },
},
}),
); );
}; };
@@ -3,14 +3,12 @@ import {
type PowerValidation, type PowerValidation,
PowerValidationSchema, PowerValidationSchema,
} from "@app/validation/config/power.ts"; } from "@app/validation/config/power.ts";
import { create } from "@bufbuild/protobuf";
import { import {
DynamicForm, DynamicForm,
type DynamicFormFormInit, type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface PowerConfigProps { interface PowerConfigProps {
@@ -19,24 +17,16 @@ interface PowerConfigProps {
export const Power = ({ onFormInit }: PowerConfigProps) => { export const Power = ({ onFormInit }: PowerConfigProps) => {
useWaitForConfig({ configCase: "power" }); useWaitForConfig({ configCase: "power" });
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } = const { setChange, config, getEffectiveConfig, removeChange } = useDevice();
useDevice(); const { t } = useTranslation("config");
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: PowerValidation) => { const onSubmit = (data: PowerValidation) => {
if (deepCompareConfig(config.power, data, true)) { if (deepCompareConfig(config.power, data, true)) {
removeWorkingConfig("power"); removeChange({ type: "config", variant: "power" });
return; return;
} }
setWorkingConfig( setChange({ type: "config", variant: "power" }, data, config.power);
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "power",
value: data,
},
}),
);
}; };
return ( return (
@@ -4,7 +4,6 @@ import {
type RawSecurity, type RawSecurity,
RawSecuritySchema, RawSecuritySchema,
} from "@app/validation/config/security.ts"; } from "@app/validation/config/security.ts";
import { create } from "@bufbuild/protobuf";
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx"; import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { createZodResolver } from "@components/Form/createZodResolver.ts"; import { createZodResolver } from "@components/Form/createZodResolver.ts";
@@ -15,7 +14,6 @@ import {
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts"; import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { type DefaultValues, useForm } from "react-hook-form"; import { type DefaultValues, useForm } from "react-hook-form";
@@ -27,15 +25,10 @@ interface SecurityConfigProps {
export const Security = ({ onFormInit }: SecurityConfigProps) => { export const Security = ({ onFormInit }: SecurityConfigProps) => {
useWaitForConfig({ configCase: "security" }); useWaitForConfig({ configCase: "security" });
const { const { config, setChange, setDialogOpen, getEffectiveConfig, removeChange } =
config, useDevice();
setWorkingConfig,
setDialogOpen,
getEffectiveConfig,
removeWorkingConfig,
} = useDevice();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("config");
const defaultConfig = config.security; const defaultConfig = config.security;
const defaultValues = { const defaultValues = {
@@ -103,17 +96,14 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
}; };
if (deepCompareConfig(config.security, payload, true)) { if (deepCompareConfig(config.security, payload, true)) {
removeWorkingConfig("security"); removeChange({ type: "config", variant: "security" });
return; return;
} }
setWorkingConfig( setChange(
create(Protobuf.Config.ConfigSchema, { { type: "config", variant: "security" },
payloadVariant: { payload,
case: "security", config.security,
value: payload,
},
}),
); );
}; };
@@ -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<UserValidation>;
}
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 (
<DynamicForm<UserValidation>
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"),
},
],
},
]}
/>
);
};
+2 -2
View File
@@ -108,9 +108,9 @@ export const Sidebar = ({ children }: SidebarProps) => {
}, },
{ name: t("navigation.map"), icon: MapIcon, page: "map" }, { name: t("navigation.map"), icon: MapIcon, page: "map" },
{ {
name: t("navigation.config"), name: t("navigation.settings"),
icon: SettingsIcon, icon: SettingsIcon,
page: "config", page: "settings",
}, },
{ {
name: `${t("navigation.nodes")} (${displayedNodeCount})`, name: `${t("navigation.nodes")} (${displayedNodeCount})`,
@@ -21,7 +21,7 @@ export const SidebarSection = ({
as="h3" as="h3"
className={cn( className={cn(
"mb-2", "mb-2",
"uppercase tracking-wider text-md", "capitalize tracking-wider text-sm",
"transition-all duration-300 ease-in-out", "transition-all duration-300 ease-in-out",
"whitespace-nowrap overflow-hidden", "whitespace-nowrap overflow-hidden",
isCollapsed isCollapsed
@@ -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<ConfigChangeKeyString, ChangeEntry>;
}
/**
* Convert structured key to string for Map lookup
*/
export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
switch (key.type) {
case "config":
return `config:${key.variant}`;
case "moduleConfig":
return `moduleConfig:${key.variant}`;
case "channel":
return `channel:${key.index}`;
case "user":
return "user";
}
}
/**
* 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;
}
@@ -19,9 +19,7 @@ export const mockDeviceStore: Device = {
channels: new Map(), channels: new Map(),
config: {} as Protobuf.LocalOnly.LocalConfig, config: {} as Protobuf.LocalOnly.LocalConfig,
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig, moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
workingConfig: [], changeRegistry: { changes: new Map() },
workingModuleConfig: [],
workingChannelConfig: [],
hardware: {} as Protobuf.Mesh.MyNodeInfo, hardware: {} as Protobuf.Mesh.MyNodeInfo,
metadata: new Map(), metadata: new Map(),
traceroutes: new Map(), traceroutes: new Map(),
@@ -56,17 +54,8 @@ export const mockDeviceStore: Device = {
setStatus: vi.fn(), setStatus: vi.fn(),
setConfig: vi.fn(), setConfig: vi.fn(),
setModuleConfig: 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(), getEffectiveConfig: vi.fn(),
getEffectiveModuleConfig: vi.fn(), getEffectiveModuleConfig: vi.fn(),
setWorkingChannelConfig: vi.fn(),
getWorkingChannelConfig: vi.fn(),
removeWorkingChannelConfig: vi.fn(),
setHardware: vi.fn(), setHardware: vi.fn(),
setActiveNode: vi.fn(), setActiveNode: vi.fn(),
setPendingSettingsChanges: vi.fn(), setPendingSettingsChanges: vi.fn(),
@@ -90,4 +79,21 @@ export const mockDeviceStore: Device = {
getUnreadCount: vi.fn().mockReturnValue(0), getUnreadCount: vi.fn().mockReturnValue(0),
getNeighborInfo: vi.fn(), getNeighborInfo: vi.fn(),
addNeighborInfo: 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([]),
}; };
@@ -52,25 +52,7 @@ function makeChannel(index: number) {
function makeWaypoint(id: number, expire?: number) { function makeWaypoint(id: number, expire?: number) {
return create(Protobuf.Mesh.WaypointSchema, { id, expire }); return create(Protobuf.Mesh.WaypointSchema, { id, expire });
} }
function makeConfig(fields: Record<string, any>) {
return create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "device",
value: create(Protobuf.Config.Config_DeviceConfigSchema, fields),
},
});
}
function makeModuleConfig(fields: Record<string, any>) {
return create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "mqtt",
value: create(
Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema,
fields,
),
},
});
}
function makeAdminMessage(fields: Record<string, any>) { function makeAdminMessage(fields: Record<string, any>) {
return create(Protobuf.Admin.AdminMessageSchema, fields); 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(() => { beforeEach(() => {
idbMem.clear(); idbMem.clear();
vi.clearAllMocks(); 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 { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState(); const state = useDeviceStore.getState();
const device = state.addDevice(42); const device = state.addDevice(42);
@@ -138,14 +120,17 @@ describe("DeviceStore – working/effective config API", () => {
); );
// working deviceConfig.role = ROUTER // working deviceConfig.role = ROUTER
device.setWorkingConfig( const routerConfig = create(Protobuf.Config.Config_DeviceConfigSchema, {
makeConfig({
role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER, role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
}), });
); device.setChange({ type: "config", variant: "device" }, routerConfig);
// expect working deviceConfig.role = ROUTER // expect change is tracked
const working = device.getWorkingConfig("device"); 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(working?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER);
// expect effective deviceConfig.role = ROUTER // expect effective deviceConfig.role = ROUTER
@@ -154,30 +139,27 @@ describe("DeviceStore – working/effective config API", () => {
Protobuf.Config.Config_DeviceConfig_Role.ROUTER, Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
); );
// remove working, effective should equal base // remove change, effective should equal base
device.removeWorkingConfig("device"); device.removeChange({ type: "config", variant: "device" });
expect(device.getWorkingConfig("device")).toBeUndefined(); expect(device.hasConfigChange("device")).toBe(false);
expect(device.getEffectiveConfig("device")?.role).toBe( expect(device.getEffectiveConfig("device")?.role).toBe(
Protobuf.Config.Config_DeviceConfig_Role.CLIENT, Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
); );
// add multiple, then clear all // add multiple, then clear all
device.setWorkingConfig(makeConfig({})); device.setChange({ type: "config", variant: "device" }, routerConfig);
device.setWorkingConfig( device.setChange({ type: "config", variant: "position" }, {});
makeConfig({ device.clearAllChanges();
deviceRole: Protobuf.Config.Config_DeviceConfig_Role.ROUTER, expect(device.hasConfigChange("device")).toBe(false);
}), expect(device.hasConfigChange("position")).toBe(false);
);
device.removeWorkingConfig(); // clears all
expect(device.getWorkingConfig("device")).toBeUndefined();
}); });
it("setWorkingModuleConfig/getWorkingModuleConfig and getEffectiveModuleConfig", async () => { it("setChange/hasChange for moduleConfig and getEffectiveModuleConfig", async () => {
const { useDeviceStore } = await freshStore(false); const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState(); const state = useDeviceStore.getState();
const device = state.addDevice(7); const device = state.addDevice(7);
// base moduleConfig.mqtt empty; add working mqtt host // base moduleConfig.mqtt with base address
device.setModuleConfig( device.setModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, { create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: { 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"); // remove change
expect(mqtt?.address).toBe("mqtt://working"); device.removeChange({ type: "moduleConfig", variant: "mqtt" });
expect(mqtt?.address).toBe("mqtt://working"); expect(device.hasModuleConfigChange("mqtt")).toBe(false);
device.removeWorkingModuleConfig("mqtt");
expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined();
expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe( expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe(
"mqtt://base", "mqtt://base",
); );
// Clear all // Clear all
device.setWorkingModuleConfig(makeModuleConfig({ address: "x" })); device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt);
device.setWorkingModuleConfig(makeModuleConfig({ address: "y" })); device.clearAllChanges();
device.removeWorkingModuleConfig(); expect(device.hasModuleConfigChange("mqtt")).toBe(false);
expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined();
}); });
it("channel working config add/update/remove/get", async () => { it("channel change tracking add/update/remove/get", async () => {
const { useDeviceStore } = await freshStore(false); const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState(); const state = useDeviceStore.getState();
const device = state.addDevice(9); const device = state.addDevice(9);
device.setWorkingChannelConfig(makeChannel(0)); const channel0 = makeChannel(0);
device.setWorkingChannelConfig( const channel1 = create(Protobuf.Channel.ChannelSchema, {
create(Protobuf.Channel.ChannelSchema, {
index: 1, index: 1,
settings: { name: "one" }, settings: { name: "one" },
}), });
);
expect(device.getWorkingChannelConfig(0)?.index).toBe(0); device.setChange({ type: "channel", index: 0 }, channel0);
expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("one"); 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 // update channel 1
device.setWorkingChannelConfig( const channel1Updated = create(Protobuf.Channel.ChannelSchema, {
create(Protobuf.Channel.ChannelSchema, {
index: 1, index: 1,
settings: { name: "uno" }, settings: { name: "uno" },
}), });
); device.setChange({ type: "channel", index: 1 }, channel1Updated);
expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("uno"); const ch1Updated = device.getChange({ type: "channel", index: 1 }) as
| Protobuf.Channel.Channel
| undefined;
expect(ch1Updated?.settings?.name).toBe("uno");
// remove specific // remove specific
device.removeWorkingChannelConfig(1); device.removeChange({ type: "channel", index: 1 });
expect(device.getWorkingChannelConfig(1)).toBeUndefined(); expect(device.hasChannelChange(1)).toBe(false);
// remove all // remove all
device.removeWorkingChannelConfig(); device.clearAllChanges();
expect(device.getWorkingChannelConfig(0)).toBeUndefined(); 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) // Old device with myNodeNum=777 and some waypoints (one expired)
const oldDevice = state.addDevice(1); 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.setHardware(makeHardware(777));
oldDevice.addWaypoint( oldDevice.addWaypoint(
@@ -393,10 +400,10 @@ describe("DeviceStore – traceroutes & waypoints retention + merge on setHardwa
// Remove waypoint // Remove waypoint
oldDevice.removeWaypoint(102, false); oldDevice.removeWaypoint(102, false);
expect(oldDevice.connection?.sendWaypoint).not.toHaveBeenCalled(); expect(mockSendWaypoint).not.toHaveBeenCalled();
await oldDevice.removeWaypoint(101, true); // toMesh=true 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); expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(98);
+237 -206
View File
@@ -10,6 +10,21 @@ import {
persist, persist,
subscribeWithSelector, subscribeWithSelector,
} from "zustand/middleware"; } 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 { import type {
Dialogs, Dialogs,
DialogVariant, DialogVariant,
@@ -42,9 +57,7 @@ export interface Device extends DeviceData {
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>; channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
config: Protobuf.LocalOnly.LocalConfig; config: Protobuf.LocalOnly.LocalConfig;
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig; moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
workingConfig: Protobuf.Config.Config[]; changeRegistry: ChangeRegistry; // Unified change tracking
workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
workingChannelConfig: Protobuf.Channel.Channel[];
hardware: Protobuf.Mesh.MyNodeInfo; hardware: Protobuf.Mesh.MyNodeInfo;
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>; metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
connection?: MeshDevice; connection?: MeshDevice;
@@ -58,27 +71,12 @@ export interface Device extends DeviceData {
setStatus: (status: Types.DeviceStatusEnum) => void; setStatus: (status: Types.DeviceStatusEnum) => void;
setConfig: (config: Protobuf.Config.Config) => void; setConfig: (config: Protobuf.Config.Config) => void;
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setWorkingConfig: (config: Protobuf.Config.Config) => void;
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
getWorkingConfig<K extends ValidConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined;
getWorkingModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
getEffectiveConfig<K extends ValidConfigType>( getEffectiveConfig<K extends ValidConfigType>(
payloadVariant: K, payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined; ): Protobuf.LocalOnly.LocalConfig[K] | undefined;
getEffectiveModuleConfig<K extends ValidModuleConfigType>( getEffectiveModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K, payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined; ): 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; setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
setActiveNode: (node: number) => void; setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void; setPendingSettingsChanges: (state: boolean) => void;
@@ -116,6 +114,27 @@ export interface Device extends DeviceData {
neighborInfo: Protobuf.Mesh.NeighborInfo, neighborInfo: Protobuf.Mesh.NeighborInfo,
) => void; ) => void;
getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined; 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 { export interface deviceState {
@@ -157,9 +176,7 @@ function deviceFactory(
channels: new Map(), channels: new Map(),
config: create(Protobuf.LocalOnly.LocalConfigSchema), config: create(Protobuf.LocalOnly.LocalConfigSchema),
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema), moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
workingConfig: [], changeRegistry: createChangeRegistry(),
workingModuleConfig: [],
workingChannelConfig: [],
hardware: create(Protobuf.Mesh.MyNodeInfoSchema), hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
metadata: new Map(), metadata: new Map(),
connection: undefined, connection: undefined,
@@ -302,130 +319,6 @@ function deviceFactory(
}), }),
); );
}, },
setWorkingConfig: (config: Protobuf.Config.Config) => {
set(
produce<PrivateDeviceState>((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<PrivateDeviceState>((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<K extends ValidConfigType>(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<K extends ValidModuleConfigType>(
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<PrivateDeviceState>((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<PrivateDeviceState>((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<K extends ValidConfigType>( getEffectiveConfig<K extends ValidConfigType>(
payloadVariant: K, payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined { ): Protobuf.LocalOnly.LocalConfig[K] | undefined {
@@ -437,11 +330,13 @@ function deviceFactory(
return; return;
} }
const workingValue = device.changeRegistry.changes.get(
serializeKey({ type: "config", variant: payloadVariant }),
)?.value as Protobuf.LocalOnly.LocalConfig[K] | undefined;
return { return {
...device.config[payloadVariant], ...device.config[payloadVariant],
...device.workingConfig.find( ...workingValue,
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value,
}; };
}, },
getEffectiveModuleConfig<K extends ValidModuleConfigType>( getEffectiveModuleConfig<K extends ValidModuleConfigType>(
@@ -452,69 +347,16 @@ function deviceFactory(
return; return;
} }
const workingValue = device.changeRegistry.changes.get(
serializeKey({ type: "moduleConfig", variant: payloadVariant }),
)?.value as Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
return { return {
...device.moduleConfig[payloadVariant], ...device.moduleConfig[payloadVariant],
...device.workingModuleConfig.find( ...workingValue,
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value,
}; };
}, },
setWorkingChannelConfig: (config: Protobuf.Channel.Channel) => {
set(
produce<PrivateDeviceState>((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<PrivateDeviceState>((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) => { setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set( set(
produce<PrivateDeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
@@ -855,6 +697,195 @@ function deviceFactory(
} }
return device.neighborInfo.get(nodeNum); return device.neighborInfo.get(nodeNum);
}, },
// New unified change tracking methods
setChange: (
key: ConfigChangeKey,
value: unknown,
originalValue?: unknown,
) => {
set(
produce<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((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);
},
}; };
} }
@@ -1,4 +1,8 @@
import type { Protobuf } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core";
import type {
ValidConfigType,
ValidModuleConfigType,
} from "./changeRegistry.ts";
interface Dialogs { interface Dialogs {
import: boolean; import: boolean;
@@ -22,16 +26,7 @@ interface Dialogs {
type DialogVariant = keyof Dialogs; type DialogVariant = keyof Dialogs;
type ValidConfigType = Exclude< type Page = "messages" | "map" | "settings" | "channels" | "nodes";
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 WaypointWithMetadata = Protobuf.Mesh.Waypoint & { type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
metadata: { metadata: {
+1 -1
View File
@@ -52,7 +52,7 @@ i18next
"channels", "channels",
"commandPalette", "commandPalette",
"common", "common",
"deviceConfig", "config",
"moduleConfig", "moduleConfig",
"dashboard", "dashboard",
"dialog", "dialog",
+1 -1
View File
@@ -19,7 +19,6 @@ import {
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import { Protobuf, Types } from "@meshtastic/core"; import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Config/ChannelConfig.tsx";
import { useNavigate, useParams } from "@tanstack/react-router"; import { useNavigate, useParams } from "@tanstack/react-router";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { import {
@@ -30,6 +29,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getChannelName } from "../components/PageComponents/Channels/Channels.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
@@ -1,11 +1,10 @@
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx"; import { Bluetooth } from "@components/PageComponents/Settings/Bluetooth.tsx";
import { Device } from "@components/PageComponents/Config/Device/index.tsx"; import { Device } from "@components/PageComponents/Settings/Device/index.tsx";
import { Display } from "@components/PageComponents/Config/Display.tsx"; import { Display } from "@components/PageComponents/Settings/Display.tsx";
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx"; import { Network } from "@components/PageComponents/Settings/Network/index.tsx";
import { Network } from "@components/PageComponents/Config/Network/index.tsx"; import { Position } from "@components/PageComponents/Settings/Position.tsx";
import { Position } from "@components/PageComponents/Config/Position.tsx"; import { Power } from "@components/PageComponents/Settings/Power.tsx";
import { Power } from "@components/PageComponents/Config/Power.tsx"; import { User } from "@components/PageComponents/Settings/User.tsx";
import { Security } from "@components/PageComponents/Config/Security/Security.tsx";
import { Spinner } from "@components/UI/Spinner.tsx"; import { Spinner } from "@components/UI/Spinner.tsx";
import { import {
Tabs, Tabs,
@@ -23,21 +22,25 @@ interface ConfigProps {
} }
type TabItem = { type TabItem = {
case: ValidConfigType; case: ValidConfigType | "user";
label: string; label: string;
element: ComponentType<ConfigProps>; element: ComponentType<ConfigProps>;
count?: number; count?: number;
}; };
export const DeviceConfig = ({ onFormInit }: ConfigProps) => { export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
const { getWorkingConfig } = useDevice(); const { hasConfigChange, hasUserChange } = useDevice();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("config");
const tabs: TabItem[] = [ const tabs: TabItem[] = [
{
case: "user",
label: t("page.tabUser"),
element: User,
},
{ {
case: "device", case: "device",
label: t("page.tabDevice"), label: t("page.tabDevice"),
element: Device, element: Device,
count: 0,
}, },
{ {
case: "position", case: "position",
@@ -59,30 +62,28 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
label: t("page.tabDisplay"), label: t("page.tabDisplay"),
element: Display, element: Display,
}, },
{
case: "lora",
label: t("page.tabLora"),
element: LoRa,
},
{ {
case: "bluetooth", case: "bluetooth",
label: t("page.tabBluetooth"), label: t("page.tabBluetooth"),
element: Bluetooth, element: Bluetooth,
}, },
{
case: "security",
label: t("page.tabSecurity"),
element: Security,
},
] as const; ] as const;
const flags = useMemo( 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 ( return (
<Tabs defaultValue={t("page.tabDevice")}> <Tabs defaultValue={t("page.tabUser")}>
<TabsList className="w-full dark:bg-slate-700"> <TabsList className="w-full dark:bg-slate-700">
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger
@@ -34,7 +34,7 @@ type TabItem = {
}; };
export const ModuleConfig = ({ onFormInit }: ConfigProps) => { export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
const { getWorkingModuleConfig } = useDevice(); const { hasModuleConfigChange } = useDevice();
const { t } = useTranslation("moduleConfig"); const { t } = useTranslation("moduleConfig");
const tabs: TabItem[] = [ const tabs: TabItem[] = [
{ {
@@ -97,8 +97,8 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
const flags = useMemo( const flags = useMemo(
() => () =>
new Map(tabs.map((tab) => [tab.case, getWorkingModuleConfig(tab.case)])), new Map(tabs.map((tab) => [tab.case, hasModuleConfigChange(tab.case)])),
[tabs, getWorkingModuleConfig], [tabs, hasModuleConfigChange],
); );
return ( return (
@@ -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: <T extends object>(methods: UseFormReturn<T>) => void;
}
type TabItem = {
case: ValidConfigType;
label: string;
element: ComponentType<ConfigProps>;
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 defaultValue={t("page.tabLora")}>
<TabsList className="w-full dark:bg-slate-700">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
className="dark:text-white relative"
>
{tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
</span>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<tab.element onFormInit={onFormInit} />
</Suspense>
</TabsContent>
))}
</Tabs>
);
};
@@ -1,3 +1,4 @@
import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
import { PageLayout } from "@components/PageLayout.tsx"; import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.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 { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores"; import { useDevice } from "@core/stores";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { ChannelConfig } from "@pages/Config/ChannelConfig.tsx"; import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx"; import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx"; import { useNavigate, useRouterState } from "@tanstack/react-router";
import { import {
BoxesIcon,
LayersIcon, LayersIcon,
RadioTowerIcon,
RefreshCwIcon, RefreshCwIcon,
RouterIcon,
SaveIcon, SaveIcon,
SaveOff, SaveOff,
SettingsIcon,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form"; import type { FieldValues, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { RadioConfig } from "./RadioConfig.tsx";
const ConfigPage = () => { const ConfigPage = () => {
const { const {
workingConfig, getAllConfigChanges,
workingModuleConfig, getAllModuleConfigChanges,
workingChannelConfig, getAllChannelChanges,
connection, connection,
removeWorkingConfig, clearAllChanges,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
setConfig, setConfig,
setModuleConfig, setModuleConfig,
addChannel, addChannel,
getConfigChangeCount,
getModuleConfigChangeCount,
getChannelChangeCount,
} = useDevice(); } = useDevice();
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module" | "channel"
>("device");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true }); const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
const unsubRef = useRef<(() => void) | null>(null); const unsubRef = useRef<(() => void) | null>(null);
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null); const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
const { toast } = useToast(); 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( const onFormInit = useCallback(
<T extends FieldValues>(methods: UseFormReturn<T>) => { <T extends FieldValues>(methods: UseFormReturn<T>) => {
@@ -69,7 +110,6 @@ const ConfigPage = () => {
[], [],
); );
// Cleanup subscription on unmount
useEffect(() => { useEffect(() => {
return () => unsubRef.current?.(); return () => unsubRef.current?.();
}, []); }, []);
@@ -78,9 +118,12 @@ const ConfigPage = () => {
setIsSaving(true); setIsSaving(true);
try { 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( await Promise.all(
workingChannelConfig.map((channel) => channelChanges.map((channel) =>
connection?.setChannel(channel).then(() => { connection?.setChannel(channel).then(() => {
toast({ toast({
title: t("toast.savedChannel.title", { title: t("toast.savedChannel.title", {
@@ -93,7 +136,7 @@ const ConfigPage = () => {
); );
await Promise.all( await Promise.all(
workingConfig.map((newConfig) => configChanges.map((newConfig) =>
connection?.setConfig(newConfig).then(() => { connection?.setConfig(newConfig).then(() => {
toast({ toast({
title: t("toast.saveSuccess.title"), title: t("toast.saveSuccess.title"),
@@ -106,7 +149,7 @@ const ConfigPage = () => {
); );
await Promise.all( await Promise.all(
workingModuleConfig.map((newModuleConfig) => moduleConfigChanges.map((newModuleConfig) =>
connection?.setModuleConfig(newModuleConfig).then(() => connection?.setModuleConfig(newModuleConfig).then(() =>
toast({ toast({
title: t("toast.saveSuccess.title"), 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(); await connection?.commitEditSettings();
} }
workingChannelConfig.forEach((newChannel) => { channelChanges.forEach((newChannel) => {
addChannel(newChannel); addChannel(newChannel);
}); });
workingConfig.forEach((newConfig) => { configChanges.forEach((newConfig) => {
setConfig(newConfig); setConfig(newConfig);
}); });
workingModuleConfig.forEach((newModuleConfig) => { moduleConfigChanges.forEach((newModuleConfig) => {
setModuleConfig(newModuleConfig); setModuleConfig(newModuleConfig);
}); });
removeWorkingChannelConfig(); clearAllChanges();
removeWorkingConfig();
removeWorkingModuleConfig();
if (formMethods) { if (formMethods) {
formMethods.reset(formMethods.getValues(), { formMethods.reset(formMethods.getValues(), {
@@ -144,7 +185,6 @@ const ConfigPage = () => {
keepValues: true, keepValues: true,
}); });
// Force RHF to re-validate and emit state
formMethods.trigger(); formMethods.trigger();
} }
} catch (_error) { } catch (_error) {
@@ -162,77 +202,49 @@ const ConfigPage = () => {
}, [ }, [
toast, toast,
t, t,
workingConfig, getAllConfigChanges,
connection, connection,
workingModuleConfig, getAllModuleConfigChanges,
workingChannelConfig, getAllChannelChanges,
formMethods, formMethods,
addChannel, addChannel,
setConfig, setConfig,
setModuleConfig, setModuleConfig,
removeWorkingConfig, clearAllChanges,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
]); ]);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
if (formMethods) { if (formMethods) {
formMethods.reset(); formMethods.reset();
} }
removeWorkingChannelConfig(); clearAllChanges();
removeWorkingConfig(); }, [formMethods, clearAllChanges]);
removeWorkingModuleConfig();
}, [
formMethods,
removeWorkingConfig,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
]);
const leftSidebar = useMemo( const leftSidebar = useMemo(
() => ( () => (
<Sidebar> <Sidebar>
<SidebarSection label={t("sidebar.label")} className="py-2 px-0"> <SidebarSection label={t("sidebar.label")} className="py-2 px-0">
{sections.map((section) => (
<SidebarButton <SidebarButton
label={t("navigation.radioConfig")} key={section.key}
active={activeConfigSection === "device"} label={section.label}
onClick={() => setActiveConfigSection("device")} active={activeSection?.key === section.key}
Icon={SettingsIcon} onClick={() => navigate({ to: section.route.to })}
isDirty={workingConfig.length > 0} Icon={section.icon}
count={workingConfig.length} isDirty={section.changeCount > 0}
/> count={section.changeCount}
<SidebarButton
label={t("navigation.moduleConfig")}
active={activeConfigSection === "module"}
onClick={() => setActiveConfigSection("module")}
Icon={BoxesIcon}
isDirty={workingModuleConfig.length > 0}
count={workingModuleConfig.length}
/>
<SidebarButton
label={t("navigation.channelConfig")}
active={activeConfigSection === "channel"}
onClick={() => setActiveConfigSection("channel")}
Icon={LayersIcon}
isDirty={workingChannelConfig.length > 0}
count={workingChannelConfig.length}
/> />
))}
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
), ),
[ [sections, activeSection?.key, navigate, t],
activeConfigSection,
workingConfig,
workingModuleConfig,
workingChannelConfig,
t,
],
); );
const hasDrafts = const hasDrafts =
workingConfig.length > 0 || getConfigChangeCount() > 0 ||
workingModuleConfig.length > 0 || getModuleConfigChangeCount() > 0 ||
workingChannelConfig.length > 0; getChannelChangeCount() > 0;
const hasPending = hasDrafts || rhfState.isDirty; const hasPending = hasDrafts || rhfState.isDirty;
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0"; const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
const saveDisabled = isSaving || !rhfState.isValid || !hasPending; const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
@@ -291,26 +303,16 @@ const ConfigPage = () => {
], ],
); );
const ActiveComponent = activeSection?.component;
return ( return (
<PageLayout <PageLayout
contentClassName="overflow-auto" contentClassName="overflow-auto"
leftBar={leftSidebar} leftBar={leftSidebar}
label={ label={activeSection?.label ?? ""}
activeConfigSection === "device"
? t("navigation.radioConfig")
: activeConfigSection === "module"
? t("navigation.moduleConfig")
: t("navigation.channelConfig")
}
actions={actions} actions={actions}
> >
{activeConfigSection === "device" ? ( <ActiveComponent onFormInit={onFormInit} />
<DeviceConfig onFormInit={onFormInit} />
) : activeConfigSection === "module" ? (
<ModuleConfig onFormInit={onFormInit} />
) : (
<ChannelConfig onFormInit={onFormInit} />
)}
</PageLayout> </PageLayout>
); );
}; };
+28 -4
View File
@@ -1,10 +1,10 @@
import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import type { useAppStore, useMessageStore } from "@core/stores"; import type { useAppStore, useMessageStore } from "@core/stores";
import ConfigPage from "@pages/Config/index.tsx";
import { Dashboard } from "@pages/Dashboard/index.tsx"; import { Dashboard } from "@pages/Dashboard/index.tsx";
import MapPage from "@pages/Map/index.tsx"; import MapPage from "@pages/Map/index.tsx";
import MessagesPage from "@pages/Messages.tsx"; import MessagesPage from "@pages/Messages.tsx";
import NodesPage from "@pages/Nodes/index.tsx"; import NodesPage from "@pages/Nodes/index.tsx";
import ConfigPage from "@pages/Settings/index.tsx";
import { import {
createRootRouteWithContext, createRootRouteWithContext,
createRoute, createRoute,
@@ -109,9 +109,33 @@ export const mapWithParamsRoute = createRoute({
// }), // }),
}); });
const configRoute = createRoute({ export const settingsRoute = createRoute({
getParentRoute: () => rootRoute, 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, component: ConfigPage,
}); });
@@ -133,7 +157,7 @@ const routeTree = rootRoute.addChildren([
messagesWithParamsRoute, messagesWithParamsRoute,
mapRoute, mapRoute,
mapWithParamsRoute, mapWithParamsRoute,
configRoute, settingsRoute.addChildren([radioRoute, deviceRoute, moduleRoute]),
nodesRoute, nodesRoute,
dialogWithParamsRoute, dialogWithParamsRoute,
]); ]);
+4 -4
View File
@@ -12,10 +12,10 @@ import commandPaletteEN from "@public/i18n/locales/en/commandPalette.json" with
import commonEN from "@public/i18n/locales/en/common.json" with { import commonEN from "@public/i18n/locales/en/common.json" with {
type: "json", type: "json",
}; };
import dashboardEN from "@public/i18n/locales/en/dashboard.json" with { import configEN from "@public/i18n/locales/en/config.json" with {
type: "json", type: "json",
}; };
import deviceConfigEN from "@public/i18n/locales/en/deviceConfig.json" with { import dashboardEN from "@public/i18n/locales/en/dashboard.json" with {
type: "json", type: "json",
}; };
import dialogEN from "@public/i18n/locales/en/dialog.json" with { import dialogEN from "@public/i18n/locales/en/dialog.json" with {
@@ -53,7 +53,7 @@ const appNamespaces = [
"channels", "channels",
"commandPalette", "commandPalette",
"common", "common",
"deviceConfig", "config",
"moduleConfig", "moduleConfig",
"dashboard", "dashboard",
"dialog", "dialog",
@@ -76,7 +76,7 @@ i18n.use(initReactI18next).init({
channels: channelsEN, channels: channelsEN,
commandPalette: commandPaletteEN, commandPalette: commandPaletteEN,
common: commonEN, common: commonEN,
deviceConfig: deviceConfigEN, config: configEN,
moduleConfig: moduleConfigEN, moduleConfig: moduleConfigEN,
dashboard: dashboardEN, dashboard: dashboardEN,
dialog: dialogEN, dialog: dialogEN,
@@ -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<typeof UserValidationSchema>;