* Added lora region and preset maps
* Protos
* Address PR review feedback
- Log (and break/skip) when the region preset map exceeds its array bounds
instead of silently dropping regions
- Derive test bounds from the generated nanopb array sizes via sizeof()
instead of hard-coded magic numbers
- Fix want_config sequence comment (missing comma, STATE_SEND_MODULECONFIG)
- Specify a language on the spec's fenced code blocks (markdownlint MD040)
* Fix want_config stall: handle STATE_SEND_REGION_PRESETS in PhoneAPI::available()
available() had a separate per-state switch that wasn't updated for the new
state, so it returned false ('unexpected state 5') and getFromRadio() was
never called - the config handshake stalled after metadata and the client
timed out. Verified via the native simulator integration test.
15 KiB
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 reservesFromRadiofield 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)
message FromRadio {
uint32 id = 1;
oneof payload_variant {
// ... fields 2..18 unchanged ...
LoRaRegionPresetMap region_presets = 19;
}
}
2.2 Messages
// 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:
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_configand 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<RegionCode, RegionPresetInfo>:
struct RegionPresetInfo { Set<ModemPreset> presets; ModemPreset default; bool licensedOnly }
fun decode(map: LoRaRegionPresetMap): Map<RegionCode, RegionPresetInfo> {
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.
-
Absent region ⇒ no constraint. If a
RegionCodedoes not appear inregion_groups, the client has no compatibility info for it and must not restrict its preset choices (fall back to allowing the fullModemPresetlist). This happens for a handful ofRegionCodeenum values that have no firmware band table entry (today:EU_874,EU_917,ITU1_70CM,ITU2_70CM,ITU3_70CM). -
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. -
default_presetis always a member of that group'spresets. 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). -
licensed_onlymarks ham/amateur bands. Surface a warning or gate (the firmware also requires the operator'sis_licensedflag for these regions; coordinate the two so the user isn't allowed to pick a licensed band without acknowledging licensing). -
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 resultingLoRaConfigand reflect the (possibly changed) region back into the UI. -
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(whenuse_preset/use_modem_presetis 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
FromRadiooneof 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/ModemPresetvalues may appear over time. Decoders should pass through unknown enum values rather than crashing; an unknown region inregion_groupsis harmless (the client just won't have a localized name for it).
8. Platform notes
Verified against the
mainbranch 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, currently2.7.25); generated package isorg.meshtastic.proto. Aregion_presets-aware build requires a new publishedorg.meshtastic:protobufsrelease, then bumping that one version string. - The protobufs are Wire-generated, so the
FromRadiooneof is not apayloadVariantCaseenum — each arm is a nullable field. Handle the new variant inFromRadioPacketHandlerImpl.handleFromRadio(...)(core/data/.../manager/FromRadioPacketHandlerImpl.kt) by adding aregionPresets != null -> …arm to the existingwhen { … }, delegating to a handler (mirrorhandleLocalMetadata/handleConfigComplete). - State holder: expose the decoded map from
RadioConfigRepository/RadioConfigRepositoryImplas aFlow(mirroringlocalConfigFlow/channelSetFlow), consumed byfeature/settings/.../radio/RadioConfigViewModel.kt. - UI: the region & preset dropdowns are
DropDownPreferences infeature/settings/.../radio/component/LoRaConfigItemList.kt(public composableLoRaConfigScreen). Gate/filter theChannelOption(preset) dropdown by the selectedRegionInfo'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 theprotobufsgit submodule viascripts/gen_protos.sh. To get field 19: advance theprotobufssubmodule, runscripts/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 realswitch decodedInfo.payloadVariant { … }— add a.regionPresetscase, with the handler inAccessoryManager+FromRadio.swift(mirrorhandleConfig/handleMetadata). - Persistence: config is SwiftData (
@Modelentities), upserted viaMeshPackets/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 thePicker("Region", …)(RegionCodes.userSelectable) andPicker("Presets", …)(ModemPresets.userSelectable, gated onusePreset). Filter the presets picker by the selected region's entry. Enums live inMeshtastic/Enums/LoraConfigEnums.swift.
8.3 Other clients
- python (
meshtastic/ Meshtastic-python) and web consume the published protobufs; they will seeregion_presetsonce 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.