Skip to main content

pamoja_radios/
linux.rs

1//! Opening a LoRa radio on a Linux board.
2//!
3//! On a Raspberry Pi or any Linux board a radio module's SPI bus is a file such as
4//! `/dev/spidev0.0`, and its reset and BUSY pins are lines on a GPIO chip such as
5//! `/dev/gpiochip0`. [`open_sx126x`] and [`open_sx127x`] open them through the Linux
6//! backends of `pamoja-hal`, reset the chip, and hand it back as a [`LinuxRadio`], so a
7//! gateway, a Python script, or a C# service drives a radio with no bus code of its own.
8//! The kernel's SPI interface has to be turned on first; on a Raspberry Pi that is
9//! `dtparam=spi=on` in `config.txt`.
10//!
11//! Only Linux has spidev and the GPIO character device. Everywhere else this module still
12//! builds and [`LinuxRadio`] still names a type, but opening a radio returns
13//! [`OpenError::Unsupported`], so a program and the language bindings compile on any
14//! platform and say plainly where no radio can be reached.
15//!
16//! # Examples
17//!
18//! ```no_run
19//! use pamoja_lora::LinkSettings;
20//! use pamoja_radios::linux::{self, Wiring};
21//! use pamoja_radios::radio::RadioConfig;
22//! use pamoja_radios::sx127x::config::PaOutput;
23//! use pamoja_radios::sx127x::Board;
24//!
25//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
26//! // An RFM95W on the Raspberry Pi's SPI0, chip select CE0, with its reset pin on GPIO25.
27//! let wiring = Wiring::new("/dev/spidev0.0", "/dev/gpiochip0", 25);
28//! let mut radio = linux::open_sx127x(&wiring, Board::new(PaOutput::PaBoost))?;
29//! radio.configure(RadioConfig::new(868_100_000, LinkSettings::new(9, 125_000), 14))?;
30//! let airtime_us = radio.transmit(b"21.5")?;
31//! println!("sent in {airtime_us} us");
32//! # Ok(())
33//! # }
34//! ```
35
36use std::fmt;
37use std::path::PathBuf;
38
39use embedded_hal::delay::DelayNs;
40use embedded_hal::digital::{self, InputPin, OutputPin};
41use embedded_hal::spi::{self, Operation, SpiDevice};
42
43use crate::radio::{Radio, RadioError};
44use crate::{sx126x, sx127x};
45
46/// The SPI mode both families take: CPOL 0 and CPHA 0, the clock idling low and data sampled
47/// on its rising edge, as section 8.2 of the SX126x family's datasheets gives it and as
48/// RadioLib opens both families.
49pub const SPI_MODE: u8 = 0;
50
51/// The SPI clock a radio is opened at unless its wiring names another, in hertz.
52///
53/// RadioLib opens both families at 2 MHz by default. A slower clock tolerates longer leads
54/// between the board and the module, and [`Wiring::with_spi_hz`] sets another.
55pub const DEFAULT_SPI_HZ: u32 = 2_000_000;
56
57/// The name the kernel shows as holding a radio's GPIO lines, which `gpioinfo` prints.
58pub const CONSUMER: &str = "pamoja-radio";
59
60/// The SPI device a radio is opened on: the kernel's spidev device.
61#[cfg(target_os = "linux")]
62pub type Spi = pamoja_hal::linux::SpidevDevice;
63
64/// The SPI device a radio is opened on, which only Linux has.
65#[cfg(not(target_os = "linux"))]
66pub type Spi = Unavailable;
67
68/// A GPIO line a radio's reset or BUSY pin is on: a line of the GPIO character device.
69#[cfg(target_os = "linux")]
70pub type Line = pamoja_hal::linux::CdevPin;
71
72/// A GPIO line a radio's reset or BUSY pin is on, which only Linux opens.
73#[cfg(not(target_os = "linux"))]
74pub type Line = Unavailable;
75
76/// The delay a radio's driver waits with: the process sleeping.
77#[cfg(target_os = "linux")]
78pub type Delay = pamoja_hal::linux::Delay;
79
80/// The delay a radio's driver waits with, which no opened radio needs off Linux.
81#[cfg(not(target_os = "linux"))]
82pub type Delay = Unavailable;
83
84/// What the SPI device reports when a transfer fails.
85#[cfg(target_os = "linux")]
86pub type SpiError = pamoja_hal::linux::SPIError;
87
88/// What the SPI device reports when a transfer fails.
89#[cfg(not(target_os = "linux"))]
90pub type SpiError = spi::ErrorKind;
91
92/// Why the SPI device or a GPIO line could not be opened.
93#[cfg(target_os = "linux")]
94pub type BusError = pamoja_hal::linux::OpenError;
95
96/// Why the SPI device or a GPIO line could not be opened, which never happens off Linux
97/// because nothing is opened there.
98#[cfg(not(target_os = "linux"))]
99pub type BusError = Unavailable;
100
101/// A radio opened on a Linux board.
102pub type LinuxRadio = Radio<Spi, Line, Line, Delay>;
103
104/// The bus, line, and delay of a platform with no spidev or GPIO character device.
105///
106/// No value of it exists, so a [`LinuxRadio`] can be named on any platform and opened only on
107/// Linux.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
109pub enum Unavailable {}
110
111impl fmt::Display for Unavailable {
112    fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match *self {}
114    }
115}
116
117impl std::error::Error for Unavailable {}
118
119impl spi::ErrorType for Unavailable {
120    type Error = spi::ErrorKind;
121}
122
123impl SpiDevice for Unavailable {
124    fn transaction(&mut self, _: &mut [Operation<'_, u8>]) -> Result<(), spi::ErrorKind> {
125        match *self {}
126    }
127}
128
129impl digital::ErrorType for Unavailable {
130    type Error = digital::ErrorKind;
131}
132
133impl InputPin for Unavailable {
134    fn is_high(&mut self) -> Result<bool, digital::ErrorKind> {
135        match *self {}
136    }
137
138    fn is_low(&mut self) -> Result<bool, digital::ErrorKind> {
139        match *self {}
140    }
141}
142
143impl OutputPin for Unavailable {
144    fn set_low(&mut self) -> Result<(), digital::ErrorKind> {
145        match *self {}
146    }
147
148    fn set_high(&mut self) -> Result<(), digital::ErrorKind> {
149        match *self {}
150    }
151}
152
153impl DelayNs for Unavailable {
154    fn delay_ns(&mut self, _: u32) {
155        match *self {}
156    }
157}
158
159/// Where a radio module is wired on a Linux board.
160///
161/// # Examples
162///
163/// ```
164/// use pamoja_radios::linux::{Wiring, DEFAULT_SPI_HZ};
165///
166/// // An SX1262 board on SPI0's second chip select, BUSY on GPIO24 and reset on GPIO25.
167/// let wiring = Wiring::new("/dev/spidev0.1", "/dev/gpiochip0", 25).with_busy_line(24);
168/// assert_eq!(wiring.busy_line, Some(24));
169/// assert_eq!(wiring.spi_hz, DEFAULT_SPI_HZ);
170/// ```
171#[derive(Clone, Debug, PartialEq, Eq, Hash)]
172pub struct Wiring {
173    /// The SPI device file, one per chip select, such as `/dev/spidev0.0`.
174    pub spi: PathBuf,
175    /// The SPI clock in hertz.
176    pub spi_hz: u32,
177    /// The GPIO chip the lines are on, `/dev/gpiochip0` for a Raspberry Pi's header.
178    pub gpio_chip: PathBuf,
179    /// The line the module's reset pin is on: its offset on the chip, which on a Raspberry Pi
180    /// is the BCM GPIO number.
181    pub reset_line: u32,
182    /// The line an SX126x's BUSY pin is on. The SX127x has no BUSY pin.
183    pub busy_line: Option<u32>,
184}
185
186impl Wiring {
187    /// Describes a module on an SPI device with its reset pin on a GPIO line, clocked at
188    /// [`DEFAULT_SPI_HZ`] and with no BUSY line.
189    ///
190    /// # Arguments
191    ///
192    /// * `spi` - the SPI device file.
193    /// * `gpio_chip` - the GPIO chip the lines are on.
194    /// * `reset_line` - the line the reset pin is on.
195    ///
196    /// # Returns
197    ///
198    /// The wiring.
199    pub fn new(spi: impl Into<PathBuf>, gpio_chip: impl Into<PathBuf>, reset_line: u32) -> Wiring {
200        Wiring {
201            spi: spi.into(),
202            spi_hz: DEFAULT_SPI_HZ,
203            gpio_chip: gpio_chip.into(),
204            reset_line,
205            busy_line: None,
206        }
207    }
208
209    /// Returns the wiring with an SX126x's BUSY pin on a line.
210    ///
211    /// # Arguments
212    ///
213    /// * `line` - the line the BUSY pin is on.
214    ///
215    /// # Returns
216    ///
217    /// The wiring.
218    pub fn with_busy_line(mut self, line: u32) -> Wiring {
219        self.busy_line = Some(line);
220        self
221    }
222
223    /// Returns the wiring with another SPI clock.
224    ///
225    /// # Arguments
226    ///
227    /// * `hz` - the clock in hertz.
228    ///
229    /// # Returns
230    ///
231    /// The wiring.
232    pub fn with_spi_hz(mut self, hz: u32) -> Wiring {
233        self.spi_hz = hz;
234        self
235    }
236}
237
238/// Why a radio could not be opened.
239#[derive(Debug)]
240pub enum OpenError {
241    /// The platform has no spidev or GPIO character device, which is any platform but Linux.
242    Unsupported,
243    /// An SX126x was opened on wiring that names no BUSY line.
244    NoBusyLine,
245    /// A device file could not be opened: the SPI device or the GPIO chip.
246    Bus {
247        /// The file that could not be opened.
248        device: PathBuf,
249        /// Why, which is most often a kernel interface left off or a missing group.
250        error: BusError,
251    },
252    /// The chip did not come up after its reset, which is most often a wiring or a power
253    /// problem.
254    Radio(RadioError<SpiError>),
255}
256
257impl fmt::Display for OpenError {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            OpenError::Unsupported => f.write_str(
261                "a LoRa radio is opened through spidev and the GPIO character device, which only Linux has",
262            ),
263            OpenError::NoBusyLine => {
264                f.write_str("an SX126x needs its BUSY line, and the wiring names none")
265            }
266            OpenError::Bus { device, error } => write!(f, "{}: {error}", device.display()),
267            OpenError::Radio(error) => error.fmt(f),
268        }
269    }
270}
271
272impl std::error::Error for OpenError {
273    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
274        match self {
275            OpenError::Bus { error, .. } => Some(error),
276            OpenError::Radio(error) => Some(error),
277            OpenError::Unsupported | OpenError::NoBusyLine => None,
278        }
279    }
280}
281
282/// Opens an SX1261, SX1262, SX1268, or LLCC68 module and resets it.
283///
284/// # Arguments
285///
286/// * `wiring` - the SPI device and the lines, which must name the BUSY line.
287/// * `board` - how the module wires the chip: its amplifier, clock, antenna switch, and
288///   regulator.
289///
290/// # Returns
291///
292/// The radio, reset and in standby, ready for [`Radio::configure`].
293///
294/// # Errors
295///
296/// Returns [`OpenError::NoBusyLine`] if the wiring names no BUSY line,
297/// [`OpenError::Unsupported`] on any platform but Linux, [`OpenError::Bus`] if the SPI device
298/// or a line cannot be opened, and [`OpenError::Radio`] if no SX126x answers.
299pub fn open_sx126x(wiring: &Wiring, board: sx126x::Board) -> Result<LinuxRadio, OpenError> {
300    let busy_line = wiring.busy_line.ok_or(OpenError::NoBusyLine)?;
301    platform::open_sx126x(wiring, busy_line, board)
302}
303
304/// Opens an SX1276, SX1277, SX1278, or SX1279 module, such as an RFM95W, and resets it into
305/// LoRa mode.
306///
307/// # Arguments
308///
309/// * `wiring` - the SPI device and the reset line.
310/// * `board` - how the module wires the chip: its amplifier output and clock.
311///
312/// # Returns
313///
314/// The radio, reset and in standby, ready for [`Radio::configure`].
315///
316/// # Errors
317///
318/// Returns [`OpenError::Unsupported`] on any platform but Linux, [`OpenError::Bus`] if the
319/// SPI device or the reset line cannot be opened, and [`OpenError::Radio`] if no SX127x
320/// answers.
321pub fn open_sx127x(wiring: &Wiring, board: sx127x::Board) -> Result<LinuxRadio, OpenError> {
322    platform::open_sx127x(wiring, board)
323}
324
325#[cfg(target_os = "linux")]
326mod platform {
327    use std::path::Path;
328
329    use embedded_hal::digital::PinState;
330    use pamoja_hal::linux;
331
332    use super::{LinuxRadio, OpenError, Wiring, CONSUMER, SPI_MODE};
333    use crate::radio::Radio;
334    use crate::{sx126x, sx127x};
335
336    pub(super) fn open_sx126x(
337        wiring: &Wiring,
338        busy_line: u32,
339        board: sx126x::Board,
340    ) -> Result<LinuxRadio, OpenError> {
341        let spi = spi(wiring)?;
342        let busy = linux::input(&wiring.gpio_chip, busy_line, CONSUMER)
343            .map_err(|error| bus(&wiring.gpio_chip, error))?;
344        let reset = reset(wiring)?;
345        start(Radio::Sx126x(sx126x::Sx126x::new(
346            spi,
347            busy,
348            reset,
349            linux::delay(),
350            board,
351        )))
352    }
353
354    pub(super) fn open_sx127x(
355        wiring: &Wiring,
356        board: sx127x::Board,
357    ) -> Result<LinuxRadio, OpenError> {
358        let spi = spi(wiring)?;
359        let reset = reset(wiring)?;
360        start(Radio::Sx127x(sx127x::Sx127x::new(
361            spi,
362            reset,
363            linux::delay(),
364            board,
365        )))
366    }
367
368    fn spi(wiring: &Wiring) -> Result<linux::SpidevDevice, OpenError> {
369        linux::spi(&wiring.spi, SPI_MODE, wiring.spi_hz).map_err(|error| bus(&wiring.spi, error))
370    }
371
372    fn reset(wiring: &Wiring) -> Result<linux::CdevPin, OpenError> {
373        linux::output(
374            &wiring.gpio_chip,
375            wiring.reset_line,
376            CONSUMER,
377            PinState::High,
378        )
379        .map_err(|error| bus(&wiring.gpio_chip, error))
380    }
381
382    fn start(mut radio: LinuxRadio) -> Result<LinuxRadio, OpenError> {
383        radio.init().map_err(OpenError::Radio)?;
384        Ok(radio)
385    }
386
387    fn bus(device: &Path, error: linux::OpenError) -> OpenError {
388        OpenError::Bus {
389            device: device.to_path_buf(),
390            error,
391        }
392    }
393}
394
395#[cfg(not(target_os = "linux"))]
396mod platform {
397    use super::{LinuxRadio, OpenError, Wiring};
398    use crate::{sx126x, sx127x};
399
400    pub(super) fn open_sx126x(
401        _: &Wiring,
402        _: u32,
403        _: sx126x::Board,
404    ) -> Result<LinuxRadio, OpenError> {
405        Err(OpenError::Unsupported)
406    }
407
408    pub(super) fn open_sx127x(_: &Wiring, _: sx127x::Board) -> Result<LinuxRadio, OpenError> {
409        Err(OpenError::Unsupported)
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::sx126x::config::PowerAmplifier;
417    use crate::sx127x::config::PaOutput;
418
419    #[test]
420    fn wiring_starts_at_the_default_clock_with_no_busy_line() {
421        let wiring = Wiring::new("/dev/spidev0.0", "/dev/gpiochip0", 25);
422        assert_eq!(wiring.spi_hz, 2_000_000);
423        assert_eq!(wiring.busy_line, None);
424        let wiring = wiring.with_busy_line(24).with_spi_hz(8_000_000);
425        assert_eq!((wiring.busy_line, wiring.spi_hz), (Some(24), 8_000_000));
426    }
427
428    #[test]
429    fn an_sx126x_is_not_opened_without_its_busy_line() {
430        let wiring = Wiring::new("/dev/spidev0.0", "/dev/gpiochip0", 25);
431        let refused = open_sx126x(&wiring, sx126x::Board::new(PowerAmplifier::HighPower)).err();
432        assert!(matches!(refused, Some(OpenError::NoBusyLine)));
433    }
434
435    #[cfg(target_os = "linux")]
436    #[test]
437    fn a_missing_spi_device_is_named_in_the_error() {
438        let wiring = Wiring::new("/dev/spidev-pamoja-absent", "/dev/gpiochip0", 25);
439        let refused = open_sx127x(&wiring, sx127x::Board::new(PaOutput::PaBoost))
440            .err()
441            .expect("no such device exists");
442        assert!(matches!(refused, OpenError::Bus { .. }));
443        assert!(
444            refused
445                .to_string()
446                .starts_with("/dev/spidev-pamoja-absent: "),
447            "{refused}"
448        );
449    }
450
451    #[cfg(not(target_os = "linux"))]
452    #[test]
453    fn only_linux_opens_a_radio() {
454        let wiring = Wiring::new("/dev/spidev0.0", "/dev/gpiochip0", 25);
455        let refused = open_sx127x(&wiring, sx127x::Board::new(PaOutput::PaBoost)).err();
456        assert!(matches!(refused, Some(OpenError::Unsupported)));
457        assert!(OpenError::Unsupported.to_string().contains("only Linux"));
458    }
459}