feat(web): pilot Position / LoRa / MQTT pages on ConfigEditor; Android-aligned layout + labels

First three settings pages migrated off the legacy changeRegistry to the
SDK ConfigEditor. Section structure, field order, and visible strings
are taken from the Meshtastic-Android source of truth so the web and
mobile UIs converge.

Position (radio config):
- Four cards in Android order: Position Packet → Device GPS → Position
  Flags → Advanced Device GPS.
- Card 1 fields: positionBroadcastSecs, positionBroadcastSmartEnabled,
  broadcastSmartMinimumIntervalSecs (renamed "Smart Interval"),
  broadcastSmartMinimumDistance ("Smart Distance").
- Card 2 fields: fixedPosition, lat/lng/altitude (when fixed), gpsMode
  ("GPS Mode (Physical Hardware)"), gpsUpdateInterval ("GPS Polling
  Interval"). gpsMode + gpsUpdateInterval disable when fixedPosition is
  on; lat/lng/altitude disable when it's off.
- Card 3 fields: positionFlags multiselect (description from Android).
- Card 4 fields: rxGpio ("GPS Receive GPIO"), txGpio ("GPS Transmit
  GPIO"), gpsEnGpio ("GPS EN GPIO").
- onSubmit now calls editor.setRadioSection("position", payload). The
  setFixedPosition admin message path is unchanged.

LoRa (radio config):
- Two cards: Options + Advanced (was three: Mesh / Waveform / Radio).
- Options: region, usePreset, modemPreset (when usePreset),
  bandwidth/spreadFactor/codingRate (when !usePreset).
- Advanced: ignoreMqtt, configOkToMqtt, txEnabled, overrideDutyCycle,
  hopLimit (now "Number of Hops", select 0..7), channelNum,
  sx126xRxBoostedGain ("RX Boosted Gain"), overrideFrequency
  ("Frequency Override"), txPower.
- Drop frequencyOffset (Android does not surface it).
- onSubmit calls editor.setRadioSection("lora", payload).

MQTT (module config):
- Two cards: MQTT Config + Map reporting (new card).
- Card 1: enabled ("MQTT enabled"), address, username, password,
  encryptionEnabled, jsonEnabled, tlsEnabled, root ("Root topic"),
  proxyToClientEnabled.
- Card 2: mapReportingEnabled, mapReportSettings.shouldReportLocation
  (consent gate, "I agree."), positionPrecision (12-15 buckets),
  publishIntervalSecs ("Map reporting interval (seconds)").
- positionPrecision + publishIntervalSecs disable until both
  mapReportingEnabled and shouldReportLocation are true.
- onSubmit calls editor.setModuleSection("mqtt", payload).
- Validation schema gains shouldReportLocation; address/username/
  password limits widened to 63 to match proto, root capped at 31.

Settings/index.tsx handleSave:
- After the legacy changeRegistry flow, runs editor.commit() when the
  editor is dirty. Two beginEdit/commitEdit windows are fine — the
  device handles them sequentially. Once the rest of the settings pages
  migrate, the legacy flow goes away and only editor.commit() remains.
- Save button gating includes editor.isDirty so users can save
  ConfigEditor-only changes (e.g. just a LoRa region tweak).

i18n:
- New section labels (config.json position.{positionPacket,deviceGps,
  advancedDeviceGps}; lora.{optionsCard,advancedCard}; moduleConfig.json
  mqtt.{mqttConfigCard,mapReportingCard}).
- Field labels reworded to match Android strings.xml.
- mqtt.mapReportSettings.shouldReportLocation gains label, description,
  consentHeader, consentText (consent text quoted verbatim from
  Android).
This commit is contained in:
Dan Ditomaso
2026-04-25 23:11:09 -04:00
parent 5a5755fb2b
commit 3429e481d7
7 changed files with 354 additions and 377 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<MqttValidation>;
}
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 (
<DynamicForm<MqttValidation>
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" },
],
},
],

View File

@@ -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<LoRaValidation>;
}
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") },
},
],
},
]}

View File

@@ -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<PositionValidation>;
}
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<typeof setChange> | 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) => {
<DynamicForm<PositionValidation>
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",
},
],
},
],
},
]}
/>
);

View File

@@ -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;

View File

@@ -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,