Skip to main content

pamoja_radios/sx1302/
lbt.rs

1//! Listening before talking, on the SX1261 beside the concentrator.
2//!
3//! Some bands forbid transmitting into a channel somebody else is already using. The
4//! concentrator cannot check for itself while it is busy receiving, so a gateway that needs
5//! this carries a second radio, an SX1261, wired to the same bus behind the concentrator. It
6//! samples the channel, compares what it hears against a threshold, and the concentrator asks
7//! it for permission before a trigger is armed.
8//!
9//! That radio takes commands rather than register writes: an opcode byte, then its payload.
10//! This module builds those frames and reads the answers back. It opens no bus.
11//!
12//! Two conversions here are worth stating plainly, because neither is what the numbers look
13//! like. A threshold in dBm is written as a doubled negative, so -80 dBm goes out as 160. And
14//! a scan time is not a duration the radio counts: it is a number of samples, and only two
15//! durations have one, so anything else is refused rather than rounded.
16
17use super::sx1261::Bandwidth;
18use super::tx::Chain;
19
20/// The command that starts a carrier check.
21pub const OP_LBT_START: u8 = 0x9a;
22
23/// The command that starts a spectral scan.
24pub const OP_SPECTRAL_SCAN: u8 = 0x9b;
25
26/// The command that writes one of the radio's own registers.
27pub const OP_WRITE_REGISTER: u8 = 0x0d;
28
29/// The command that puts the radio in frequency synthesis mode, which ends a check.
30pub const OP_SET_FS: u8 = 0xc1;
31
32pub use super::sx1261::REG_RSSI_WINDOW;
33
34/// How long the radio waits between the samples it takes, as the radio counts it.
35///
36/// Eleven is about 8.2 microseconds. Ten and twelve are the neighboring settings the
37/// reference mentions, at roughly 7.68 and 8.68.
38pub const SAMPLE_INTERVAL: u8 = 11;
39
40/// Which of the radio's pins reports the answer back to the concentrator.
41pub const GPIO_ID: u8 = 1;
42
43/// How many results a spectral scan returns.
44pub const SCAN_RESULTS: usize = 33;
45
46/// How long the radio listens for before it answers.
47///
48/// The radio counts samples rather than time, and only these two durations have a count, so
49/// they are the only ones it takes.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum ScanTime {
52    /// A short check, long enough for the channel to be plainly busy or plainly clear.
53    Short,
54    /// A long check, for a band whose rules ask for one.
55    Long,
56}
57
58impl ScanTime {
59    /// How long this check listens, in microseconds.
60    ///
61    /// # Returns
62    ///
63    /// The duration, which is also how long a caller waits before asking for the answer.
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use pamoja_radios::sx1302::lbt::ScanTime;
69    ///
70    /// assert_eq!(ScanTime::Short.micros(), 128);
71    /// assert_eq!(ScanTime::Long.micros(), 5000);
72    /// ```
73    #[must_use]
74    pub const fn micros(&self) -> u16 {
75        match self {
76            ScanTime::Short => 128,
77            ScanTime::Long => 5000,
78        }
79    }
80
81    /// How many samples the radio takes to fill that duration.
82    ///
83    /// # Returns
84    ///
85    /// The count the radio is programmed with. It is not the duration divided by anything a
86    /// caller can work out, so it is carried rather than computed.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use pamoja_radios::sx1302::lbt::ScanTime;
92    ///
93    /// assert_eq!(ScanTime::Short.samples(), 24);
94    /// assert_eq!(ScanTime::Long.samples(), 715);
95    /// ```
96    #[must_use]
97    pub const fn samples(&self) -> u16 {
98        match self {
99            ScanTime::Short => 24,
100            ScanTime::Long => 715,
101        }
102    }
103
104    /// Reads a duration a caller asked for.
105    ///
106    /// # Arguments
107    ///
108    /// * `micros` - the duration in microseconds.
109    ///
110    /// # Returns
111    ///
112    /// The check that takes that long, or `None` for a duration the radio has no count for.
113    /// A near miss is refused rather than rounded, because a check shorter than the rules ask
114    /// for is worse than no check at all.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use pamoja_radios::sx1302::lbt::ScanTime;
120    ///
121    /// assert_eq!(ScanTime::of_micros(128), Some(ScanTime::Short));
122    /// assert_eq!(ScanTime::of_micros(5000), Some(ScanTime::Long));
123    /// assert_eq!(ScanTime::of_micros(4999), None, "a near miss is not rounded");
124    /// ```
125    #[must_use]
126    pub const fn of_micros(micros: u16) -> Option<ScanTime> {
127        match micros {
128            128 => Some(ScanTime::Short),
129            5000 => Some(ScanTime::Long),
130            _ => None,
131        }
132    }
133}
134
135/// Writes a threshold the way the radio takes it.
136///
137/// The radio counts in half decibels below zero, so a threshold in dBm goes out doubled and
138/// with its sign dropped: -80 dBm becomes 160.
139///
140/// # Arguments
141///
142/// * `dbm` - the threshold, which is negative for any real one.
143///
144/// # Returns
145///
146/// The byte the radio is programmed with.
147///
148/// # Examples
149///
150/// ```
151/// use pamoja_radios::sx1302::lbt::threshold_byte;
152///
153/// assert_eq!(threshold_byte(-80), 160);
154/// assert_eq!(threshold_byte(-60), 120);
155/// ```
156#[must_use]
157pub const fn threshold_byte(dbm: i8) -> u8 {
158    (dbm as i16 * -2) as u8
159}
160
161/// Reads a threshold byte back as decibels.
162///
163/// # Arguments
164///
165/// * `byte` - what the radio was programmed with.
166///
167/// # Returns
168///
169/// The threshold in dBm.
170///
171/// # Examples
172///
173/// ```
174/// use pamoja_radios::sx1302::lbt::{threshold_byte, threshold_dbm};
175///
176/// // Every threshold a caller would set survives the round trip.
177/// for dbm in -120..0 {
178///     assert_eq!(threshold_dbm(threshold_byte(dbm)), dbm);
179/// }
180/// ```
181#[must_use]
182pub const fn threshold_dbm(byte: u8) -> i8 {
183    -((byte / 2) as i8)
184}
185
186/// Builds the command that starts a carrier check.
187///
188/// # Arguments
189///
190/// * `scan_time` - how long to listen for.
191/// * `threshold_dbm` - the level above which the channel counts as busy.
192///
193/// # Returns
194///
195/// The opcode and its payload. A caller sends it, waits
196/// [`micros`](ScanTime::micros), and only then asks the concentrator for a trigger.
197///
198/// # Examples
199///
200/// ```
201/// use pamoja_radios::sx1302::lbt::{start, ScanTime, OP_LBT_START};
202///
203/// let (opcode, payload) = start(ScanTime::Short, -80);
204/// assert_eq!(opcode, OP_LBT_START);
205/// assert_eq!(payload, [11, 0, 24, 160, 1]);
206/// ```
207#[must_use]
208pub const fn start(scan_time: ScanTime, threshold_dbm: i8) -> (u8, [u8; 5]) {
209    let samples = scan_time.samples();
210    (
211        OP_LBT_START,
212        [
213            SAMPLE_INTERVAL,
214            (samples >> 8) as u8,
215            (samples & 0xff) as u8,
216            threshold_byte(threshold_dbm),
217            GPIO_ID,
218        ],
219    )
220}
221
222/// Builds the two commands that end a carrier check.
223///
224/// # Returns
225///
226/// The register write that releases the radio, then the command that parks it. Both go out in
227/// this order, and the second takes no payload.
228///
229/// # Examples
230///
231/// ```
232/// use pamoja_radios::sx1302::lbt::{stop, OP_SET_FS, OP_WRITE_REGISTER};
233///
234/// let ((release_op, release), (park_op, park)) = stop();
235/// assert_eq!(release_op, OP_WRITE_REGISTER);
236/// assert_eq!(release, [0x08, 0x9b, 0x00], "clear the register that holds the check");
237/// assert_eq!(park_op, OP_SET_FS);
238/// assert!(park.is_empty());
239/// ```
240#[must_use]
241pub const fn stop() -> ((u8, [u8; 3]), (u8, [u8; 0])) {
242    (
243        (
244            OP_WRITE_REGISTER,
245            [
246                (REG_RSSI_WINDOW >> 8) as u8,
247                (REG_RSSI_WINDOW & 0xff) as u8,
248                0x00,
249            ],
250        ),
251        (OP_SET_FS, []),
252    )
253}
254
255/// Builds the command that starts a spectral scan.
256///
257/// # Arguments
258///
259/// * `scans` - how many sweeps to take.
260///
261/// # Returns
262///
263/// The opcode and its payload.
264///
265/// # Examples
266///
267/// ```
268/// use pamoja_radios::sx1302::lbt::{spectral_scan, OP_SPECTRAL_SCAN};
269///
270/// let (opcode, payload) = spectral_scan(1000);
271/// assert_eq!(opcode, OP_SPECTRAL_SCAN);
272/// assert_eq!(payload, [0x03, 0xe8, 11]);
273/// ```
274#[must_use]
275pub const fn spectral_scan(scans: u16) -> (u8, [u8; 3]) {
276    // The reference fills three bytes here and then asks its bus layer to send nine, which
277    // reads past the end of its own buffer. Three is what it actually fills, and what the
278    // radio is documented to take, so three is what goes out.
279    (
280        OP_SPECTRAL_SCAN,
281        [(scans >> 8) as u8, (scans & 0xff) as u8, SAMPLE_INTERVAL],
282    )
283}
284
285/// How many one-millisecond polls a checked transmission is given to start, and then its
286/// status to clear: half a second each, as Semtech's `lgw_lbt_tx_status` waits.
287pub const OUTCOME_POLLS: u32 = 500;
288
289/// What the host writes to the gain control's first mailbox to clear a transmit status.
290pub const CLEAR_TRANSMIT_STATUS: u8 = 0xff;
291
292/// What it writes once the status reads clear, to acknowledge it.
293pub const ACKNOWLEDGE_CLEARED: u8 = 0x00;
294
295/// How long before a packet leaves the concentrator reads the check's answer, in
296/// microseconds, which a channel's transmit time has to leave room for.
297pub const SENSE_LEAD_US: u32 = 1_500;
298
299/// How far a transmission's carrier may be from a check channel's and still be that channel,
300/// in hertz. The reference allows ten kilohertz, since carriers pass through floating point on
301/// their way from a configuration file.
302pub const FREQUENCY_TOLERANCE_HZ: u32 = 10_000;
303
304/// Whether the gain control reports that a transmission has started on a chain.
305///
306/// # Arguments
307///
308/// * `status` - the gain control's status register.
309/// * `chain` - the chain the packet was armed on.
310///
311/// # Returns
312///
313/// Bit 0 for chain A and bit 1 for chain B.
314///
315/// # Examples
316///
317/// ```
318/// use pamoja_radios::sx1302::lbt::started;
319/// use pamoja_radios::sx1302::tx::Chain;
320///
321/// assert!(started(0b0000_0001, Chain::A));
322/// assert!(!started(0b0000_0001, Chain::B));
323/// ```
324#[must_use]
325pub const fn started(status: u8, chain: Chain) -> bool {
326    status & (1 << chain_bit(chain)) != 0
327}
328
329/// Whether the carrier check kept a started transmission off the air.
330///
331/// # Arguments
332///
333/// * `status` - the gain control's status register, read once the transmission started.
334/// * `chain` - the chain the packet was armed on.
335///
336/// # Returns
337///
338/// Bit 6 for chain A and bit 7 for chain B.
339///
340/// # Examples
341///
342/// ```
343/// use pamoja_radios::sx1302::lbt::refused;
344/// use pamoja_radios::sx1302::tx::Chain;
345///
346/// assert!(refused(0b0100_0001, Chain::A), "started, and held back by the check");
347/// assert!(!refused(0b1000_0001, Chain::A), "bit 7 is chain B's");
348/// ```
349#[must_use]
350pub const fn refused(status: u8, chain: Chain) -> bool {
351    status & (1 << (6 + chain_bit(chain))) != 0
352}
353
354const fn chain_bit(chain: Chain) -> u8 {
355    match chain {
356        Chain::A => 0,
357        Chain::B => 1,
358    }
359}
360
361/// A channel a gateway checks before it transmits on it.
362#[derive(Clone, Copy, Debug, PartialEq, Eq)]
363pub struct Channel {
364    /// The channel's carrier, in hertz.
365    pub frequency_hz: u32,
366    /// Its width.
367    pub bandwidth: Bandwidth,
368    /// How long a check listens.
369    pub scan_time: ScanTime,
370    /// How long one transmission may hold the channel, in milliseconds.
371    pub transmit_time_ms: u16,
372}
373
374impl Channel {
375    /// Whether this is the channel a transmission goes out on.
376    ///
377    /// # Arguments
378    ///
379    /// * `frequency_hz` - the transmission's carrier.
380    /// * `bandwidth_hz` - its bandwidth.
381    ///
382    /// # Returns
383    ///
384    /// Whether the widths match and the carriers are within [`FREQUENCY_TOLERANCE_HZ`].
385    #[must_use]
386    pub const fn covers(&self, frequency_hz: u32, bandwidth_hz: u32) -> bool {
387        let width = match self.bandwidth {
388            Bandwidth::Khz125 => 125_000,
389            Bandwidth::Khz250 => 250_000,
390        };
391        width == bandwidth_hz && self.frequency_hz.abs_diff(frequency_hz) <= FREQUENCY_TOLERANCE_HZ
392    }
393
394    /// Whether a transmission fits the time the channel allows.
395    ///
396    /// The concentrator reads the check's answer [`SENSE_LEAD_US`] before the packet leaves,
397    /// so that much of the transmit time is spent before the packet starts.
398    ///
399    /// # Arguments
400    ///
401    /// * `airtime_us` - how long the transmission holds the air.
402    ///
403    /// # Returns
404    ///
405    /// Whether it ends inside the channel's transmit time.
406    ///
407    /// # Examples
408    ///
409    /// ```
410    /// use pamoja_radios::sx1302::lbt::{Channel, ScanTime};
411    /// use pamoja_radios::sx1302::sx1261::Bandwidth;
412    ///
413    /// let channel = Channel {
414    ///     frequency_hz: 922_100_000,
415    ///     bandwidth: Bandwidth::Khz125,
416    ///     scan_time: ScanTime::Long,
417    ///     transmit_time_ms: 400,
418    /// };
419    /// assert!(channel.fits(398_500));
420    /// assert!(!channel.fits(398_501));
421    /// ```
422    #[must_use]
423    pub const fn fits(&self, airtime_us: u64) -> bool {
424        let allowed = (self.transmit_time_ms as u64 * 1_000).saturating_sub(SENSE_LEAD_US as u64);
425        airtime_us <= allowed
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn only_two_durations_have_a_sample_count() {
435        assert_eq!(ScanTime::of_micros(128), Some(ScanTime::Short));
436        assert_eq!(ScanTime::of_micros(5000), Some(ScanTime::Long));
437
438        // Anything else is refused. A check shorter than the rules ask for would pass while
439        // meaning nothing, which is worse than refusing to run one.
440        for micros in [0, 127, 129, 1000, 4999, 5001, u16::MAX] {
441            assert_eq!(ScanTime::of_micros(micros), None, "{micros}");
442        }
443    }
444
445    #[test]
446    fn a_duration_and_its_count_belong_together() {
447        assert_eq!(ScanTime::Short.micros(), 128);
448        assert_eq!(ScanTime::Short.samples(), 24);
449        assert_eq!(ScanTime::Long.micros(), 5000);
450        assert_eq!(ScanTime::Long.samples(), 715);
451
452        // Every duration reads back as the check it names.
453        for scan in [ScanTime::Short, ScanTime::Long] {
454            assert_eq!(ScanTime::of_micros(scan.micros()), Some(scan));
455        }
456    }
457
458    #[test]
459    fn a_threshold_goes_out_doubled_and_positive() {
460        // The radio counts half decibels below zero, so the sign is dropped on the way out.
461        assert_eq!(threshold_byte(-80), 160);
462        assert_eq!(threshold_byte(-60), 120);
463        assert_eq!(threshold_byte(-1), 2);
464        assert_eq!(threshold_byte(0), 0);
465
466        // And every threshold a caller would set survives the round trip.
467        for dbm in -127..=0 {
468            assert_eq!(threshold_dbm(threshold_byte(dbm)), dbm, "{dbm} dBm");
469        }
470    }
471
472    #[test]
473    fn the_start_command_carries_the_count_not_the_duration() {
474        let (opcode, payload) = start(ScanTime::Short, -80);
475        assert_eq!(opcode, OP_LBT_START);
476        assert_eq!(payload, [SAMPLE_INTERVAL, 0, 24, 160, GPIO_ID]);
477
478        // The long check needs two bytes for its count, which is why it is not one byte wide.
479        let (_, payload) = start(ScanTime::Long, -80);
480        assert_eq!(payload[1], (715 >> 8) as u8);
481        assert_eq!(payload[2], (715 & 0xff) as u8);
482        assert_eq!(u16::from_be_bytes([payload[1], payload[2]]), 715);
483    }
484
485    #[test]
486    fn the_threshold_reaches_the_command_unchanged() {
487        for dbm in [-40i8, -70, -80, -90, -110] {
488            let (_, payload) = start(ScanTime::Long, dbm);
489            assert_eq!(payload[3], threshold_byte(dbm));
490            assert_eq!(threshold_dbm(payload[3]), dbm);
491        }
492    }
493
494    #[test]
495    fn stopping_clears_the_register_then_parks_the_radio() {
496        let ((release_op, release), (park_op, park)) = stop();
497
498        // The register that holds the check is cleared by address, most significant first.
499        assert_eq!(release_op, OP_WRITE_REGISTER);
500        assert_eq!(release[0], (REG_RSSI_WINDOW >> 8) as u8);
501        assert_eq!(release[1], (REG_RSSI_WINDOW & 0xff) as u8);
502        assert_eq!(release[2], 0x00, "cleared, not set");
503        assert_eq!(
504            u16::from_be_bytes([release[0], release[1]]),
505            REG_RSSI_WINDOW
506        );
507
508        // Parking takes no payload at all.
509        assert_eq!(park_op, OP_SET_FS);
510        assert!(park.is_empty());
511    }
512
513    #[test]
514    fn a_spectral_scan_carries_its_sweep_count() {
515        let (opcode, payload) = spectral_scan(1000);
516        assert_eq!(opcode, OP_SPECTRAL_SCAN);
517        assert_eq!(u16::from_be_bytes([payload[0], payload[1]]), 1000);
518        assert_eq!(payload[2], SAMPLE_INTERVAL);
519
520        // The count spans both bytes, so a sweep past 255 is not truncated.
521        let (_, payload) = spectral_scan(u16::MAX);
522        assert_eq!(u16::from_be_bytes([payload[0], payload[1]]), u16::MAX);
523    }
524
525    #[test]
526    fn the_commands_do_not_collide() {
527        // Four opcodes, all different, none of them zero.
528        let opcodes = [OP_LBT_START, OP_SPECTRAL_SCAN, OP_WRITE_REGISTER, OP_SET_FS];
529        for (at, opcode) in opcodes.iter().enumerate() {
530            assert_ne!(*opcode, 0);
531            for other in &opcodes[at + 1..] {
532                assert_ne!(opcode, other);
533            }
534        }
535    }
536}