Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
15 changes: 13 additions & 2 deletions src/components/admin-commands/RadioConfigurationSection.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -95,6 +95,12 @@ export const RadioConfigurationSection: React.FC<RadioConfigurationSectionProps>
}) => {
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 (
<CollapsibleSection
id="radio-config"
Expand Down Expand Up @@ -131,12 +137,17 @@ export const RadioConfigurationSection: React.FC<RadioConfigurationSectionProps>
className="setting-input"
style={{ width: '300px' }}
>
{MODEM_PRESET_OPTIONS.map(preset => (
{legalPresetOptions.map(preset => (
<option key={preset.value} value={preset.value}>
{preset.name} - {preset.description} ({preset.params})
</option>
))}
</select>
{hasFilteredPresets && (
<span className="setting-description" style={{ marginTop: '0.4rem', display: 'block' }}>
{t('lora_config.preset_filtered_note')}
</span>
)}
</div>
) : (
<>
Expand Down
18 changes: 16 additions & 2 deletions src/components/configuration/LoRaConfigSection.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -87,6 +87,15 @@ const LoRaConfigSection: React.FC<LoRaConfigSectionProps> = ({
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,
Expand Down Expand Up @@ -252,7 +261,7 @@ const LoRaConfigSection: React.FC<LoRaConfigSectionProps> = ({
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
}}
>
{MODEM_PRESET_OPTIONS.map(option => (
{legalPresetOptions.map(option => (
<div
key={option.value}
onClick={() => {
Expand Down Expand Up @@ -291,6 +300,11 @@ const LoRaConfigSection: React.FC<LoRaConfigSectionProps> = ({
</div>
)}
</div>
{hasFilteredPresets && (
<span className="setting-description" style={{ marginTop: '0.4rem', display: 'block' }}>
{t('lora_config.preset_filtered_note')}
</span>
)}
</div>
)}
{!usePreset && (
Expand Down
109 changes: 108 additions & 1 deletion src/components/configuration/constants.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
});
});
144 changes: 138 additions & 6 deletions src/components/configuration/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string> = {
27: 'ITU1_2M',
28: 'ITU23_2M'
Expand All @@ -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<number, RegionFreqInfo> = {
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<number, { normal: number; wide: number }> = {
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[] = [
Expand Down
Loading