Skip to main content

hdds_micro/transport/lora/
config.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2025-2026 naskel.com
3
4//! LoRa configuration
5
6/// Spreading Factor (SF7-SF12)
7///
8/// Higher SF = longer range but slower data rate
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[repr(u8)]
11#[derive(Default)]
12pub enum SpreadingFactor {
13    /// SF7: Fastest, shortest range (~11 kbps @ 250kHz BW)
14    Sf7 = 7,
15    /// SF8: (~6.25 kbps @ 250kHz BW)
16    Sf8 = 8,
17    /// SF9: (~3.5 kbps @ 125kHz BW)
18    #[default]
19    Sf9 = 9,
20    /// SF10: (~2 kbps @ 125kHz BW)
21    Sf10 = 10,
22    /// SF11: (~1 kbps @ 125kHz BW)
23    Sf11 = 11,
24    /// SF12: Slowest, longest range (~300 bps @ 125kHz BW)
25    Sf12 = 12,
26}
27
28impl SpreadingFactor {
29    /// Get register value
30    pub const fn value(self) -> u8 {
31        self as u8
32    }
33
34    /// Approximate data rate in bits per second (at 125kHz BW, CR 4/5)
35    pub const fn approx_bitrate(self) -> u32 {
36        match self {
37            Self::Sf7 => 5470,
38            Self::Sf8 => 3125,
39            Self::Sf9 => 1760,
40            Self::Sf10 => 980,
41            Self::Sf11 => 440,
42            Self::Sf12 => 250,
43        }
44    }
45}
46
47/// Bandwidth
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[repr(u8)]
50#[derive(Default)]
51pub enum Bandwidth {
52    /// 7.8 kHz
53    Bw7_8 = 0,
54    /// 10.4 kHz
55    Bw10_4 = 1,
56    /// 15.6 kHz
57    Bw15_6 = 2,
58    /// 20.8 kHz
59    Bw20_8 = 3,
60    /// 31.25 kHz
61    Bw31_25 = 4,
62    /// 41.7 kHz
63    Bw41_7 = 5,
64    /// 62.5 kHz
65    Bw62_5 = 6,
66    /// 125 kHz (most common)
67    #[default]
68    Bw125 = 7,
69    /// 250 kHz
70    Bw250 = 8,
71    /// 500 kHz
72    Bw500 = 9,
73}
74
75impl Bandwidth {
76    /// Get register value
77    pub const fn value(self) -> u8 {
78        self as u8
79    }
80
81    /// Get bandwidth in Hz
82    pub const fn hz(self) -> u32 {
83        match self {
84            Self::Bw7_8 => 7_800,
85            Self::Bw10_4 => 10_400,
86            Self::Bw15_6 => 15_600,
87            Self::Bw20_8 => 20_800,
88            Self::Bw31_25 => 31_250,
89            Self::Bw41_7 => 41_700,
90            Self::Bw62_5 => 62_500,
91            Self::Bw125 => 125_000,
92            Self::Bw250 => 250_000,
93            Self::Bw500 => 500_000,
94        }
95    }
96}
97
98/// Coding Rate (error correction)
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100#[repr(u8)]
101#[derive(Default)]
102pub enum CodingRate {
103    /// 4/5 - least overhead
104    #[default]
105    Cr4_5 = 1,
106    /// 4/6
107    Cr4_6 = 2,
108    /// 4/7
109    Cr4_7 = 3,
110    /// 4/8 - most error correction
111    Cr4_8 = 4,
112}
113
114impl CodingRate {
115    /// Get register value
116    pub const fn value(self) -> u8 {
117        self as u8
118    }
119}
120
121/// LoRa configuration
122#[derive(Debug, Clone)]
123pub struct LoRaConfig {
124    /// Center frequency in MHz (e.g., 868.0 for EU, 915.0 for US)
125    pub frequency_mhz: f32,
126
127    /// Spreading factor
128    pub spreading_factor: SpreadingFactor,
129
130    /// Bandwidth
131    pub bandwidth: Bandwidth,
132
133    /// Coding rate
134    pub coding_rate: CodingRate,
135
136    /// TX power in dBm (2-20, hardware dependent)
137    pub tx_power_dbm: i8,
138
139    /// RX timeout in milliseconds
140    pub rx_timeout_ms: u32,
141
142    /// Preamble length (symbols)
143    pub preamble_length: u16,
144
145    /// Enable CRC
146    pub crc_enabled: bool,
147}
148
149impl LoRaConfig {
150    /// Create configuration from profile
151    pub fn from_profile(profile: LoRaProfile, frequency_mhz: f32) -> Self {
152        match profile {
153            LoRaProfile::Fast => Self {
154                frequency_mhz,
155                spreading_factor: SpreadingFactor::Sf7,
156                bandwidth: Bandwidth::Bw250,
157                coding_rate: CodingRate::Cr4_5,
158                tx_power_dbm: 14,
159                rx_timeout_ms: 1000,
160                preamble_length: 8,
161                crc_enabled: true,
162            },
163            LoRaProfile::Balanced => Self {
164                frequency_mhz,
165                spreading_factor: SpreadingFactor::Sf9,
166                bandwidth: Bandwidth::Bw125,
167                coding_rate: CodingRate::Cr4_5,
168                tx_power_dbm: 14,
169                rx_timeout_ms: 3000,
170                preamble_length: 8,
171                crc_enabled: true,
172            },
173            LoRaProfile::LongRange => Self {
174                frequency_mhz,
175                spreading_factor: SpreadingFactor::Sf12,
176                bandwidth: Bandwidth::Bw125,
177                coding_rate: CodingRate::Cr4_8,
178                tx_power_dbm: 20,
179                rx_timeout_ms: 10000,
180                preamble_length: 12,
181                crc_enabled: true,
182            },
183        }
184    }
185
186    /// EU 868 MHz band (863-870 MHz)
187    pub fn eu868(profile: LoRaProfile) -> Self {
188        Self::from_profile(profile, 868.0)
189    }
190
191    /// US 915 MHz band (902-928 MHz)
192    pub fn us915(profile: LoRaProfile) -> Self {
193        Self::from_profile(profile, 915.0)
194    }
195
196    /// Calculate time on air for a packet (in milliseconds)
197    ///
198    /// Based on Semtech LoRa modem designer's guide formulas.
199    pub fn time_on_air_ms(&self, payload_bytes: usize) -> u32 {
200        let sf = self.spreading_factor.value() as u32;
201        let bw = self.bandwidth.hz();
202        let cr = self.coding_rate.value() as u32;
203        let preamble = self.preamble_length as u32;
204        let payload = payload_bytes as u32;
205
206        // Symbol duration in microseconds = 2^SF * 1_000_000 / BW
207        // Example: SF9, 125kHz -> 512 * 1_000_000 / 125_000 = 4096 us
208        let t_sym_us = ((1u64 << sf) * 1_000_000) / (bw as u64);
209
210        // Preamble duration in microseconds
211        // n_preamble = preamble_length + 4.25 symbols (using 4 for integer math)
212        let t_preamble_us = (preamble + 4) * t_sym_us as u32;
213
214        // Payload symbols calculation (from LoRa modem spec)
215        // DE = 1 if LowDataRateOptimize is enabled (SF >= 11)
216        let de = if sf >= 11 { 1u32 } else { 0 };
217
218        // Header is enabled (H = 0 means header enabled, adding 20 bits)
219        // CRC is enabled (adds 16 bits)
220        let header_bits = 20u32; // header overhead
221
222        // Simplified payload symbol calculation
223        // n_payload = 8 + max(ceil((8*PL - 4*SF + 28 + 16) / (4*(SF - 2*DE))) * (CR + 4), 0)
224        let numerator = (8 * payload + header_bits + 16).saturating_sub(4 * sf);
225        let denominator = 4 * (sf.saturating_sub(2 * de));
226
227        let n_payload = if denominator > 0 && numerator > 0 {
228            let ceil_div = numerator.div_ceil(denominator);
229            8 + ceil_div * (cr + 4)
230        } else {
231            8
232        };
233
234        let t_payload_us = n_payload as u64 * t_sym_us;
235
236        // Total time in milliseconds
237        ((t_preamble_us as u64 + t_payload_us) / 1000) as u32
238    }
239}
240
241impl Default for LoRaConfig {
242    fn default() -> Self {
243        Self::eu868(LoRaProfile::Balanced)
244    }
245}
246
247/// Pre-defined LoRa profiles
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum LoRaProfile {
250    /// Fast: SF7, 250kHz - ~11 kbps, short range
251    Fast,
252    /// Balanced: SF9, 125kHz - ~3 kbps, medium range
253    Balanced,
254    /// Long Range: SF12, 125kHz - ~300 bps, maximum range
255    LongRange,
256}
257
258impl LoRaProfile {
259    /// Get approximate data rate in bps
260    pub const fn approx_bitrate(self) -> u32 {
261        match self {
262            Self::Fast => 11000,
263            Self::Balanced => 3000,
264            Self::LongRange => 300,
265        }
266    }
267
268    /// Get approximate range in meters (outdoor, line of sight)
269    pub const fn approx_range_m(self) -> u32 {
270        match self {
271            Self::Fast => 2000,
272            Self::Balanced => 5000,
273            Self::LongRange => 15000,
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_spreading_factor_values() {
284        assert_eq!(SpreadingFactor::Sf7.value(), 7);
285        assert_eq!(SpreadingFactor::Sf12.value(), 12);
286    }
287
288    #[test]
289    fn test_bandwidth_hz() {
290        assert_eq!(Bandwidth::Bw125.hz(), 125_000);
291        assert_eq!(Bandwidth::Bw250.hz(), 250_000);
292    }
293
294    #[test]
295    fn test_profile_configs() {
296        let fast = LoRaConfig::from_profile(LoRaProfile::Fast, 868.0);
297        assert_eq!(fast.spreading_factor, SpreadingFactor::Sf7);
298        assert_eq!(fast.bandwidth, Bandwidth::Bw250);
299
300        let long = LoRaConfig::from_profile(LoRaProfile::LongRange, 915.0);
301        assert_eq!(long.spreading_factor, SpreadingFactor::Sf12);
302        assert_eq!(long.tx_power_dbm, 20);
303    }
304
305    #[test]
306    fn test_time_on_air() {
307        let config = LoRaConfig::eu868(LoRaProfile::Balanced);
308        let toa = config.time_on_air_ms(50);
309
310        // SF9, 125kHz, 50 bytes: expected ~200-400ms range
311        // Symbol time = 512 * 1_000_000 / 125_000 = 4096 us
312        // Preamble (8+4) * 4096 = 49152 us = 49 ms
313        // Payload symbols ~= 8 + ceil((400+36-36)/28)*5 = 8 + 72 = 80 symbols
314        // Payload time = 80 * 4096 = 327680 us = 327 ms
315        // Total ~= 376 ms
316        assert!(
317            toa > 100 && toa < 600,
318            "ToA was {} ms, expected 100-600",
319            toa
320        );
321    }
322}