diff --git a/packages/web/public/i18n/locales/en/config.json b/packages/web/public/i18n/locales/en/config.json index dd1ba678..35fa3c6f 100644 --- a/packages/web/public/i18n/locales/en/config.json +++ b/packages/web/public/i18n/locales/en/config.json @@ -121,15 +121,15 @@ } }, "lora": { - "title": "Mesh Settings", - "description": "Settings for the LoRa mesh", + "title": "LoRa", + "description": "LoRa radio configuration", "bandwidth": { "description": "Channel bandwidth in kHz", "label": "Bandwidth" }, "boostedRxGain": { "description": "Boosted RX gain", - "label": "Boosted RX Gain" + "label": "RX Boosted Gain" }, "codingRate": { "description": "The denominator of the coding rate", @@ -140,24 +140,24 @@ "label": "Frequency Offset" }, "frequencySlot": { - "description": "LoRa frequency channel number", + "description": "Your node's operating frequency is calculated based on the region, modem preset, and this field. When 0, the slot is automatically calculated based on the primary channel name and will change from the default public slot.", "label": "Frequency Slot" }, "hopLimit": { - "description": "Maximum number of hops", - "label": "Hop Limit" + "description": "Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. 0 hop broadcast messages will not get ACKs.", + "label": "Number of Hops" }, "ignoreMqtt": { "description": "Don't forward MQTT messages over the mesh", "label": "Ignore MQTT" }, "modemPreset": { - "description": "Modem preset to use", - "label": "Modem Preset" + "description": "Available modem presets, default is Long Fast.", + "label": "Presets" }, "okToMqtt": { "description": "When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT", - "label": "OK to MQTT" + "label": "Ok to MQTT" }, "overrideDutyCycle": { "description": "Override Duty Cycle", @@ -165,10 +165,10 @@ }, "overrideFrequency": { "description": "Override frequency", - "label": "Override Frequency" + "label": "Frequency Override" }, "region": { - "description": "Sets the region for your node", + "description": "The region where you will be using your radios.", "label": "Region" }, "spreadingFactor": { @@ -198,6 +198,14 @@ "radioSettings": { "label": "Radio Settings", "description": "Settings for the LoRa radio" + }, + "optionsCard": { + "label": "Options", + "description": "LoRa core options" + }, + "advancedCard": { + "label": "Advanced", + "description": "Advanced LoRa parameters" } }, "network": { @@ -271,15 +279,15 @@ } }, "position": { - "title": "Position Settings", - "description": "Settings for the position module", + "title": "Position", + "description": "Position settings", "broadcastInterval": { - "description": "How often your position is sent out over the mesh", + "description": "The maximum interval that can elapse without a node broadcasting a position.", "label": "Broadcast Interval" }, "enablePin": { - "description": "GPS module enable pin override", - "label": "Enable Pin" + "description": "GPS enable GPIO pin override", + "label": "GPS EN GPIO" }, "fixedPosition": { "description": "Don't report GPS position, but a manually-specified one", @@ -299,35 +307,35 @@ }, "gpsMode": { "description": "Configure whether device GPS is Enabled, Disabled, or Not Present", - "label": "GPS Mode" + "label": "GPS Mode (Physical Hardware)" }, "gpsUpdateInterval": { - "description": "How often a GPS fix should be acquired", - "label": "GPS Update Interval" + "description": "How often should we try to get a GPS position (<10sec keeps GPS on).", + "label": "GPS Polling Interval" }, "positionFlags": { - "description": "Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.", + "description": "Optional fields to include when assembling position messages. The more fields are included, the larger the message will be — leading to longer airtime and a higher risk of packet loss.", "label": "Position Flags" }, "receivePin": { - "description": "GPS module RX pin override", - "label": "Receive Pin" + "description": "GPS receive GPIO pin override", + "label": "GPS Receive GPIO" }, "smartPositionEnabled": { "description": "Only send position when there has been a meaningful change in location", - "label": "Enable Smart Position" + "label": "Smart Position" }, "smartPositionMinDistance": { - "description": "Minimum distance (in meters) that must be traveled before a position update is sent", - "label": "Smart Position Minimum Distance" + "description": "The minimum distance change in meters to be considered for a smart position broadcast.", + "label": "Smart Distance" }, "smartPositionMinInterval": { - "description": "Minimum interval (in seconds) that must pass before a position update is sent", - "label": "Smart Position Minimum Interval" + "description": "The fastest that position updates will be sent if the minimum distance has been satisfied.", + "label": "Smart Interval" }, "transmitPin": { - "description": "GPS module TX pin override", - "label": "Transmit Pin" + "description": "GPS transmit GPIO pin override", + "label": "GPS Transmit GPIO" }, "intervalsSettings": { "description": "How often to send position updates", @@ -346,6 +354,18 @@ "unset": "Unset", "vehicleHeading": "Vehicle heading", "vehicleSpeed": "Vehicle speed" + }, + "positionPacket": { + "label": "Position Packet", + "description": "Position broadcast configuration" + }, + "deviceGps": { + "label": "Device GPS", + "description": "Device GPS configuration" + }, + "advancedDeviceGps": { + "label": "Advanced Device GPS", + "description": "GPIO pin overrides for the GPS hardware" } }, "power": { diff --git a/packages/web/public/i18n/locales/en/moduleConfig.json b/packages/web/public/i18n/locales/en/moduleConfig.json index f3599598..42cc0e64 100644 --- a/packages/web/public/i18n/locales/en/moduleConfig.json +++ b/packages/web/public/i18n/locales/en/moduleConfig.json @@ -218,34 +218,34 @@ } }, "mqtt": { - "title": "MQTT Settings", - "description": "Settings for the MQTT module", + "title": "MQTT", + "description": "MQTT module configuration", "enabled": { - "label": "Enabled", + "label": "MQTT enabled", "description": "Enable or disable MQTT" }, "address": { - "label": "MQTT Server Address", + "label": "Address", "description": "MQTT server address to use for default/custom servers" }, "username": { - "label": "MQTT Username", + "label": "Username", "description": "MQTT username to use for default/custom servers" }, "password": { - "label": "MQTT Password", + "label": "Password", "description": "MQTT password to use for default/custom servers" }, "encryptionEnabled": { - "label": "Encryption Enabled", + "label": "Encryption enabled", "description": "Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data." }, "jsonEnabled": { - "label": "JSON Enabled", + "label": "JSON output enabled", "description": "Whether to send/consume JSON packets on MQTT" }, "tlsEnabled": { - "label": "TLS Enabled", + "label": "TLS enabled", "description": "Enable or disable TLS" }, "root": { @@ -253,20 +253,20 @@ "description": "MQTT root topic to use for default/custom servers" }, "proxyToClientEnabled": { - "label": "MQTT Client Proxy Enabled", + "label": "Proxy to client enabled", "description": "Utilizes the network connection to proxy MQTT messages to the client." }, "mapReportingEnabled": { - "label": "Map Reporting Enabled", - "description": "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, short and long name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name." + "label": "Map reporting", + "description": "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, long and short name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name." }, "mapReportSettings": { "publishIntervalSecs": { - "label": "Map Report Publish Interval (s)", + "label": "Map reporting interval (seconds)", "description": "Interval in seconds to publish map reports" }, "positionPrecision": { - "label": "Approximate Location", + "label": "Approximate location", "description": "Position shared will be accurate within this distance", "options": { "metric_km23": "Within 23 km", @@ -290,7 +290,21 @@ "imperial_ft300": "Within 300 feet", "imperial_ft150": "Within 150 feet" } + }, + "shouldReportLocation": { + "label": "I agree.", + "description": "I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT.", + "consentHeader": "Consent to Share Unencrypted Node Data via MQTT", + "consentText": "By enabling this feature, you acknowledge and expressly consent to the transmission of your device's real-time geographic location over the MQTT protocol without encryption. This location data may be used for purposes such as live map reporting, device tracking, and related telemetry functions." } + }, + "mqttConfigCard": { + "label": "MQTT Config", + "description": "MQTT broker connection" + }, + "mapReportingCard": { + "label": "Map reporting", + "description": "Periodic unencrypted map report" } }, "neighborInfo": { diff --git a/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx b/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx index b6887a4d..01eff332 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx @@ -3,21 +3,47 @@ import { type MqttValidation, MqttValidationSchema } from "@app/validation/modul import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; -import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { Protobuf } from "@meshtastic/sdk"; +import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { useTranslation } from "react-i18next"; interface MqttModuleConfigProps { onFormInit: DynamicFormFormInit; } +const EMPTY_MODULES_SIGNAL = { + value: {} as { mqtt?: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig }, + peek: () => ({}) as { mqtt?: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig }, + subscribe: () => () => {}, +} as const; + +const populateDefaults = (cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined) => + cfg + ? { + ...cfg, + mapReportSettings: cfg.mapReportSettings ?? { + publishIntervalSecs: 3600, + positionPrecision: 13, + shouldReportLocation: false, + }, + } + : undefined; + export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "mqtt" }); - const { config, moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice(); + const { config, moduleConfig, getEffectiveModuleConfig } = useDevice(); + const editor = useConfigEditor(); + const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL); + + const effectiveMqtt = + modules.mqtt ?? + (getEffectiveModuleConfig("mqtt") as Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined); + const { t } = useTranslation("moduleConfig"); const onSubmit = (data: MqttValidation) => { + if (!editor) return; const payload = { ...data, mapReportSettings: create( @@ -25,40 +51,42 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => { data.mapReportSettings, ), }; - - if (deepCompareConfig(moduleConfig.mqtt, payload, true)) { - removeChange({ type: "moduleConfig", variant: "mqtt" }); - return; - } - - setChange({ type: "moduleConfig", variant: "mqtt" }, payload, moduleConfig.mqtt); + editor.setModuleSection( + "mqtt", + payload as unknown as Protobuf.ModuleConfig.ModuleConfig_MQTTConfig, + ); }; - const populateDefaultValues = ( - cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined, - ) => { - return cfg + const positionPrecisionOptions = + config.display?.units === 0 ? { - ...cfg, - mapReportSettings: cfg.mapReportSettings ?? { - publishIntervalSecs: 0, - positionPrecision: 10, - }, + [t("mqtt.mapReportSettings.positionPrecision.options.metric_km23")]: 10, + [t("mqtt.mapReportSettings.positionPrecision.options.metric_km12")]: 11, + [t("mqtt.mapReportSettings.positionPrecision.options.metric_km5_8")]: 12, + [t("mqtt.mapReportSettings.positionPrecision.options.metric_km2_9")]: 13, + [t("mqtt.mapReportSettings.positionPrecision.options.metric_km1_5")]: 14, + [t("mqtt.mapReportSettings.positionPrecision.options.metric_m700")]: 15, } - : undefined; - }; + : { + [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi15")]: 10, + [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi7_3")]: 11, + [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi3_6")]: 12, + [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi1_8")]: 13, + [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_9")]: 14, + [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_5")]: 15, + }; return ( onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={MqttValidationSchema} - defaultValues={populateDefaultValues(moduleConfig.mqtt)} - values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))} + defaultValues={populateDefaults(moduleConfig.mqtt)} + values={populateDefaults(effectiveMqtt)} fieldGroups={[ { - label: t("mqtt.title"), - description: t("mqtt.description"), + label: t("mqtt.mqttConfigCard.label"), + description: t("mqtt.mqttConfigCard.description"), fields: [ { type: "toggle", @@ -71,98 +99,87 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => { name: "address", label: t("mqtt.address.label"), description: t("mqtt.address.description"), - disabledBy: [ - { - fieldName: "enabled", - }, - ], + disabledBy: [{ fieldName: "enabled" }], }, { type: "text", name: "username", label: t("mqtt.username.label"), description: t("mqtt.username.description"), - disabledBy: [ - { - fieldName: "enabled", - }, - ], + disabledBy: [{ fieldName: "enabled" }], }, { type: "password", name: "password", label: t("mqtt.password.label"), description: t("mqtt.password.description"), - disabledBy: [ - { - fieldName: "enabled", - }, - ], + disabledBy: [{ fieldName: "enabled" }], }, { type: "toggle", name: "encryptionEnabled", label: t("mqtt.encryptionEnabled.label"), description: t("mqtt.encryptionEnabled.description"), - disabledBy: [ - { - fieldName: "enabled", - }, - ], + disabledBy: [{ fieldName: "enabled" }], }, { type: "toggle", name: "jsonEnabled", label: t("mqtt.jsonEnabled.label"), description: t("mqtt.jsonEnabled.description"), - disabledBy: [ - { - fieldName: "enabled", - }, - ], + disabledBy: [{ fieldName: "enabled" }], }, { type: "toggle", name: "tlsEnabled", label: t("mqtt.tlsEnabled.label"), description: t("mqtt.tlsEnabled.description"), - disabledBy: [ - { - fieldName: "enabled", - }, - ], + disabledBy: [{ fieldName: "enabled" }], }, { type: "text", name: "root", label: t("mqtt.root.label"), description: t("mqtt.root.description"), - disabledBy: [ - { - fieldName: "enabled", - }, - ], + disabledBy: [{ fieldName: "enabled" }], }, { type: "toggle", name: "proxyToClientEnabled", label: t("mqtt.proxyToClientEnabled.label"), description: t("mqtt.proxyToClientEnabled.description"), - disabledBy: [ - { - fieldName: "enabled", - }, - ], + disabledBy: [{ fieldName: "enabled" }], }, + ], + }, + { + label: t("mqtt.mapReportingCard.label"), + description: t("mqtt.mapReportingCard.description"), + fields: [ { type: "toggle", name: "mapReportingEnabled", label: t("mqtt.mapReportingEnabled.label"), description: t("mqtt.mapReportingEnabled.description"), + disabledBy: [{ fieldName: "enabled" }], + }, + { + type: "toggle", + name: "mapReportSettings.shouldReportLocation", + label: t("mqtt.mapReportSettings.shouldReportLocation.label"), + description: t("mqtt.mapReportSettings.shouldReportLocation.description"), + disabledBy: [{ fieldName: "enabled" }, { fieldName: "mapReportingEnabled" }], + }, + { + type: "select", + name: "mapReportSettings.positionPrecision", + label: t("mqtt.mapReportSettings.positionPrecision.label"), + description: t("mqtt.mapReportSettings.positionPrecision.description"), + properties: { enumValue: positionPrecisionOptions }, disabledBy: [ - { - fieldName: "enabled", - }, + { fieldName: "enabled" }, + { fieldName: "mapReportingEnabled" }, + { fieldName: "mapReportSettings.shouldReportLocation" }, ], }, { @@ -170,58 +187,11 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => { name: "mapReportSettings.publishIntervalSecs", label: t("mqtt.mapReportSettings.publishIntervalSecs.label"), description: t("mqtt.mapReportSettings.publishIntervalSecs.description"), - properties: { - suffix: t("unit.second.plural"), - }, + properties: { suffix: t("unit.second.plural") }, disabledBy: [ - { - fieldName: "enabled", - }, - { - fieldName: "mapReportingEnabled", - }, - ], - }, - { - type: "select", - name: "mapReportSettings.positionPrecision", - label: t("mqtt.mapReportSettings.positionPrecision.label"), - description: t("mqtt.mapReportSettings.positionPrecision.description"), - properties: { - enumValue: - config.display?.units === 0 - ? { - [t("mqtt.mapReportSettings.positionPrecision.options.metric_km23")]: 10, - [t("mqtt.mapReportSettings.positionPrecision.options.metric_km12")]: 11, - [t("mqtt.mapReportSettings.positionPrecision.options.metric_km5_8")]: 12, - [t("mqtt.mapReportSettings.positionPrecision.options.metric_km2_9")]: 13, - [t("mqtt.mapReportSettings.positionPrecision.options.metric_km1_5")]: 14, - [t("mqtt.mapReportSettings.positionPrecision.options.metric_m700")]: 15, - [t("mqtt.mapReportSettings.positionPrecision.options.metric_m350")]: 16, - [t("mqtt.mapReportSettings.positionPrecision.options.metric_m200")]: 17, - [t("mqtt.mapReportSettings.positionPrecision.options.metric_m90")]: 18, - [t("mqtt.mapReportSettings.positionPrecision.options.metric_m50")]: 19, - } - : { - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi15")]: 10, - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi7_3")]: 11, - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi3_6")]: 12, - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi1_8")]: 13, - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_9")]: 14, - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_5")]: 15, - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_2")]: 16, - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft600")]: 17, - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft300")]: 18, - [t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft150")]: 19, - }, - }, - disabledBy: [ - { - fieldName: "enabled", - }, - { - fieldName: "mapReportingEnabled", - }, + { fieldName: "enabled" }, + { fieldName: "mapReportingEnabled" }, + { fieldName: "mapReportSettings.shouldReportLocation" }, ], }, ], diff --git a/packages/web/src/components/PageComponents/Settings/LoRa.tsx b/packages/web/src/components/PageComponents/Settings/LoRa.tsx index a8a4aaf1..05c76564 100644 --- a/packages/web/src/components/PageComponents/Settings/LoRa.tsx +++ b/packages/web/src/components/PageComponents/Settings/LoRa.tsx @@ -2,26 +2,35 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig"; import { type LoRaValidation, LoRaValidationSchema } from "@app/validation/config/lora.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/sdk"; +import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { useTranslation } from "react-i18next"; interface LoRaConfigProps { onFormInit: DynamicFormFormInit; } + +const EMPTY_RADIO_SIGNAL = { + value: {} as { lora?: Protobuf.Config.Config_LoRaConfig }, + peek: () => ({}) as { lora?: Protobuf.Config.Config_LoRaConfig }, + subscribe: () => () => {}, +} as const; + export const LoRa = ({ onFormInit }: LoRaConfigProps) => { useWaitForConfig({ configCase: "lora" }); - const { config, setChange, getEffectiveConfig, removeChange } = useDevice(); + const { config, getEffectiveConfig } = useDevice(); + const editor = useConfigEditor(); + const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL); + + const effectiveLora = + radio.lora ?? (getEffectiveConfig("lora") as Protobuf.Config.Config_LoRaConfig | undefined); + const { t } = useTranslation("config"); const onSubmit = (data: LoRaValidation) => { - if (deepCompareConfig(config.lora, data, true)) { - removeChange({ type: "config", variant: "lora" }); - return; - } - - setChange({ type: "config", variant: "lora" }, data, config.lora); + if (!editor) return; + editor.setRadioSection("lora", data as unknown as Protobuf.Config.Config_LoRaConfig); }; return ( @@ -30,11 +39,11 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { onFormInit={onFormInit} validationSchema={LoRaValidationSchema} defaultValues={config.lora} - values={getEffectiveConfig("lora")} + values={effectiveLora} fieldGroups={[ { - label: t("lora.title"), - description: t("lora.description"), + label: t("lora.optionsCard.label"), + description: t("lora.optionsCard.description"), fields: [ { type: "select", @@ -45,21 +54,52 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode, }, }, + { + type: "toggle", + name: "usePreset", + label: t("lora.usePreset.label"), + description: t("lora.usePreset.description"), + }, { type: "select", - name: "hopLimit", - label: t("lora.hopLimit.label"), - description: t("lora.hopLimit.description"), + name: "modemPreset", + label: t("lora.modemPreset.label"), + description: t("lora.modemPreset.description"), + disabledBy: [{ fieldName: "usePreset" }], properties: { - enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 }, + enumValue: Protobuf.Config.Config_LoRaConfig_ModemPreset, + formatEnumName: true, }, }, { type: "number", - name: "channelNum", - label: t("lora.frequencySlot.label"), - description: t("lora.frequencySlot.description"), + name: "bandwidth", + label: t("lora.bandwidth.label"), + description: t("lora.bandwidth.description"), + disabledBy: [{ fieldName: "usePreset", invert: true }], + properties: { suffix: t("unit.kilohertz") }, }, + { + type: "number", + name: "spreadFactor", + label: t("lora.spreadingFactor.label"), + description: t("lora.spreadingFactor.description"), + disabledBy: [{ fieldName: "usePreset", invert: true }], + properties: { suffix: t("unit.cps") }, + }, + { + type: "number", + name: "codingRate", + label: t("lora.codingRate.label"), + description: t("lora.codingRate.description"), + disabledBy: [{ fieldName: "usePreset", invert: true }], + }, + ], + }, + { + label: t("lora.advancedCard.label"), + description: t("lora.advancedCard.description"), + fields: [ { type: "toggle", name: "ignoreMqtt", @@ -72,97 +112,12 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { label: t("lora.okToMqtt.label"), description: t("lora.okToMqtt.description"), }, - ], - }, - { - label: t("lora.waveformSettings.label"), - description: t("lora.waveformSettings.description"), - fields: [ - { - type: "toggle", - name: "usePreset", - label: t("lora.usePreset.label"), - description: t("lora.usePreset.description"), - }, - { - type: "select", - name: "modemPreset", - label: t("lora.modemPreset.label"), - description: t("lora.modemPreset.description"), - disabledBy: [ - { - fieldName: "usePreset", - }, - ], - properties: { - enumValue: Protobuf.Config.Config_LoRaConfig_ModemPreset, - formatEnumName: true, - }, - }, - { - type: "number", - name: "bandwidth", - label: t("lora.bandwidth.label"), - description: t("lora.bandwidth.description"), - disabledBy: [ - { - fieldName: "usePreset", - invert: true, - }, - ], - properties: { - suffix: t("unit.kilohertz"), - }, - }, - { - type: "number", - name: "spreadFactor", - label: t("lora.spreadingFactor.label"), - description: t("lora.spreadingFactor.description"), - - disabledBy: [ - { - fieldName: "usePreset", - invert: true, - }, - ], - properties: { - suffix: t("unit.cps"), - }, - }, - { - type: "number", - name: "codingRate", - label: t("lora.codingRate.label"), - description: t("lora.codingRate.description"), - disabledBy: [ - { - fieldName: "usePreset", - invert: true, - }, - ], - }, - ], - }, - { - label: t("lora.radioSettings.label"), - description: t("lora.radioSettings.description"), - fields: [ { type: "toggle", name: "txEnabled", label: t("lora.transmitEnabled.label"), description: t("lora.transmitEnabled.description"), }, - { - type: "number", - name: "txPower", - label: t("lora.transmitPower.label"), - description: t("lora.transmitPower.description"), - properties: { - suffix: t("unit.dbm"), - }, - }, { type: "toggle", name: "overrideDutyCycle", @@ -170,14 +125,20 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { description: t("lora.overrideDutyCycle.description"), }, { - type: "number", - name: "frequencyOffset", - label: t("lora.frequencyOffset.label"), - description: t("lora.frequencyOffset.description"), + type: "select", + name: "hopLimit", + label: t("lora.hopLimit.label"), + description: t("lora.hopLimit.description"), properties: { - suffix: t("unit.hertz"), + enumValue: { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 }, }, }, + { + type: "number", + name: "channelNum", + label: t("lora.frequencySlot.label"), + description: t("lora.frequencySlot.description"), + }, { type: "toggle", name: "sx126xRxBoostedGain", @@ -194,6 +155,13 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { step: 0.001, }, }, + { + type: "number", + name: "txPower", + label: t("lora.transmitPower.label"), + description: t("lora.transmitPower.description"), + properties: { suffix: t("unit.dbm") }, + }, ], }, ]} diff --git a/packages/web/src/components/PageComponents/Settings/Position.tsx b/packages/web/src/components/PageComponents/Settings/Position.tsx index 9037bd61..88a4bb72 100644 --- a/packages/web/src/components/PageComponents/Settings/Position.tsx +++ b/packages/web/src/components/PageComponents/Settings/Position.tsx @@ -8,55 +8,62 @@ import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicF import { type FlagName, usePositionFlags } from "@core/hooks/usePositionFlags.ts"; import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts"; import { useDevice } from "@core/stores"; -import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { Protobuf } from "@meshtastic/sdk"; +import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; interface PositionConfigProps { onFormInit: DynamicFormFormInit; } + +const EMPTY_RADIO_SIGNAL = { + value: {} as { position?: Protobuf.Config.Config_PositionConfig }, + peek: () => ({}) as { position?: Protobuf.Config.Config_PositionConfig }, + subscribe: () => () => {}, +} as const; + export const Position = ({ onFormInit }: PositionConfigProps) => { useWaitForConfig({ configCase: "position" }); - const { setChange, config, getEffectiveConfig, removeChange, queueAdminMessage } = useDevice(); + const { config, getEffectiveConfig, queueAdminMessage } = useDevice(); + const editor = useConfigEditor(); const myNode = useMyNodeAsProto(); + const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL); + + const effectivePosition = + radio.position ?? + (getEffectiveConfig("position") as Protobuf.Config.Config_PositionConfig | undefined); + const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( - getEffectiveConfig("position")?.positionFlags ?? 0, + effectivePosition?.positionFlags ?? 0, ); const { t } = useTranslation("config"); const currentPosition = myNode?.position; - - const effectiveConfig = getEffectiveConfig("position"); const displayUnits = getEffectiveConfig("display")?.units; const formValues = useMemo(() => { return { ...config.position, - ...effectiveConfig, - // Include current position coordinates if available + ...effectivePosition, latitude: currentPosition?.latitudeI ? currentPosition.latitudeI / 1e7 : undefined, longitude: currentPosition?.longitudeI ? currentPosition.longitudeI / 1e7 : undefined, altitude: currentPosition?.altitude ?? 0, } as PositionValidation; - }, [config.position, effectiveConfig, currentPosition]); + }, [config.position, effectivePosition, currentPosition]); const onSubmit = (data: PositionValidation) => { - // Exclude position coordinates from config payload (they're handled via admin message) const { latitude: _latitude, longitude: _longitude, altitude: _altitude, ...configData } = data; const payload = { ...configData, positionFlags: flagsValue }; - // Save config first - let configResult: ReturnType | undefined; - if (deepCompareConfig(config.position, payload, true)) { - removeChange({ type: "config", variant: "position" }); - configResult = undefined; - } else { - configResult = setChange({ type: "config", variant: "position" }, payload, config.position); + if (editor) { + editor.setRadioSection( + "position", + payload as unknown as Protobuf.Config.Config_PositionConfig, + ); } - // Then handle position coordinates via admin message if fixedPosition is enabled if (data.fixedPosition && data.latitude !== undefined && data.longitude !== undefined) { const message = create(Protobuf.Admin.AdminMessageSchema, { payloadVariant: { @@ -72,8 +79,6 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { queueAdminMessage(message); } - - return configResult; }; const onPositonFlagChange = useCallback( @@ -87,7 +92,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { onSubmit={(data) => { data.positionFlags = flagsValue; - return onSubmit(data); + onSubmit(data); }} onFormInit={onFormInit} validationSchema={PositionValidationSchema} @@ -95,9 +100,16 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { values={formValues} fieldGroups={[ { - label: t("position.title"), - description: t("position.description"), + label: t("position.positionPacket.label"), + description: t("position.positionPacket.description"), fields: [ + { + type: "number", + name: "positionBroadcastSecs", + label: t("position.broadcastInterval.label"), + description: t("position.broadcastInterval.description"), + properties: { suffix: t("unit.second.plural") }, + }, { type: "toggle", name: "positionBroadcastSmartEnabled", @@ -105,27 +117,32 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { description: t("position.smartPositionEnabled.description"), }, { - type: "select", - name: "gpsMode", - label: t("position.gpsMode.label"), - description: t("position.gpsMode.description"), - properties: { - enumValue: Protobuf.Config.Config_PositionConfig_GpsMode, - }, + type: "number", + name: "broadcastSmartMinimumIntervalSecs", + label: t("position.smartPositionMinInterval.label"), + description: t("position.smartPositionMinInterval.description"), + properties: { suffix: t("unit.second.plural") }, + disabledBy: [{ fieldName: "positionBroadcastSmartEnabled" }], }, + { + type: "number", + name: "broadcastSmartMinimumDistance", + label: t("position.smartPositionMinDistance.label"), + description: t("position.smartPositionMinDistance.description"), + disabledBy: [{ fieldName: "positionBroadcastSmartEnabled" }], + }, + ], + }, + { + label: t("position.deviceGps.label"), + description: t("position.deviceGps.description"), + fields: [ { type: "toggle", name: "fixedPosition", label: t("position.fixedPosition.label"), description: t("position.fixedPosition.description"), - disabledBy: [ - { - fieldName: "gpsMode", - selector: Protobuf.Config.Config_PositionConfig_GpsMode.ENABLED, - }, - ], }, - // Position coordinate fields (only shown when fixedPosition is enabled) { type: "number", name: "latitude", @@ -134,15 +151,9 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { properties: { step: 0.0000001, suffix: "Degrees", - fieldLength: { - max: 10, - }, + fieldLength: { max: 10 }, }, - disabledBy: [ - { - fieldName: "fixedPosition", - }, - ], + disabledBy: [{ fieldName: "fixedPosition" }], }, { type: "number", @@ -152,15 +163,9 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { properties: { step: 0.0000001, suffix: "Degrees", - fieldLength: { - max: 10, - }, + fieldLength: { max: 10 }, }, - disabledBy: [ - { - fieldName: "fixedPosition", - }, - ], + disabledBy: [{ fieldName: "fixedPosition" }], }, { type: "number", @@ -179,12 +184,32 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { ? "Feet" : "Meters", }, - disabledBy: [ - { - fieldName: "fixedPosition", - }, - ], + disabledBy: [{ fieldName: "fixedPosition" }], }, + { + type: "select", + name: "gpsMode", + label: t("position.gpsMode.label"), + description: t("position.gpsMode.description"), + properties: { + enumValue: Protobuf.Config.Config_PositionConfig_GpsMode, + }, + disabledBy: [{ fieldName: "fixedPosition", invert: true }], + }, + { + type: "number", + name: "gpsUpdateInterval", + label: t("position.gpsUpdateInterval.label"), + description: t("position.gpsUpdateInterval.description"), + properties: { suffix: t("unit.second.plural") }, + disabledBy: [{ fieldName: "fixedPosition", invert: true }], + }, + ], + }, + { + label: t("position.positionFlags.label"), + description: t("position.positionFlags.description"), + fields: [ { type: "multiSelect", name: "positionFlags", @@ -198,6 +223,12 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { enumValue: getAllFlags(), }, }, + ], + }, + { + label: t("position.advancedDeviceGps.label"), + description: t("position.advancedDeviceGps.description"), + fields: [ { type: "number", name: "rxGpio", @@ -218,52 +249,6 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { }, ], }, - { - label: t("position.intervalsSettings.label"), - description: t("position.intervalsSettings.description"), - fields: [ - { - type: "number", - name: "positionBroadcastSecs", - label: t("position.broadcastInterval.label"), - description: t("position.broadcastInterval.description"), - properties: { - suffix: t("unit.second.plural"), - }, - }, - { - type: "number", - name: "gpsUpdateInterval", - label: t("position.gpsUpdateInterval.label"), - description: t("position.gpsUpdateInterval.description"), - properties: { - suffix: t("unit.second.plural"), - }, - }, - { - type: "number", - name: "broadcastSmartMinimumDistance", - label: t("position.smartPositionMinDistance.label"), - description: t("position.smartPositionMinDistance.description"), - disabledBy: [ - { - fieldName: "positionBroadcastSmartEnabled", - }, - ], - }, - { - type: "number", - name: "broadcastSmartMinimumIntervalSecs", - label: t("position.smartPositionMinInterval.label"), - description: t("position.smartPositionMinInterval.description"), - disabledBy: [ - { - fieldName: "positionBroadcastSmartEnabled", - }, - ], - }, - ], - }, ]} /> ); diff --git a/packages/web/src/pages/Settings/index.tsx b/packages/web/src/pages/Settings/index.tsx index 363e33b9..53c08f8f 100644 --- a/packages/web/src/pages/Settings/index.tsx +++ b/packages/web/src/pages/Settings/index.tsx @@ -8,6 +8,7 @@ import { useToast } from "@core/hooks/useToast.ts"; import { useDevice } from "@core/stores"; import { cn } from "@core/utils/cn.ts"; import { Protobuf } from "@meshtastic/sdk"; +import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx"; import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx"; import { useNavigate, useRouterState } from "@tanstack/react-router"; @@ -41,6 +42,11 @@ const ConfigPage = () => { getAdminMessageChangeCount, } = useDevice(); + const editor = useConfigEditor(); + const editorIsDirty = useSignal( + editor?.isDirty ?? { value: false, peek: () => false, subscribe: () => () => {} }, + ); + const [isSaving, setIsSaving] = useState(false); const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true }); const unsubRef = useRef<(() => void) | null>(null); @@ -168,6 +174,17 @@ const ConfigPage = () => { await connection?.commitEditSettings(); } + // Run the SDK ConfigEditor commit for migrated sections (currently + // Position / LoRa / MQTT). It wraps its own beginEdit/commitEdit, so + // sequencing it after the legacy flow is fine — devices accept + // multiple edit windows in the same save. + if (editor && editor.isDirty.peek()) { + const result = await editor.commit(); + if (result.status === "error") { + throw result.error; + } + } + // Send queued admin messages after configs are committed if (adminMessages.length > 0) { await Promise.all( @@ -228,6 +245,7 @@ const ConfigPage = () => { setConfig, setModuleConfig, clearAllChanges, + editor, ]); const handleReset = useCallback(() => { @@ -262,7 +280,8 @@ const ConfigPage = () => { getConfigChangeCount() > 0 || getModuleConfigChangeCount() > 0 || getChannelChangeCount() > 0 || - adminMessageChangeCount > 0; + adminMessageChangeCount > 0 || + editorIsDirty; const hasPending = hasDrafts || rhfState.isDirty; const buttonOpacity = hasPending ? "opacity-100" : "opacity-0"; const saveDisabled = isSaving || !rhfState.isValid || !hasPending; diff --git a/packages/web/src/validation/moduleConfig/mqtt.ts b/packages/web/src/validation/moduleConfig/mqtt.ts index fe47c93d..bf9b99ca 100644 --- a/packages/web/src/validation/moduleConfig/mqtt.ts +++ b/packages/web/src/validation/moduleConfig/mqtt.ts @@ -3,17 +3,18 @@ import { z } from "zod/v4"; export const MqttValidationMapReportSettingsSchema = z.object({ publishIntervalSecs: z.coerce.number().int(), positionPrecision: z.coerce.number().int(), + shouldReportLocation: z.boolean(), }); export const MqttValidationSchema = z.object({ enabled: z.boolean(), - address: z.string().min(0).max(30), - username: z.string().min(0).max(30), - password: z.string().min(0).max(30), + address: z.string().min(0).max(63), + username: z.string().min(0).max(63), + password: z.string().min(0).max(63), encryptionEnabled: z.boolean(), jsonEnabled: z.boolean(), tlsEnabled: z.boolean(), - root: z.string(), + root: z.string().max(31), proxyToClientEnabled: z.boolean(), mapReportingEnabled: z.boolean(), mapReportSettings: MqttValidationMapReportSettingsSchema,