umsh_ulcp/
profiles.rs

1//! Vetted LoRa PHY profiles.
2//!
3//! The one table every shipped preset comes from: firmware factory
4//! defaults, host tools, the web debugger, and the iOS app through
5//! `umsh-mobile-core`. Tests and examples spell out their own PHY
6//! literals instead of reaching for this table, so editing an entry
7//! here never silently rewrites a test.
8//!
9//! The MeshCore entries mirror that project's published suggested
10//! settings, <https://api.meshcore.nz/api/v1/config>
11//! (`config.suggested_radio_settings.entries`), read 2026-08-26. The
12//! two entries MeshCore marks deprecated are left out, as is its
13//! `path_hash_size`, which has no UMSH meaning.
14
15use crate::ids::DUTY_LIMIT_DISABLED;
16
17/// A vetted PHY profile: the four parameters two nodes must agree on to
18/// hear each other, plus the local settings vetted alongside them.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct PhyProfile {
21    /// Stable identifier. `meshcore-` plus an ISO 3166-1 alpha-2
22    /// country code, or `eu` where the profile spans the union.
23    pub id: &'static str,
24    /// Display name, as an operator picking a profile sees it.
25    pub name: &'static str,
26    /// Center frequency in kHz (`PROP_PHY_FREQ`).
27    pub freq_khz: u32,
28    /// LoRa bandwidth in Hz (`PROP_PHY_LORA_BW`).
29    pub bw_hz: u32,
30    /// LoRa spreading factor (`PROP_PHY_LORA_SF`).
31    pub sf: u8,
32    /// Coding-rate denominator, 5 for 4/5 through 8 for 4/8
33    /// (`PROP_PHY_LORA_CR`).
34    pub cr_denom: u8,
35    /// Transmit power vetted for this profile's band and occupied
36    /// bandwidth. `None` where no power has been vetted, in which case
37    /// adopting the profile leaves a device's configured power alone.
38    pub tx_power_dbm: Option<i8>,
39    /// Duty-cycle limit in `PROP_PHY_DUTY_LIMIT` units.
40    pub duty_limit: u16,
41    /// SX126x-style 16-bit sync word (`PROP_PHY_LORA_SW`).
42    pub sync_word: u16,
43    /// Preamble symbols to emit on transmit.
44    pub tx_preamble_symbols: u16,
45}
46
47impl PhyProfile {
48    /// Whether a node on this profile and one on the given parameters
49    /// can hear each other.
50    ///
51    /// Transmit power and the duty-cycle limit are excluded. Neither
52    /// changes what a receiver can decode, and a radio reports power
53    /// clamped to what it can actually reach — comparing power would
54    /// call two radios on the same profile different merely because one
55    /// of them cannot transmit as hard as the other.
56    pub const fn interoperates_with(
57        &self,
58        freq_khz: u32,
59        bw_hz: u32,
60        sf: u8,
61        cr_denom: u8,
62    ) -> bool {
63        self.freq_khz == freq_khz
64            && self.bw_hz == bw_hz
65            && self.sf == sf
66            && self.cr_denom == cr_denom
67    }
68}
69
70/// The private-network sync word every vetted profile uses. The spec
71/// suggests it in `docs/protocol/src/ulcp-radio.md`, and it is what
72/// `enable_public_network = false` selects on an SX126x.
73pub const DEFAULT_SYNC_WORD: u16 = 0x1424;
74
75/// The LoRa bandwidths a device accepts for `PROP_PHY_LORA_BW`, in Hz.
76pub const SUPPORTED_BANDWIDTHS_HZ: [u32; 10] = [
77    7_810, 10_420, 15_630, 20_830, 31_250, 41_670, 62_500, 125_000, 250_000, 500_000,
78];
79
80/// Preamble symbols MeshCore nodes transmit at SF7-SF8 since v1.16.
81/// Receivers detect after a shorter minimum, so this figure constrains
82/// transmit only.
83const MESHCORE_TX_PREAMBLE: u16 = 32;
84
85/// Roughly 10% of the hour, for the European 869.4-869.65 MHz sub-band
86/// where that ceiling is a condition of use.
87const DUTY_10_PERCENT: u16 = 6_553;
88
89/// The profile firmware ships on and the app recommends.
90const MESHCORE_US_CA: PhyProfile = PhyProfile {
91    id: "meshcore-us-ca",
92    name: "MeshCore USA/Canada (recommended)",
93    freq_khz: 910_525,
94    bw_hz: 62_500,
95    sf: 7,
96    cr_denom: 5,
97    // Vetted for this profile's band at this occupied bandwidth. Other
98    // profiles carry their own figure or none; there is no rule here to
99    // extrapolate from.
100    tx_power_dbm: Some(21),
101    duty_limit: DUTY_LIMIT_DISABLED,
102    sync_word: DEFAULT_SYNC_WORD,
103    tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
104};
105
106const UMSH_US_CA: PhyProfile = PhyProfile {
107    id: "umsh-us-ca",
108    name: "UMSH USA/Canada (recommended)",
109    freq_khz: 917_500,
110    bw_hz: 500_000,
111    sf: 10,
112    cr_denom: 5,
113    tx_power_dbm: Some(30),
114    duty_limit: DUTY_LIMIT_DISABLED,
115    sync_word: DEFAULT_SYNC_WORD,
116    tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
117};
118
119/// Every vetted profile, in the order an operator is offered them: the
120/// default first, then the rest as MeshCore publishes them.
121pub const VETTED: &[PhyProfile] = &[
122    UMSH_US_CA,
123    MESHCORE_US_CA,
124    PhyProfile {
125        id: "meshcore-au",
126        name: "MeshCore Australia",
127        freq_khz: 915_800,
128        bw_hz: 250_000,
129        sf: 10,
130        cr_denom: 5,
131        tx_power_dbm: None,
132        duty_limit: DUTY_LIMIT_DISABLED,
133        sync_word: DEFAULT_SYNC_WORD,
134        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
135    },
136    PhyProfile {
137        id: "meshcore-au-narrow",
138        name: "MeshCore Australia (narrow)",
139        freq_khz: 916_575,
140        bw_hz: 62_500,
141        sf: 7,
142        cr_denom: 8,
143        tx_power_dbm: None,
144        duty_limit: DUTY_LIMIT_DISABLED,
145        sync_word: DEFAULT_SYNC_WORD,
146        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
147    },
148    PhyProfile {
149        id: "meshcore-au-mid",
150        name: "MeshCore Australia (mid)",
151        freq_khz: 915_075,
152        bw_hz: 125_000,
153        sf: 9,
154        cr_denom: 5,
155        tx_power_dbm: None,
156        duty_limit: DUTY_LIMIT_DISABLED,
157        sync_word: DEFAULT_SYNC_WORD,
158        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
159    },
160    PhyProfile {
161        id: "meshcore-au-sa-wa",
162        name: "MeshCore Australia: SA, WA",
163        freq_khz: 923_125,
164        bw_hz: 62_500,
165        sf: 8,
166        cr_denom: 8,
167        tx_power_dbm: None,
168        duty_limit: DUTY_LIMIT_DISABLED,
169        sync_word: DEFAULT_SYNC_WORD,
170        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
171    },
172    PhyProfile {
173        id: "meshcore-au-qld",
174        name: "MeshCore Australia: QLD",
175        freq_khz: 923_125,
176        bw_hz: 62_500,
177        sf: 8,
178        cr_denom: 5,
179        tx_power_dbm: None,
180        duty_limit: DUTY_LIMIT_DISABLED,
181        sync_word: DEFAULT_SYNC_WORD,
182        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
183    },
184    PhyProfile {
185        id: "meshcore-br",
186        name: "MeshCore Brazil",
187        freq_khz: 923_125,
188        bw_hz: 62_500,
189        sf: 8,
190        cr_denom: 8,
191        tx_power_dbm: None,
192        duty_limit: DUTY_LIMIT_DISABLED,
193        sync_word: DEFAULT_SYNC_WORD,
194        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
195    },
196    PhyProfile {
197        id: "meshcore-cr",
198        name: "MeshCore Costa Rica",
199        freq_khz: 910_525,
200        bw_hz: 125_000,
201        sf: 11,
202        cr_denom: 5,
203        tx_power_dbm: None,
204        duty_limit: DUTY_LIMIT_DISABLED,
205        sync_word: DEFAULT_SYNC_WORD,
206        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
207    },
208    PhyProfile {
209        id: "meshcore-eu-narrow",
210        name: "MeshCore EU/UK (narrow)",
211        freq_khz: 869_618,
212        bw_hz: 62_500,
213        sf: 8,
214        cr_denom: 8,
215        tx_power_dbm: None,
216        duty_limit: DUTY_10_PERCENT,
217        sync_word: DEFAULT_SYNC_WORD,
218        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
219    },
220    PhyProfile {
221        id: "meshcore-cz-narrow",
222        name: "MeshCore Czech Republic (narrow)",
223        freq_khz: 869_432,
224        bw_hz: 62_500,
225        sf: 7,
226        cr_denom: 5,
227        tx_power_dbm: None,
228        duty_limit: DUTY_10_PERCENT,
229        sync_word: DEFAULT_SYNC_WORD,
230        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
231    },
232    PhyProfile {
233        id: "meshcore-eu-433-long-range",
234        name: "MeshCore EU 433 MHz (long range)",
235        freq_khz: 433_650,
236        bw_hz: 250_000,
237        sf: 11,
238        cr_denom: 5,
239        tx_power_dbm: None,
240        duty_limit: DUTY_LIMIT_DISABLED,
241        sync_word: DEFAULT_SYNC_WORD,
242        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
243    },
244    PhyProfile {
245        id: "meshcore-eu-433-narrow",
246        name: "MeshCore EU 433 MHz (narrow)",
247        freq_khz: 433_650,
248        bw_hz: 62_500,
249        sf: 8,
250        cr_denom: 8,
251        tx_power_dbm: None,
252        duty_limit: DUTY_LIMIT_DISABLED,
253        sync_word: DEFAULT_SYNC_WORD,
254        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
255    },
256    PhyProfile {
257        id: "meshcore-hu",
258        name: "MeshCore Hungary",
259        freq_khz: 869_618,
260        bw_hz: 62_500,
261        sf: 7,
262        cr_denom: 5,
263        tx_power_dbm: None,
264        duty_limit: DUTY_10_PERCENT,
265        sync_word: DEFAULT_SYNC_WORD,
266        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
267    },
268    PhyProfile {
269        id: "meshcore-nl",
270        name: "MeshCore Netherlands",
271        freq_khz: 869_618,
272        bw_hz: 62_500,
273        sf: 7,
274        cr_denom: 5,
275        tx_power_dbm: None,
276        duty_limit: DUTY_10_PERCENT,
277        sync_word: DEFAULT_SYNC_WORD,
278        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
279    },
280    PhyProfile {
281        id: "meshcore-nz-narrow",
282        name: "MeshCore New Zealand (narrow)",
283        freq_khz: 917_375,
284        bw_hz: 62_500,
285        sf: 7,
286        cr_denom: 5,
287        tx_power_dbm: None,
288        duty_limit: DUTY_LIMIT_DISABLED,
289        sync_word: DEFAULT_SYNC_WORD,
290        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
291    },
292    PhyProfile {
293        id: "meshcore-nz-gisborne",
294        name: "MeshCore New Zealand (Gisborne)",
295        freq_khz: 917_375,
296        bw_hz: 250_000,
297        sf: 11,
298        cr_denom: 5,
299        tx_power_dbm: None,
300        duty_limit: DUTY_LIMIT_DISABLED,
301        sync_word: DEFAULT_SYNC_WORD,
302        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
303    },
304    PhyProfile {
305        id: "meshcore-pt-433",
306        name: "MeshCore Portugal 433",
307        freq_khz: 433_375,
308        bw_hz: 62_500,
309        sf: 9,
310        cr_denom: 6,
311        tx_power_dbm: None,
312        duty_limit: DUTY_LIMIT_DISABLED,
313        sync_word: DEFAULT_SYNC_WORD,
314        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
315    },
316    PhyProfile {
317        id: "meshcore-pt-868",
318        name: "MeshCore Portugal 868",
319        freq_khz: 869_618,
320        bw_hz: 62_500,
321        sf: 7,
322        cr_denom: 6,
323        tx_power_dbm: None,
324        duty_limit: DUTY_10_PERCENT,
325        sync_word: DEFAULT_SYNC_WORD,
326        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
327    },
328    PhyProfile {
329        id: "meshcore-sk",
330        name: "MeshCore Slovakia",
331        freq_khz: 869_618,
332        bw_hz: 62_500,
333        sf: 7,
334        cr_denom: 5,
335        tx_power_dbm: None,
336        duty_limit: DUTY_10_PERCENT,
337        sync_word: DEFAULT_SYNC_WORD,
338        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
339    },
340    PhyProfile {
341        id: "meshcore-ch",
342        name: "MeshCore Switzerland",
343        freq_khz: 869_618,
344        bw_hz: 62_500,
345        sf: 8,
346        cr_denom: 8,
347        tx_power_dbm: None,
348        duty_limit: DUTY_10_PERCENT,
349        sync_word: DEFAULT_SYNC_WORD,
350        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
351    },
352    PhyProfile {
353        id: "meshcore-vn-narrow",
354        name: "MeshCore Vietnam (narrow)",
355        freq_khz: 920_250,
356        bw_hz: 62_500,
357        sf: 8,
358        cr_denom: 5,
359        tx_power_dbm: None,
360        duty_limit: DUTY_LIMIT_DISABLED,
361        sync_word: DEFAULT_SYNC_WORD,
362        tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
363    },
364];
365
366/// The profile a device comes up on out of the box, and after a factory
367/// reset.
368pub const DEFAULT: &PhyProfile = &UMSH_US_CA;
369
370/// `DEFAULT`'s vetted transmit power, for the firmware defaults that
371/// need a plain value. A default profile without one fails the build.
372pub const DEFAULT_TX_POWER_DBM: i8 = match DEFAULT.tx_power_dbm {
373    Some(power) => power,
374    None => panic!("the default profile must carry a vetted transmit power"),
375};
376
377/// The profile with the given identifier.
378pub fn by_id(id: &str) -> Option<&'static PhyProfile> {
379    VETTED.iter().find(|profile| profile.id == id)
380}
381
382/// The vetted profile the given parameters spell out, if any.
383pub fn matching(freq_khz: u32, bw_hz: u32, sf: u8, cr_denom: u8) -> Option<&'static PhyProfile> {
384    VETTED
385        .iter()
386        .find(|profile| profile.interoperates_with(freq_khz, bw_hz, sf, cr_denom))
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn identifiers_are_unique_and_well_formed() {
395        for (index, profile) in VETTED.iter().enumerate() {
396            assert!(
397                profile
398                    .id
399                    .bytes()
400                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'),
401                "{} is not kebab-case",
402                profile.id
403            );
404            assert!(!profile.name.is_empty(), "{} has no name", profile.id);
405            for other in &VETTED[index + 1..] {
406                assert_ne!(profile.id, other.id, "duplicate identifier");
407            }
408        }
409    }
410
411    #[test]
412    fn parameters_are_settable_on_a_device() {
413        for profile in VETTED {
414            assert!(
415                SUPPORTED_BANDWIDTHS_HZ.contains(&profile.bw_hz),
416                "{} has an unsupported bandwidth",
417                profile.id
418            );
419            assert!(
420                (5..=12).contains(&profile.sf),
421                "{} has a bad SF",
422                profile.id
423            );
424            assert!(
425                (5..=8).contains(&profile.cr_denom),
426                "{} has a bad coding rate",
427                profile.id
428            );
429            // The narrowest tunable range of any radio UMSH ships on.
430            assert!(
431                (150_000..=960_000).contains(&profile.freq_khz),
432                "{} is outside every shipped radio's range",
433                profile.id
434            );
435            assert_eq!(profile.sync_word, DEFAULT_SYNC_WORD, "{}", profile.id);
436            assert_eq!(
437                profile.tx_preamble_symbols, MESHCORE_TX_PREAMBLE,
438                "{}",
439                profile.id
440            );
441        }
442    }
443
444    #[test]
445    fn european_sub_band_profiles_carry_its_duty_ceiling() {
446        for profile in VETTED {
447            let in_sub_band = (869_400..=869_650).contains(&profile.freq_khz);
448            let expected = if in_sub_band {
449                DUTY_10_PERCENT
450            } else {
451                DUTY_LIMIT_DISABLED
452            };
453            assert_eq!(profile.duty_limit, expected, "{}", profile.id);
454        }
455    }
456
457    #[test]
458    fn the_default_is_vetted_and_reachable_by_id() {
459        // Which entry is the default is a shipping decision, free to
460        // move; that it is in the table and answers to its own
461        // identifier is not. `DEFAULT_TX_POWER_DBM` needs no assertion
462        // here — its `match` fails the build if the default carries no
463        // vetted power.
464        assert!(VETTED.contains(DEFAULT));
465        assert_eq!(by_id(DEFAULT.id), Some(DEFAULT));
466        assert_eq!(by_id("nonesuch"), None);
467    }
468
469    #[test]
470    fn interop_ignores_local_settings() {
471        // Literals rather than a table entry: what is under test is the
472        // comparison rule, not any shipped profile's parameters.
473        let profile = PhyProfile {
474            id: "test",
475            name: "Test",
476            freq_khz: 906_875,
477            bw_hz: 250_000,
478            sf: 9,
479            cr_denom: 6,
480            tx_power_dbm: Some(14),
481            duty_limit: DUTY_LIMIT_DISABLED,
482            sync_word: DEFAULT_SYNC_WORD,
483            tx_preamble_symbols: MESHCORE_TX_PREAMBLE,
484        };
485        // The same four parameters, turned down and duty-limited.
486        let quieter = PhyProfile {
487            tx_power_dbm: Some(2),
488            duty_limit: DUTY_10_PERCENT,
489            ..profile
490        };
491        assert_ne!(profile, quieter);
492        assert!(profile.interoperates_with(906_875, 250_000, 9, 6));
493        assert!(quieter.interoperates_with(906_875, 250_000, 9, 6));
494
495        // Each of the four on its own breaks interop.
496        assert!(!profile.interoperates_with(906_975, 250_000, 9, 6));
497        assert!(!profile.interoperates_with(906_875, 125_000, 9, 6));
498        assert!(!profile.interoperates_with(906_875, 250_000, 10, 6));
499        assert!(!profile.interoperates_with(906_875, 250_000, 9, 5));
500    }
501
502    #[test]
503    fn every_vetted_profile_is_found_by_its_own_parameters() {
504        for profile in VETTED {
505            let found = matching(
506                profile.freq_khz,
507                profile.bw_hz,
508                profile.sf,
509                profile.cr_denom,
510            )
511            .unwrap_or_else(|| panic!("{} finds no vetted profile", profile.id));
512            // Not necessarily the entry asked about. Countries sharing a
513            // band share a radio configuration — `meshcore-hu`,
514            // `meshcore-nl` and `meshcore-sk` are one PHY under three
515            // names — so a lookup answers with the first interoperable
516            // entry, and the four parameters cannot name a country.
517            assert!(
518                found.interoperates_with(
519                    profile.freq_khz,
520                    profile.bw_hz,
521                    profile.sf,
522                    profile.cr_denom
523                ),
524                "{} matched {}",
525                profile.id,
526                found.id
527            );
528        }
529        // Nothing sits at 0 kHz: `parameters_are_settable_on_a_device`
530        // holds every entry inside a real radio's tuning range.
531        assert_eq!(matching(0, 0, 0, 0), None);
532    }
533}