diff --git a/public/locales/en.json b/public/locales/en.json index a9fa54125..ff24f1f3c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -3190,6 +3190,7 @@ "lora_config.region": "Region Code", "lora_config.region_description": "Select your regulatory region for frequency/power limits", "lora_config.amateur_band_warning": "This region operates in a licensed amateur (ham) radio band. A valid amateur radio license is required, and you are responsible for complying with local band-plan, power, and station identification rules.", + "lora_config.preset_filtered_note": "Some modem presets are hidden because they require more bandwidth than this region's frequency band allows.", "lora_config.hop_limit": "Hop Limit", "lora_config.hop_limit_description": "Maximum number of hops for mesh packets. Range: 1-7 (default: 3)", "lora_config.tx_power": "Transmit Power (dBm)", diff --git a/src/components/admin-commands/RadioConfigurationSection.tsx b/src/components/admin-commands/RadioConfigurationSection.tsx index 33428a5d7..3d9bcda5b 100644 --- a/src/components/admin-commands/RadioConfigurationSection.tsx +++ b/src/components/admin-commands/RadioConfigurationSection.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { MODEM_PRESET_OPTIONS, REGION_OPTIONS, isAmateurRadioRegion } from '../configuration/constants'; +import { MODEM_PRESET_OPTIONS, REGION_OPTIONS, isAmateurRadioRegion, getLegalPresetOptions } from '../configuration/constants'; import type { Channel } from '../../types/device'; interface RadioConfigurationSectionProps { @@ -95,6 +95,12 @@ export const RadioConfigurationSection: React.FC }) => { const { t } = useTranslation(); + // Filter the modem-preset picker to presets legal for the selected region, + // mirroring the official mobile apps (issue #3924, Part 1). The currently + // selected preset is always retained so the picker reflects the device state. + const legalPresetOptions = getLegalPresetOptions(region, modemPreset); + const hasFilteredPresets = legalPresetOptions.length < MODEM_PRESET_OPTIONS.length; + return ( className="setting-input" style={{ width: '300px' }} > - {MODEM_PRESET_OPTIONS.map(preset => ( + {legalPresetOptions.map(preset => ( ))} + {hasFilteredPresets && ( + + {t('lora_config.preset_filtered_note')} + + )} ) : ( <> diff --git a/src/components/configuration/LoRaConfigSection.tsx b/src/components/configuration/LoRaConfigSection.tsx index f47fadcb8..ecc53bebe 100644 --- a/src/components/configuration/LoRaConfigSection.tsx +++ b/src/components/configuration/LoRaConfigSection.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { MODEM_PRESET_OPTIONS, REGION_OPTIONS, FEM_LNA_MODE_OPTIONS, isAmateurRadioRegion } from './constants'; +import { MODEM_PRESET_OPTIONS, REGION_OPTIONS, FEM_LNA_MODE_OPTIONS, isAmateurRadioRegion, getLegalPresetOptions } from './constants'; import { useSaveBar } from '../../hooks/useSaveBar'; interface LoRaConfigSectionProps { @@ -87,6 +87,15 @@ const LoRaConfigSection: React.FC = ({ const { t } = useTranslation(); const [isPresetDropdownOpen, setIsPresetDropdownOpen] = useState(false); + // Filter the modem-preset picker to presets legal for the selected region, + // mirroring the official mobile apps (issue #3924, Part 1). The currently + // selected preset is always retained so the picker reflects the device state. + const legalPresetOptions = useMemo( + () => getLegalPresetOptions(region, modemPreset), + [region, modemPreset] + ); + const hasFilteredPresets = legalPresetOptions.length < MODEM_PRESET_OPTIONS.length; + // Track initial values for change detection const initialValuesRef = useRef({ usePreset, modemPreset, bandwidth, spreadFactor, codingRate, frequencyOffset, @@ -252,7 +261,7 @@ const LoRaConfigSection: React.FC = ({ boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }} > - {MODEM_PRESET_OPTIONS.map(option => ( + {legalPresetOptions.map(option => (
{ @@ -291,6 +300,11 @@ const LoRaConfigSection: React.FC = ({
)} + {hasFilteredPresets && ( + + {t('lora_config.preset_filtered_note')} + + )} )} {!usePreset && ( diff --git a/src/components/configuration/constants.test.ts b/src/components/configuration/constants.test.ts index e6b4b2fde..25704e4c5 100644 --- a/src/components/configuration/constants.test.ts +++ b/src/components/configuration/constants.test.ts @@ -1,5 +1,26 @@ import { describe, it, expect } from 'vitest'; -import { REGION_OPTIONS } from './constants'; +import { + REGION_OPTIONS, + MODEM_PRESET_OPTIONS, + getPresetBandwidthKHz, + isPresetLegalForRegion, + getLegalPresetOptions +} from './constants'; + +// RegionCode values used below (see REGION_OPTIONS / config.proto). +const US = 1; +const EU_868 = 3; +const RU = 9; +const LORA_24 = 13; +const ITU3_2M = 33; // firmware develop-branch region: bounds not in table -> permissive + +// ModemPreset values (see MODEM_PRESET_OPTIONS / PRESET_MAP). +const LONG_FAST = 0; +const LONG_SLOW = 1; +const LONG_MODERATE = 7; +const SHORT_TURBO = 8; +const LONG_TURBO = 9; +const NARROW_FAST = 12; // not in firmware switch -> LONG_FAST/250 kHz fallback // Guards against RegionCode drift from meshtastic/protobufs config.proto (#3927). // When upstream adds a RegionCode value, extend REGION_OPTIONS and bump the max here. @@ -31,3 +52,89 @@ describe('REGION_OPTIONS', () => { expect(byValue.get(37)?.startsWith('ITU2_125CM')).toBe(true); }); }); + +// Region -> modem-preset legality (issue #3924, Part 1). Mirrors firmware's +// `(freqEnd - freqStart) >= presetBandwidthKHz/1000` fit-check. +describe('getPresetBandwidthKHz', () => { + it('returns firmware bandwidths for known presets (normal bands)', () => { + expect(getPresetBandwidthKHz(LONG_FAST, false)).toBe(250); + expect(getPresetBandwidthKHz(LONG_SLOW, false)).toBe(125); + expect(getPresetBandwidthKHz(LONG_MODERATE, false)).toBe(125); + expect(getPresetBandwidthKHz(SHORT_TURBO, false)).toBe(500); + expect(getPresetBandwidthKHz(LONG_TURBO, false)).toBe(500); + }); + + it('returns wide-LoRa bandwidths for the 2.4 GHz band', () => { + expect(getPresetBandwidthKHz(LONG_FAST, true)).toBe(812.5); + expect(getPresetBandwidthKHz(SHORT_TURBO, true)).toBe(1625); + }); + + it('falls back to LONG_FAST (250 kHz) for presets not in the firmware switch', () => { + expect(getPresetBandwidthKHz(NARROW_FAST, false)).toBe(250); + expect(getPresetBandwidthKHz(999, false)).toBe(250); + expect(getPresetBandwidthKHz(NARROW_FAST, true)).toBe(812.5); + }); +}); + +describe('isPresetLegalForRegion', () => { + it('EU_868 (0.25 MHz span) rejects the two 500 kHz presets', () => { + expect(isPresetLegalForRegion(EU_868, SHORT_TURBO)).toBe(false); + expect(isPresetLegalForRegion(EU_868, LONG_TURBO)).toBe(false); + }); + + it('EU_868 still allows presets that fit (<= 250 kHz)', () => { + expect(isPresetLegalForRegion(EU_868, LONG_FAST)).toBe(true); + expect(isPresetLegalForRegion(EU_868, LONG_SLOW)).toBe(true); + expect(isPresetLegalForRegion(EU_868, NARROW_FAST)).toBe(true); // fallback 250 <= 250 + }); + + it('RU (exactly 0.5 MHz span) allows the 500 kHz presets', () => { + expect(isPresetLegalForRegion(RU, SHORT_TURBO)).toBe(true); + expect(isPresetLegalForRegion(RU, LONG_TURBO)).toBe(true); + }); + + it('wide US band allows every preset', () => { + for (const opt of MODEM_PRESET_OPTIONS) { + expect(isPresetLegalForRegion(US, opt.value)).toBe(true); + } + }); + + it('LORA_24 uses wide bandwidths but its 83.5 MHz span allows every preset', () => { + for (const opt of MODEM_PRESET_OPTIONS) { + expect(isPresetLegalForRegion(LORA_24, opt.value)).toBe(true); + } + }); + + it('is permissive for regions with unknown bounds and for null/undefined', () => { + for (const opt of MODEM_PRESET_OPTIONS) { + expect(isPresetLegalForRegion(ITU3_2M, opt.value)).toBe(true); + } + expect(isPresetLegalForRegion(null, SHORT_TURBO)).toBe(true); + expect(isPresetLegalForRegion(undefined, SHORT_TURBO)).toBe(true); + }); +}); + +describe('getLegalPresetOptions', () => { + it('drops the 500 kHz presets for EU_868', () => { + const values = getLegalPresetOptions(EU_868, LONG_FAST).map((o) => o.value); + expect(values).not.toContain(SHORT_TURBO); + expect(values).not.toContain(LONG_TURBO); + expect(values).toContain(LONG_FAST); + }); + + it('returns every preset for a wide region', () => { + expect(getLegalPresetOptions(US, LONG_FAST)).toHaveLength(MODEM_PRESET_OPTIONS.length); + }); + + it('retains an illegal current preset so the picker is never blank', () => { + const values = getLegalPresetOptions(EU_868, SHORT_TURBO).map((o) => o.value); + expect(values).toContain(SHORT_TURBO); // illegal but currently selected -> kept + expect(values).not.toContain(LONG_TURBO); // illegal and not selected -> dropped + }); + + it('preserves MODEM_PRESET_OPTIONS ordering', () => { + const legal = getLegalPresetOptions(US); + const order = MODEM_PRESET_OPTIONS.map((o) => o.value); + expect(legal.map((o) => o.value)).toEqual(order); + }); +}); diff --git a/src/components/configuration/constants.ts b/src/components/configuration/constants.ts index 2bafef783..729598eac 100644 --- a/src/components/configuration/constants.ts +++ b/src/components/configuration/constants.ts @@ -142,12 +142,18 @@ export const REGION_OPTIONS: RegionOption[] = [ // 27 = ITU1_2M (ITU Region 1 amateur 2m, 144-146 MHz) // 28 = ITU23_2M (ITU Region 2/3 amateur 2m, 144-148 MHz) // -// NOTE: Firmware 2.8 introduces a region -> preset legality map (sent once by -// the device) that the official mobile apps use to filter the preset picker to -// presets that are legal for the selected region. MeshMonitor cannot consume -// that map yet because the vendored protobufs (v2.7.26) predate it; when the -// protobuf submodule is bumped to a 2.8 release this list can be replaced by -// the device-reported legality data. See issue #3924. +// NOTE (issue #3924, Part 1 — preset legality filtering): +// There is NO protobuf wire field that carries a region -> preset legality map. +// (Verified against meshtastic/protobufs config.proto: Config.LoRaConfig only +// exposes `region` (field 7) and `modem_preset` (field 2); no legality map was +// ever added at any 2.8.x tag.) Instead, the official mobile apps replicate a +// firmware-side computation locally: a modem preset is legal for a region iff +// at least one channel of the preset's bandwidth fits inside the region's +// frequency band, i.e. `(freqEnd - freqStart) >= presetBandwidthKHz / 1000` +// (spacing is 0 for every current region, so this collapses to a simple +// span >= bandwidth test). If the check fails, firmware silently rewrites the +// preset to LONG_FAST. See REGION_FREQ_INFO / isPresetLegalForRegion below, +// which mirror that table + math WITHOUT requiring a protobuf submodule bump. export const AMATEUR_RADIO_REGIONS: Record = { 27: 'ITU1_2M', 28: 'ITU23_2M' @@ -165,6 +171,132 @@ export function isAmateurRadioRegion(region: number | null | undefined): boolean return Object.prototype.hasOwnProperty.call(AMATEUR_RADIO_REGIONS, region); } +// --- Region -> modem-preset legality (issue #3924, Part 1) --- +// +// Mirrors the Meshtastic firmware region table + preset-bandwidth switch so the +// modem-preset picker can be filtered to presets that are legal for the +// selected region, matching the official mobile apps. NO protobuf field carries +// this — it is a client-side computation (see the note above AMATEUR_RADIO_REGIONS). +// +// Firmware sources (github.com/meshtastic/firmware): +// - src/mesh/RadioInterface.cpp regions[] table (RDEF macro): freqStart/freqEnd. +// - src/mesh/MeshRadio.h modemPresetToParams(): preset -> bandwidth (kHz). +// - RadioInterface::applyModemConfig / bootstrapLoRaConfigFromPreset: the +// `(freqEnd - freqStart) < bwKHz/1000` fit-check that falls back to LONG_FAST. +// +// In practice only EU_868 (0.25 MHz span) filters anything: it rejects the two +// 500 kHz presets (SHORT_TURBO, LONG_TURBO). Regions whose bounds are not listed +// here (e.g. the ITU amateur bands and the newer EU narrow-band variants, which +// live on the firmware develop branch) are treated as permissive — all presets +// legal — since their spans comfortably exceed every preset bandwidth and we +// prefer not to filter without authoritative bounds. + +interface RegionFreqInfo { + /** Band start in MHz (RadioInterface.cpp regions[] freqStart). */ + start: number; + /** Band end in MHz (RadioInterface.cpp regions[] freqEnd). */ + end: number; + /** True for the 2.4 GHz wide-LoRa band (LORA_24), which uses wider bandwidths. */ + wideLora?: boolean; +} + +// RegionCode value -> frequency band. Values transcribed from firmware master. +const REGION_FREQ_INFO: Record = { + 0: { start: 902.0, end: 928.0 }, // UNSET (defaults to US band) + 1: { start: 902.0, end: 928.0 }, // US + 2: { start: 433.0, end: 434.0 }, // EU_433 + 3: { start: 869.4, end: 869.65 }, // EU_868 (0.25 MHz — rejects 500 kHz presets) + 4: { start: 470.0, end: 510.0 }, // CN + 5: { start: 920.5, end: 923.5 }, // JP + 6: { start: 915.0, end: 928.0 }, // ANZ + 7: { start: 920.0, end: 923.0 }, // KR + 8: { start: 920.0, end: 925.0 }, // TW + 9: { start: 868.7, end: 869.2 }, // RU (0.5 MHz — 500 kHz just fits) + 10: { start: 865.0, end: 867.0 }, // IN + 11: { start: 864.0, end: 868.0 }, // NZ_865 + 12: { start: 920.0, end: 925.0 }, // TH + 13: { start: 2400.0, end: 2483.5, wideLora: true }, // LORA_24 + 14: { start: 433.0, end: 434.7 }, // UA_433 + 15: { start: 868.0, end: 868.6 }, // UA_868 + 16: { start: 433.0, end: 435.0 }, // MY_433 + 17: { start: 919.0, end: 924.0 }, // MY_919 + 18: { start: 917.0, end: 925.0 }, // SG_923 + 19: { start: 433.0, end: 434.7 }, // PH_433 + 20: { start: 868.0, end: 869.4 }, // PH_868 + 21: { start: 915.0, end: 918.0 }, // PH_915 + 22: { start: 433.05, end: 434.79 }, // ANZ_433 + 23: { start: 433.075, end: 434.775 }, // KZ_433 + 24: { start: 863.0, end: 868.0 }, // KZ_863 + 25: { start: 865.0, end: 868.0 }, // NP_865 + 26: { start: 902.0, end: 907.5 } // BR_902 +}; + +// Modem-preset LoRa bandwidth in kHz, mirroring firmware modemPresetToParams() +// (MeshRadio.h). `normal` = sub-GHz bands; `wide` = 2.4 GHz wide-LoRa (LORA_24). +// Presets NOT implemented in firmware's switch (VERY_LONG_SLOW, LITE_*, NARROW_*, +// TINY_*) fall through to the LONG_FAST default (250 kHz) — see DEFAULT_PRESET_BW. +const PRESET_BANDWIDTH_KHZ: Record = { + 0: { normal: 250, wide: 812.5 }, // LONG_FAST (default) + 1: { normal: 125, wide: 406.25 }, // LONG_SLOW + 3: { normal: 250, wide: 812.5 }, // MEDIUM_SLOW + 4: { normal: 250, wide: 812.5 }, // MEDIUM_FAST + 5: { normal: 250, wide: 812.5 }, // SHORT_SLOW + 6: { normal: 250, wide: 812.5 }, // SHORT_FAST + 7: { normal: 125, wide: 406.25 }, // LONG_MODERATE + 8: { normal: 500, wide: 1625 }, // SHORT_TURBO + 9: { normal: 500, wide: 1625 } // LONG_TURBO +}; +const DEFAULT_PRESET_BW = { normal: 250, wide: 812.5 }; // LONG_FAST fallback + +/** + * LoRa bandwidth (kHz) firmware would use for the given modem preset, matching + * modemPresetToParams(). Unknown/unimplemented presets fall back to LONG_FAST. + */ +export function getPresetBandwidthKHz(preset: number, wideLora: boolean): number { + const bw = PRESET_BANDWIDTH_KHZ[preset] ?? DEFAULT_PRESET_BW; + return wideLora ? bw.wide : bw.normal; +} + +/** + * True if `preset` is legal for `region`, mirroring the firmware fit-check + * `(freqEnd - freqStart) >= presetBandwidthKHz / 1000`. Regions with unknown + * bounds (not in REGION_FREQ_INFO) and null/undefined regions are treated as + * permissive (all presets legal). + */ +export function isPresetLegalForRegion( + region: number | null | undefined, + preset: number +): boolean { + if (region === null || region === undefined) { + return true; + } + const info = REGION_FREQ_INFO[region]; + if (!info) { + return true; // no authoritative bounds -> do not filter + } + const spanMHz = info.end - info.start; + const bandwidthMHz = getPresetBandwidthKHz(preset, !!info.wideLora) / 1000; + // Small epsilon so IEEE-754 subtraction (e.g. 869.2 - 868.7) doesn't + // mis-exclude a preset whose bandwidth lands exactly on the region span. + return spanMHz >= bandwidthMHz - 1e-9; +} + +/** + * Returns the subset of MODEM_PRESET_OPTIONS that are legal for `region`. + * `currentPreset`, when supplied, is always retained even if it is illegal, so + * the picker still reflects a device's actual (possibly out-of-band) setting + * instead of rendering blank. Preserves MODEM_PRESET_OPTIONS ordering. + */ +export function getLegalPresetOptions( + region: number | null | undefined, + currentPreset?: number +): ModemPresetOption[] { + return MODEM_PRESET_OPTIONS.filter( + option => + isPresetLegalForRegion(region, option.value) || option.value === currentPreset + ); +} + // Config.LoRaConfig.FEM_LNA_Mode (firmware >= v2.7.20, meshtastic/firmware#9809). // Value 0 (DISABLED) is the proto3 zero/default and a real selectable mode. export const FEM_LNA_MODE_OPTIONS: RegionOption[] = [