1use crate::ids::DUTY_LIMIT_DISABLED;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct PhyProfile {
21 pub id: &'static str,
24 pub name: &'static str,
26 pub freq_khz: u32,
28 pub bw_hz: u32,
30 pub sf: u8,
32 pub cr_denom: u8,
35 pub tx_power_dbm: Option<i8>,
39 pub duty_limit: u16,
41 pub sync_word: u16,
43 pub tx_preamble_symbols: u16,
45}
46
47impl PhyProfile {
48 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
70pub const DEFAULT_SYNC_WORD: u16 = 0x1424;
74
75pub 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
80const MESHCORE_TX_PREAMBLE: u16 = 32;
84
85const DUTY_10_PERCENT: u16 = 6_553;
88
89const 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 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
119pub 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
366pub const DEFAULT: &PhyProfile = &UMSH_US_CA;
369
370pub 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
377pub fn by_id(id: &str) -> Option<&'static PhyProfile> {
379 VETTED.iter().find(|profile| profile.id == id)
380}
381
382pub 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 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 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 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 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 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 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 assert_eq!(matching(0, 0, 0, 0), None);
532 }
533}