micropython-lib/micropython/lora/README.md

57 KiB

LoRa driver

This MicroPython library provides synchronous and asynchronous wireless drivers for Semtech's LoRa (Long Range Radio) modem devices.

(LoRa is a registered trademark or service mark of Semtech Corporation or its affiliates.)

Support

Currently these radio modem chipsets are supported:

  • SX1261
  • SX1262
  • SX1276
  • SX1277
  • SX1278
  • SX1279
  • STM32WL55 "sub-GHz radio" peripheral

Most radio configuration features are supported, as well as transmitting or receiving packets.

This library can be used on any MicroPython port which supports the machine.SPI interface.

Installation

First, install at least one of the following "base" LoRa packages:

  • lora-sync to use the synchronous LoRa modem API.
  • lora-async to use the asynchronous LoRa modem API with asyncio. Support for asyncio must be included in your MicroPython build to use lora-async.

Second, install at least one of the following modem chipset drivers for the modem model that matches your hardware:

  • lora-sx126x for SX1261 & SX1262 support.
  • lora-sx127x for SX1276-SX1279 support.
  • lora-stm32wl5 for STM32WL55 support.

It's recommended to install only the packages that you need, to save firmware size.

Installing any of these packages will automatically also install a common base package, lora.

For more information about how to install packages, or "freeze" them into a firmware image, consult the MicroPython documentation on "Package management".

Initializing Driver

Creating SX1262 or SX1261

This is the synchronous modem class, and requires lora-sync to be installed:

from machine import SPI, Pin
import lora import SX1262  # or SX1261, depending on which you have

def get_modem():
    # The LoRa configuration will depend on your board and location, see
    # below under "Modem Configuration" for some possible examples.
    lora_cfg = { 'freq_khz': SEE_BELOW_FOR_CORRECT_VALUE }

    # To instantiate SPI correctly, see
    # https://docs.micropython.org/en/latest/library/machine.SPI.html
    spi = SPI(0, baudrate=2000_000)
    cs = Pin(9)

    # or SX1261(), depending on which you have
    return SX1262(spi, cs,
                 busy=Pin(2),  # Required
                 dio1=Pin(20),   # Optional, recommended
                 reset=Pin(15),  # Optional, recommended
                 lora_cfg=lora_cfg)

modem = get_modem()

Creating SX127x

This is the synchronous modem class, and requires lora-sync to be installed:

from machine import SPI, Pin
# or SX1277, SX1278, SX1279, depending on which you have
from lora import SX1276

def get_modem():
    # The LoRa configuration will depend on your board and location, see
    # below under "Modem Configuration" for some possible examples.
    lora_cfg = { 'freq_khz': SEE_BELOW_FOR_CORRECT_VALUE }

    # To instantiate SPI correctly, see
    # https://docs.micropython.org/en/latest/library/machine.SPI.html
    spi = SPI(0, baudrate=2000_000)
    cs = Pin(9)

    # or SX1277, SX1278, SX1279, depending on which you have
    return SX1276(spi, cs,
                  dio0=Pin(10),  # Optional, recommended
                  dio1=Pin(11),  # Optional, recommended
                  reset=Pin(13),  # Optional, recommended
                  lora_cfg=lora_cfg)

modem = get_modem()

Note: Because SX1276, SX1277, SX1278 and SX1279 are very similar, currently the driver uses the same code for any. Dealing with per-part limitations (for example: lower max frequency, lower maximum SF value) is responsibility of the calling code. When possible please use the correct class anyhow, as per-part code may be added in the future.

Creating STM32WL55

from lora import WL55SubGhzModem

def get_modem():
    # The LoRa configuration will depend on your board and location, see
    # below under "Modem Configuration" for some possible examples.
    lora_cfg = { 'freq_khz': SEE_BELOW_FOR_CORRECT_VALUE }
    return WL55SubGhzModem(lora_cfg)

modem = get_modem()

Note: As this is an internal peripheral of the STM32WL55 microcontroller, support also depends on MicroPython being built for a board based on this microcontroller.

Notes about initialisation

  • See below for details about the lora_cfg structure that configures the modem's LoRa registers.
  • Connecting radio "dio" pins as shown above is optional but recommended so the driver can use pin interrupts for radio events. If not, the driver needs to poll the chip instead. Interrupts allow reduced power consumption and may also improve receive sensitivity (by removing SPI bus noise during receive operations.)

All constructor parameters

Here is a full list of parameters that can be passed to both constructors:

S1261/SX1262

(Note: It's important to instantiate the correct object as these two modems have small differences in their command protocols.)

Parameter Required Description
spi Yes Instance of a machine.SPI object or compatible, for the modem's SPI interface (modem MISO, MOSI, SCK pins).
cs Yes Instance of a machine.Pin input, as connected to the modem's NSS pin.
busy Yes Instance of a machine.Pin input, as connected to the modem's BUSY pin.
dio1 No Instance of a machine.Pin input, as connected to the modem's DIO1 pin. If not provided then interrupts cannot be used to detect radio events.
dio2_rf_sw No, defaults to True By default, configures the modem's DIO2 pin as an RF switch. The modem will drive this pin high when transmitting and low otherwise. Set this parameter to False if DIO2 is connected elsewhere on your LoRa board/module and you don't want it toggling on transmit.
dio3_tcxo_millivolts No If set to an integer value, DIO3 will be used as a variable voltage source for the modem's main TCXO clock source. DIO3 will automatically disable the TCXO to save power when the 32MHz clock source is not needed. The value is units of millivolts and should be one of the voltages listed in the SX1261 datasheet section 13.3.6 "SetDIO3AsTCXOCtrl". Any value between 1600 and 3300 can be specified and the driver will round down to a lower supported voltage step if necessary. The manufacturer of the LoRa board or module you are using should be able to tell you what value to pass here, if any.
dio3_tcxo_start_time_us No This value is ignored unless dio3_tcxo_millivolts is set, and is the startup delay in microseconds for the TCXO connected to DIO3. Each time the modem needs to enable the TCXO, it will wait this long. The default value is 1000 (1ms). Values can be set in multiples of 15.625us and range from 0us to 262 seconds (settings this high will make the modem unusable).
reset No If set to a machine.Pin output attached to the modem's NRESET pin , then it will be used to hard reset the modem before initializing it. If unset, the programmer is responsible for ensuring the modem is in an idle state when the constructor is called.
lora_cfg No If set to an initial LoRa configuration then the modem is set up with this configuration. If not set here, can be set by calling configure() later on.
ant_sw No Optional antenna switch object instance, see below for description.

SX1276/SX1277/SX1278/SX1279

Parameter Required Description
spi Yes Instance of a machine.SPI object or compatible, for the modem's SPI interface (modem MISO, MOSI, SCK pins).
cs Yes Instance of a machine.Pin input, as connected to the modem's NSS pin.
dio0 No Instance of a machine.Pin input, as connected to the modem's DIO0 pin. If set, allows the driver to use interrupts to detect "RX done" and "TX done" events.
dio1 No Instance of a machine.Pin input, as connected to the modem's DIO1/DCLK pin. If set, allows the driver to use interrupts to detect "RX timeout" events. Setting this pin requires dio0 to also be set.
reset No If set to a machine.Pin output attached the modem's NRESET pin , it will be used to hard reset the modem before initializing it. If unset, the programmer is responsible for ensuring the modem should be is in an idle state when the object is instantiated.
lora_cfg No If set to an initial LoRa configuration then the modem is set up with this configuration. If not set here, can be set by calling configure() later on.
ant_sw No Optional antenna switch object instance, see below for description.

STM32WL55

Parameter Required Description
lora_cfg No If set to an initial LoRa configuration then the modem is set up with this configuration. If not set here, can be set by calling configure() later on.
tcxo_millivolts No Defaults to 1700. The voltage supplied on pin PB0_VDDTCXO. See dio3_tcxo_millivolts above for details, this parameter has the same behaviour.
ant_sw No Defaults to an instance of lora.NucleoWL55RFConfig class for the NUCLEO-WL55 development board. Set to None to disable any automatic antenna switching. See below for description.

Modem Configuration

It is necessary to correctly configure the modem before use. At minimum, the correct RF frequency must be set. There are many additional LoRa modem radio configuration settings that may be important. For two LoRa modem modems to communicate, their radio configurations must be compatible.

Different regions in the world also have RF regulations which you must abide by. Check RF communications regulations for the location you are in, to determine which configurations are legal for you to use.

Modem configuration can be set in two ways:

  • Pass lora_cfg keyword parameter to the modem class constructor (see examples above).
  • Call modem.configure(lora_cfg) at any time

Where lora_cfg is a dict containing configuration keys and values. If a key is missing, the value set in the modem is unchanged.

Basic Configuration

The minimal configuration is the modem frequency:

lora_cfg = { 'freq_khz': 916000 }
modem.configure(lora_cfg)

The example above sets the main frequency to 916.0MHz (916,000kHz), and leaves the rest of the modem settings at their defaults. If you have two of the same module using this driver, setting the same frequency on both like this may be enough for them to communicate. However, the other default settings are a compromise that may not give the best range, bandwidth, reliability, etc. for your application. The defaults may not even be legal to transmit with in your region!

Other parameters, particularly Bandwidth (lora_cfg["bw"]), Spreading Factor (lora_cfg["sf"]), and transmit power (lora_cfg["output_power"]) may be limited in your region. You should find this out as well and ensure the configuration you are using is allowed. This is your responsibility!

Defaults

If you don't configure anything, the default settings are equivalent to:

lora_cfg = { 'freq_khz': None, # Must set this
             'sf': 7,
             'coding_rate': 5,  # 4/5 Coding
             'bw': '125',
             }

With the default output_power level depending on the radio in use.

Choosing Other Parameters

Valid choices are determined by two things:

  • Regulatory rules in your region. This information is provided by regional authorities, but it may also be useful to consult the LoRaWAN Regional Parameters document (official) and the (Things Network (unofficial) for LoRaWAN frequency plans.

    Even if you're not connecting to a LoRaWAN network, if you choose frequency and bandwidth settings from the LoRaWAN recommendations for your region then they should be legal to use.

  • Design of the radio module/modem/board you are using. RF antenna components are usually tailored for particular frequency ranges. One some boards only particular antenna ports or other features may be connected.

Longer Range Configuration

Here is an example aiming for higher range and lower data rate with the main frequency set again to 916Mhz (for the "AU915" Australian region):

lora_cfg = { 'freq_khz': 916000,
             'sf': 12,
             'bw': '62.5',  # kHz
             'coding_rate': 8,
             'output_power': 20,  # dBm
             'rx_boost': True,
         }

Quick explanation of these settings, for more detailed explanations see the next section below:

  • Setting sf to maximum (higher "Spreading Factor") means each LoRa "chirp" takes longer, for more range but lower data rate.
  • Setting bw bandwidth setting lower makes the signal less susceptible to noise, but again slower. 62.5kHz is the lowest setting recommended by Semtech unless the modem uses a TCXO for high frequency stability, rather than a cheaper crystal.
  • Setting coding_rate higher to 4/8 means that more Forward Error Correction information is sent, slowing the data rate but increasing the chance of a packet being received correctly.
  • Setting output_power to 20dBm will select the maximum (or close to it) for the radio, which may be less than 20dBm.
  • Enabling rx_boost will increase the receive sensitivity of the radio, if it supports this.
Additional Australia-specific regulatory explanation

The LoRaWAN AU915 specifications suggest 125kHz bandwidth. To tell that it's OK to set bw lower, consult the Australian Low Interference Potential Devices class license. This class license allows Digital Modulation Transmitters in the 915-928MHz band to transmit up to 1W Maximum EIRP provided "The radiated peak power spectral density in any 3 kHz must not exceed 25 mW per 3 kHz".

output_power set to 20dBm is 100mW, over 62.5kHz bandwidth gives 1.6mW/kHz. This leaves significant headroom for antenna gain that might increase radiated power in some directions.)

Configuration Keys

These keys can be set in the lora_cfg dict argument to configure(), and correspond to the parameters documented in this section.

Consult the datasheet for the LoRa modem you are using for an in-depth description of each of these parameters.

Values which are unset when configure() is called will keep their existing values.

freq_khz - RF Frequency

Type: int (recommended) or float (if supported by port)

LoRa RF frequency in kHz. See above for notes about regulatory limits on this value.

The antenna and RF matching components on a particular LoRa device may only support a particular frequency range. Consult the manufacturer's documentation.

sf - Spreading Factor

Type: int

Spreading Factor, numeric value only. Higher spreading factors allow reception of weaker signals but have slower data rate.

The supported range of SF values varies depending on the modem chipset:

Spreading Factor Supported SX126x Supported SX127x
5 Yes No
6 Yes [*] Yes [*]
7 Yes Yes
8 Yes Yes
9 Yes Yes
10 Yes Yes, except SX1277[^]
11 Yes Yes, except SX1277[^]
12 Yes Yes, except SX2177[^]

[*] SF6 is not compatible between SX126x and SX127x chipsets.

[^] SX1276, SX1278 and SX1279 all support SF6-SF12. SX1277 only supports SF6-SF9. This limitation is not checked by the driver.

bw - Bandwidth

Type: int or str

Default: 125

Bandwidth value in kHz. Must be exactly one of these LoRa bandwidth values:

  • 7.8
  • 10.4
  • 15.6
  • 20.8
  • 31.25
  • 41.7
  • 62.5
  • 125
  • 250
  • 500

Higher bandwidth transmits data faster and reduces peak spectral density when transmitting, but is more susceptible to interference.

IF setting bandwidth below 62.5kHz then Semtech recommends using a hardware TCXO as the modem clock source, not a cheaper crystal. Consult the modem datasheet and your hardware maker's reference for more information and to determine which clock source your LoRa modem hardware is using.

For non-integer bandwidth values, it's recommended to always set this parameter as a str (i.e. "15.6") not a numeric float.

coding_rate - FEC Coding Rate

Type: int

Default: 5

Forward Error Correction (FEC) coding rate is expressed as a ratio, 4/N. The value passed in the configuration is N:

Value Error coding rate
5 4/5
6 4/6
7 4/7
8 4/8

Setting a higher value makes transmission slower but increases the chance of receiving successfully in a noisy environment

In explicit header mode (the default), coding_rate only needs to be set by the transmitter and the receiver will automatically choose the correct rate when receiving based on the received header. In implicit header mode (see implicit_header), this value must be set the same on both transmitter and receiver.

tx_ant - TX Antenna

Supported: SX127x only.

Type: str, not case sensitive

Default: RFO_HF or RFO_LF (low power)

SX127x modems and STM32WL55 microcontrollers have multiple antenna pins for different power levels and frequency ranges. The board/module that the LoRa modem chip is on may have particular antenna connections, or even an RF switch that needs to be set via a GPIO to connect an antenna pin to a particular output (see ant_sw, below).

The driver must configure the modem to use the correct pin for a particular hardware antenna connection before transmitting. When receiving, the modem chooses the correct pin based on the selected frequency.

A common symptom of incorrect tx_ant setting is an extremely weak RF signal.

Consult modem datasheet for more details.

SX127x tx_ant
Value RF Transmit Pin
"PA_BOOST" PA_BOOST pin (high power)
Any other value RFO_HF or RFO_LF pin (low power)

Pin "RFO_HF" is automatically used for frequencies above 862MHz, and is not supported on SX1278. "RFO_LF" is used for frequencies below 862MHz. Consult datasheet Table 32 "Frequency Bands" for more details.

WL55SubGhzModem tx_ant
Value RF Transmit Pin
"PA_BOOST" RFO_HP pin (high power)
Any other value RFO_LP pin (low power)

NOTE: Currently the PA_BOOST HP antenna output is lower than it should be on this board, due to an unknown driver bug.

If setting tx_ant value, also set output_power at the same time or again before transmitting.

output_power - Transmit output power level

Type: int

Default: Depends on modem

Nominal TX output power in dBm. The possible range depends on the modem and for some modems the tx_ant configuration.

Modem tx_ant value Range (dBm) "Optimal" (dBm)
SX1261 N/A -17 to +15 +10, +14 or +15 [*][^]
SX1262 N/A -9 to +22 +14, +17, +20, +22 [*]
SX127x "PA_BOOST" +2 to +17, or +20 Any
SX127x RFO_HF or RFO_LF -4 to +15 Any
WL55SubGhzModem "PA_BOOST" -9 to +22 +14, +17, +20, +22 [*]
WL55SubGhzModem Any other value (not None) -17 to +14 +10, +14 or +15 [*][^]

Values which are out of range for the modem will be clamped at the minimum/maximum values shown above.

Actual radiated TX power for RF regulatory purposes depends on the RF hardware, antenna, and the rest of the modem configuration. It should be measured and tuned empirically not determined from this configuration information alone.

[*] For some modems the datasheet shows "Optimal" Power Amplifier configuration values for these output power levels. If setting one of these levels, the optimal settings from the datasheet are applied automatically by the driver. Therefore it is recommended to use one of these power levels if possible.

[^] In the marked configurations +15dBm is only possible with frequency above 400MHz, will be +14dBm otherwise.

implicit_header - Implicit/Explicit Header Mode

Type: bool

Default: False

LoRa supports both implicit and explicit header modes. Explicit header mode (implicit_header set to False) is the default.

implicit_header must be set the same on both sender and receiver.

  • In explicit header mode (default), each transmitted LoRa packet has a header which contains information about the payload length, coding_rate value in use, and whether the payload has a CRC attached (crc_en). The receiving modem decodes and verifies the header and uses the values to receive the correct length payload and verify the CRC if enabled.
  • In implicit header mode (implicit_header set to True), this header is not sent and this information must be already be known and configured by both sender and receiver. Specifically:
    • crc_en setting should be set the same on both sender and receiver.
    • coding_rate setting must match between the sender and receiver.
    • Receiver must provide the rx_length argument when calling either recv() or start_recv(). This length must match the length in bytes of the payload sent by the sender.

crc_en - Enable CRCs

Type: bool

Default: True

LoRa packets can have a 16-bit CRC attached to determine if a packet is received correctly without corruption.

  • In explicit header mode (default), the sender will attach a CRC if crc_en is True. crc_en parameter is ignored by the receiver, which determines if there is a CRC based on the received header and will check it if so.
  • In implicit header mode, the sender will only include a CRC if crc_en is True and the receiver will only check the CRC if crc_en is True.

By default, if CRC checking is enabled on the receiver then the LoRa modem driver silently drops packets with invalid CRCs. Setting modem.rx_crc_error = True will change this so that packets with failed CRCs are returned to the caller, with the crc_error field set to True (see RxPacket, below).

auto_image_cal - Automatic Image Calibration

Supported: SX127x only.

Type: bool

Default: False

If set True, enable automatic image re-calibration in the modem if the temperature changes significantly. This may avoid RF performance issues caused by frequency drift, etc. Setting this value may lead to dropped packets received when an automatic calibration event is in progress.

Consult SX127x datasheet for more information.

syncword - Sync Word

Type: int

Default: 0x12

LoRa Sync Words are used to differentiate LoRa packets as being for Public or Private networks. Sync Word must match between sender and receiver.

For SX127x this value is an 8-bit integer. Supported values 0x12 for Private Networks (default, most users) and 0x34 for Public Networks (LoRaWAN only).

For SX126x this value is a 16-bit integer. Supported values 0x1424 for Private

Networks (default, most users) and 0x3444 for Public Networks. However the driver will automatically translate values configured using the 8-bit SX127x format for software compatibility, so setting an 8-bit value is supported on all modems.

You probably shouldn't change this value from the default, unless connecting to a LoRaWAN network.

pa_ramp_us - PA Ramp Time

Type: int

Default: 40us

Power Amplifier ramp up/down time, as expressed in microseconds.

The exact values supported on each radio are different. Configuring an unsupported value will cause the driver to choose the next highest value that is supported for that radio.

Value (us) Supported SX126x Supported SX127x
10 Yes Yes
12 No Yes
15 No Yes
20 Yes Yes
25 No Yes
31 No Yes
40 Yes Yes
50 No Yes
62 No Yes
80 Yes No
100 No Yes
125 No Yes
200 Yes No
250 No Yes
500 No Yes
800 Yes No
1000 No Yes
1700 Yes No
2000 No Yes
3400 Yes Yes

preamble_len - Preamble Length

Type: int Default: 12

Length of the preamble sequence, in units of symbols.

invert_iq_tx/invert_iq_rx - Invert I/Q

Type: bool

Default: Both False

If invert_iq_tx or invert_iq_rx is set then IQ polarity is inverted in the radio for either TX or RX, respectively. The receiver's invert_iq_rx setting must match the sender's invert_iq_tx setting.

This is necessary for LoRaWAN where end-devices transmit with inverted IQ relative to gateways.

Note: The current SX127x datasheet incorrectly documents the modem register setting corresponding to invert_iq_tx. This driver configures TX polarity correctly for compatibility with other LoRa modems, most other SX127x drivers, and LoRaWAN. However, there are some SX127x drivers that follow the datasheet description, and they will set invert_iq_tx opposite to this.

rx_boost - Boost receive sensitivity

Type: bool

Default: False

Enable additional receive sensitivity if available.

  • On SX126x, this makes use of the "Rx Boosted gain" option.
  • On SX127x, this option is available for HF bands only and sets the LNA boost register field.

lna_gain - Receiver LNA gain

Supported: SX127x only.

Type: int or None

Default: 1

Adjust the LNA gain level for receiving. Valid values are None to enable Automatic Gain Control, or integer gain levels 1 to 6 where 1 is maximum gain (default).

Sending & Receiving

Simple API

The driver has a "simple" API to easily send and receive LoRa packets. The API is fully synchronous, meaning the caller is blocked until the LoRa operation (send or receive) is done. The Simple API doesn't support starting a send while a receive in progress (or vice versa). It is suitable for simple applications only.

For an example that uses the simple API, see examples/reliable_delivery/sender.py.

send

To send (transmit) a LoRa packet using the configured modulation settings:

def send(self, packet, tx_at_ms=None)

Example:

modem.send(b'Hello world')
  • send() transmits a LoRa packet with the provided payload bytes, and returns once transmission is complete.
  • The return value is the timestamp when transmission completed, as a time.ticks_ms() result. It will be more accurate if the modem was initialized to use interrupts.

For precise timing of sent packets, there is an optional tx_at_ms argument which is a timestamp (as a time.ticks_ms() value). If set, the packet will be sent as close as possible to this timestamp and the function will block until that time arrives:

modem.send(b'Hello world', time.ticks_add(time.ticks_ms(), 250))

(This allows more precise timing of sent packets, without needing to account for the length of the packet to be copied to the modem.)

receive

def recv(self, timeout_ms=None, rx_length=0xFF, rx_packet=None)

Examples:

with_timeout = modem.recv(2000)

print(repr(with_timeout))

wait_forever = modem.recv()

print(repr(wait_forever))
  • recv() receives a LoRa packet from the modem.
  • Returns None on timeout, or an RxPacket instance with the packet on success.
  • Optional arguments:
    • timeout_ms. Optional, sets a receive timeout in milliseconds. If None (default value), then the function will block indefinitely until a packet is received.
    • rx_length. Necessary to set if implicit_header is set to True (see above). This is the length of the packet to receive. Ignored in the default LoRa explicit header mode, where the received radio header includes the length.
    • rx_packet. Optional, this can be an RxPacket object previously received from the modem. If the newly received packet has the same length, this object is reused and returned to save an allocation. If the newly received packet has a different length, a new RxPacket object is allocated and returned instead.

RxPacket

RxPacket is a class that wraps a bytearray holding the LoRa packet payload, meaning it can be passed anywhere that accepts a buffer object (like bytes, bytearray).

However it also has the following metadata object variables:

  • ticks_ms - is a timestamp of time.ticks_ms() called at the time the packet was received. Timestamp will be more accurate if the modem was initialized to use interrupts.

  • snr - is the Signal to Noise ratio of the received packet, in units of dB * 4. Higher values indicate better signal.

  • rssi - is the Received Signal Strength indicator value in units of dBm. Higher (less negative) values indicate more signal strength.

  • crc_error - In the default configuration, this value will always be False as packets with invalid CRCs are dropped. If the modem.rx_crc_error flag is set to True, then a packet with an invalid CRC will be returned with this flag set to True.

    Note that CRC is only ever checked on receive in particular configurations, see the crc_en configuration item above for an explanation. If CRC is not checked on receive, and crc_error will always be False.

Example:

rx = modem.recv(1000)

if rx:
    print(f'Received {len(rx)} byte packet at '
          f'{rx.ticks_ms}ms, with SNR {rx.snr} '
          f'RSSI {rx.rssi} valid_crc {rx.valid_crc}')

Asynchronous API

Not being able to do anything else while waiting for the modem is very limiting. Async Python is an excellent match for this kind of application!

To use async Python, first install lora-async and then instantiate the async version of the LoRA modem class. The async versions have the prefix Async at the beginning of the class name. For example:

import asyncio
from lora import AsyncSX1276

def get_async_modem():
    # The LoRa configuration will depend on your board and location, see
    # below under "Modem Configuration" for some possible examples.
    lora_cfg = { 'freq_khz': SEE_BELOW_FOR_CORRECT_VALUE }

    # To instantiate SPI correctly, see
    # https://docs.micropython.org/en/latest/library/machine.SPI.html
    spi = SPI(0, baudrate=2000_000)
    cs = Pin(9)

    # or AsyncSX1261, AsyncSX1262, AsyncSX1277, AsyncSX1278, SX1279, etc.
    return AsyncSX1276(spi, cs,
                       dio0=Pin(10),  # Optional, recommended
                       dio1=Pin(11),  # Optional, recommended
                       reset=Pin(13),  # Optional, recommended
                       lora_cfg=lora_cfg)

modem = get_async_modem()

async def recv_coro():
    rx = await modem.recv(2000)
    if rx:
        print(f'Received: {rx}')
    else:
        print('Timeout!')


async def send_coro():
    counter = 0
    while True:
        await modem.send(f'Hello world #{counter}'.encode())
        print('Sent!')
        await asyncio.sleep(5)
        counter += 1

async def init():
    await asyncio.gather(
        asyncio.create_task(send_coro()),
        asyncio.create_task(recv_coro())
    )

asyncio.run(init())

For a more complete example, see examples/reliable_delivery/sender_async.py.

  • The modem.recv() and modem.send() coroutines take the same arguments as the synchronous class' functions recv() and send(), as documented above.
  • However, because these are async coroutines it's possible for other async tasks to execute while they are blocked waiting for modem operations.
  • It is possible to await the send() coroutine while a recv() is in progress. The receive will automatically resume once the modem finishes sending. Send always has priority over receive.
  • However, at most one task should be awaiting each of receive and send. For example, it's not possible for two tasks to await modem.send() at the same time.

Async Continuous Receive

An additional API provides a Python async iterator that will continuously receive packets from the modem:

async def keep_receiving():
    async for packet in am.recv_continuous():
        print(f'Received: {packet}')

For a more complete example, see examples/reliable_delivery/receiver_async.py.

Receiving will continue and the iterator will yield packets unless another task calls modem.stop() or modem.standby() (see below for a description of these functions).

Same as the async recv() API, it's possible for another task to send while this iterator is in use.

Low-Level API

This API allows other code to execute while waiting for LoRa operations, without using asyncio coroutines.

This is a traditional asynchronous-style API that requires manual management of modem timing, interrupts, packet timeouts, etc. It's very easy to write spaghetti code with this API. If asyncio is available on your board, the async Python API is probably an easier choice to get the same functionality with less complicated code.

However, if you absolutely need maximum control over the modem and the rest of your board then this may be the API for you!

Receiving

will_irq =  modem.start_recv(timeout_ms=1000, continuous=False)

rx = True
while rx is True:
    if will_irq:
        # Add code to sleep and wait for an IRQ,
        # if necessary call modem.irq_triggered() to verify
        # that the modem IRQ was actually triggered.
        pass
    rx = modem.poll_recv()

    # Do anything else you need the application to do

if rx:  # isinstance(rx, lora.RxPacket)
    print(f'Received: {rx}')
else:  # rx is False
    print('Timed out')

For an example that uses the low-level receive API for continuous receive, see examples/reliable_delivery/receiver.py.

The steps to receive packet(s) with the low-level API are:

  1. Call modem.start_recv(timeout_ms=None, continuous=False, rx_length=0xFF).

    • timeout_ms is an optional timeout in milliseconds, same as the Simple API recv().
    • Set continuous=True for the modem to continuously receive and not go into standby after the first packet is received. If setting continuous to True, timeout_ms must be None.
    • rx_length is an optional argument, only used when LoRa implicit headers are configured. See the Simple API description above for details.

    The return value of this function is truthy if interrupts will be used for the receive, falsey otherwise.

  2. If interrupts are being used, wait for an interrupt to occur. Steps may include configuring the modem interrupt pins as wake sources and putting the host into a light sleep mode. See the general description of "Interrupts", below.

    Alternatively, if timeout_ms was set then caller can wait for at least the timeout period before checking if the modem received anything or timed out.

    It is also possible to simply call poll_recv() in a loop, but doing this too frequently may significantly degrade the RF receive performance depending on the hardware.

  3. Call modem.poll_recv(). This function checks the receive state and returns a value indicating the current state:

    • True if the modem is still receiving and the caller should call this function again in the future. This can be caused by any of:

      • Modem is still waiting in 'single' mode (continuous=False) to receive a packet or time out.
      • Modem is in continuous receive mode so will always be receiving.
      • The modem is actually sending right now, but the driver will resume receiving after the send completes.
      • The modem received a packet with an invalid CRC (and modem.rx_crc_error = False). The driver has just now discarded it and resumed the modem receive operation.
    • False if the modem is not currently receiving. This can be caused by any of:

      • No receive has been started.
      • A single receive has timed out.
      • The receive was aborted. See the standby() and sleep() functions below.
    • An instance of the RxPacket class. This means the modem has received this packet since the last call to poll_recv(). Whether or not the modem is still receiving after this depends on whether the receive was started in continuous mode or not.)

  4. If poll_recv() returned True, go back to step 2 and wait for the next opportunity to call poll_recv(). (Note that it's necessary to test using is True to distinguish between True and a new packet.)

It is possible to also send packets while receiving and looping between steps 2 and 4. The driver will automatically suspend receiving and resume it again once sending is done. It's OK to call either the Simple API send() function or the low-level send API (see below) in order to do this.

The purpose of the low-level API is to allow code to perform other unrelated functions during steps 2 and 3. It's still recommended to call modem.poll_recv() as soon as possible after a modem interrupt has occurred, especially in continuous receive mode when multiple packets may be received rapidly.

To cancel a receive in progress, call modem.standby() or modem.sleep(), see below for descriptions of these functions.

Important: None of the MicroPython lora driver is thread-safe. It's OK for different MicroPython threads to manage send and receive, but the caller is responsible for adding locking so that different threads are not calling any modem APIs concurrently. Async MicroPython may provide a cleaner and simpler choice for this kind of firmware architecture.

Sending

The low-level API for sending is similar to the low-level API for receiving:

  1. Call modem.prepare_send(payload) with the packet payload. This will put the modem into standby (pausing receive if necessary), configure the modem registers, and copy the payload into the modem FIFO buffer.

  2. Call modem.start_send(packet) to actually start sending.

    Sending is split into these two steps to allow accurate send timing. prepare_send() may take a variable amount of time to copy data to the modem, configure registers, etc. Then start_send() only performs the minimum fixed duration operation to start sending, so transmit should start very soon after this function is called.

    The return value of start_send() function is truthy if an interrupt is enabled to signal the send completing, falsey otherwise.

    Not calling both prepare_send() or start_send() in order, or calling any other modem functions between prepare_send() and start_send(), is not supported and will result in incorrect behaviour.

  3. Wait for the send to complete. This is possible in any of three different ways:

    • If interrupts are enabled, wait for an interrupt to occur. Steps may include configuring the modem interrupt pins as wake sources and putting the host into a light sleep mode. See the general description of "Interrupts", below.
    • Calculate the packet "time on air" by calling modem.get_time_on_air_us(len(packet)) and wait at least this long.
    • Call modem.poll_send() in a loop (see next step) until it confirms the send has completed.
  4. Call modem.poll_send() to check transmission state, and to automatically resume a receive operation if one was suspended by prepare_send(). The result of this function is one of:

    • True if a send is in progress and the caller should call again.

    • False if no send is in progress.

    • An int value. This is returned the first time poll_send() is called after a send ended. The value is the time.ticks_ms() timestamp of the time that the send completed. If interrupts are enabled, this is the time the "send done" ISR executed. Otherwise, it will be the time that poll_send() was just called.

    Note that modem.poll_send() returns an int only one time per successful transmission. Any subsequent calls will return False as there is no longer a send in progress.

    To abort a send in progress, call modem.standby() or modem.sleep(), see the descriptions of these functions below. Subsequent calls to poll_send() will return False.

  5. If poll_send() returned True, repeat steps 3 through 5.

Important: Unless a transmission is aborted, poll_send() MUST be called at least once after start_send() and should be repeatedly called until it returns a value other than True. poll_send() can also be called after a send is aborted, but this is optional. If poll_send() is not called correctly then the driver's internal state will not correctly update and no subsequent receive will be able to start.

It's also possible to mix the simple send() API with the low-level receive API, if this is more convenient for your application.

Interrupts

If interrupt pins are in use then it's important for a programmer using the low-level API to handle interrupts correctly.

It's only possible to rely on interrupts if the correct hardware interrupt lines are configured. Consult the modem reference datasheet, or check if the value of start_recv() or start_send() is truthy, in order to know if hardware interrupts can be used. Otherwise, the modem must be polled to know when an operation has completed.

There are two kinds of interrupts:

  • A hardware interrupt (set in the driver by Pin.irq()) will be triggered on the rising edge of a modem interrupt line (DIO0, DIO1, etc). The driver will attempt to configure these for RX Done, RX Timeout and TX Done events if possible and applicable for the modem operation, and will handle them.

    It's possible for the programmer to configure these pins as hardware wake sources and put the board into a low-power sleep mode, to be woken when the modem finishes its operation.

  • A "soft" interrupt is triggered by the driver if an operation is aborted (see standby() and sleep(), below), or if a receive operation "soft times out". A receive "soft times out" if a receive is paused by a send operation and after the send operation completes then the timeout period for the receive has already elapsed. In these cases, the driver's radio ISR routine is called but no hardware interrupt occurs.

To detect if a modem interrupt has occurred, the programmer can use any of the following different approaches:

  • Port-specific functions to determine a hardware wakeup cause. Note that this can only detect hardware interrupts.
  • Call the modem.irq_triggered() function. This is a lightweight function that returns True if the modem ISR has been executed since the last time a send or receive started. It is cleared when poll_recv() or poll_send() is called after an interrupt, or when a new operation is started. The idea is to use this as a lightweight "should I call poll_recv() or poll_send() now?" check function if there's no easy way to determine which interrupt has woken the board up.
  • Implement a custom interrupt callback function and call modem.set_irq_callback() to install it. The function will be called if a hardware interrupt occurs, possibly in hard interrupt context. Refer to the documentation about writing interrupt handlers for more information. It may also be called if the driver triggers a soft interrupt. The lora-async modem classes install their own callback here, so it's not possible to mix this approach with the provided asynchronous API.
  • Call modem.poll_recv() or modem.poll_send(). This takes more time and uses more power as it reads the modem IRQ status directly from the modem via SPI, but it also give the most definite result.

As a "belts and braces" protection against unknown driver bugs or modem bugs, it's best practice to not rely on an interrupt occurring and to also include some logic that periodically times out and polls the modem state "just in case".

Other Functions

CRC Error Counter

Modem objects have a variable modem.crc_errors which starts at 0 and is incremented by one each time a received CRC error or packet header error is detected by the modem. The programmer can read this value to know the current CRC error count, and also write it (for example, to clear it periodically by setting to 0).

For an alternative method to know about CRC errors when they occur, set modem.rx_crc_error = True (see crc_en, above, for more details.)

Modem Standby

Calling modem.standby() puts the modem immediately into standby mode. In the case of SX1261 and SX1262, the 32MHz oscillator is started.

Any current send or receive operations are immediately aborted. The implications of this depends on the API in use:

  • The simple API does not support calling standby() while a receive or send is in progress.
  • The async API handles this situation automatically. Any blocked send() or recv() async coroutine will return None. The recv_continuous() iterator will stop iterating.
  • The low-level API relies on the programmer to handle this case. When the modem goes to standby, a "soft interrupt" occurs that will trigger the radio ISR and any related callback, but this is not a hardware interrupt so may not wake the CPU if the programmer has put it back to sleep. Any subsequent calls to poll_recv() or poll_send() will both return (False, None) as no operation is in progress. The programmer needs to ensure that any code that is blocking waiting for an interrupt has the chance to wake up and call poll_recv() and/or poll_send() to detect that the operation(s) have been aborted.

Modem Sleep

Calling modem.sleep() puts the modem into a low power sleep mode with configuration retention. The modem will automatically wake the next time an operation is started, or can be woken manually by calling modem.standby(). Waking the modem may take some time, consult the modem datasheet for details.

As with standby(), any current send or receive operations are immediately aborted. The implications of this are the same as listed for standby, above.

Check if modem is idle

The modem.is_idle() function will return True unless the modem is currently sending or receiving.

Packet length calculations

Calling modem.get_time_on_air_us(plen) will return the "on air time" in microseconds for a packet of length plen, according to the current modem configuration. This can be used to synchronise modem operations, choose timeouts, or predict when a send will complete.

Unlike the other modem API functions, this function doesn't interact with hardware at all so it can be safely called concurrently with other modem APIs.

Antenna switch object

The modem constructors have an optional ant_sw parameter which allows passing in an antenna switch object to be called by the driver. This allows automatically configuring some GPIOs or other hardware settings each time the modem changes between TX and RX modes, and goes idle.

The argument should be an object which implements three functions: tx(tx_arg), rx(), and idle(). For example:

class MyAntennaSwitch:
    def tx(self, tx_arg):
        ant_sw_gpio(1)  # Set GPIO high

    def rx(self):
        ant_sw_gpio(0)  # Set GPIO low

    def idle(self):
        pass
  • tx() is called a short time before the modem starts sending.
  • rx() is called a short time before the modem starts receiving.
  • idle() is called at some point after each send or receive completes, and may be called multiple times.

The meaning of tx_arg depends on the modem:

  • For SX127x it is True if the PA_BOOST tx_ant setting is in use (see above), and False otherwise.
  • For SX1262 it is True (indicating High Power mode).
  • For SX1261 it is False (indicating Low Power mode).
  • For WL55SubGhzModem it is True if the PA_BOOST tx_ant setting is in use (see above), and False otherwise.

This parameter can be ignored if it's already known what modem and antenna is being used.

WL55SubGhzModem ant_sw

When instantiating the WL55SubGhzModem and AsyncWL55SubGHzModem classes, the default ant_sw parameter is not None. Instead, the default will instantiate an object of type lora.NucleoWL55RFConfig. This implements the antenna switch connections for the ST NUCLEO-WL55 development board (as connected to GPIO pins C4, C5 and C3). See ST document UM2592 (PDF) Figure 18 for details.

When using these modem classes (only), to disable any automatic antenna switching behaviour it's necessary to explicitly set ant_sw=None.

Troubleshooting

Some common errors and their causes:

RuntimeError: BUSY timeout

The SX1261/2 drivers will raise this exception if the modem's TCXO fails to provide the necessary clock signal when starting a transmit or receive operation, or moving into "standby" mode.

Sometimes, this means the constructor parameter dio3_tcxo_millivolts (see above) must be set as the SX126x chip DIO3 output pin is the power source for the TCXO connected to the modem. Often this parameter should be set to 3300 (3.3V) but it may be another value, consult the documentation for your LoRa modem module.