Browse Source

New channel config (#807)

* Channel config rework

Add staged channel config with tabbed UI, import/export workflow, and global form state refactor

* Improve import dialog config comparison and UI labels

* Review fixes

* Improve state handling

* Fix default filter behaviour

---------

Co-authored-by: philon- <[email protected]>
pull/824/head
Jeremy Gallant 10 months ago
committed by GitHub
parent
commit
1946000d14
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      packages/web/public/i18n/locales/en/channels.json
  2. 14
      packages/web/public/i18n/locales/en/dialog.json
  3. 7
      packages/web/public/i18n/locales/en/ui.json
  4. 192
      packages/web/src/components/Dialog/ImportDialog.tsx
  5. 2
      packages/web/src/components/Dialog/QRDialog.tsx
  6. 23
      packages/web/src/components/Form/DynamicForm.tsx
  7. 20
      packages/web/src/components/Form/FormPasswordGenerator.tsx
  8. 3
      packages/web/src/components/Form/FormSelect.tsx
  9. 181
      packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx
  10. 1
      packages/web/src/components/PageComponents/Config/Bluetooth.tsx
  11. 1
      packages/web/src/components/PageComponents/Config/Device/index.tsx
  12. 1
      packages/web/src/components/PageComponents/Config/Display.tsx
  13. 1
      packages/web/src/components/PageComponents/Config/LoRa.tsx
  14. 1
      packages/web/src/components/PageComponents/Config/Network/index.tsx
  15. 1
      packages/web/src/components/PageComponents/Config/Position.tsx
  16. 1
      packages/web/src/components/PageComponents/Config/Power.tsx
  17. 49
      packages/web/src/components/PageComponents/Config/Security/Security.tsx
  18. 1
      packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
  19. 1
      packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx
  20. 1
      packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  21. 1
      packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
  22. 1
      packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  23. 1
      packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx
  24. 1
      packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
  25. 1
      packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  26. 1
      packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx
  27. 1
      packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx
  28. 1
      packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx
  29. 1
      packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx
  30. 6
      packages/web/src/components/Sidebar.tsx
  31. 20
      packages/web/src/components/UI/Generator.tsx
  32. 59
      packages/web/src/core/stores/appStore/index.ts
  33. 4
      packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
  34. 62
      packages/web/src/core/stores/deviceStore/index.ts
  35. 86
      packages/web/src/pages/Channels.tsx
  36. 95
      packages/web/src/pages/Config/ChannelConfig.tsx
  37. 6
      packages/web/src/pages/Config/DeviceConfig.tsx
  38. 2
      packages/web/src/pages/Config/ModuleConfig.tsx
  39. 188
      packages/web/src/pages/Config/index.tsx
  40. 2
      packages/web/src/pages/Messages.tsx
  41. 8
      packages/web/src/routes.tsx
  42. 3
      packages/web/src/validation/channel.ts

4
packages/web/public/i18n/locales/en/channels.json

@ -3,7 +3,9 @@
"sectionLabel": "Channels",
"channelName": "Channel: {{channelName}}",
"broadcastLabel": "Primary",
"channelIndex": "Ch {{index}}"
"channelIndex": "Ch {{index}}",
"import": "Import",
"export": "Export"
},
"validation": {
"pskInvalid": "Please enter a valid {{bits}} bit PSK."

14
packages/web/public/i18n/locales/en/dialog.json

@ -16,15 +16,19 @@
}
},
"import": {
"description": "The current LoRa configuration will be overridden.",
"description": "Import a Channel Set from a Meshtastic URL. <br />Valid Meshtasic URLs start with \"<italic>https://meshtastic.org/e/...</italic>\"",
"error": {
"invalidUrl": "Invalid Meshtastic URL"
},
"channelPrefix": "Channel: ",
"channelPrefix": "Channel ",
"primary": "Primary ",
"doNotImport": "No not import",
"channelName": "Name",
"channelSlot": "Slot",
"channelSetUrl": "Channel Set/QR Code URL",
"channels": "Channels:",
"usePreset": "Use Preset?",
"title": "Import Channel Set"
"useLoraConfig": "Import LoRa Config",
"presetDescription": "The current LoRa Config will be replaced.",
"title": "Import Channels"
},
"locationResponse": {
"title": "Location: {{identifier}}",

7
packages/web/public/i18n/locales/en/ui.json

@ -4,9 +4,10 @@
"messages": "Messages",
"map": "Map",
"config": "Config",
"channels": "Channels",
"radioConfig": "Radio Config",
"moduleConfig": "Module Config",
"channels": "Channels",
"channelConfig": "Channel Config",
"nodes": "Nodes"
},
"app": {
@ -67,6 +68,10 @@
"title": "Saving Config",
"description": "The configuration change {{case}} has been saved."
},
"saveAllSuccess": {
"title": "Saved",
"description": "All configuration changes have been saved."
},
"favoriteNode": {
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
"action": {

192
packages/web/src/components/Dialog/ImportDialog.tsx

@ -11,13 +11,20 @@ import {
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import { Trans, useTranslation } from "react-i18next";
export interface ImportDialogProps {
open: boolean;
@ -26,12 +33,15 @@ export interface ImportDialogProps {
}
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const { config, channels } = useDevice();
const { t } = useTranslation("dialog");
const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
const [validUrl, setValidUrl] = useState<boolean>(false);
const [updateConfig, setUpdateConfig] = useState<boolean>(true);
const [importIndex, setImportIndex] = useState<number[]>([]);
const { connection } = useDevice();
const { setWorkingChannelConfig, setWorkingConfig } = useDevice();
useEffect(() => {
// the channel information is contained in the URL's fragment, which will be present after a
@ -55,12 +65,17 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
)
.replace(/-/g, "+")
.replace(/_/g, "/");
setChannelSet(
fromBinary(
Protobuf.AppOnly.ChannelSetSchema,
toByteArray(paddedString),
),
const newChannelSet = fromBinary(
Protobuf.AppOnly.ChannelSetSchema,
toByteArray(paddedString),
);
const newImportChannelArray = newChannelSet.settings.map((_, idx) => idx);
setChannelSet(newChannelSet);
setImportIndex(newImportChannelArray);
setUpdateConfig(newChannelSet?.loraConfig !== undefined);
setValidUrl(true);
} catch (_error) {
setValidUrl(false);
@ -71,29 +86,62 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const apply = () => {
channelSet?.settings.map(
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, {
index,
role:
index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
}),
);
if (importIndex[index] === -1) {
return;
}
const payload = create(Protobuf.Channel.ChannelSchema, {
index: importIndex[index],
role:
importIndex[index] === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
});
if (
!deepCompareConfig(
channels.get(importIndex[index] ?? 0),
payload,
true,
)
) {
setWorkingChannelConfig(payload);
}
},
);
if (channelSet?.loraConfig) {
connection?.setConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: channelSet.loraConfig,
},
}),
);
if (channelSet?.loraConfig && updateConfig) {
const payload = {
...config.lora,
...channelSet.loraConfig,
};
if (!deepCompareConfig(config.lora, payload, true)) {
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: payload,
},
}),
);
}
}
// Reset state after import
setImportDialogInput("");
setChannelSet(undefined);
setValidUrl(false);
setImportIndex([]);
setUpdateConfig(true);
onOpenChange(false);
};
const onSelectChange = (value: string, index: number) => {
const newImportIndex = [...importIndex];
newImportIndex[index] = Number.parseInt(value);
setImportIndex(newImportIndex);
};
return (
@ -102,55 +150,85 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
<DialogClose />
<DialogHeader>
<DialogTitle>{t("import.title")}</DialogTitle>
<DialogDescription>{t("import.description")}</DialogDescription>
<DialogDescription>
<Trans
i18nKey={"import.description"}
components={{ italic: <i />, br: <br /> }}
/>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Label>{t("import.channelSetUrl")}</Label>
<Input
value={importDialogInput}
suffix={validUrl ? "✅" : "❌"}
variant={
importDialogInput === ""
? "default"
: validUrl
? "dirty"
: "invalid"
}
onChange={(e) => {
setImportDialogInput(e.target.value);
}}
/>
{validUrl && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-6 mt-2">
<div className="flex w-full gap-2">
<div className="w-36">
<Label>{t("import.usePreset")}</Label>
<div className=" flex items-center">
<Switch
disabled
checked={channelSet?.loraConfig?.usePreset ?? true}
className="ml-3 mr-4"
checked={updateConfig}
onCheckedChange={(next) => setUpdateConfig(next)}
/>
<Label className="">
{t("import.useLoraConfig")}
<span className="block pt-2 font-normal text-s">
{t("import.presetDescription")}
</span>
</Label>
</div>
{/* <Select
label="Modem Preset"
disabled
value={channelSet?.loraConfig?.modemPreset}
>
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
</Select> */}
</div>
{/* <Select
label="Region"
disabled
value={channelSet?.loraConfig?.region}
>
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
</Select> */}
<span className="text-md block font-medium text-text-primary">
{t("import.channels")}
</span>
<div className="flex w-40 flex-col gap-1">
{channelSet?.settings.map((channel) => (
<div className="flex justify-between" key={channel.id}>
<Label>
<div className="flex w-full flex-col gap-2">
<div className="flex items-center font-semibold text-sm">
<span className="flex-1">{t("import.channelName")}</span>
<span className="flex-1">{t("import.channelSlot")}</span>
</div>
{channelSet?.settings.map((channel, index) => (
<div
className="flex items-center"
key={`channel_${channel.id}_${index}`}
>
<Label className="flex-1">
{channel.name.length
? channel.name
: `${t("import.channelPrefix")}${channel.id}`}
</Label>
<Checkbox key={channel.id} />
<Select
onValueChange={(value) => onSelectChange(value, index)}
value={importIndex[index]?.toString()}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 8 }, (_, i) => i).map((i) => (
<SelectItem
key={`index_${i}`}
disabled={importIndex.includes(i) && index !== i}
value={i.toString()}
>
{i === 0
? t("import.primary")
: `${t("import.channelPrefix")}${i}`}
</SelectItem>
))}
<SelectItem value="-1">
{t("import.doNotImport")}
</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>

2
packages/web/src/components/Dialog/QRDialog.tsx

@ -82,7 +82,7 @@ export const QRDialog = ({
: `${t("page.channelIndex", {
ns: "channels",
index: channel.index,
})}${channel.index}`}
})}`}
</Label>
<Checkbox
key={channel.index}

23
packages/web/src/components/Form/DynamicForm.tsx

@ -7,8 +7,6 @@ import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore } from "@core/stores";
import { dotPaths } from "@core/utils/dotPath.ts";
import { useEffect } from "react";
import {
type Control,
@ -66,7 +64,6 @@ export interface DynamicFormProps<T extends FieldValues> {
fields: FieldProps<T>[];
}[];
validationSchema?: ZodType<T>;
formId?: string;
}
export type DynamicFormFormInit<T extends FieldValues> = (
@ -83,10 +80,8 @@ export function DynamicForm<T extends FieldValues>({
values,
fieldGroups,
validationSchema,
formId,
}: DynamicFormProps<T>) {
const { t } = useTranslation();
const { addError, removeError } = useAppStore();
const internalMethods = useForm<T>({
mode: "onChange",
@ -95,6 +90,7 @@ export function DynamicForm<T extends FieldValues>({
? createZodResolver(validationSchema)
: undefined,
shouldFocusError: false,
shouldUnregister: true,
resetOptions: { keepDefaultValues: true },
values,
});
@ -110,23 +106,6 @@ export function DynamicForm<T extends FieldValues>({
}
}, [onFormInit, propMethods, internalMethods]);
useEffect(() => {
const errorKeys = Object.keys(formState.errors);
if (formId) {
if (errorKeys.length === 0) {
dotPaths(getValues()).forEach((key) => {
removeError(key);
});
removeError(formId);
} else {
errorKeys.forEach((key) => {
addError(key, "");
});
addError(formId, "");
}
}
}, [formState.errors, addError, formId, getValues, removeError]);
const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,

20
packages/web/src/components/Form/FormPasswordGenerator.tsx

@ -4,8 +4,7 @@ import type {
} from "@components/Form/DynamicForm.tsx";
import { Generator } from "@components/UI/Generator.tsx";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import { useEffect } from "react";
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
import { Controller, type FieldValues } from "react-hook-form";
import type { ButtonVariant } from "../UI/Button.tsx";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
@ -34,36 +33,29 @@ export function PasswordGenerator<T extends FieldValues>({
invalid,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const { isVisible } = usePasswordVisibilityToggle();
const { trigger } = useFormContext();
useEffect(() => {
trigger(field.name);
}, [field.name, trigger]);
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => (
render={({ field: controllerField }) => (
<Generator
type={field.hide && !isVisible ? "password" : "text"}
id={field.id}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={(e) => {
if (field.inputChange) {
field.inputChange(e);
}
onChange(e);
const value = e.target.value;
field.inputChange?.(e); // call any external handler
controllerField.onChange(value); // ensure RHF receives just the value
}}
selectChange={field.selectChange ?? (() => {})}
value={value}
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
actionButtons={field.actionButtons}
showPasswordToggle={field.showPasswordToggle}
showCopyButton={field.showCopyButton}
{...field.properties}
{...rest}
{...controllerField}
disabled={disabled}
/>
)}

3
packages/web/src/components/Form/FormSelect.tsx

@ -70,6 +70,9 @@ export function SelectInput<T extends FieldValues>({
const handleValueChange = async (newValue: string) => {
const selectedKey = valueToKeyMap[newValue];
if (!selectedKey) {
return;
}
if (field.validate) {
const isValid = await field.validate(selectedKey);

181
packages/web/src/components/PageComponents/Channel.tsx → packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx

@ -1,118 +1,165 @@
import { makeChannelSchema } from "@app/validation/channel.ts";
import {
type ChannelValidation,
makeChannelSchema,
} from "@app/validation/channel.ts";
import { create } from "@bufbuild/protobuf";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { type DefaultValues, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { infer as zodInfer } from "zod/v4";
export interface SettingsPanelProps {
onFormInit: DynamicFormFormInit<ChannelValidation>;
channel: Protobuf.Channel.Channel;
}
export const Channel = ({ channel }: SettingsPanelProps) => {
const { config, connection, addChannel } = useDevice();
export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
const {
config,
setWorkingChannelConfig,
getWorkingChannelConfig,
removeWorkingChannelConfig,
} = useDevice();
const { t } = useTranslation(["channels", "ui", "dialog"]);
const { toast } = useToast();
const defaultConfig = channel;
const defaultValues = {
...defaultConfig,
...{
settings: {
...defaultConfig?.settings,
psk: fromByteArray(defaultConfig?.settings?.psk ?? new Uint8Array(0)),
moduleSettings: {
...defaultConfig?.settings?.moduleSettings,
positionPrecision:
defaultConfig?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: defaultConfig?.settings?.moduleSettings?.positionPrecision,
},
},
},
};
const effectiveConfig = getWorkingChannelConfig(channel.index) ?? channel;
const formValues = {
...effectiveConfig,
...{
settings: {
...effectiveConfig?.settings,
psk: fromByteArray(effectiveConfig?.settings?.psk ?? new Uint8Array(0)),
moduleSettings: {
...effectiveConfig?.settings?.moduleSettings,
positionPrecision:
effectiveConfig?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: effectiveConfig?.settings?.moduleSettings?.positionPrecision,
},
},
},
};
const [preSharedDialogOpen, setPreSharedDialogOpen] =
useState<boolean>(false);
const [pass, setPass] = useState<string>(
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
);
const [byteCount, setBytes] = useState<number>(
channel?.settings?.psk.length ?? 16,
effectiveConfig?.settings?.psk.length ?? 16,
);
const ChannelValidationSchema = useMemo(() => {
return makeChannelSchema(byteCount);
}, [byteCount]);
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
const formMethods = useForm<ChannelValidation>({
mode: "onChange",
defaultValues: defaultValues as DefaultValues<ChannelValidation>,
resolver: createZodResolver(ChannelValidationSchema),
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values: formValues as ChannelValidation,
});
const { setValue, trigger, handleSubmit, formState } = formMethods;
useEffect(() => {
onFormInit?.(formMethods);
}, [onFormInit, formMethods]);
// Since byteCount is an independent state, we need to use the effective value
// from the channel config to ensure the form updates when the setting changes
const effectiveByteCount = effectiveConfig.settings?.psk.length ?? 16;
const lastEffectiveRef = useRef<number>(effectiveByteCount);
useEffect(() => {
if (effectiveByteCount !== lastEffectiveRef.current) {
lastEffectiveRef.current = effectiveByteCount;
setBytes(effectiveByteCount);
trigger("settings.psk");
}
}, [effectiveByteCount, trigger]);
const onSubmit = (data: ChannelValidation) => {
const channel = create(Protobuf.Channel.ChannelSchema, {
if (!formState.isReady) {
return;
}
const payload = {
...data,
settings: {
...data.settings,
psk: toByteArray(pass),
psk: toByteArray(data.settings.psk),
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
...data.settings.moduleSettings,
positionPrecision: data.settings.moduleSettings.positionPrecision,
}),
},
});
connection?.setChannel(channel).then(() => {
console.debug(
t("toast.savedChannel.title", {
ns: "ui",
channelName: channel.settings?.name,
}),
);
toast({
title: t("toast.savedChannel.title", {
ns: "ui",
channelName: channel.settings?.name,
}),
});
addChannel(channel);
});
};
if (deepCompareConfig(channel, payload, true)) {
removeWorkingChannelConfig(channel.index);
return;
}
setWorkingChannelConfig(create(Protobuf.Channel.ChannelSchema, payload));
};
const preSharedKeyRegenerate = () => {
const preSharedKeyRegenerate = async () => {
const newPsk = btoa(
cryptoRandomString({
length: byteCount ?? 0,
length: byteCount ?? 16,
type: "alphanumeric",
}),
);
setPass(newPsk);
setValue("settings.psk", newPsk, { shouldDirty: true });
setPreSharedDialogOpen(false);
};
const preSharedClickEvent = () => {
setPreSharedDialogOpen(true);
};
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
setPass(e.currentTarget?.value);
const valid = await trigger("settings.psk");
if (valid) {
handleSubmit(onSubmit)(); // manually invoke form submit
}
};
const selectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setBytes(count);
if (!Number.isNaN(count)) {
setBytes(count);
trigger("settings.psk");
}
};
return (
<>
<DynamicForm<ChannelValidation>
propMethods={formMethods}
onSubmit={onSubmit}
submitType="onSubmit"
validationSchema={ChannelValidationSchema}
hasSubmitButton
defaultValues={{
...channel,
...{
settings: {
...channel?.settings,
psk: pass,
moduleSettings: {
...channel?.settings?.moduleSettings,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
},
},
}}
fieldGroups={[
{
label: t("settings.label"),
@ -140,19 +187,17 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
id: "channel-psk",
label: t("psk.label"),
description: t("psk.description"),
devicePSKBitCount: byteCount ?? 0,
inputChange: inputChangeEvent,
devicePSKBitCount: byteCount ?? 16,
selectChange: selectChangeEvent,
actionButtons: [
{
text: t("psk.generate"),
variant: "success",
onClick: preSharedClickEvent,
onClick: () => setPreSharedDialogOpen(true),
},
],
hide: true,
properties: {
value: pass,
showPasswordToggle: true,
showCopyButton: true,
},

1
packages/web/src/components/PageComponents/Config/Bluetooth.tsx

@ -44,7 +44,6 @@ export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={BluetoothValidationSchema}
formId="Config_BluetoothConfig"
defaultValues={config.bluetooth}
values={getEffectiveConfig("bluetooth")}
fieldGroups={[

1
packages/web/src/components/PageComponents/Config/Device/index.tsx

@ -46,7 +46,6 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DeviceValidationSchema}
formId="Config_DeviceConfig"
defaultValues={config.device}
values={getEffectiveConfig("device")}
fieldGroups={[

1
packages/web/src/components/PageComponents/Config/Display.tsx

@ -43,7 +43,6 @@ export const Display = ({ onFormInit }: DisplayConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DisplayValidationSchema}
formId="Config_DisplayConfig"
defaultValues={config.display}
values={getEffectiveConfig("display")}
fieldGroups={[

1
packages/web/src/components/PageComponents/Config/LoRa.tsx

@ -44,7 +44,6 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={LoRaValidationSchema}
formId="Config_LoRaConfig"
defaultValues={config.lora}
values={getEffectiveConfig("lora")}
fieldGroups={[

1
packages/web/src/components/PageComponents/Config/Network/index.tsx

@ -62,7 +62,6 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NetworkValidationSchema}
formId="Config_NetworkConfig"
defaultValues={{
...config.network,
ipv4Config: {

1
packages/web/src/components/PageComponents/Config/Position.tsx

@ -62,7 +62,6 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
}}
onFormInit={onFormInit}
validationSchema={PositionValidationSchema}
formId="Config_PositionConfig"
defaultValues={config.position}
values={getEffectiveConfig("position")}
fieldGroups={[

1
packages/web/src/components/PageComponents/Config/Power.tsx

@ -44,7 +44,6 @@ export const Power = ({ onFormInit }: PowerConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={PowerValidationSchema}
formId="Config_PowerConfig"
defaultValues={config.power}
values={getEffectiveConfig("power")}
fieldGroups={[

49
packages/web/src/components/PageComponents/Config/Security/Security.tsx

@ -12,7 +12,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useAppStore, useDevice } from "@core/stores";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { Protobuf } from "@meshtastic/core";
@ -35,21 +35,34 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
removeWorkingConfig,
} = useDevice();
const { removeError } = useAppStore();
const { t } = useTranslation("deviceConfig");
const securityConfig = getEffectiveConfig("security");
const defaultConfig = config.security;
const defaultValues = {
...securityConfig,
...defaultConfig,
...{
privateKey: fromByteArray(defaultConfig?.privateKey ?? new Uint8Array(0)),
publicKey: fromByteArray(defaultConfig?.publicKey ?? new Uint8Array(0)),
adminKey: [
fromByteArray(defaultConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(defaultConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(defaultConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
],
},
};
const effectiveConfig = getEffectiveConfig("security");
const formValues = {
...effectiveConfig,
...{
privateKey: fromByteArray(
securityConfig?.privateKey ?? new Uint8Array(0),
effectiveConfig?.privateKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(securityConfig?.publicKey ?? new Uint8Array(0)),
publicKey: fromByteArray(effectiveConfig?.publicKey ?? new Uint8Array(0)),
adminKey: [
fromByteArray(securityConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(securityConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(securityConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
fromByteArray(effectiveConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(effectiveConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(effectiveConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
],
},
};
@ -60,8 +73,9 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
resolver: createZodResolver(RawSecuritySchema),
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values: formValues as RawSecurity,
});
const { setValue, formState } = formMethods;
const { setValue, trigger, handleSubmit, formState } = formMethods;
useEffect(() => {
onFormInit?.(formMethods);
@ -108,19 +122,21 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
updatePublicKey(fromByteArray(privateKey));
};
const updatePublicKey = (privateKey: string) => {
const updatePublicKey = async (privateKey: string) => {
try {
const publicKey = fromByteArray(
getX25519PublicKey(toByteArray(privateKey)),
);
setValue("privateKey", privateKey);
setValue("publicKey", publicKey);
setValue("privateKey", privateKey, { shouldDirty: true });
setValue("publicKey", publicKey, { shouldDirty: true });
removeError("privateKey");
removeError("publicKey");
setPrivateKeyDialogOpen(false);
} catch (_e) {
setValue("privateKey", privateKey);
setValue("privateKey", privateKey, { shouldDirty: true });
}
const valid = await trigger(["privateKey", "publicKey"]);
if (valid) {
handleSubmit(onSubmit)(); // manually invoke form submit
}
};
@ -137,7 +153,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
<DynamicForm<RawSecurity>
propMethods={formMethods}
onSubmit={onSubmit}
formId="Config_SecurityConfig"
fieldGroups={[
{
label: t("security.title"),

1
packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx

@ -50,7 +50,6 @@ export const AmbientLighting = ({
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={AmbientLightingValidationSchema}
formId="ModuleConfig_AmbientLightingConfig"
defaultValues={moduleConfig.ambientLighting}
values={getEffectiveModuleConfig("ambientLighting")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx

@ -48,7 +48,6 @@ export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={AudioValidationSchema}
formId="ModuleConfig_AudioConfig"
defaultValues={moduleConfig.audio}
values={getEffectiveModuleConfig("audio")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx

@ -51,7 +51,6 @@ export const CannedMessage = ({
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={CannedMessageValidationSchema}
formId="ModuleConfig_CannedMessageConfig"
defaultValues={moduleConfig.cannedMessage}
values={getEffectiveModuleConfig("cannedMessage")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx

@ -51,7 +51,6 @@ export const DetectionSensor = ({
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DetectionSensorValidationSchema}
formId="ModuleConfig_DetectionSensorConfig"
defaultValues={moduleConfig.detectionSensor}
values={getEffectiveModuleConfig("detectionSensor")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx

@ -51,7 +51,6 @@ export const ExternalNotification = ({
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={ExternalNotificationValidationSchema}
formId="ModuleConfig_ExternalNotificationConfig"
defaultValues={moduleConfig.externalNotification}
values={getEffectiveModuleConfig("externalNotification")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx

@ -72,7 +72,6 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={MqttValidationSchema}
formId="ModuleConfig_MqttConfig"
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx

@ -49,7 +49,6 @@ export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NeighborInfoValidationSchema}
formId="ModuleConfig_NeighborInfoConfig"
defaultValues={moduleConfig.neighborInfo}
values={getEffectiveModuleConfig("neighborInfo")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx

@ -49,7 +49,6 @@ export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={PaxcounterValidationSchema}
formId="ModuleConfig_PaxcounterConfig"
defaultValues={moduleConfig.paxcounter}
values={getEffectiveModuleConfig("paxcounter")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx

@ -50,7 +50,6 @@ export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={RangeTestValidationSchema}
formId="ModuleConfig_RangeTestConfig"
defaultValues={moduleConfig.rangeTest}
values={getEffectiveModuleConfig("rangeTest")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx

@ -49,7 +49,6 @@ export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={SerialValidationSchema}
formId="ModuleConfig_SerialConfig"
defaultValues={moduleConfig.serial}
values={getEffectiveModuleConfig("serial")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx

@ -49,7 +49,6 @@ export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={StoreForwardValidationSchema}
formId="ModuleConfig_StoreForwardConfig"
defaultValues={moduleConfig.storeForward}
values={getEffectiveModuleConfig("storeForward")}
fieldGroups={[

1
packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx

@ -49,7 +49,6 @@ export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={TelemetryValidationSchema}
formId="ModuleConfig_TelemetryConfig"
defaultValues={moduleConfig.telemetry}
values={getEffectiveModuleConfig("telemetry")}
fieldGroups={[

6
packages/web/src/components/Sidebar.tsx

@ -13,7 +13,6 @@ import { cn } from "@core/utils/cn.ts";
import { useLocation, useNavigate } from "@tanstack/react-router";
import {
CircleChevronLeft,
LayersIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
@ -113,11 +112,6 @@ export const Sidebar = ({ children }: SidebarProps) => {
icon: SettingsIcon,
page: "config",
},
{
name: t("navigation.channels"),
icon: LayersIcon,
page: "channels",
},
{
name: `${t("navigation.nodes")} (${displayedNodeCount})`,
icon: UsersIcon,

20
packages/web/src/components/UI/Generator.tsx

@ -7,7 +7,6 @@ import {
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
export interface ActionButton {
@ -48,7 +47,6 @@ const Generator = ({
showCopyButton,
...props
}: GeneratorProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const passwordRequiredBitSize = bits
@ -76,23 +74,6 @@ const Generator = ({
},
];
// Invokes onChange event on the input element when the value changes from the parent component
useEffect(() => {
if (!inputRef.current) {
return;
}
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (!setValue) {
return;
}
inputRef.current.value = "";
setValue.call(inputRef.current, value);
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
}, [value]);
return (
<>
<Input
@ -102,7 +83,6 @@ const Generator = ({
value={value}
onChange={inputChange}
disabled={disabled}
ref={inputRef}
showCopyButton={showCopyButton}
showPasswordToggle={showPasswordToggle}
/>

59
packages/web/src/core/stores/appStore/index.ts

@ -8,11 +8,6 @@ export interface RasterSource {
tileSize: number;
}
interface ErrorState {
field: string;
message: string;
}
interface AppState {
selectedDeviceId: number;
devices: {
@ -24,7 +19,6 @@ interface AppState {
nodeNumToBeRemoved: number;
connectDialogOpen: boolean;
nodeNumDetails: number;
errors: ErrorState[];
setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void;
@ -36,15 +30,6 @@ interface AppState {
setNodeNumToBeRemoved: (nodeNum: number) => void;
setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
// Error management
hasErrors: () => boolean;
getErrorMessage: (field: string) => string | undefined;
hasFieldError: (field: string) => boolean;
addError: (field: string, message: string) => void;
removeError: (field: string) => void;
clearErrors: () => void;
setNewErrors: (newErrors: ErrorState[]) => void;
}
export const useAppStore = create<AppState>()((set, get) => ({
@ -56,7 +41,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
errors: [],
setRasterSources: (sources: RasterSource[]) => {
set(
@ -114,47 +98,4 @@ export const useAppStore = create<AppState>()((set, get) => ({
set(() => ({
nodeNumDetails: nodeNum,
})),
hasErrors: () => {
const state = get();
return state.errors.length > 0;
},
getErrorMessage: (field: string) => {
const state = get();
return state.errors.find((err) => err.field === field)?.message;
},
hasFieldError: (field: string) => {
const state = get();
return state.errors.some((err) => err.field === field);
},
addError: (field: string, message: string) => {
set(
produce<AppState>((draft) => {
draft.errors = [
...draft.errors.filter((e) => e.field !== field),
{ field, message },
];
}),
);
},
removeError: (field: string) => {
set(
produce<AppState>((draft) => {
draft.errors = draft.errors.filter((e) => e.field !== field);
}),
);
},
clearErrors: () => {
set(
produce<AppState>((draft) => {
draft.errors = [];
}),
);
},
setNewErrors: (newErrors: ErrorState[]) => {
set(
produce<AppState>((draft) => {
draft.errors = newErrors;
}),
);
},
}));

4
packages/web/src/core/stores/deviceStore/deviceStore.mock.ts

@ -20,6 +20,7 @@ export const mockDeviceStore: Device = {
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
workingConfig: [],
workingModuleConfig: [],
workingChannelConfig: [],
hardware: {} as Protobuf.Mesh.MyNodeInfo,
metadata: new Map(),
traceroutes: new Map(),
@ -57,6 +58,9 @@ export const mockDeviceStore: Device = {
removeWorkingModuleConfig: vi.fn(),
getEffectiveConfig: vi.fn(),
getEffectiveModuleConfig: vi.fn(),
setWorkingChannelConfig: vi.fn(),
getWorkingChannelConfig: vi.fn(),
removeWorkingChannelConfig: vi.fn(),
setHardware: vi.fn(),
setActiveNode: vi.fn(),
setPendingSettingsChanges: vi.fn(),

62
packages/web/src/core/stores/deviceStore/index.ts

@ -30,6 +30,7 @@ export interface Device {
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
workingConfig: Protobuf.Config.Config[];
workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
workingChannelConfig: Protobuf.Channel.Channel[];
hardware: Protobuf.Mesh.MyNodeInfo;
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
traceroutes: Map<
@ -85,6 +86,11 @@ export interface Device {
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
setWorkingChannelConfig: (channelNum: Protobuf.Channel.Channel) => void;
getWorkingChannelConfig: (
index: Types.ChannelNumber,
) => Protobuf.Channel.Channel | undefined;
removeWorkingChannelConfig: (channelNum?: Types.ChannelNumber) => void;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void;
@ -139,6 +145,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
workingConfig: [],
workingModuleConfig: [],
workingChannelConfig: [],
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
metadata: new Map(),
traceroutes: new Map(),
@ -443,6 +450,61 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
};
},
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) => {
set(
produce<PrivateDeviceState>((draft) => {

86
packages/web/src/pages/Channels.tsx

@ -1,86 +0,0 @@
import { Channel } from "@components/PageComponents/Channel.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { useDevice } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { Types } from "@meshtastic/core";
import i18next from "i18next";
import { QrCodeIcon, UploadIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
return channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? i18next.t("page.broadcastLabel")
: i18next.t("page.channelIndex", {
ns: "channels",
index: channel.index,
});
};
const ChannelsPage = () => {
const { t } = useTranslation("channels");
const { channels, setDialogOpen } = useDevice();
const [activeChannel] = useState<Types.ChannelNumber>(
Types.ChannelNumber.Primary,
);
const currentChannel = channels.get(activeChannel);
const allChannels = Array.from(channels.values());
return (
<PageLayout
contentClassName="overflow-auto"
leftBar={<Sidebar />}
label={
currentChannel
? getChannelName(currentChannel)
: t("loading", { ns: "common" })
}
actions={[
{
key: "import",
icon: UploadIcon,
onClick() {
setDialogOpen("import", true);
},
},
{
key: "qr",
icon: QrCodeIcon,
onClick() {
setDialogOpen("QR", true);
},
},
]}
>
<Tabs defaultValue="0">
<TabsList className="dark:bg-slate-800">
{allChannels.map((channel) => (
<TabsTrigger
key={channel.index}
value={channel.index.toString()}
className="dark:text-white"
>
{getChannelName(channel)}
</TabsTrigger>
))}
</TabsList>
{allChannels.map((channel) => (
<TabsContent key={channel.index} value={channel.index.toString()}>
<Channel key={channel.index} channel={channel} />
</TabsContent>
))}
</Tabs>
</PageLayout>
);
};
export default ChannelsPage;

95
packages/web/src/pages/Config/ChannelConfig.tsx

@ -0,0 +1,95 @@
import { Channel } from "@app/components/PageComponents/ChannelConfig/Channel";
import { Button } from "@components/UI/Button.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { useDevice } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import i18next from "i18next";
import { QrCodeIcon, UploadIcon } from "lucide-react";
import { 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;
}
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
return channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? i18next.t("page.broadcastLabel")
: i18next.t("page.channelIndex", {
ns: "channels",
index: channel.index,
});
};
export const ChannelConfig = ({ onFormInit }: ConfigProps) => {
const { channels, getWorkingChannelConfig, setDialogOpen } = useDevice();
const { t } = useTranslation("channels");
const allChannels = Array.from(channels.values());
const flags = useMemo(
() =>
new Map(
allChannels.map((channel) => [
channel.index,
getWorkingChannelConfig(channel.index),
]),
),
[allChannels, getWorkingChannelConfig],
);
return (
<Tabs defaultValue="channel_0">
<TabsList className="w-full dark:bg-slate-700">
{allChannels.map((channel) => (
<TabsTrigger
key={`channel_${channel.index}`}
value={`channel_${channel.index}`}
className="dark:text-white relative"
>
{getChannelName(channel)}
{flags.get(channel.index) && (
<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>
))}
<Button
className="ml-auto mr-1 h-8"
onClick={() => setDialogOpen("import", true)}
>
<UploadIcon className="mr-2" size={14} />
{t("page.import")}
</Button>
<Button className=" h-8" onClick={() => setDialogOpen("QR", true)}>
<QrCodeIcon className="mr-2" size={14} />
{t("page.export")}
</Button>
</TabsList>
{allChannels.map((channel) => (
<TabsContent
key={`channel_${channel.index}`}
value={`channel_${channel.index}`}
>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<Channel
key={channel.index}
onFormInit={onFormInit}
channel={channel}
/>
</Suspense>
</TabsContent>
))}
</Tabs>
);
};

6
packages/web/src/pages/Config/DeviceConfig.tsx

@ -83,7 +83,7 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
return (
<Tabs defaultValue={t("page.tabDevice")}>
<TabsList className="dark:bg-slate-700">
<TabsList className="w-full dark:bg-slate-700">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
@ -103,9 +103,7 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<tab.element onFormInit={onFormInit} />
</Suspense>
<tab.element onFormInit={onFormInit} />
</Suspense>
</TabsContent>
))}

2
packages/web/src/pages/Config/ModuleConfig.tsx

@ -103,7 +103,7 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
return (
<Tabs defaultValue={t("page.tabMqtt")}>
<TabsList className="dark:bg-slate-800">
<TabsList className="w-full dark:bg-slate-800">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}

188
packages/web/src/pages/Config/index.tsx

@ -3,18 +3,20 @@ import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useAppStore, useDevice } from "@core/stores";
import { useDevice } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { ChannelConfig } from "@pages/Config/ChannelConfig.tsx";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import {
BoxesIcon,
LayersIcon,
RefreshCwIcon,
SaveIcon,
SaveOff,
SettingsIcon,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -22,18 +24,22 @@ const ConfigPage = () => {
const {
workingConfig,
workingModuleConfig,
workingChannelConfig,
connection,
removeWorkingConfig,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
setConfig,
setModuleConfig,
addChannel,
} = useDevice();
const { hasErrors } = useAppStore();
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module"
"device" | "module" | "channel"
>("device");
const [isSaving, setIsSaving] = useState(false);
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
const unsubRef = useRef<(() => void) | null>(null);
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
const { toast } = useToast();
const { t } = useTranslation("deviceConfig");
@ -41,21 +47,51 @@ const ConfigPage = () => {
const onFormInit = useCallback(
<T extends FieldValues>(methods: UseFormReturn<T>) => {
setFormMethods(methods as UseFormReturn);
setRhfState({
// Assume defailt on init, changes will be caught by subscription
isDirty: false,
isValid: true,
});
// Unsubscribe from previous subscriptions & subscribe to form changes
unsubRef.current?.();
unsubRef.current = methods.subscribe({
formState: { isDirty: true, isValid: true },
callback: ({ isValid, isDirty }) => {
setRhfState({
isDirty: isDirty ?? false,
isValid: isValid ?? true,
});
},
});
},
[],
);
const handleSave = useCallback(async () => {
if (hasErrors()) {
return toast({
title: t("toast.validationError.title"),
description: t("toast.validationError.description"),
});
}
// Cleanup subscription on unmount
useEffect(() => {
return () => unsubRef.current?.();
}, []);
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
// Save all working channel configs first, doesn't require a commit/reboot
await Promise.all(
workingChannelConfig.map((channel) =>
connection?.setChannel(channel).then(() => {
toast({
title: t("toast.savedChannel.title", {
ns: "ui",
channelName: channel.settings?.name,
}),
});
}),
),
);
await Promise.all(
workingConfig.map((newConfig) =>
connection?.setConfig(newConfig).then(() => {
@ -82,24 +118,31 @@ const ConfigPage = () => {
),
);
await connection?.commitEditSettings().then(() => {
if (formMethods) {
formMethods.reset(
{},
{
keepValues: true,
},
);
}
if (workingConfig.length > 0 || workingModuleConfig.length > 0) {
await connection?.commitEditSettings();
}
workingConfig.map((newConfig) => setConfig(newConfig));
workingModuleConfig.map((newModuleConfig) =>
setModuleConfig(newModuleConfig),
);
workingChannelConfig.forEach((newChannel) => addChannel(newChannel));
workingConfig.forEach((newConfig) => setConfig(newConfig));
workingModuleConfig.forEach((newModuleConfig) =>
setModuleConfig(newModuleConfig),
);
removeWorkingConfig();
removeWorkingModuleConfig();
});
removeWorkingChannelConfig();
removeWorkingConfig();
removeWorkingModuleConfig();
if (formMethods) {
formMethods.reset(formMethods.getValues(), {
keepDirty: false,
keepErrors: false,
keepTouched: false,
keepValues: true,
});
// Force RHF to re-validate and emit state
formMethods.trigger();
}
} catch (_error) {
toast({
title: t("toast.configSaveError.title"),
@ -107,29 +150,40 @@ const ConfigPage = () => {
});
} finally {
setIsSaving(false);
toast({
title: t("toast.saveAllSuccess.title"),
description: t("toast.saveAllSuccess.description"),
});
}
}, [
hasErrors,
toast,
t,
workingConfig,
connection,
workingModuleConfig,
workingChannelConfig,
formMethods,
addChannel,
setConfig,
setModuleConfig,
removeWorkingConfig,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
]);
const handleReset = useCallback(() => {
if (formMethods) {
formMethods.reset();
}
removeWorkingChannelConfig();
removeWorkingConfig();
removeWorkingModuleConfig();
}, [formMethods, removeWorkingConfig, removeWorkingModuleConfig]);
}, [
formMethods,
removeWorkingConfig,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
]);
const leftSidebar = useMemo(
() => (
@ -151,35 +205,33 @@ const ConfigPage = () => {
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>
</Sidebar>
),
[activeConfigSection, workingConfig, workingModuleConfig, t],
[
activeConfigSection,
workingConfig,
workingModuleConfig,
workingChannelConfig,
t,
],
);
const buttonOpacity = useMemo(() => {
const isFormDirty = formMethods?.formState.isDirty ?? false;
const hasDirtyFields =
(Object.keys(formMethods?.formState.dirtyFields ?? {}).length ?? 0) > 0;
const hasWorkingConfig = workingConfig.length > 0;
const hasWorkingModuleConfig = workingModuleConfig.length > 0;
const shouldShowButton =
(isFormDirty && hasDirtyFields) ||
hasWorkingConfig ||
hasWorkingModuleConfig;
return shouldShowButton ? "opacity-100" : "opacity-0";
}, [
formMethods?.formState.isDirty,
formMethods?.formState.dirtyFields,
workingConfig,
workingModuleConfig,
]);
const isValid = useMemo(() => {
return Object.keys(formMethods?.formState.errors ?? {}).length === 0;
}, [formMethods?.formState.errors]);
const hasDrafts =
workingConfig.length > 0 ||
workingModuleConfig.length > 0 ||
workingChannelConfig.length > 0;
const hasPending = hasDrafts || rhfState.isDirty;
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
const actions = useMemo(
() => [
@ -207,15 +259,13 @@ const ConfigPage = () => {
},
{
key: "save",
icon: !isValid ? SaveOff : SaveIcon,
icon: !hasPending ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled:
isSaving ||
!isValid ||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
iconClasses: !isValid
? "text-red-400 cursor-not-allowed"
: "cursor-pointer",
disabled: saveDisabled,
iconClasses:
!rhfState.isValid && hasPending
? "text-red-400 cursor-not-allowed"
: "cursor-pointer",
className: cn([
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black",
@ -227,10 +277,10 @@ const ConfigPage = () => {
],
[
isSaving,
isValid,
hasPending,
rhfState.isValid,
saveDisabled,
buttonOpacity,
workingConfig,
workingModuleConfig,
handleReset,
handleSave,
t,
@ -244,14 +294,18 @@ const ConfigPage = () => {
label={
activeConfigSection === "device"
? t("navigation.radioConfig")
: t("navigation.moduleConfig")
: activeConfigSection === "module"
? t("navigation.moduleConfig")
: t("navigation.channelConfig")
}
actions={actions}
>
{activeConfigSection === "device" ? (
<DeviceConfig onFormInit={onFormInit} />
) : (
) : activeConfigSection === "module" ? (
<ModuleConfig onFormInit={onFormInit} />
) : (
<ChannelConfig onFormInit={onFormInit} />
)}
</PageLayout>
);

2
packages/web/src/pages/Messages.tsx

@ -19,7 +19,7 @@ import {
import { cn } from "@core/utils/cn.ts";
import { randId } from "@core/utils/randId.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Channels.tsx";
import { getChannelName } from "@pages/Config/ChannelConfig.tsx";
import { useNavigate, useParams } from "@tanstack/react-router";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import {

8
packages/web/src/routes.tsx

@ -1,6 +1,5 @@
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import type { useAppStore, useMessageStore } from "@core/stores";
import ChannelsPage from "@pages/Channels.tsx";
import ConfigPage from "@pages/Config/index.tsx";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import MapPage from "@pages/Map/index.tsx";
@ -81,12 +80,6 @@ const configRoute = createRoute({
component: ConfigPage,
});
const channelsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/channels",
component: ChannelsPage,
});
const nodesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/nodes",
@ -105,7 +98,6 @@ const routeTree = rootRoute.addChildren([
messagesWithParamsRoute,
mapRoute,
configRoute,
channelsRoute,
nodesRoute,
dialogWithParamsRoute,
]);

3
packages/web/src/validation/channel.ts

@ -35,3 +35,6 @@ export function makeChannelSchema(allowedBytes: number) {
role: RoleEnum,
});
}
const ChannelValidationSchema = makeChannelSchema(0); // generate a schema that doesn't validate PSK length, just structure, for type purposes
export type ChannelValidation = z.infer<typeof ChannelValidationSchema>;

Loading…
Cancel
Save