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- <philon-@users.noreply.github.com>
This commit is contained in:
Jeremy Gallant
2025-09-01 09:07:34 -04:00
committed by GitHub
co-authored by philon-
parent 01fa030ef9
commit 1946000d14
42 changed files with 597 additions and 456 deletions
@@ -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,
},
@@ -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={[
@@ -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={[
@@ -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={[
@@ -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={[
@@ -62,7 +62,6 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NetworkValidationSchema}
formId="Config_NetworkConfig"
defaultValues={{
...config.network,
ipv4Config: {
@@ -62,7 +62,6 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
}}
onFormInit={onFormInit}
validationSchema={PositionValidationSchema}
formId="Config_PositionConfig"
defaultValues={config.position}
values={getEffectiveConfig("position")}
fieldGroups={[
@@ -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={[
@@ -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"),
@@ -50,7 +50,6 @@ export const AmbientLighting = ({
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={AmbientLightingValidationSchema}
formId="ModuleConfig_AmbientLightingConfig"
defaultValues={moduleConfig.ambientLighting}
values={getEffectiveModuleConfig("ambientLighting")}
fieldGroups={[
@@ -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={[
@@ -51,7 +51,6 @@ export const CannedMessage = ({
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={CannedMessageValidationSchema}
formId="ModuleConfig_CannedMessageConfig"
defaultValues={moduleConfig.cannedMessage}
values={getEffectiveModuleConfig("cannedMessage")}
fieldGroups={[
@@ -51,7 +51,6 @@ export const DetectionSensor = ({
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DetectionSensorValidationSchema}
formId="ModuleConfig_DetectionSensorConfig"
defaultValues={moduleConfig.detectionSensor}
values={getEffectiveModuleConfig("detectionSensor")}
fieldGroups={[
@@ -51,7 +51,6 @@ export const ExternalNotification = ({
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={ExternalNotificationValidationSchema}
formId="ModuleConfig_ExternalNotificationConfig"
defaultValues={moduleConfig.externalNotification}
values={getEffectiveModuleConfig("externalNotification")}
fieldGroups={[
@@ -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={[
@@ -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={[
@@ -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={[
@@ -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={[
@@ -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={[
@@ -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={[
@@ -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={[