diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md new file mode 100644 index 000000000..b62fe7757 --- /dev/null +++ b/docs/lora_region_preset_compatibility_client_spec.md @@ -0,0 +1,277 @@ +# LoRa Region → Preset Compatibility — Client Implementation Spec + +**Status:** Draft for 2.8 · **Audience:** Meshtastic client app developers (Android first, +Apple second, then web/python) · **Firmware side:** implemented in `firmware` +(`FromRadio.region_presets`, see below). + +> This document lives in the firmware repo while the feature is developed. It is meant to +> graduate to `meshtastic/protobufs` (and/or the docs site) alongside the upstream protobuf +> PR that reserves `FromRadio` field **19**. + +--- + +## 1. Why this exists + +For 2.8 the LoRa regions and modem presets were reworked. **Not every modem preset is legal +in every region** — narrow EU SRD bands, the EU 868 "narrow" band, amateur/ham bands, and +the 2.4 GHz band each accept only a specific subset of presets. The firmware already +enforces this internally (it clamps or rejects illegal combinations), but until now a client +had no way to _know_ the rules, so a user could pick an illegal region+preset pair in the UI +and only discover the problem after the device silently corrected it. + +This feature has the firmware **declare the legal region→preset combinations** to the client +during the `want_config` handshake, so the client UI can constrain the preset picker to the +valid set for the currently selected region (and warn about licensed-only bands). It is +purely advisory metadata — the firmware remains the source of truth and still +validates/clamps on its own. + +--- + +## 2. Protocol additions + +Three new messages in `meshtastic/mesh.proto`, plus one new `FromRadio` oneof variant. + +### 2.1 `FromRadio.region_presets` (field 19) + +```proto +message FromRadio { + uint32 id = 1; + oneof payload_variant { + // ... fields 2..18 unchanged ... + LoRaRegionPresetMap region_presets = 19; + } +} +``` + +### 2.2 Messages + +```proto +// A distinct set of legal modem presets shared by one or more LoRa regions. +message LoRaPresetGroup { + repeated Config.LoRaConfig.ModemPreset presets = 1; // legal presets for this group + Config.LoRaConfig.ModemPreset default_preset = 2; // always one of `presets` + bool licensed_only = 3; // ham/amateur band → warn/gate +} + +// Associates a single LoRa region with its preset group (by index). +message LoRaRegionPresets { + Config.LoRaConfig.RegionCode region = 1; + uint32 group_index = 2; // index into LoRaRegionPresetMap.groups +} + +// The full map, delivered grouped to fit one FromRadio packet. +message LoRaRegionPresetMap { + repeated LoRaPresetGroup groups = 1; // each distinct preset list + repeated LoRaRegionPresets region_groups = 2; // every known region → a group index +} +``` + +### 2.3 Why grouped (and the size envelope clients should respect) + +A `FromRadio` packet is capped at **512 bytes** (`MAX_TO_FROM_RADIO_SIZE`). Most regions +share one identical preset list (the "standard" 9-preset list), so the map is delivered +**grouped**: `groups` holds each _distinct_ preset list once, and `region_groups` maps every +known region to one of those groups by index. This keeps the encoded size additive +(`groups` + `region_groups`) rather than multiplicative, well under the cap. + +nanopb (firmware) array bounds — clients do **not** need to enforce these, but they bound +what you can receive: + +| field | max_count | +| ----------------------------------- | ------------------------------------ | +| `LoRaRegionPresetMap.groups` | 8 | +| `LoRaRegionPresetMap.region_groups` | 38 (= number of `RegionCode` values) | +| `LoRaPresetGroup.presets` | 11 | + +--- + +## 3. When it is delivered + +`region_presets` is sent **once** during the `want_config` handshake, as a single +`FromRadio` message, in this position: + +```text +my_info → (deviceuiConfig) → node_info(self) → metadata → region_presets → channel… → config… → moduleConfig… → node_info(others)… → fileInfo… → config_complete_id → (live packets) +``` + +i.e. **immediately after `metadata` and before the first `channel`**. + +- It is included for a normal full `want_config` and for the **config-only** nonce. +- It is **omitted** for the **nodes-only** nonce (that path skips metadata/config entirely). +- A client must **not** assume it always arrives (see §5). + +--- + +## 4. Decoding into a usable lookup + +Flatten the grouped wire form into `Map`: + +```text +struct RegionPresetInfo { Set presets; ModemPreset default; bool licensedOnly } + +fun decode(map: LoRaRegionPresetMap): Map { + result = {} + for (rg in map.region_groups) { + if (rg.group_index >= map.groups.size) continue // defensive: malformed/forward data + g = map.groups[rg.group_index] + result[rg.region] = RegionPresetInfo( + presets = g.presets.toSet(), + default = g.default_preset, + licensedOnly = g.licensed_only) + } + return result +} +``` + +Persist this map alongside the rest of the downloaded config so the LoRa config screen can +read it synchronously. + +--- + +## 5. Semantics & rules (the load-bearing part) + +These rules are what keep the UX correct across firmware versions. Implement all of them. + +1. **Absent region ⇒ no constraint.** If a `RegionCode` does not appear in `region_groups`, + the client has _no_ compatibility info for it and **must not restrict** its preset + choices (fall back to allowing the full `ModemPreset` list). This happens for a handful + of `RegionCode` enum values that have no firmware band table entry (today: `EU_874`, + `EU_917`, `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`). + +2. **Absent message ⇒ no constraint.** Firmware older than 2.8 never sends `region_presets`. + New clients **must** tolerate the message being absent entirely and keep their existing + (unconstrained) behavior. Do not block the config screen waiting for it. + +3. **`default_preset`** is always a member of that group's `presets`. Use it to pre-select a + preset when the user switches to a region whose valid set does not include the currently + selected preset (instead of leaving an illegal selection or guessing). + +4. **`licensed_only`** marks ham/amateur bands. Surface a warning or gate (the firmware also + requires the operator's `is_licensed` flag for these regions; coordinate the two so the + user isn't allowed to pick a licensed band without acknowledging licensing). + +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 + across a preset change** — after an admin config write, re-read the resulting + `LoRaConfig` and reflect the (possibly changed) region back into the UI. + +6. **Use it as a UI guard, not a validator of truth.** The firmware still validates/clamps + on its own. The map exists to prevent the user from _selecting_ an illegal combo; it is + not a security or correctness boundary. + +--- + +## 6. UI/UX recommendations + +- In the LoRa config screen, when a region is selected, **filter/enable the modem-preset + picker to that region's `presets`** (when `use_preset`/`use_modem_preset` is on). +- If the current preset is not in the newly selected region's set, switch the selection to + that region's `default_preset`. +- Show a **licensed badge / confirmation** for regions where `licensed_only == true`. +- If a region is absent from the map (rule §5.1) or the whole message is absent (§5.2), + render the full preset list as before — never show an empty picker. + +--- + +## 7. Forward / backward compatibility + +- **Old clients, new firmware:** an unknown `FromRadio` oneof variant (field 19) is ignored + by protobuf/nanopb decoders; the relative ordering of the known messages is unchanged, so + existing apps are unaffected. +- **New clients, old firmware:** message simply never arrives → treat as "no constraints" + (§5.2). +- **Enum growth:** new `RegionCode`/`ModemPreset` values may appear over time. Decoders + should pass through unknown enum values rather than crashing; an unknown region in + `region_groups` is harmless (the client just won't have a localized name for it). + +--- + +## 8. Platform notes + +> Verified against the `main` branch of each repo. Both have been refactored away from +> older layouts; re-pin file paths against a specific commit if you need them durable. + +### 8.1 Android — `meshtastic/Meshtastic-Android` (Kotlin / Compose, KMP) + +- **Protobufs are a published Maven artifact, _not_ a submodule.** Declared in + `gradle/libs.versions.toml` (`org.meshtastic:protobufs`, currently `2.7.25`); generated + package is **`org.meshtastic.proto`**. **A `region_presets`-aware build requires a new + published `org.meshtastic:protobufs` release**, then bumping that one version string. +- **The protobufs are Wire-generated**, so the `FromRadio` oneof is **not** a + `payloadVariantCase` enum — each arm is a **nullable field**. Handle the new variant in + `FromRadioPacketHandlerImpl.handleFromRadio(...)` + (`core/data/.../manager/FromRadioPacketHandlerImpl.kt`) by adding a + `regionPresets != null -> …` arm to the existing `when { … }`, delegating to a handler + (mirror `handleLocalMetadata` / `handleConfigComplete`). +- **State holder:** expose the decoded map from `RadioConfigRepository` / + `RadioConfigRepositoryImpl` as a `Flow` (mirroring `localConfigFlow`/`channelSetFlow`), + consumed by `feature/settings/.../radio/RadioConfigViewModel.kt`. +- **UI:** the region & preset dropdowns are `DropDownPreference`s in + `feature/settings/.../radio/component/LoRaConfigItemList.kt` (public composable + `LoRaConfigScreen`). Gate/filter the `ChannelOption` (preset) dropdown by the selected + `RegionInfo`'s entry in the map. + +### 8.2 Apple — `meshtastic/Meshtastic-Apple` (Swift / SwiftUI) + +- **Protobufs are vendored** into a local Swift package `MeshtasticProtobufs` + (`MeshtasticProtobufs/Sources/meshtastic/*.pb.swift`), generated from the `protobufs` git + submodule via `scripts/gen_protos.sh`. **To get field 19:** advance the `protobufs` + submodule, run `scripts/gen_protos.sh`, commit the regenerated `.pb.swift` + submodule + pointer. (No published-artifact dependency — Apple can regenerate from any commit.) +- **Dispatch:** `AccessoryManager.processFromRadio(_:)` + (`Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift`) is a real + `switch decodedInfo.payloadVariant { … }` — add a `.regionPresets` case, with the handler + in `AccessoryManager+FromRadio.swift` (mirror `handleConfig` / `handleMetadata`). +- **Persistence:** config is **SwiftData** (`@Model` entities), upserted via + `MeshPackets`/`UpdateSwiftData.swift`. Store the decoded map (e.g. on a settings/connection + model) so the LoRa view can read it. +- **UI:** `Meshtastic/Views/Settings/Config/LoRaConfig.swift` (`struct LoRaConfig: View`) + has the `Picker("Region", …)` (`RegionCodes.userSelectable`) and `Picker("Presets", …)` + (`ModemPresets.userSelectable`, gated on `usePreset`). Filter the presets picker by the + selected region's entry. Enums live in `Meshtastic/Enums/LoraConfigEnums.swift`. + +### 8.3 Other clients + +- **python (`meshtastic` / Meshtastic-python)** and **web** consume the published protobufs; + they will see `region_presets` once their protobuf dependency includes field 19, and can + ignore it until then (it decodes as an unknown field). + +--- + +## 9. Reference payload (current firmware table) + +For decoder unit tests. With the 2.8 region table, the firmware emits **6 groups**. Group +indices are assigned in region-table order (first region to use a profile creates its group), +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 | +| 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 | +| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW | +| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW | + +`region_groups` (region → group_index): + +| group | regions | +| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 | +| 1 | EU_868 | +| 2 | EU_866 | +| 3 | EU_N_868 | +| 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. +> +> Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`, +> `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`. + +This table is generated from the firmware's region table at runtime; treat the firmware as +authoritative and these values as the expected snapshot for the 2.8 table. diff --git a/protobufs b/protobufs index 362516671..f16a13e43 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 36251667179496c1e1261f4e4e5b349a51e5e861 +Subproject commit f16a13e43393f3c6a88a1da36e75dfc29f448d3e diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 965f3c46e..3387887c2 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -88,6 +88,11 @@ extern const RegionInfo *myRegion; extern void initRegion(); extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code); +// Fill `map` with the region->valid-preset table, grouped so regions sharing a +// preset list reference the same group. Sent to clients during want_config so +// their UI can block illegal region+preset combinations. +extern void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map); + // Valid LoRa spread factor range and defaults constexpr uint8_t LORA_SF_MIN = 5; constexpr uint8_t LORA_SF_MAX = 12; diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index d37a3e837..5ee553bdc 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -12,6 +12,7 @@ #include "Channels.h" #include "Default.h" #include "FSCommon.h" +#include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" #include "PacketHistory.h" @@ -516,9 +517,10 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) STATE_SEND_UIDATA, STATE_SEND_OWN_NODEINFO, STATE_SEND_METADATA, - STATE_SEND_CHANNELS + STATE_SEND_REGION_PRESETS, // region -> valid modem presets (one message) + STATE_SEND_CHANNELS, STATE_SEND_CONFIG, - STATE_SEND_MODULE_CONFIG, + STATE_SEND_MODULECONFIG, STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to the client STATE_SEND_FILEMANIFEST, STATE_SEND_COMPLETE_ID, @@ -636,7 +638,20 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) memset(&fromRadioScratch.metadata, 0, sizeof(fromRadioScratch.metadata)); } #endif + state = STATE_SEND_REGION_PRESETS; + break; + + case STATE_SEND_REGION_PRESETS: + // Tell the client which modem presets are legal in each region so its UI + // can block illegal region+preset combinations. This is public RF / + // regulatory information (region and modem_preset are already in the + // unauthenticated LoRa whitelist below), so it is sent unconditionally — + // even an unauthorized/locked-down client can render a correct picker. + LOG_DEBUG("Send region preset map"); + fromRadioScratch.which_payload_variant = meshtastic_FromRadio_region_presets_tag; + getRegionPresetMap(fromRadioScratch.region_presets); state = STATE_SEND_CHANNELS; + config_state = 0; // STATE_SEND_CHANNELS indexes channels starting at 0 break; case STATE_SEND_CHANNELS: @@ -1517,6 +1532,7 @@ bool PhoneAPI::available() case STATE_SEND_CONFIG: case STATE_SEND_MODULECONFIG: case STATE_SEND_METADATA: + case STATE_SEND_REGION_PRESETS: case STATE_SEND_OWN_NODEINFO: case STATE_SEND_FILEMANIFEST: case STATE_SEND_COMPLETE_ID: diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index cd5939b0c..ec7a593c1 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -46,6 +46,7 @@ class PhoneAPI STATE_SEND_MY_INFO, // send our my info record STATE_SEND_OWN_NODEINFO, STATE_SEND_METADATA, + STATE_SEND_REGION_PRESETS, // Send the region->valid-preset map (one message) STATE_SEND_CHANNELS, // Send all channels STATE_SEND_CONFIG, // Replacement for the old Radioconfig STATE_SEND_MODULECONFIG, // Send Module specific config diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 0d973e2e2..281587d44 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -605,6 +605,62 @@ const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code) return r; } +void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map) +{ + map = meshtastic_LoRaRegionPresetMap_init_zero; + + const size_t maxGroups = sizeof(map.groups) / sizeof(map.groups[0]); + const size_t maxRegions = sizeof(map.region_groups) / sizeof(map.region_groups[0]); + const size_t maxPresets = sizeof(map.groups[0].presets) / sizeof(map.groups[0].presets[0]); + + // Coalesce regions that share an identical preset list into one group. Two + // regions belong to the same group when they share the same RegionProfile + // (which owns the preset list + licensing) AND the same default preset. + // Keyed by profile pointer, not the preset-array pointer: PROFILE_NARROW and + // PROFILE_HAM_100KHZ share PRESETS_NARROW but differ in licensedOnly. + const RegionProfile *groupProfile[sizeof(map.groups) / sizeof(map.groups[0])] = {}; + + for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) { + // No room left to map any further region; once full we can't add more, so + // log once and stop. An incomplete map means clients won't constrain the + // omitted regions, so this must be discoverable rather than silent. + if (map.region_groups_count >= maxRegions) { + LOG_ERROR("Region preset map full at %u regions; remaining regions omitted", (unsigned)maxRegions); + break; + } + + // Find the group this region belongs to, or create it. + int gi = -1; + for (pb_size_t g = 0; g < map.groups_count; g++) { + if (groupProfile[g] == r->profile && map.groups[g].default_preset == r->getDefaultPreset()) { + gi = g; + break; + } + } + if (gi < 0) { + if (map.groups_count >= maxGroups) { + // Out of group slots (should not happen for the current table). The + // region can't be advertised; skip it but make the gap visible. + LOG_ERROR("Region preset map out of group slots (%u); region %d omitted", (unsigned)maxGroups, r->code); + continue; + } + gi = map.groups_count++; + groupProfile[gi] = r->profile; + meshtastic_LoRaPresetGroup &grp = map.groups[gi]; + 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]; + } + + // Map this region to its group (capacity checked at the top of the loop). + meshtastic_LoRaRegionPresets &rg = map.region_groups[map.region_groups_count++]; + rg.region = r->code; + rg.group_index = (uint8_t)gi; + } +} + /** * Get duty cycle for current region. EU_866: 10% for routers, 2.5% for mobile. */ diff --git a/src/mesh/generated/meshtastic/mesh.pb.cpp b/src/mesh/generated/meshtastic/mesh.pb.cpp index a68ffabac..ac991c580 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.cpp +++ b/src/mesh/generated/meshtastic/mesh.pb.cpp @@ -96,6 +96,15 @@ PB_BIND(meshtastic_Neighbor, meshtastic_Neighbor, AUTO) PB_BIND(meshtastic_DeviceMetadata, meshtastic_DeviceMetadata, AUTO) +PB_BIND(meshtastic_LoRaPresetGroup, meshtastic_LoRaPresetGroup, AUTO) + + +PB_BIND(meshtastic_LoRaRegionPresets, meshtastic_LoRaRegionPresets, AUTO) + + +PB_BIND(meshtastic_LoRaRegionPresetMap, meshtastic_LoRaRegionPresetMap, 2) + + PB_BIND(meshtastic_Heartbeat, meshtastic_Heartbeat, AUTO) diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 5b27f01d1..ce92d81f1 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -1355,6 +1355,53 @@ typedef struct _meshtastic_DeviceMetadata { uint32_t excluded_modules; } meshtastic_DeviceMetadata; +/* A distinct set of legal modem presets shared by one or more LoRa regions. + Regions that have an identical preset list / default / licensing reference + the same group (by index) via LoRaRegionPresetMap.region_groups. This keeps + the whole map small enough to fit in a single FromRadio packet, since most + regions share the one standard preset list. */ +typedef struct _meshtastic_LoRaPresetGroup { + /* The modem presets that are legal for every region referencing this group. */ + pb_size_t presets_count; + meshtastic_Config_LoRaConfig_ModemPreset presets[11]; + /* The firmware's default modem preset for regions in this group. + Always one of `presets`. Clients should select this when switching to one + of these regions, or when the current preset is not legal in the new region. */ + meshtastic_Config_LoRaConfig_ModemPreset default_preset; + /* True if regions referencing this group are for licensed operators only + (e.g. amateur / ham radio bands). Clients should warn or gate accordingly. */ + bool licensed_only; +} meshtastic_LoRaPresetGroup; + +/* Associates a single LoRa region with its preset group. */ +typedef struct _meshtastic_LoRaRegionPresets { + /* The LoRa region this entry describes. */ + meshtastic_Config_LoRaConfig_RegionCode region; + /* Index into LoRaRegionPresetMap.groups for the preset list that is legal + in `region`. */ + uint8_t group_index; +} meshtastic_LoRaRegionPresets; + +/* Map describing which modem presets are valid for each LoRa region. Sent by + the firmware during the want_config handshake (as FromRadio.region_presets) + so that client UIs can prevent illegal region+preset selections. + + Delivery is grouped to save space: `groups` holds each distinct preset list, + and `region_groups` maps every known region to one of those groups by index. + A region that does NOT appear in `region_groups` carries no constraint + information and should not be restricted by the client (e.g. firmware that + predates this message, or a region with no firmware table entry). Clients + must also tolerate this whole message being absent. */ +typedef struct _meshtastic_LoRaRegionPresetMap { + /* One entry per distinct (preset-list, default, licensing) combination. + Referenced by index from `region_groups`. */ + pb_size_t groups_count; + meshtastic_LoRaPresetGroup groups[8]; + /* One entry per known LoRa region, pointing at its preset group. */ + pb_size_t region_groups_count; + meshtastic_LoRaRegionPresets region_groups[38]; +} meshtastic_LoRaRegionPresetMap; + /* Packets from the radio to the phone will appear on the fromRadio characteristic. It will support READ and NOTIFY. When a new packet arrives the device will BLE notify? It will sit in that descriptor until consumed by the phone, @@ -1411,6 +1458,12 @@ typedef struct _meshtastic_FromRadio { to report success or failure. Replaces the earlier scheme of encoding state as magic-string prefixes inside ClientNotification. */ meshtastic_LockdownStatus lockdown_status; + /* Map of which modem presets are legal in each LoRa region. Sent once + during the want_config handshake (right after `metadata`, before the + first `channel`) so client UIs can prevent the user from selecting an + illegal region+preset combination. A region that does not appear in + any group carries no constraint info and should not be restricted. */ + meshtastic_LoRaRegionPresetMap region_presets; }; } meshtastic_FromRadio; @@ -1604,6 +1657,12 @@ extern "C" { #define meshtastic_DeviceMetadata_role_ENUMTYPE meshtastic_Config_DeviceConfig_Role #define meshtastic_DeviceMetadata_hw_model_ENUMTYPE meshtastic_HardwareModel +#define meshtastic_LoRaPresetGroup_presets_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset +#define meshtastic_LoRaPresetGroup_default_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset + +#define meshtastic_LoRaRegionPresets_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode + + @@ -1641,6 +1700,9 @@ extern "C" { #define meshtastic_NeighborInfo_init_default {0, 0, 0, 0, {meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default}} #define meshtastic_Neighbor_init_default {0, 0, 0, 0} #define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_LoRaPresetGroup_init_default {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} +#define meshtastic_LoRaRegionPresets_init_default {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} +#define meshtastic_LoRaRegionPresetMap_init_default {0, {meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default}, 0, {meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default}} #define meshtastic_Heartbeat_init_default {0} #define meshtastic_NodeRemoteHardwarePin_init_default {0, false, meshtastic_RemoteHardwarePin_init_default} #define meshtastic_ChunkedPayload_init_default {0, 0, 0, {0, {0}}} @@ -1676,6 +1738,9 @@ extern "C" { #define meshtastic_NeighborInfo_init_zero {0, 0, 0, 0, {meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero}} #define meshtastic_Neighbor_init_zero {0, 0, 0, 0} #define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_LoRaPresetGroup_init_zero {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} +#define meshtastic_LoRaRegionPresets_init_zero {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} +#define meshtastic_LoRaRegionPresetMap_init_zero {0, {meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero}, 0, {meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero}} #define meshtastic_Heartbeat_init_zero {0} #define meshtastic_NodeRemoteHardwarePin_init_zero {0, false, meshtastic_RemoteHardwarePin_init_zero} #define meshtastic_ChunkedPayload_init_zero {0, 0, 0, {0, {0}}} @@ -1866,6 +1931,13 @@ extern "C" { #define meshtastic_DeviceMetadata_hasRemoteHardware_tag 10 #define meshtastic_DeviceMetadata_hasPKC_tag 11 #define meshtastic_DeviceMetadata_excluded_modules_tag 12 +#define meshtastic_LoRaPresetGroup_presets_tag 1 +#define meshtastic_LoRaPresetGroup_default_preset_tag 2 +#define meshtastic_LoRaPresetGroup_licensed_only_tag 3 +#define meshtastic_LoRaRegionPresets_region_tag 1 +#define meshtastic_LoRaRegionPresets_group_index_tag 2 +#define meshtastic_LoRaRegionPresetMap_groups_tag 1 +#define meshtastic_LoRaRegionPresetMap_region_groups_tag 2 #define meshtastic_FromRadio_id_tag 1 #define meshtastic_FromRadio_packet_tag 2 #define meshtastic_FromRadio_my_info_tag 3 @@ -1884,6 +1956,7 @@ extern "C" { #define meshtastic_FromRadio_clientNotification_tag 16 #define meshtastic_FromRadio_deviceuiConfig_tag 17 #define meshtastic_FromRadio_lockdown_status_tag 18 +#define meshtastic_FromRadio_region_presets_tag 19 #define meshtastic_Heartbeat_nonce_tag 1 #define meshtastic_ToRadio_packet_tag 1 #define meshtastic_ToRadio_want_config_id_tag 3 @@ -2128,7 +2201,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,mqttClientProxyMessage,mqttC X(a, STATIC, ONEOF, MESSAGE, (payload_variant,fileInfo,fileInfo), 15) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,clientNotification,clientNotification), 16) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17) \ -X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18) +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,region_presets,region_presets), 19) #define meshtastic_FromRadio_CALLBACK NULL #define meshtastic_FromRadio_DEFAULT NULL #define meshtastic_FromRadio_payload_variant_packet_MSGTYPE meshtastic_MeshPacket @@ -2146,6 +2220,7 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_sta #define meshtastic_FromRadio_payload_variant_clientNotification_MSGTYPE meshtastic_ClientNotification #define meshtastic_FromRadio_payload_variant_deviceuiConfig_MSGTYPE meshtastic_DeviceUIConfig #define meshtastic_FromRadio_payload_variant_lockdown_status_MSGTYPE meshtastic_LockdownStatus +#define meshtastic_FromRadio_payload_variant_region_presets_MSGTYPE meshtastic_LoRaRegionPresetMap #define meshtastic_LockdownStatus_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UENUM, state, 1) \ @@ -2264,6 +2339,27 @@ X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) #define meshtastic_DeviceMetadata_CALLBACK NULL #define meshtastic_DeviceMetadata_DEFAULT NULL +#define meshtastic_LoRaPresetGroup_FIELDLIST(X, a) \ +X(a, STATIC, REPEATED, UENUM, presets, 1) \ +X(a, STATIC, SINGULAR, UENUM, default_preset, 2) \ +X(a, STATIC, SINGULAR, BOOL, licensed_only, 3) +#define meshtastic_LoRaPresetGroup_CALLBACK NULL +#define meshtastic_LoRaPresetGroup_DEFAULT NULL + +#define meshtastic_LoRaRegionPresets_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, region, 1) \ +X(a, STATIC, SINGULAR, UINT32, group_index, 2) +#define meshtastic_LoRaRegionPresets_CALLBACK NULL +#define meshtastic_LoRaRegionPresets_DEFAULT NULL + +#define meshtastic_LoRaRegionPresetMap_FIELDLIST(X, a) \ +X(a, STATIC, REPEATED, MESSAGE, groups, 1) \ +X(a, STATIC, REPEATED, MESSAGE, region_groups, 2) +#define meshtastic_LoRaRegionPresetMap_CALLBACK NULL +#define meshtastic_LoRaRegionPresetMap_DEFAULT NULL +#define meshtastic_LoRaRegionPresetMap_groups_MSGTYPE meshtastic_LoRaPresetGroup +#define meshtastic_LoRaRegionPresetMap_region_groups_MSGTYPE meshtastic_LoRaRegionPresets + #define meshtastic_Heartbeat_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, nonce, 1) #define meshtastic_Heartbeat_CALLBACK NULL @@ -2328,6 +2424,9 @@ extern const pb_msgdesc_t meshtastic_Compressed_msg; extern const pb_msgdesc_t meshtastic_NeighborInfo_msg; extern const pb_msgdesc_t meshtastic_Neighbor_msg; extern const pb_msgdesc_t meshtastic_DeviceMetadata_msg; +extern const pb_msgdesc_t meshtastic_LoRaPresetGroup_msg; +extern const pb_msgdesc_t meshtastic_LoRaRegionPresets_msg; +extern const pb_msgdesc_t meshtastic_LoRaRegionPresetMap_msg; extern const pb_msgdesc_t meshtastic_Heartbeat_msg; extern const pb_msgdesc_t meshtastic_NodeRemoteHardwarePin_msg; extern const pb_msgdesc_t meshtastic_ChunkedPayload_msg; @@ -2365,6 +2464,9 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_NeighborInfo_fields &meshtastic_NeighborInfo_msg #define meshtastic_Neighbor_fields &meshtastic_Neighbor_msg #define meshtastic_DeviceMetadata_fields &meshtastic_DeviceMetadata_msg +#define meshtastic_LoRaPresetGroup_fields &meshtastic_LoRaPresetGroup_msg +#define meshtastic_LoRaRegionPresets_fields &meshtastic_LoRaRegionPresets_msg +#define meshtastic_LoRaRegionPresetMap_fields &meshtastic_LoRaRegionPresetMap_msg #define meshtastic_Heartbeat_fields &meshtastic_Heartbeat_msg #define meshtastic_NodeRemoteHardwarePin_fields &meshtastic_NodeRemoteHardwarePin_msg #define meshtastic_ChunkedPayload_fields &meshtastic_ChunkedPayload_msg @@ -2388,6 +2490,9 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_KeyVerificationNumberInform_size 58 #define meshtastic_KeyVerificationNumberRequest_size 52 #define meshtastic_KeyVerification_size 79 +#define meshtastic_LoRaPresetGroup_size 26 +#define meshtastic_LoRaRegionPresetMap_size 490 +#define meshtastic_LoRaRegionPresets_size 5 #define meshtastic_LockdownStatus_size 53 #define meshtastic_LogRecord_size 426 #define meshtastic_LowEntropyKey_size 0 diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index 104e6b36b..892c7f695 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -200,6 +200,87 @@ static void test_applyModemConfig_customCodingRateLowerThanPreset() TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr()); } +// ----------------------------------------------------------------------- +// getRegionPresetMap() — region->valid-preset map sent to clients during want_config +// ----------------------------------------------------------------------- + +static size_t countKnownRegions() +{ + size_t n = 0; + for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) + n++; + return n; +} + +// Every region in the firmware table (except the UNSET sentinel) must appear +// exactly once in the map, and all counts must stay within the mesh.options bounds +// (exceeding them would mean nanopb silently truncates the wire message). +static void test_regionPresetMap_coversAllRegionsWithinBounds() +{ + meshtastic_LoRaRegionPresetMap map; + getRegionPresetMap(map); + + const size_t known = countKnownRegions(); + TEST_ASSERT_EQUAL_UINT((unsigned)known, (unsigned)map.region_groups_count); + + // Bounds derived from the generated nanopb arrays (mesh.options max_count), so + // this stays correct if those bounds change. + const size_t maxGroups = sizeof(map.groups) / sizeof(map.groups[0]); + const size_t maxRegions = sizeof(map.region_groups) / sizeof(map.region_groups[0]); + TEST_ASSERT_GREATER_THAN_UINT(0, map.groups_count); + TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxGroups, map.groups_count); + TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxRegions, map.region_groups_count); + + // Each known region appears exactly once. + for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) { + int hits = 0; + for (pb_size_t i = 0; i < map.region_groups_count; i++) + if (map.region_groups[i].region == r->code) + hits++; + TEST_ASSERT_EQUAL_INT(1, hits); + } +} + +// The advertised presets must agree with the live region table: every preset is +// legal in its region, the default is among them, and the licensed flag matches. +static void test_regionPresetMap_matchesRegionTable() +{ + meshtastic_LoRaRegionPresetMap map; + getRegionPresetMap(map); + + for (pb_size_t i = 0; i < map.region_groups_count; i++) { + meshtastic_Config_LoRaConfig_RegionCode code = map.region_groups[i].region; + uint8_t gi = map.region_groups[i].group_index; + TEST_ASSERT_LESS_THAN_UINT(map.groups_count, gi); + + 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. + 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])); + + // Default preset matches the table, is legal, and is present in the list. + TEST_ASSERT_EQUAL(r->getDefaultPreset(), grp.default_preset); + TEST_ASSERT_TRUE(r->supportsPreset(grp.default_preset)); + bool defaultInList = false; + for (pb_size_t p = 0; p < grp.presets_count; p++) + if (grp.presets[p] == grp.default_preset) + defaultInList = true; + TEST_ASSERT_TRUE(defaultInList); + + // Licensed flag matches the region's profile. + TEST_ASSERT_EQUAL(r->profile->licensedOnly, grp.licensed_only); + } +} + void setUp(void) { mockMeshService = new MockMeshService(); @@ -241,6 +322,8 @@ void setup() RUN_TEST(test_applyModemConfig_codingRateMatchesPreset); RUN_TEST(test_applyModemConfig_customCodingRateHigherThanPreset); RUN_TEST(test_applyModemConfig_customCodingRateLowerThanPreset); + RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds); + RUN_TEST(test_regionPresetMap_matchesRegionTable); exit(UNITY_END()); }