diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md index d2debef3f..bb1749672 100644 --- a/docs/lora_region_preset_compatibility_client_spec.md +++ b/docs/lora_region_preset_compatibility_client_spec.md @@ -153,7 +153,10 @@ These rules are what keep the UX correct across firmware versions. Implement all 5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions (`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a preset that belongs to a sibling's list, the firmware **swaps the region** rather than - rejecting the preset. Consequence for clients: **do not assume the region is immutable + rejecting the preset. To make this visible in the picker, the firmware advertises the + **same superset** (the union of the trio's presets) for all three sibling regions, so a + client filtering per §6 will offer every EU 86x preset regardless of which sibling is + currently selected. Consequence for clients: **do not assume the region is immutable across a preset change** - after an admin config write, re-read the resulting `LoRaConfig` and reflect the (possibly changed) region back into the UI. @@ -249,12 +252,23 @@ so they are stable as listed here: | group_index | default_preset | licensed_only | presets | | ----------------------- | -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | | 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO | -| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE | -| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW | -| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW | +| 1 (EU 868) | `LONG_FAST` | false | _EU 86x superset_ (see below) | +| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | _EU 86x superset_ (see below) | +| 3 (EU 868 narrow) | `NARROW_SLOW` | false | _EU 86x superset_ (see below) | | 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW | | 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW | +The **EU 86x superset** advertised by groups 1, 2 and 3 is the union of the trio's own +band presets, because the firmware auto-swaps region within the trio on preset selection +(§5), so any of these is a legal pick from any of the three regions: + +```text +LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, LITE_FAST, LITE_SLOW, NARROW_FAST, NARROW_SLOW +``` + +The three groups still differ by `default_preset` (`LONG_FAST` / `LITE_FAST` / `NARROW_SLOW`), +which is why they remain distinct groups despite sharing this preset list. + `region_groups` (region → group_index): | group | regions | @@ -266,9 +280,11 @@ so they are stable as listed here: | 4 | ITU1_2M, ITU2_2M, ITU3_2M | | 5 | ITU2_125CM | -> Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups -> because they differ in `licensed_only`. Decoders must key on the group, not on the preset -> list, to preserve the licensing flag. +> Note that several groups can carry overlapping preset lists but remain distinct: groups 1, +> 2 and 3 share the EU 86x superset yet differ in `default_preset`, and group **5** (ham +> 100 kHz) shares the `NARROW_*` presets with group 3 but differs in `licensed_only`. +> Decoders must key on the group, not on the preset list, to preserve `default_preset` and +> the licensing flag. > > Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`, > `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`. diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index b577a7f35..f616e866a 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -55,6 +55,34 @@ static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = {PRESET static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_TINY[] = {PRESET(TINY_FAST), PRESET(TINY_SLOW), MODEM_PRESET_END}; +// The EU_868/EU_866/EU_N_868 trio share the 868 band but own mutually exclusive preset +// profiles. Selecting a preset locked to a sibling swaps the region to that sibling (see +// regionSwapForPreset), so from any region in the trio every one of these presets is +// selectable. This union is what we advertise to clients as the trio's legal list. It is a +// display-only superset: on-device enforcement still uses each region's own disjoint +// profile->presets, so this must never be assigned to a RegionProfile (that would make +// supportsPreset() accept the preset in place and defeat the swap). Keep in sync with the +// EU_868/EU_866/EU_N_868 profile lists below. Sized to the 11-preset wire cap. +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_SUPERSET[] = { + PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), PRESET(SHORT_SLOW), PRESET(SHORT_FAST), + PRESET(LONG_MODERATE), PRESET(LITE_FAST), PRESET(LITE_SLOW), PRESET(NARROW_FAST), PRESET(NARROW_SLOW), MODEM_PRESET_END}; + +// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset +// locked to a sibling means the user wants that sibling region, not the default preset. +static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = { + meshtastic_Config_LoRaConfig_RegionCode_EU_868, + meshtastic_Config_LoRaConfig_RegionCode_EU_866, + meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, +}; + +static bool isSwappableEuRegion(meshtastic_Config_LoRaConfig_RegionCode code) +{ + for (auto c : SWAPPABLE_EU_REGIONS) + if (c == code) + return true; + return false; +} + // Region profiles: bundle preset list + regulatory parameters shared across regions // presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1}; @@ -687,8 +715,13 @@ void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map) grp.default_preset = r->getDefaultPreset(); grp.licensed_only = r->profile->licensedOnly; grp.presets_count = 0; - for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++) - grp.presets[grp.presets_count++] = r->profile->presets[i]; + // EU 86x siblings advertise the trio's superset - any of those presets is + // reachable from here via an automatic region swap. Every other region + // advertises exactly its own enforced profile list. + const meshtastic_Config_LoRaConfig_ModemPreset *advertised = + isSwappableEuRegion(r->code) ? PRESETS_EU_SUPERSET : r->profile->presets; + for (size_t i = 0; advertised[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++) + grp.presets[grp.presets_count++] = advertised[i]; } // Map this region to its group (capacity checked at the top of the loop). @@ -963,14 +996,6 @@ static void sendErrorNotification(const char *msg, meshtastic_LogRecord_Level le service->sendClientNotification(cn); } -// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset -// locked to a sibling means the user wants that sibling region, not the default preset. -static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = { - meshtastic_Config_LoRaConfig_RegionCode_EU_868, - meshtastic_Config_LoRaConfig_RegionCode_EU_866, - meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, -}; - /** * If currentRegion is one of the swappable EU regions and preset belongs to a sibling in * that trio, return the sibling region that owns the preset. Returns nullptr otherwise. @@ -978,12 +1003,7 @@ static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = { const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion, meshtastic_Config_LoRaConfig_ModemPreset preset) { - bool currentIsSwappable = false; - for (auto code : SWAPPABLE_EU_REGIONS) { - if (code == currentRegion) - currentIsSwappable = true; - } - if (!currentIsSwappable) + if (!isSwappableEuRegion(currentRegion)) return nullptr; for (auto code : SWAPPABLE_EU_REGIONS) { diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index c62e6b267..df2442164 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -292,16 +292,30 @@ static void test_regionPresetMap_matchesRegionTable() const meshtastic_LoRaPresetGroup &grp = map.groups[gi]; const RegionInfo *r = getRegion(code); - // Group's list is non-empty, within the generated array bound, and is the - // region's full list. + // Group's list is non-empty and within the generated array bound. const size_t maxPresets = sizeof(grp.presets) / sizeof(grp.presets[0]); TEST_ASSERT_GREATER_THAN_UINT(0, grp.presets_count); TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxPresets, grp.presets_count); - TEST_ASSERT_EQUAL_UINT((unsigned)r->getNumPresets(), (unsigned)grp.presets_count); - // Every advertised preset is legal in this region. - for (pb_size_t p = 0; p < grp.presets_count; p++) - TEST_ASSERT_TRUE(r->supportsPreset(grp.presets[p])); + // Every advertised preset must be selectable from this region: either legal here, + // or legal in a sibling the firmware will auto-swap us to (the EU 86x trio, which + // advertises the union of the trio's presets rather than just its own). + for (pb_size_t p = 0; p < grp.presets_count; p++) { + bool selectable = + r->supportsPreset(grp.presets[p]) || RadioInterface::regionSwapForPreset(code, grp.presets[p]) != nullptr; + TEST_ASSERT_TRUE(selectable); + } + + // The region's own enforced presets must all be advertised (advertised is a + // superset of the enforced list, never a subset). + const meshtastic_Config_LoRaConfig_ModemPreset *enforced = r->getAvailablePresets(); + for (size_t e = 0; e < r->getNumPresets(); e++) { + bool advertised = false; + for (pb_size_t p = 0; p < grp.presets_count; p++) + if (grp.presets[p] == enforced[e]) + advertised = true; + TEST_ASSERT_TRUE(advertised); + } // Default preset matches the table, is legal, and is present in the list. TEST_ASSERT_EQUAL(r->getDefaultPreset(), grp.default_preset);