diff --git a/micropython/lora/README.md b/micropython/lora/README.md
new file mode 100644
index 00000000..f4786afd
--- /dev/null
+++ b/micropython/lora/README.md
@@ -0,0 +1,1156 @@
+# 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
+
+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](https://docs.micropython.org/en/latest/library/asyncio.html). 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.
+
+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"](https://docs.micropython.org/en/latest/reference/packages.html).
+
+## Initializing Driver
+
+### Creating SX1262 or SX1261
+
+This is the synchronous modem class, and requires `lora-sync` to be installed:
+
+```py
+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:
+
+```py
+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.
+
+### 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.625`us 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. | |
+
+## 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:
+
+```py
+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:
+
+```py
+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)](https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters)
+ and the ([Things Network
+ (unofficial)](https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/)
+ 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):
+
+```py
+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](https://www.legislation.gov.au/Series/F2015L01438). 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 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 values:
+
+| 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.
+
+**Important**: If changing `tx_ant` value, configure `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
+SX127x only) the `tx_ant` configuration.
+
+| Modem | `tx_ant` value | Range | "Optimal" |
+|--------|------------------|-------------------|------------------------|
+| 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 |
+
+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 SX1261 and SX1262 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.
+
+[^] For SX1261 +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](https://www.thethingsnetwork.org/forum/t/should-private-lorawan-networks-use-a-different-sync-word/34496/15)
+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: `40`us
+
+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:
+
+```py
+def send(self, packet, tx_at_ms=None)
+```
+
+Example:
+
+```py
+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:
+
+```py
+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
+
+```py
+def recv(self, timeout_ms=None, rx_length=0xFF, rx_packet=None)
+```
+
+Examples:
+
+```py
+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:
+
+```py
+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:
+
+```py
+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:
+
+```py
+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
+
+```py
+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 with a
+ single argument, which is either the `Pin` that triggered a hardware interrupt
+ or `None` for a soft interrupt. Refer to the documentation about [writing interrupt
+ handlers](https://docs.micropython.org/en/latest/reference/isr_rules.html) for
+ more information. 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:
+
+```py
+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).
+
+This parameter can be ignored if it's already known what modem and antenna is being used.
+
+## 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.
+
+Usually, 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.
diff --git a/micropython/lora/examples/reliable_delivery/README.md b/micropython/lora/examples/reliable_delivery/README.md
new file mode 100644
index 00000000..878b0f8a
--- /dev/null
+++ b/micropython/lora/examples/reliable_delivery/README.md
@@ -0,0 +1,93 @@
+# LoRa Reliable Delivery Example
+
+This example shows a basic custom protocol for reliable one way communication
+from low-power remote devices to a central base device:
+
+- A single "receiver" device, running on mains power, listens continuously for
+ messages from one or more "sender" devices. Messages are payloads inside LoRa packets,
+ with some additional framing and address in the LoRa packet payload.
+- "Sender" devices are remote sensor nodes, possibly battery powered. These wake
+ up periodically, read some data from a sensor, and send it in a message to the receiver.
+- Messages are transmitted "reliably" with some custom header information,
+ meaning the receiver will acknowledge it received each message and the sender
+ will retry sending if it doesn't receive the acknowledgement.
+
+## Source Files
+
+* `lora_rd_settings.py` contains some common settings that are imported by
+ sender and receiver. These settings will need to be modified for the correct
+ frequency and other settings, before running the examples.
+* `receiver.py` and `receiver_async.py` contain a synchronous (low-level API)
+ and asynchronous (iterator API) implementation of the same receiver program,
+ respectively. These two programs should work the same, they are intended show
+ different ways the driver can be used.
+* `sender.py` and `sender_async.py` contain a synchronous (simple API) and
+ asynchronous (async API) implementation of the same sender program,
+ respectively. Because the standard async API resembles the Simple API, these
+ implementations are *very* similar. The two programs should work the same,
+ they are intended to show different ways the driver can be used.
+
+## Running the examples
+
+One way to run this example interactively:
+
+1. Install or "freeze in" the necessary lora modem driver package (`lora-sx127x`
+ or `lora-sx126x`) and optionally the `lora-async` package if using the async
+ examples (see main lora `README.md` in the above directory for details).
+2. Edit the `lora_rd_settings.py` file to set the frequency and other protocol
+ settings for your region and hardware (see main lora `README.md`).
+3. Edit the program you plan to run and fill in the `get_modem()` function with
+ the correct modem type, pin assignments, etc. for your board (see top-level
+ README). Note the `get_modem()` function should use the existing `lora_cfg`
+ variable, which holds the settings imported from `lora_rd_settings.py`.
+4. Change to this directory in a terminal.
+5. Run `mpremote mount . exec receiver.py` on one board and `mpremote mount
+ . exec sender.py` on another (or swap in `receiver_async.py` and/or
+ `sender_async.py` as desired).
+
+Consult the [mpremote
+documentation](https://docs.micropython.org/en/latest/reference/mpremote.html)
+for an explanation of these commands and the options needed to run two copies of
+`mpremote` on different serial ports at the same time.
+
+## Automatic Performance Tuning
+
+- When sending an ACK, the receiver includes the RSSI of the received
+ packet. Senders will automatically modify their output_power to minimize the
+ power consumption required to reach the receiver. Similarly, if no ACK is
+ received then they will increase their output power and also re-run Image
+ calibration in order to maximize RX performance.
+
+## Message payloads
+
+Messages are LoRa packets, set up as follows:
+
+LoRA implicit header mode, CRCs enabled.
+
+* Each remote device has a unique sixteen-bit ID (range 00x0000 to 0xFFFE). ID
+ 0xFFFF is reserved for the single receiver device.
+* An eight-bit message counter is used to identify duplicate messages
+
+* Data message format is:
+ - Sender ID (two bytes, little endian)
+ - Counter byte (incremented on each new message, not incremented on retry).
+ - Message length (1 byte)
+ - Message (variable length)
+ - Checksum byte (sum of all proceeding bytes in message, modulo 256). The LoRa
+ packet has its own 16-bit CRC, this is included as an additional way to
+ disambiguate other LoRa packets that might appear the same.
+
+* After receiving a valid data message, the receiver device should send
+ an acknowledgement message 25ms after the modem receive completed.
+
+ Acknowledgement message format:
+ - 0xFFFF (receiver station ID as two bytes)
+ - Sender's Device ID from received message (two bytes, little endian)
+ - Counter byte from received message
+ - Checksum byte from received message
+ - RSSI value as received by radio (one signed byte)
+
+* If the remote device doesn't receive a packet with the acknowledgement
+ message, it retries up to a configurable number of times (default 4) with a
+ basic exponential backoff formula.
+
diff --git a/micropython/lora/examples/reliable_delivery/lora_rd_settings.py b/micropython/lora/examples/reliable_delivery/lora_rd_settings.py
new file mode 100644
index 00000000..bbf03da5
--- /dev/null
+++ b/micropython/lora/examples/reliable_delivery/lora_rd_settings.py
@@ -0,0 +1,38 @@
+# MicroPython lora reliable_delivery example - common protocol settings
+# MIT license; Copyright (c) 2023 Angus Gratton
+
+#
+######
+# To be able to be able to communicate, most of these settings need to match on both radios.
+# Consult the example README for more information about how to use the example.
+######
+
+# LoRa protocol configuration
+#
+# Currently configured for relatively slow & low bandwidth settings, which
+# gives more link budget and possible range.
+#
+# These settings should match on receiver.
+#
+# Check the README and local regulations to know what configuration settings
+# are available.
+lora_cfg = {
+ "freq_khz": 916000,
+ "sf": 10,
+ "bw": "62.5", # kHz
+ "coding_rate": 8,
+ "preamble_len": 12,
+ "output_power": 10, # dBm
+}
+
+# Single receiver has a fixed 16-bit ID value (senders each have a unique value).
+RECEIVER_ID = 0xFFFF
+
+# Length of an ACK message in bytes.
+ACK_LENGTH = 7
+
+# Send the ACK this many milliseconds after receiving a valid message
+#
+# This can be quite a bit lower (25ms or so) if wakeup times are short
+# and _DEBUG is turned off on the modems (logging to UART delays everything).
+ACK_DELAY_MS = 100
diff --git a/micropython/lora/examples/reliable_delivery/receiver.py b/micropython/lora/examples/reliable_delivery/receiver.py
new file mode 100644
index 00000000..2ab4231d
--- /dev/null
+++ b/micropython/lora/examples/reliable_delivery/receiver.py
@@ -0,0 +1,163 @@
+# MicroPython lora reliable_delivery example - synchronous receiver program
+# MIT license; Copyright (c) 2023 Angus Gratton
+import struct
+import time
+import machine
+from machine import SPI, Pin
+from micropython import const
+from lora import RxPacket
+
+from lora_rd_settings import RECEIVER_ID, ACK_LENGTH, ACK_DELAY_MS, lora_cfg
+
+# Change _DEBUG to const(True) to get some additional debugging output
+# about timing, RSSI, etc.
+#
+# For a lot more debugging detail, go to the modem driver and set _DEBUG there to const(True)
+_DEBUG = const(False)
+
+# Keep track of the last counter value we got from each known sender
+# this allows us to tell if packets are being lost
+last_counters = {}
+
+
+def get_modem():
+ # from lora import SX1276
+ # return SX1276(
+ # spi=SPI(1, baudrate=2000_000, polarity=0, phase=0,
+ # miso=Pin(19), mosi=Pin(27), sck=Pin(5)),
+ # cs=Pin(18),
+ # dio0=Pin(26),
+ # dio1=Pin(35),
+ # reset=Pin(14),
+ # lora_cfg=lora_cfg,
+ # )
+ raise NotImplementedError("Replace this function with one that returns a lora modem instance")
+
+
+def main():
+ print("Initializing...")
+ modem = get_modem()
+
+ print("Main loop started")
+ receiver = Receiver(modem)
+
+ while True:
+ # With wait=True, this function blocks until something is received and always
+ # returns non-None
+ sender_id, data = receiver.recv(wait=True)
+
+ # Do something with the data!
+ print(f"Received {data} from {sender_id:#x}")
+
+
+class Receiver:
+ def __init__(self, modem):
+ self.modem = modem
+ self.last_counters = {} # Track the last counter value we got from each sender ID
+ self.rx_packet = None # Reuse RxPacket object when possible, save allocation
+ self.ack_buffer = bytearray(ACK_LENGTH) # reuse the same buffer for ACK packets
+ self.skipped_packets = 0 # Counter of skipped packets
+
+ modem.calibrate()
+
+ # Start receiving immediately. We expect the modem to receive continuously
+ self.will_irq = modem.start_recv(continuous=True)
+ print("Modem initialized and started receive...")
+
+ def recv(self, wait=True):
+ # Receive a packet from the sender, including sending an ACK.
+ #
+ # Returns a tuple of the 16-bit sender id and the sensor data payload.
+ #
+ # This function should be called very frequently from the main loop (at
+ # least every ACK_DELAY_MS milliseconds), to avoid not sending ACKs in time.
+ #
+ # If 'wait' argument is True (default), the function blocks indefinitely
+ # until a packet is received. If False then it will return None
+ # if no packet is available.
+ #
+ # Note that because we called start_recv(continuous=True), the modem
+ # will keep receiving on its own - even if when we call send() to
+ # send an ACK.
+ while True:
+ rx = self.modem.poll_recv(rx_packet=self.rx_packet)
+
+ if isinstance(rx, RxPacket): # value will be True or an RxPacket instance
+ decoded = self._handle_rx(rx)
+ if decoded:
+ return decoded # valid LoRa packet and valid for this application
+
+ if not wait:
+ return None
+
+ # Otherwise, wait for an IRQ (or have a short sleep) and then poll recv again
+ # (receiver is not a low power node, so don't bother with sleep modes.)
+ if self.will_irq:
+ while not self.modem.irq_triggered():
+ machine.idle()
+ else:
+ time.sleep_ms(1)
+
+ def _handle_rx(self, rx):
+ # Internal function to handle a received packet and either send an ACK
+ # and return the sender and the payload, or return None if packet
+ # payload is invalid or a duplicate.
+
+ if len(rx) < 5: # 4 byte header plus 1 byte checksum
+ print("Invalid packet length")
+ return None
+
+ sender_id, counter, data_len = struct.unpack(" {tx_done}ms took {tx_time}ms expected {expected}")
+
+ # Check if the data we received is fresh or stale
+ if sender_id not in self.last_counters:
+ print(f"New device id {sender_id:#x}")
+ elif self.last_counters[sender_id] == counter:
+ print(f"Duplicate packet received from {sender_id:#x}")
+ return None
+ elif counter != 1:
+ # If the counter from this sender has gone up by more than 1 since
+ # last time we got a packet, we know there is some packet loss.
+ #
+ # (ignore the case where the new counter is 1, as this probably
+ # means a reset.)
+ delta = (counter - 1 - self.last_counters[sender_id]) & 0xFF
+ if delta:
+ print(f"Skipped/lost {delta} packets from {sender_id:#x}")
+ self.skipped_packets += delta
+
+ self.last_counters[sender_id] = counter
+ return sender_id, rx[4:-1]
+
+
+if __name__ == "__main__":
+ main()
diff --git a/micropython/lora/examples/reliable_delivery/receiver_async.py b/micropython/lora/examples/reliable_delivery/receiver_async.py
new file mode 100644
index 00000000..72a456db
--- /dev/null
+++ b/micropython/lora/examples/reliable_delivery/receiver_async.py
@@ -0,0 +1,121 @@
+# MicroPython lora reliable_delivery example - asynchronous receiver program
+# MIT license; Copyright (c) 2023 Angus Gratton
+import struct
+import time
+import asyncio
+from machine import SPI, Pin
+from micropython import const
+
+from lora_rd_settings import RECEIVER_ID, ACK_LENGTH, ACK_DELAY_MS, lora_cfg
+
+# Change _DEBUG to const(True) to get some additional debugging output
+# about timing, RSSI, etc.
+#
+# For a lot more debugging detail, go to the modem driver and set _DEBUG there to const(True)
+_DEBUG = const(False)
+
+# Keep track of the last counter value we got from each known sender
+# this allows us to tell if packets are being lost
+last_counters = {}
+
+
+def get_async_modem():
+ # from lora import AsyncSX1276
+ # return AsyncSX1276(
+ # spi=SPI(1, baudrate=2000_000, polarity=0, phase=0,
+ # miso=Pin(19), mosi=Pin(27), sck=Pin(5)),
+ # cs=Pin(18),
+ # dio0=Pin(26),
+ # dio1=Pin(35),
+ # reset=Pin(14),
+ # lora_cfg=lora_cfg,
+ # )
+ raise NotImplementedError("Replace this function with one that returns a lora modem instance")
+
+
+def main():
+ # Initializing the modem.
+ #
+
+ print("Initializing...")
+ modem = get_async_modem()
+ asyncio.run(recv_continuous(modem, rx_callback))
+
+
+async def rx_callback(sender_id, data):
+ # Do something with the data!
+ print(f"Received {data} from {sender_id:#x}")
+
+
+async def recv_continuous(modem, callback):
+ # Async task which receives packets from the AsyncModem recv_continuous()
+ # iterator, checks if they are valid, and send back an ACK if needed.
+ #
+ # On each successful message, we await callback() to allow the application
+ # to do something with the data. Callback args are sender_id (as int) and the bytes
+ # of the message payload.
+
+ last_counters = {} # Track the last counter value we got from each sender ID
+ ack_buffer = bytearray(ACK_LENGTH) # reuse the same buffer for ACK packets
+ skipped_packets = 0 # Counter of skipped packets
+
+ modem.calibrate()
+
+ async for rx in modem.recv_continuous():
+ # Filter 'rx' packet to determine if it's valid for our application
+ if len(rx) < 5: # 4 byte header plus 1 byte checksum
+ print("Invalid packet length")
+ continue
+
+ sender_id, counter, data_len = struct.unpack(" {tx_done}ms took {tx_time}ms expected {expected}")
+
+ # Check if the data we received is fresh or stale
+ if sender_id not in last_counters:
+ print(f"New device id {sender_id:#x}")
+ elif last_counters[sender_id] == counter:
+ print(f"Duplicate packet received from {sender_id:#x}")
+ continue
+ elif counter != 1:
+ # If the counter from this sender has gone up by more than 1 since
+ # last time we got a packet, we know there is some packet loss.
+ #
+ # (ignore the case where the new counter is 1, as this probably
+ # means a reset.)
+ delta = (counter - 1 - last_counters[sender_id]) & 0xFF
+ if delta:
+ print(f"Skipped/lost {delta} packets from {sender_id:#x}")
+ skipped_packets += delta
+
+ last_counters[sender_id] = counter
+ await callback(sender_id, rx[4:-1])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/micropython/lora/examples/reliable_delivery/sender.py b/micropython/lora/examples/reliable_delivery/sender.py
new file mode 100644
index 00000000..2fba0d4d
--- /dev/null
+++ b/micropython/lora/examples/reliable_delivery/sender.py
@@ -0,0 +1,213 @@
+# MicroPython lora reliable_delivery example - synchronous sender program
+# MIT license; Copyright (c) 2023 Angus Gratton
+import machine
+from machine import SPI, Pin
+import random
+import struct
+import time
+
+from lora_rd_settings import RECEIVER_ID, ACK_LENGTH, ACK_DELAY_MS, lora_cfg
+
+SLEEP_BETWEEN_MS = 5000 # Main loop should sleep this long between sending data to the receiver
+
+MAX_RETRIES = 4 # Retry each message this often if no ACK is received
+
+# Initial retry is after this long. Increases by 1.25x each subsequent retry.
+BASE_RETRY_TIMEOUT_MS = 1000
+
+# Add random jitter to each retry period, up to this long. Useful to prevent two
+# devices ending up in sync.
+RETRY_JITTER_MS = 1500
+
+# If reported RSSI value is lower than this, increase
+# output power 1dBm
+RSSI_WEAK_THRESH = -110
+
+# If reported RSSI value is higher than this, decrease
+# output power 1dBm
+RSSI_STRONG_THRESH = -70
+
+# IMPORTANT: Set this to the maximum output power in dBm that is permitted in
+# your regulatory environment.
+OUTPUT_MAX_DBM = 15
+OUTPUT_MIN_DBM = -20
+
+
+def get_modem():
+ # from lora import SX1276
+ # return SX1276(
+ # spi=SPI(1, baudrate=2000_000, polarity=0, phase=0,
+ # miso=Pin(19), mosi=Pin(27), sck=Pin(5)),
+ # cs=Pin(18),
+ # dio0=Pin(26),
+ # dio1=Pin(35),
+ # reset=Pin(14),
+ # lora_cfg=lora_cfg,
+ # )
+ raise NotImplementedError("Replace this function with one that returns a lora modem instance")
+
+
+def main():
+ modem = get_modem()
+
+ # Unique ID of this sender, 16-bit number. This method of generating an ID is pretty crummy,
+ # if using this in a real application then probably better to store these in the filesystem or
+ # something like that
+ DEVICE_ID = sum(b for b in machine.unique_id()) & 0xFFFF
+
+ sender = Sender(modem, DEVICE_ID)
+ while True:
+ sensor_data = get_sensor_data()
+ sender.send(sensor_data)
+
+ # Sleep until the next time we should read the sensor data and send it to
+ # the receiver.
+ #
+ # The goal for the device firmware is to spend most of its time in the lowest
+ # available sleep state, to save power.
+ #
+ # Note that if the sensor(s) in a real program generates events, these can be
+ # hooked to interrupts and used to wake Micropython up to send data,
+ # instead.
+ modem.sleep()
+ time.sleep_ms(SLEEP_BETWEEN_MS) # TODO see if this can be machine.lightsleep()
+
+
+def get_sensor_data():
+ # Return a bytes object with the latest sensor data to send to the receiver.
+ #
+ # As this is just an example, we send a dummy payload which is just a string
+ # containing our ticks_ms() timestamp.
+ #
+ # In a real application the sensor data should usually be binary data and
+ # not a string, to save transmission size.
+ return f"Hello, ticks_ms={time.ticks_ms()}".encode()
+
+
+class Sender:
+ def __init__(self, modem, device_id):
+ self.modem = modem
+ self.device_id = device_id
+ self.counter = 0
+ self.output_power = lora_cfg["output_power"] # start with common settings power level
+ self.rx_ack = None # reuse the ack message object when we can
+
+ print(f"Sender initialized with ID {device_id:#x}")
+ random.seed(device_id)
+ self.adjust_output_power(0) # set the initial value within MIN/MAX
+
+ modem.calibrate()
+
+ def send(self, sensor_data, adjust_output_power=True):
+ # Send a packet of sensor data to the receiver reliably.
+ #
+ # Returns True if data was successfully sent and ACKed, False otherwise.
+ #
+ # If adjust_output_power==True then increase or decrease output power
+ # according to the RSSI reported in the ACK packet.
+ self.counter = (self.counter + 1) & 0xFF
+
+ # Prepare the simple payload with header and checksum
+ # See README for a summary of the simple data message format
+ payload = bytearray(len(sensor_data) + 5)
+ struct.pack_into(" RSSI_STRONG_THRESH:
+ self.adjust_output_power(-1)
+ elif rssi < RSSI_WEAK_THRESH:
+ self.adjust_output_power(1)
+
+ return True
+
+ # Otherwise, prepare to sleep briefly and then retry
+ next_try_at = time.ticks_add(sent_at, timeout)
+ sleep_time = time.ticks_diff(next_try_at, time.ticks_ms()) + random.randrange(
+ RETRY_JITTER_MS
+ )
+ if sleep_time > 0:
+ self.modem.sleep()
+ time.sleep_ms(sleep_time) # TODO: see if this can be machine.lightsleep
+
+ # add 25% timeout for next iteration
+ timeout = (timeout * 5) // 4
+
+ print(f"Failed, no ACK after {MAX_RETRIES} retries.")
+ if adjust_output_power:
+ self.adjust_output_power(2)
+ self.modem.calibrate_image() # try and improve the RX sensitivity for next time
+ return False
+
+ def _ack_is_valid(self, maybe_ack, csum):
+ # Private function to verify if the RxPacket held in 'maybe_ack' is a valid ACK for the
+ # current device_id and counter value, and provided csum value.
+ #
+ # If it is, returns the reported RSSI value from the packet.
+ # If not, returns None
+ if (not maybe_ack) or len(maybe_ack) != ACK_LENGTH:
+ return None
+
+ base_id, ack_id, ack_counter, ack_csum, rssi = struct.unpack(" RSSI_STRONG_THRESH:
+ self.adjust_output_power(-1)
+ elif rssi < RSSI_WEAK_THRESH:
+ self.adjust_output_power(1)
+
+ return True
+
+ # Otherwise, prepare to sleep briefly and then retry
+ next_try_at = time.ticks_add(sent_at, timeout)
+ sleep_time = time.ticks_diff(next_try_at, time.ticks_ms()) + random.randrange(
+ RETRY_JITTER_MS
+ )
+ if sleep_time > 0:
+ self.modem.sleep()
+ await asyncio.sleep_ms(sleep_time)
+
+ # add 25% timeout for next iteration
+ timeout = (timeout * 5) // 4
+
+ print(f"Failed, no ACK after {MAX_RETRIES} retries.")
+ if adjust_output_power:
+ self.adjust_output_power(2)
+ self.modem.calibrate_image() # try and improve the RX sensitivity for next time
+ return False
+
+ def _ack_is_valid(self, maybe_ack, csum):
+ # Private function to verify if the RxPacket held in 'maybe_ack' is a valid ACK for the
+ # current device_id and counter value, and provided csum value.
+ #
+ # If it is, returns the reported RSSI value from the packet.
+ # If not, returns None
+ if (not maybe_ack) or len(maybe_ack) != ACK_LENGTH:
+ return None
+
+ base_id, ack_id, ack_counter, ack_csum, rssi = struct.unpack(" 1
+ ):
+ # This check exists to determine that the SPI settings and modem
+ # selection are correct. Otherwise it's possible for the driver to
+ # run for quite some time before it detects an invalid response.
+ raise RuntimeError("Invalid initial status {}.".format(status))
+
+ if dio2_rf_sw:
+ self._cmd("BB", _CMD_SET_DIO2_AS_RF_SWITCH_CTRL, 1)
+
+ if dio3_tcxo_millivolts:
+ # Enable TCXO power via DIO3, if enabled
+ #
+ # timeout register is set in units of 15.625us each, use integer math
+ # to calculate and round up:
+ self._busy_timeout = (_CMD_BUSY_TIMEOUT_BASE_US + dio3_tcxo_start_time_us) * 2
+ timeout = (dio3_tcxo_start_time_us * 1000 + 15624) // 15625
+ if timeout < 0 or timeout > 1 << 24:
+ raise ValueError("{} out of range".format("dio3_tcxo_start_time_us"))
+ if dio3_tcxo_millivolts < 1600 or dio3_tcxo_millivolts > 3300:
+ raise ValueError("{} out of range".format("dio3_tcxo_millivolts"))
+ dv = dio3_tcxo_millivolts // 100 # 16 to 33
+ tcxo_trim_lookup = (
+ 16,
+ 17,
+ 18,
+ 22,
+ 24,
+ 27,
+ 30,
+ 33,
+ ) # DS Table 13-35
+ while dv not in tcxo_trim_lookup:
+ dv -= 1
+ reg_tcxo_trim = tcxo_trim_lookup.index(dv)
+
+ self._cmd(">BI", _CMD_SET_DIO3_AS_TCXO_CTRL, (reg_tcxo_trim << 24) + timeout)
+ time.sleep_ms(15)
+ # As per DS 13.3.6 SetDIO3AsTCXOCtrl, should expect error
+ # value 0x20 "XOSC_START_ERR" to be flagged as XOSC has only just
+ # started now. So clear it.
+ self._clear_errors()
+
+ self._check_error()
+
+ # If DIO1 is set, mask in just the IRQs that the driver may need to be
+ # interrupted by. This is important because otherwise an unrelated IRQ
+ # can trigger the ISR and may not be reset by the driver, leaving DIO1 high.
+ #
+ # If DIO1 is not set, all IRQs can stay masked which is the power-on state.
+ if dio1:
+ # Note: we set both Irq mask and DIO1 mask to the same value, which is redundant
+ # (one could be 0xFFFF) but may save a few bytes of bytecode.
+ self._cmd(
+ ">BHHHH",
+ _CMD_CFG_DIO_IRQ,
+ (_IRQ_RX_DONE | _IRQ_TX_DONE | _IRQ_TIMEOUT), # IRQ mask
+ (_IRQ_RX_DONE | _IRQ_TX_DONE | _IRQ_TIMEOUT), # DIO1 mask
+ 0x0, # DIO2Mask, not used
+ 0x0, # DIO3Mask, not used
+ )
+ dio1.irq(self._radio_isr, trigger=Pin.IRQ_RISING)
+
+ self._clear_irq()
+
+ self._cmd("BB", _CMD_SET_PACKET_TYPE, 1) # LoRa
+
+ if lora_cfg:
+ self.configure(lora_cfg)
+
+ def sleep(self, warm_start=True):
+ # Put the modem into sleep mode. Driver will wake the modem automatically the next
+ # time an operation starts, or call standby() to wake it manually.
+ #
+ # If the warm_start parameter is False (non-default) then the modem will
+ # lose all settings on wake. The only way to use this parameter value is
+ # to destroy this modem object after calling it, and then instantiate a new
+ # modem object on wake.
+ #
+ self._check_error() # check errors before going to sleep because we clear on wake
+ self.standby() # save some code size, this clears the driver's rx/tx state
+ self._cmd("BB", _CMD_SET_SLEEP, _flag(1 << 2, warm_start))
+ self._sleep = True
+
+ def _standby(self):
+ # Send the command for standby mode.
+ #
+ # **Don't call this function directly, call standby() instead.**
+ #
+ # (This private version doesn't update the driver's internal state.)
+ self._cmd("BB", _CMD_SET_STANDBY, 1) # STDBY_XOSC mode
+ self._clear_irq() # clear IRQs in case we just cancelled a send or receive
+
+ def is_idle(self):
+ # Returns True if the modem is idle (either in standby or in sleep).
+ #
+ # Note this function can return True in the case where the modem has temporarily gone to
+ # standby but there's a receive configured in software that will resume receiving the next
+ # time poll_recv() or poll_send() is called.
+ if self._sleep:
+ return True # getting status wakes from sleep
+ mode, _ = self._get_status()
+ return mode in (_STATUS_MODE_STANDBY_HSE32, _STATUS_MODE_STANDBY_RC)
+
+ def _wakeup(self):
+ # Wake the modem from sleep. This is called automatically the first
+ # time a modem command is sent after sleep() was called to put the modem to
+ # sleep.
+ #
+ # To manually wake the modem without initiating a new operation, call standby().
+ self._cs(0)
+ time.sleep_us(20)
+ self._cs(1)
+ self._sleep = False
+ self._clear_errors() # Clear "XOSC failed to start" which will reappear at this time
+ self._check_error() # raise an exception if any other error appears
+
+ def _decode_status(self, raw_status, check_errors=True):
+ # split the raw status, which often has reserved bits set, into the mode value
+ # and the command status value
+ mode = (raw_status & _STATUS_MODE_MASK) >> _STATUS_MODE_SHIFT
+ cmd = (raw_status & _STATUS_CMD_MASK) >> _STATUS_CMD_SHIFT
+ if check_errors and cmd in (_STATUS_CMD_EXEC_FAIL, _STATUS_CMD_ERROR):
+ raise RuntimeError("Status {},{} indicates command error".format(mode, cmd))
+ return (mode, cmd)
+
+ def _get_status(self):
+ # Issue the GetStatus command and return the decoded status of (mode value, command status)
+ res = self._cmd("B", _CMD_GET_STATUS, n_read=1)[0]
+ return self._decode_status(res)
+
+ def _check_error(self):
+ # Raise a RuntimeError if the radio has reported an error state.
+ #
+ # Return the decoded status, otherwise.
+ res = self._cmd("B", _CMD_GET_ERROR, n_read=3)
+ status = self._decode_status(res[0], False)
+ op_error = (res[1] << 8) + res[2]
+ if op_error != 0:
+ raise RuntimeError("Internal radio Status {} OpError {:#x}".format(status, op_error))
+ self._decode_status(res[0]) # raise an exception here if status shows an error
+ return status
+
+ def _clear_errors(self):
+ # Clear any errors flagged in the modem
+ self._cmd(">BH", _CMD_CLR_ERRORS, 0)
+
+ def _clear_irq(self, clear_bits=0xFFFF):
+ # Clear IRQs flagged in the modem
+ #
+ # By default, clears all IRQ bits. Otherwise, argument is the mask of bits to clear.
+ self._cmd(">BH", _CMD_CLR_IRQ_STATUS, clear_bits)
+ self._last_irq = None
+
+ def _set_tx_ant(self, tx_ant):
+ # Only STM32WL55 allows switching tx_ant from LP to HP
+ raise ConfigError("tx_ant")
+
+ def _symbol_offsets(self):
+ # Called from BaseModem.get_time_on_air_us().
+ #
+ # This function provides a way to implement the different SF5 and SF6 in SX126x,
+ # by returning two offsets: one for the overall number of symbols, and one for the
+ # number of bits used to calculate the symbol length of the payload.
+ return (2, -8) if self._sf in (5, 6) else (0, 0)
+
+ def configure(self, lora_cfg):
+ if self._rx is not False:
+ raise RuntimeError("Receiving")
+
+ if "preamble_len" in lora_cfg:
+ self._preamble_len = lora_cfg["preamble_len"]
+
+ self._invert_iq = (
+ lora_cfg.get("invert_iq_rx", self._invert_iq[0]),
+ lora_cfg.get("invert_iq_tx", self._invert_iq[1]),
+ self._invert_iq[2],
+ )
+
+ if "freq_khz" in lora_cfg:
+ self._rf_freq_hz = int(lora_cfg["freq_khz"] * 1000)
+ rffreq = (
+ self._rf_freq_hz << 25
+ ) // 32_000_000 # RF-PLL frequency = 32e^6 * RFFreq / 2^25
+ if not rffreq:
+ raise ConfigError("freq_khz") # set to a value too low
+ self._cmd(">BI", _CMD_SET_RF_FREQUENCY, rffreq)
+
+ if "syncword" in lora_cfg:
+ syncword = lora_cfg["syncword"]
+ if syncword < 0x100:
+ # "Translation from SX127x to SX126x : 0xYZ -> 0xY4Z4 :
+ # if you do not set the two 4 you might lose sensitivity"
+ # see
+ # https://www.thethingsnetwork.org/forum/t/should-private-lorawan-networks-use-a-different-sync-word/34496/15
+ syncword = 0x0404 + ((syncword & 0x0F) << 4) + ((syncword & 0xF0) << 8)
+ self._cmd(">BBH", _CMD_WRITE_REGISTER, _REG_LSYNCRH, syncword)
+
+ if "output_power" in lora_cfg:
+ pa_config_args, self._output_power = self._get_pa_tx_params(lora_cfg["output_power"])
+ self._cmd("BBBBB", _CMD_SET_PA_CONFIG, *pa_config_args)
+
+ if "pa_ramp_us" in lora_cfg:
+ self._ramp_val = self._get_pa_ramp_val(
+ lora_cfg, [10, 20, 40, 80, 200, 800, 1700, 3400]
+ )
+
+ if "output_power" in lora_cfg or "pa_ramp_us" in lora_cfg:
+ # Only send the SetTxParams command if power level or PA ramp time have changed
+ self._cmd("BBB", _CMD_SET_TX_PARAMS, self._output_power, self._ramp_val)
+
+ if any(key in lora_cfg for key in ("sf", "bw", "coding_rate")):
+ if "sf" in lora_cfg:
+ self._sf = lora_cfg["sf"]
+ if self._sf < _CFG_SF_MIN or self._sf > _CFG_SF_MAX:
+ raise ConfigError("sf")
+
+ if "bw" in lora_cfg:
+ self._bw = lora_cfg["bw"]
+
+ if "coding_rate" in lora_cfg:
+ self._coding_rate = lora_cfg["coding_rate"]
+ if self._coding_rate < 4 or self._coding_rate > 8: # 4/4 through 4/8, linearly
+ raise ConfigError("coding_rate")
+
+ bw_val, self._bw_hz = {
+ "7.8": (0x00, 7800),
+ "10.4": (0x08, 10400),
+ "15.6": (0x01, 15600),
+ "20.8": (0x09, 20800),
+ "31.25": (0x02, 31250),
+ "41.7": (0x0A, 41700),
+ "62.5": (0x03, 62500),
+ "125": (0x04, 125000),
+ "250": (0x05, 250000),
+ "500": (0x06, 500000),
+ }[str(self._bw)]
+
+ self._cmd(
+ "BBBBB",
+ _CMD_SET_MODULATION_PARAMS,
+ self._sf,
+ bw_val,
+ self._coding_rate - 4, # 4/4=0, 4/5=1, etc
+ self._get_ldr_en(), # Note: BaseModem.get_n_symbols_x4() depends on this logic
+ )
+
+ if "rx_boost" in lora_cfg:
+ # See DS Table 9-3 "Rx Gain Configuration"
+ self._reg_write(_REG_RX_GAIN, 0x96 if lora_cfg["rx_boost"] else 0x94)
+
+ self._check_error()
+
+ def _invert_workaround(self, enable):
+ # Apply workaround for DS 15.4 Optimizing the Inverted IQ Operation
+ if self._invert_iq[2] != enable:
+ val = self._read_read(_REG_IQ_POLARITY_SETUP)
+ val = (val & ~4) | _flag(4, enable)
+ self._reg_write(_REG_IQ_POLARITY_SETUP, val)
+ self._invert_iq[2] = enable
+
+ def _get_irq(self):
+ # Get currently set IRQ bits.
+ irq_status = self._cmd("B", _CMD_GET_IRQ_STATUS, n_read=3)
+ status = self._decode_status(irq_status[0])
+ flags = (irq_status[1] << 8) + irq_status[2]
+ if _DEBUG:
+ print("Status {} flags {:#x}".format(status, flags))
+ return flags
+
+ def calibrate(self):
+ # Send the Calibrate command to the radio to calibrate RC oscillators, PLL and ADC.
+ #
+ # See DS 13.1.12 Calibrate Function
+
+ # calibParam 0xFE means to calibrate all blocks.
+ self._cmd("BH", _CMD_CALIBRATE_IMAGE, args)
+
+ # Can't find anythign in Datasheet about how long image calibration
+ # takes or exactly how it signals completion. Assuming it will be
+ # similar to _CMD_CALIBRATE.
+ self._wait_not_busy(_CALIBRATE_TIMEOUT_US)
+
+ def start_recv(self, timeout_ms=None, continuous=False, rx_length=0xFF):
+ # Start receiving.
+ #
+ # Part of common low-level modem API, see README.md for usage.
+ super().start_recv(timeout_ms, continuous, rx_length) # sets _rx
+
+ if self._tx:
+ # Send is in progress and has priority, _check_recv() will start recv
+ # once send finishes (caller needs to call poll_send() for this to happen.)
+ if _DEBUG:
+ print("Delaying receive until send completes")
+ return self._dio1
+
+ # Put the modem in a known state. It's possible a different
+ # receive was in progress, this prevent anything changing while
+ # we set up the new receive
+ self._standby() # calling private version to keep driver state as-is
+
+ # Allocate the full FIFO for RX
+ self._cmd("BBB", _CMD_SET_BUFFER_BASE_ADDRESS, 0xFF, 0x0)
+
+ self._cmd(
+ ">BHBBBB",
+ _CMD_SET_PACKET_PARAMS,
+ self._preamble_len,
+ self._implicit_header,
+ rx_length, # PayloadLength, only used in implicit header mode
+ self._crc_en, # CRCType, only used in implicit header mode
+ self._invert_iq[0], # InvertIQ
+ )
+ self._invert_workaround(self._invert_iq[0])
+
+ if continuous:
+ timeout = _CONTINUOUS_TIMEOUT_VAL
+ elif timeout_ms is not None:
+ timeout = max(1, timeout_ms * 64) # units of 15.625us
+ else:
+ timeout = 0 # Single receive mode, no timeout
+
+ self._cmd(">BBH", _CMD_SET_RX, timeout >> 16, timeout)
+
+ return self._dio1
+
+ def poll_recv(self, rx_packet=None):
+ old_rx = self._rx
+ rx = super().poll_recv(rx_packet)
+
+ if rx is not True and old_rx is not False and isinstance(old_rx, int):
+ # Receiving has just stopped, and a timeout was previously set.
+ #
+ # Workaround for errata DS 15.3 "Implicit Header Mode Timeout Behaviour",
+ # which recommends to add the following after "ANY Rx with Timeout active sequence"
+ self._reg_write(_REG_RTC_CTRL, 0x00)
+ self._reg_write(_REG_EVT_CLR, self._reg_read(_REG_EVT_CLR) | _REG_EVT_CLR_MASK)
+
+ return rx
+
+ def _rx_flags_success(self, flags):
+ # Returns True if IRQ flags indicate successful receive.
+ # Specifically, from the bits in _IRQ_DRIVER_RX_MASK:
+ # - _IRQ_RX_DONE must be set
+ # - _IRQ_TIMEOUT must not be set
+ # - _IRQ_CRC_ERR must not be set
+ # - _IRQ_HEADER_ERR must not be set
+ #
+ # (Note: this is a function because the result for SX1276 depends on
+ # current config, but the result is constant here.)
+ return flags & _IRQ_DRIVER_RX_MASK == _IRQ_RX_DONE
+
+ def _read_packet(self, rx_packet, flags):
+ # Private function to read received packet (RxPacket object) from the
+ # modem, if there is one.
+ #
+ # Called from poll_recv() function, which has already checked the IRQ flags
+ # and verified a valid receive happened.
+
+ ticks_ms = self._get_last_irq()
+
+ res = self._cmd("B", _CMD_GET_RX_BUFFER_STATUS, n_read=3)
+ rx_payload_len = res[1]
+ rx_buffer_ptr = res[2] # should be 0
+
+ if rx_packet is None or len(rx_packet) != rx_payload_len:
+ rx_packet = RxPacket(rx_payload_len)
+
+ self._cmd("BB", _CMD_READ_BUFFER, rx_buffer_ptr, n_read=1, read_buf=rx_packet)
+
+ pkt_status = self._cmd("B", _CMD_GET_PACKET_STATUS, n_read=4)
+
+ rx_packet.ticks_ms = ticks_ms
+ rx_packet.snr = pkt_status[2] # SNR, units: dB *4
+ rx_packet.rssi = 0 - pkt_status[1] // 2 # RSSI, units: dBm
+ rx_packet.crc_error = (flags & _IRQ_CRC_ERR) != 0
+
+ return rx_packet
+
+ def prepare_send(self, packet):
+ # Prepare modem to start sending. Should be followed by a call to start_send()
+ #
+ # Part of common low-level modem API, see README.md for usage.
+ if len(packet) > 255:
+ raise ConfigError("packet too long")
+
+ # Put the modem in a known state. Any current receive is suspended at this point,
+ # but calling _check_recv() will resume it later.
+ self._standby() # calling private version to keep driver state as-is
+
+ self._check_error()
+
+ # Set the board antenna for correct TX mode
+ if self._ant_sw:
+ self._ant_sw.tx(self._tx_hp())
+
+ self._last_irq = None
+
+ self._cmd(
+ ">BHBBBB",
+ _CMD_SET_PACKET_PARAMS,
+ self._preamble_len,
+ self._implicit_header,
+ len(packet),
+ self._crc_en,
+ self._invert_iq[1], # _invert_iq_tx
+ )
+ self._invert_workaround(self._invert_iq[1])
+
+ # Allocate the full FIFO for TX
+ self._cmd("BBB", _CMD_SET_BUFFER_BASE_ADDRESS, 0x0, 0xFF)
+ self._cmd("BB", _CMD_WRITE_BUFFER, 0x0, write_buf=packet)
+
+ # Workaround for DS 15.1 Modulation Quality with 500 kHZ LoRa Bandwidth
+ # ... apparently this needs to be done "*before each packet transmission*"
+ if self._bw_hz == 500_000:
+ self._reg_write(0x0889, self._reg_read(0x0889) & 0xFB)
+ else:
+ self._reg_write(0x0889, self._reg_read(0x0889) | 0x04)
+
+ def start_send(self):
+ # Actually start a send that was loaded by calling prepare_send().
+ #
+ # This is split into a separate function to allow more precise timing.
+ #
+ # The driver doesn't verify the caller has done the right thing here, the
+ # modem will no doubt do something weird if prepare_send() was not called!
+ #
+ # Part of common low-level modem API, see README.md for usage.
+
+ # Currently we don't pass any TX timeout argument to the modem1,
+ # which the datasheet ominously offers as "security" for the Host MCU if
+ # the send doesn't start for some reason.
+
+ self._cmd("BBBB", _CMD_SET_TX, 0x0, 0x0, 0x0)
+
+ if _DEBUG:
+ print("status {}".format(self._get_status()))
+ self._check_error()
+
+ self._tx = True
+
+ return self._dio1
+
+ def _wait_not_busy(self, timeout_us):
+ # Wait until the radio de-asserts the busy line
+ start = time.ticks_us()
+ ticks_diff = 0
+ while self._busy():
+ ticks_diff = time.ticks_diff(time.ticks_us(), start)
+ if ticks_diff > timeout_us:
+ raise RuntimeError("BUSY timeout")
+ time.sleep_us(1)
+ if _DEBUG and ticks_diff > 105:
+ # By default, debug log any busy time that takes longer than the
+ # datasheet-promised Typical 105us (this happens when starting the 32MHz oscillator,
+ # if it's turned on and off by the modem, and maybe other times.)
+ print(f"BUSY {ticks_diff}us")
+
+ def _cmd(self, fmt, *write_args, n_read=0, write_buf=None, read_buf=None):
+ # Execute an SX1262 command
+ # fmt - Format string suitable for use with struct.pack. First item should be 'B' and
+ # corresponds to the command opcode.
+ # write_args - Arguments suitable for struct.pack using fmt. First argument should be a
+ # command opcode byte.
+ #
+ # Optional arguments:
+ # write_buf - Extra buffer to write from (for FIFO writes). Mutually exclusive with n_read
+ # or read_buf.
+ # n_read - Number of result bytes to read back at end
+ # read_buf - Extra buffer to read into (for FIFO reads)
+ #
+ # Returns None if n_read==0, otherwise a memoryview of length n_read which points into a
+ # shared buffer (buffer will be clobbered on next call to _cmd!)
+ if self._sleep:
+ self._wakeup()
+
+ # Ensure "busy" from previously issued command has de-asserted. Usually this will
+ # have happened well before _cmd() is called again.
+ self._wait_not_busy(self._busy_timeout)
+
+ # Pack write_args into _buf and wrap a memoryview of the correct length around it
+ wrlen = struct.calcsize(fmt)
+ assert n_read + wrlen <= len(self._buf) # if this fails, make _buf bigger!
+ struct.pack_into(fmt, self._buf, 0, *write_args)
+ buf = memoryview(self._buf)[: (wrlen + n_read)]
+
+ if _DEBUG:
+ print(">>> {}".format(buf[:wrlen].hex()))
+ if write_buf:
+ print(">>> {}".format(write_buf.hex()))
+ self._cs(0)
+ self._spi.write_readinto(buf, buf)
+ if write_buf:
+ self._spi.write(write_buf) # Used by _CMD_WRITE_BUFFER only
+ if read_buf:
+ self._spi.readinto(read_buf, 0xFF) # Used by _CMD_READ_BUFFER only
+ self._cs(1)
+
+ if n_read > 0:
+ res = memoryview(buf)[wrlen : (wrlen + n_read)] # noqa: E203
+ if _DEBUG:
+ print("<<< {}".format(res.hex()))
+ return res
+
+ def _reg_read(self, addr):
+ return self._cmd("BBBB", _CMD_READ_REGISTER, addr >> 8, addr & 0xFF, n_read=1)[0]
+
+ def _reg_write(self, addr, val):
+ return self._cmd("BBBB", _CMD_WRITE_REGISTER, addr >> 8, addr & 0xFF, val & 0xFF)
+
+
+class _SX1262(_SX126x):
+ # Don't construct this directly, construct lora.SX1262 or lora.AsyncSX1262
+ def __init__(
+ self,
+ spi,
+ cs,
+ busy,
+ dio1=None,
+ dio2_rf_sw=True,
+ dio3_tcxo_millivolts=None,
+ dio3_tcxo_start_time_us=1000,
+ reset=None,
+ lora_cfg=None,
+ ant_sw=None,
+ ):
+ super().__init__(
+ spi,
+ cs,
+ busy,
+ dio1,
+ dio2_rf_sw,
+ dio3_tcxo_millivolts,
+ dio3_tcxo_start_time_us,
+ reset,
+ lora_cfg,
+ ant_sw,
+ )
+
+ # Apply workaround for DS 15.2 "Better Resistance of the SX1262 Tx to Antenna Mismatch
+ self._reg_write(0x8D8, self._reg_read(0x8D8) | 0x1E)
+
+ def _tx_hp(self):
+ # SX1262 has High Power only (deviceSel==0)
+ return True
+
+ def _get_pa_tx_params(self, output_power):
+ # Given an output power level in dB, return a 2-tuple:
+ # - First item is the 3 arguments for SetPaConfig command
+ # - Second item is the power level argument value for SetTxParams command.
+ #
+ # DS 13.1.14.1 "PA Optimal Settings" gives optimally efficient
+ # values for output power +22, +20, +17, +14 dBm and "these changes make
+ # the use of nominal power either sub-optimal or unachievable" (hence it
+ # recommends setting +22dBm nominal TX Power for all these).
+ #
+ # However the modem supports output power as low as -9dBm, and there's
+ # no explanation in the datasheet of how to best set other output power
+ # levels.
+ #
+ # Semtech's own driver (sx126x.c in LoRaMac-node) only ever executes
+ # SetPaConfig with the values shown in the datasheet for +22dBm, and
+ # then executes SetTxParams with power set to the nominal value in
+ # dBm.
+ #
+ # Try for best of both worlds here: If the caller requests an "Optimal"
+ # value, use the datasheet values. Otherwise set nominal power only as
+ # per Semtech's driver.
+ output_power = int(_clamp(output_power, -9, 22))
+
+ DEFAULT = (0x4, 0x7, 0x0, 0x1)
+ OPTIMAL = {
+ 22: (DEFAULT, 22),
+ 20: ((0x3, 0x5, 0x0, 0x1), 22),
+ 17: ((0x2, 0x3, 0x0, 0x1), 22),
+ 14: ((0x2, 0x2, 0x0, 0x1), 22),
+ }
+ if output_power in OPTIMAL:
+ # Datasheet optimal values
+ return OPTIMAL[output_power]
+ else:
+ # Nominal values, as per Semtech driver
+ return (DEFAULT, output_power & 0xFF)
+
+
+class _SX1261(_SX126x):
+ # Don't construct this directly, construct lora.SX1261, or lora.AsyncSX1261
+ def __init__(
+ self,
+ spi,
+ cs,
+ busy,
+ dio1=None,
+ dio2_rf_sw=True,
+ dio3_tcxo_millivolts=None,
+ dio3_tcxo_start_time_us=1000,
+ reset=None,
+ lora_cfg=None,
+ ant_sw=None,
+ ):
+ super().__init__(
+ spi,
+ cs,
+ busy,
+ dio1,
+ dio2_rf_sw,
+ dio3_tcxo_millivolts,
+ dio3_tcxo_start_time_us,
+ reset,
+ lora_cfg,
+ ant_sw,
+ )
+
+ def _tx_hp(self):
+ # SX1261 has Low Power only (deviceSel==1)
+ return False
+
+ def _get_pa_tx_params(self, output_power):
+ # Given an output power level in dB, return a 2-tuple:
+ # - First item is the 3 arguments for SetPaConfig command
+ # - Second item is the power level argument value for SetTxParams command.
+ #
+ # As noted above for SX1262, DS 13.1.14.1 "PA Optimal Settings"
+ # gives optimally efficient values for output power +15, +14, +10 dBm
+ # but nothing specific to the other power levels (down to -17dBm).
+ #
+ # Therefore do the same as for SX1262 to set optimal values if known, nominal otherwise.
+ output_power = _clamp(int(output_power), -17, 15)
+
+ DEFAULT = (0x4, 0x0, 0x1, 0x1)
+ OPTIMAL = {
+ 15: ((0x06, 0x0, 0x1, 0x1), 14),
+ 14: (DEFAULT, 14),
+ 10: ((0x1, 0x0, 0x1, 0x1), 13),
+ }
+
+ if output_power == 15 and self._rf_freq_hz < 400_000_000:
+ # DS 13.1.14.1 has Note that PaDutyCycle is limited to 0x4 below 400MHz,
+ # so disallow the 15dBm optimal setting.
+ output_power = 14
+
+ if output_power in OPTIMAL:
+ # Datasheet optimal values
+ return OPTIMAL[output_power]
+ else:
+ # Nominal values, as per Semtech driver
+ return (DEFAULT, output_power & 0xFF)
+
+
+# Define the actual modem classes that use the SyncModem & AsyncModem "mixin-like" classes
+# to create sync and async variants.
+
+try:
+ from .sync_modem import SyncModem
+
+ class SX1261(_SX1261, SyncModem):
+ pass
+
+ class SX1262(_SX1262, SyncModem):
+ pass
+
+except ImportError:
+ pass
+
+try:
+ from .async_modem import AsyncModem
+
+ class AsyncSX1261(_SX1261, AsyncModem):
+ pass
+
+ class AsyncSX1262(_SX1262, AsyncModem):
+ pass
+
+except ImportError:
+ pass
diff --git a/micropython/lora/lora-sx126x/manifest.py b/micropython/lora/lora-sx126x/manifest.py
new file mode 100644
index 00000000..ebd66533
--- /dev/null
+++ b/micropython/lora/lora-sx126x/manifest.py
@@ -0,0 +1,3 @@
+metadata(version="0.1")
+require("lora")
+package("lora")
diff --git a/micropython/lora/lora-sx127x/lora/sx127x.py b/micropython/lora/lora-sx127x/lora/sx127x.py
new file mode 100644
index 00000000..3f94aaf0
--- /dev/null
+++ b/micropython/lora/lora-sx127x/lora/sx127x.py
@@ -0,0 +1,886 @@
+# MicroPython LoRa SX127x driver
+# MIT license; Copyright (c) 2023 Angus Gratton
+#
+# LoRa is a registered trademark or service mark of Semtech Corporation or its affiliates.
+#
+# In comments, abbreviation "DS" = Semtech SX1276/77/78/79 Datasheet rev 7 (May 2020)
+from micropython import const
+from .modem import BaseModem, ConfigError, RxPacket, _clamp, _flag
+from machine import Pin
+import struct
+import time
+
+# Set _DEBUG to const(True) to print all register reads and writes, and current register values
+# even when an update isn't needed. Plus a few additional pieces of information.
+_DEBUG = const(False)
+
+_WRITE_REG_BIT = const(1 << 7)
+
+# Registers and fields as bytecode-zerocost constants
+#
+# Where possible names are direct from DS section 4.4
+# (This means some names are slightly inconsistent, as per datasheet...)
+
+_REG_FIFO = const(0x00)
+
+_REG_OPMODE = const(0x01)
+
+_OPMODE_LONGRANGEMODE_LORA = const(1 << 7)
+_OPMODE_LONGRANGEMODE_FSK_OOK = const(0)
+_OPMODE_MODE_MASK = const(0x7)
+_OPMODE_MODE_SLEEP = const(0x0)
+_OPMODE_MODE_STDBY = const(0x1)
+_OPMODE_MODE_FSTX = const(0x2) # Frequency synthesis (TX)
+_OPMODE_MODE_TX = const(0x3)
+_OPMODE_MODE_FSRX = const(0x4) # Frequency synthesis (RX)
+_OPMODE_MODE_RX_CONTINUOUS = const(0x5)
+_OPMODE_MODE_RX_SINGLE = const(0x6)
+_OPMODE_MODE_CAD = const(0x7) # Channel Activity Detection
+
+_REG_FR_MSB = const(0x06)
+_REG_FR_MID = const(0x07)
+_REG_FR_LSB = const(0x08)
+
+_REG_PA_CONFIG = const(0x09)
+
+_PA_CONFIG_PASELECT_PA_BOOST_PIN = const(1 << 7)
+_PA_CONFIG_PASELECT_RFO_PIN = const(0x0)
+_PA_CONFIG_MAXPOWER_SHIFT = const(0x4)
+_PA_CONFIG_MAXPOWER_MASK = const(0x7)
+_PA_CONFIG_OUTPUTPOWER_SHIFT = const(0)
+_PA_CONFIG_OUTPUTPOWER_MASK = const(0xF)
+
+_REG_PA_RAMP = const(0x0A)
+_PA_RAMP_MASK = const(0x0F)
+
+_REG_LNA = const(0x0C)
+
+_LNA_GAIN_MASK = const(0x7)
+_LNA_GAIN_SHIFT = const(5)
+
+_LNA_BOOST_HF_MASK = 0x3
+_LNA_BOOST_HF_SHIFT = 0x0
+
+_REG_FIFO_ADDR_PTR = const(0x0D)
+_REG_FIFO_TX_BASE_ADDR = const(0x0E)
+_REG_FIFO_RX_BASE_ADDR = const(0x0F)
+_REG_FIFO_RX_CURRENT_ADDR = const(0x10)
+
+_REG_IRQ_FLAGS_MASK = const(0x11)
+_REG_IRQ_FLAGS = const(0x12)
+
+# IRQ mask bits are the same as the IRQ flag bits
+_IRQ_RX_TIMEOUT = const(1 << 7)
+_IRQ_RX_DONE = const(1 << 6)
+_IRQ_PAYLOAD_CRC_ERROR = const(1 << 5)
+_IRQ_VALID_HEADER = const(1 << 4)
+_IRQ_TX_DONE = const(1 << 3)
+_IRQ_CAD_DONE = const(1 << 2)
+_IRQ_FHSS_CHANGE_CHANNEL = const(1 << 1)
+_IRQ_CAD_DETECTED = const(1 << 0)
+
+_REG_RX_NB_BYTES = const(0x13)
+_REG_RX_HEADER_CNT_VALUE_MSB = const(0x14)
+_REG_RX_HEADER_CNT_VALUE_LSB = const(0x13)
+_REG_RX_PACKET_CNT_VALUE_MSB = const(0x16)
+_REG_RX_PACKET_CNT_VALUE_LSB = const(0x17)
+
+_REG_MODEM_STAT = const(0x18)
+_MODEM_STAT_RX_CODING_RATE_MASK = const(0xE)
+_MODEM_STAT_RX_CODING_RATE_SHIFT = const(5)
+_MODEM_STAT_MODEM_CLEAR = const(1 << 4)
+_MODEM_STAT_HEADER_INFO_VALID = const(1 << 3)
+_MODEM_STAT_RX_ONGOING = const(1 << 2)
+_MODEM_STAT_SIGNAL_SYNC = const(1 << 1) # Signal synchronized
+_MODEM_STAT_SIGNAL_DET = const(1 << 0) # Signal detected
+
+_REG_PKT_SNR_VAL = const(0x19)
+_REG_PKT_RSSI_VAL = const(0x1A)
+_REG_RSSI_VAL = const(0x1B)
+
+_REG_HOP_CHANNEL = const(0x1C)
+_HOP_CHANNEL_PLL_TIMEOUT = const(1 << 7)
+_HOP_CHANNEL_CRC_ON_PAYLOAD = const(1 << 6)
+_HOP_CHANNEL_FHSS_PRESENT_CHANNEL_MASK = const(0x1F)
+
+_REG_MODEM_CONFIG1 = const(0x1D)
+_MODEM_CONFIG1_BW_MASK = const(0xF)
+_MODEM_CONFIG1_BW_SHIFT = const(4)
+_MODEM_CONFIG1_BW7_8 = const(0x0)
+_MODEM_CONFIG1_BW10_4 = const(0x1)
+_MODEM_CONFIG1_BW15_6 = const(0x2)
+_MODEM_CONFIG1_BW20_8 = const(0x3)
+_MODEM_CONFIG1_BW31_25 = const(0x4)
+_MODEM_CONFIG1_BW41_7 = const(0x5)
+_MODEM_CONFIG1_BW62_5 = const(0x6)
+_MODEM_CONFIG1_BW125 = const(0x7)
+_MODEM_CONFIG1_BW250 = const(0x8) # not supported in lower band (169MHz)
+_MODEM_CONFIG1_BW500 = const(0x9) # not supported in lower band (169MHz)
+_MODEM_CONFIG1_CODING_RATE_MASK = const(0x7)
+_MODEM_CONFIG1_CODING_RATE_SHIFT = const(1)
+_MODEM_CONFIG1_CODING_RATE_45 = const(0b001)
+_MODEM_CONFIG1_CODING_RATE_46 = const(0b010)
+_MODEM_CONFIG1_CODING_RATE_47 = const(0b011)
+_MODEM_CONFIG1_CODING_RATE_48 = const(0b100)
+_MODEM_CONFIG1_IMPLICIT_HEADER_MODE_ON = const(1 << 0)
+
+_REG_MODEM_CONFIG2 = const(0x1E)
+_MODEM_CONFIG2_SF_MASK = const(0xF) # Spreading Factor
+_MODEM_CONFIG2_SF_SHIFT = const(4)
+# SF values are integers 6-12 for SF6-SF12, so skipping constants for these
+_MODEM_CONFIG2_SF_MIN = const(6) # inclusive
+_MODEM_CONFIG2_SF_MAX = const(12) # inclusive
+
+_MODEM_CONFIG2_TX_CONTINUOUS = const(1 << 3)
+_MODEM_CONFIG2_RX_PAYLOAD_CRC_ON = const(1 << 2)
+_MODEM_CONFIG2_SYMB_TIMEOUT_MSB_MASK = 0x3
+
+_REG_SYMB_TIMEOUT_LSB = const(0x1F)
+
+_REG_PREAMBLE_LEN_MSB = const(0x20)
+_REG_PREAMBLE_LEN_LSB = const(0x21)
+
+_REG_PAYLOAD_LEN = const(0x22) # Only for implicit header mode & TX
+_REG_MAX_PAYLOAD_LEN = const(0x23)
+
+_REG_HOP_PERIOD = const(0x24)
+
+_REG_FIFO_TXBYTE_ADDR = const(0x25)
+
+_REG_MODEM_CONFIG3 = const(0x26)
+_MODEM_CONFIG3_AGC_ON = const(1 << 2)
+_MODEM_CONFIG3_LOW_DATA_RATE_OPTIMIZE = const(1 << 3)
+
+_REG_DETECT_OPTIMIZE = const(0x31)
+_DETECT_OPTIMIZE_AUTOMATIC_IF_ON = const(
+ 1 << 7
+) # Bit should be cleared after reset, as per errata
+_DETECT_OPTIMIZE_MASK = 0x7
+_DETECT_OPTIMIZE_SF6 = const(0x05)
+_DETECT_OPTIMIZE_OTHER = const(0x03)
+
+# RegInvertIQ is not correctly documented in DS Rev 7 (May 2020).
+#
+# The correct behaviour for interoperability with other LoRa devices is as
+# written here:
+# https://github.com/eclipse/upm/blob/master/src/sx1276/sx1276.cxx#L1310
+#
+# Same as used in the Semtech mbed driver, here:
+# https://github.com/ARMmbed/mbed-semtech-lora-rf-drivers/blob/master/SX1276/SX1276_LoRaRadio.cpp#L778
+# https://github.com/ARMmbed/mbed-semtech-lora-rf-drivers/blob/master/SX1276/registers/sx1276Regs-LoRa.h#L443
+#
+# Specifically:
+# - The TX bit in _REG_INVERT_IQ is opposite to what's documented in the datasheet
+# (0x01 normal, 0x00 inverted)
+# - The RX bit in _REG_INVERT_IQ is as documented in the datasheet (0x00 normal, 0x40 inverted)
+# - When enabling LoRa mode, the default register value becomes 0x27 (normal RX & TX)
+# rather than the documented power-on value of 0x26.
+_REG_INVERT_IQ = const(0x33)
+_INVERT_IQ_RX = const(1 << 6)
+_INVERT_IQ_TX_OFF = const(1 << 0)
+
+_REG_DETECTION_THRESHOLD = const(0x37)
+_DETECTION_THRESHOLD_SF6 = const(0x0C)
+_DETECTION_THRESHOLD_OTHER = const(0x0A) # SF7 to SF12
+
+_REG_SYNC_WORD = const(0x39)
+
+_REG_FSKOOK_IMAGE_CAL = const(0x3B) # NOTE: Only accessible in FSK/OOK mode
+_IMAGE_CAL_START = const(1 << 6)
+_IMAGE_CAL_RUNNING = const(1 << 5)
+_IMAGE_CAL_AUTO = const(1 << 7)
+
+_REG_INVERT_IQ2 = const(0x3B)
+_INVERT_IQ2_ON = const(0x19)
+_INVERT_IQ2_OFF = const(0x1D)
+
+_REG_DIO_MAPPING1 = const(0x40)
+_DIO0_MAPPING_MASK = const(0x3)
+_DIO0_MAPPING_SHIFT = const(6)
+_DIO1_MAPPING_MASK = const(0x3)
+_DIO1_MAPPING_SHIFT = const(4)
+_DIO2_MAPPING_MASK = const(0x3)
+_DIO2_MAPPING_SHIFT = const(2)
+_DIO3_MAPPING_MASK = const(0x3)
+_DIO3_MAPPING_SHIFT = const(0)
+
+_REG_DIO_MAPPING2 = const(0x41)
+_DIO4_MAPPING_MASK = const(0x3)
+_DIO4_MAPPING_SHIFT = const(6)
+_DIO5_MAPPING_MASK = const(0x3)
+_DIO5_MAPPING_SHIFT = const(4)
+
+_REG_PA_DAC = const(0x4D)
+_PA_DAC_DEFAULT_VALUE = const(0x84) # DS 3.4.3 High Power +20 dBm Operation
+_PA_DAC_HIGH_POWER_20DBM = const(0x87)
+
+_REG_VERSION = const(0x42)
+
+# IRQs the driver masks in when receiving
+_IRQ_DRIVER_RX_MASK = const(
+ _IRQ_RX_DONE | _IRQ_RX_TIMEOUT | _IRQ_VALID_HEADER | _IRQ_PAYLOAD_CRC_ERROR
+)
+
+
+class _SX127x(BaseModem):
+ # Don't instantiate this class directly, instantiate either lora.SX1276,
+ # lora.SX1277, lora.SX1278, lora.SX1279, or lora.AsyncSX1276,
+ # lora.AsyncSX1277, lora.AsyncSX1278, lora.AsyncSX1279 as applicable.
+
+ # common IRQ masks used by the base class functions
+ _IRQ_RX_COMPLETE = _IRQ_RX_DONE | _IRQ_RX_TIMEOUT
+ _IRQ_TX_COMPLETE = _IRQ_TX_DONE
+
+ def __init__(self, spi, cs, dio0=None, dio1=None, reset=None, lora_cfg=None, ant_sw=None):
+ super().__init__(ant_sw)
+
+ self._buf1 = bytearray(1) # shared small buffers
+ self._buf2 = bytearray(2)
+ self._spi = spi
+ self._cs = cs
+
+ self._dio0 = dio0
+ self._dio1 = dio1
+
+ cs.init(Pin.OUT, value=1)
+
+ if dio0:
+ dio0.init(Pin.IN)
+ dio0.irq(self._radio_isr, trigger=Pin.IRQ_RISING)
+ if dio1:
+ dio1.init(Pin.IN)
+ dio1.irq(self._radio_isr, trigger=Pin.IRQ_RISING)
+
+ # Configuration settings that need to be tracked by the driver
+ # Note: a number of these are set in the base class constructor
+ self._pa_boost = False
+
+ if reset:
+ # If the user supplies a reset pin argument, reset the radio
+ reset.init(Pin.OUT, value=0)
+ time.sleep_ms(1)
+ reset(1)
+ time.sleep_ms(5)
+
+ version = self._reg_read(_REG_VERSION)
+ if version != 0x12:
+ raise RuntimeError("Unexpected silicon version {}".format(version))
+
+ # wake the radio and enable LoRa mode if it's not already set
+ self._set_mode(_OPMODE_MODE_STDBY)
+
+ if lora_cfg:
+ self.configure(lora_cfg)
+
+ def configure(self, lora_cfg):
+ if self._rx is not False:
+ raise RuntimeError("Receiving")
+
+ # Set frequency
+ if "freq_khz" in lora_cfg:
+ # Assuming F(XOSC)=32MHz (datasheet both implies this value can be different, and
+ # specifies it shouldn't be different!)
+ self._rf_freq_hz = int(lora_cfg["freq_khz"] * 1000)
+ fr_val = self._rf_freq_hz * 16384 // 1000_000
+ buf = bytes([fr_val >> 16, (fr_val >> 8) & 0xFF, fr_val & 0xFF])
+ self._reg_write(_REG_FR_MSB, buf)
+
+ # Turn on/off automatic image re-calibration if temperature changes. May lead to dropped
+ # packets if enabled.
+ if "auto_image_cal" in lora_cfg:
+ self._set_mode(_OPMODE_MODE_STDBY, False) # Disable LoRa mode to access FSK/OOK
+ self._reg_update(
+ _REG_FSKOOK_IMAGE_CAL,
+ _IMAGE_CAL_AUTO,
+ _flag(_IMAGE_CAL_AUTO, lora_cfg["auto_image_cal"]),
+ )
+ self._set_mode(_OPMODE_MODE_STDBY) # Switch back to LoRa mode
+
+ # Note: Common pattern below is to generate a new register value and an update_mask,
+ # and then call self._reg_update(). self._reg_update() is a
+ # no-op if update_mask==0 (no bits to change).
+
+ # Update _REG_PA_CONFIG
+ pa_config = 0x0
+ update_mask = 0x0
+
+ # Ref DS 3.4.2 "RF Power Amplifiers"
+ if "tx_ant" in lora_cfg:
+ self._pa_boost = lora_cfg["tx_ant"].upper() == "PA_BOOST"
+ pa_boost_bit = (
+ _PA_CONFIG_PASELECT_PA_BOOST_PIN if self._pa_boost else _PA_CONFIG_PASELECT_RFO_PIN
+ )
+ pa_config |= pa_boost_bit
+ update_mask |= pa_boost_bit
+ if not self._pa_boost:
+ # When using RFO, _REG_PA_DAC can keep default value always
+ # (otherwise, it's set when output_power is set in next block)
+ self._reg_write(_REG_PA_DAC, _PA_DAC_DEFAULT_VALUE)
+
+ if "output_power" in lora_cfg:
+ # See DS 3.4.2 RF Power Amplifiers
+ dbm = int(lora_cfg["output_power"])
+ if self._pa_boost:
+ if dbm >= 20:
+ output_power = 0x15 # 17dBm setting
+ pa_dac = _PA_DAC_HIGH_POWER_20DBM
+ else:
+ dbm = _clamp(dbm, 2, 17) # +2 to +17dBm only
+ output_power = dbm - 2
+ pa_dac = _PA_DAC_DEFAULT_VALUE
+ self._reg_write(_REG_PA_DAC, pa_dac)
+ else:
+ # In RFO mode, Output Power is computed from two register fields
+ # - MaxPower and OutputPower.
+ #
+ # Do what the Semtech LoraMac-node driver does here, which is to
+ # set max_power at one extreme or the other (0 or 7) and then
+ # calculate the output_power setting based on this baseline.
+ dbm = _clamp(dbm, -4, 15)
+ if dbm > 0:
+ # MaxPower to maximum
+ pa_config |= _PA_CONFIG_MAXPOWER_MASK << _PA_CONFIG_MAXPOWER_SHIFT
+
+ # Pout (dBm) == 10.8dBm + 0.6*maxPower - (15 - register value)
+ # 10.8+0.6*7 == 15dBm, so pOut = register_value (0 to 15 dBm)
+ output_power = dbm
+ else:
+ # MaxPower field will be set to 0
+
+ # Pout (dBm) == 10.8dBm - (15 - OutputPower)
+ # OutputPower == Pout (dBm) + 4.2
+ output_power = dbm + 4 # round down to 4.0, to keep using integer math
+
+ pa_config |= output_power << _PA_CONFIG_OUTPUTPOWER_SHIFT
+ update_mask |= (
+ _PA_CONFIG_OUTPUTPOWER_MASK << _PA_CONFIG_OUTPUTPOWER_SHIFT
+ | _PA_CONFIG_MAXPOWER_MASK << _PA_CONFIG_MAXPOWER_SHIFT
+ )
+
+ self._reg_update(_REG_PA_CONFIG, update_mask, pa_config)
+
+ if "pa_ramp_us" in lora_cfg:
+ # other fields in this register are reserved to 0 or unused
+ self._reg_write(
+ _REG_PA_RAMP,
+ self._get_pa_ramp_val(
+ lora_cfg,
+ [10, 12, 15, 20, 25, 31, 40, 50, 62, 100, 125, 250, 500, 1000, 2000, 3400],
+ ),
+ )
+
+ # If a hard reset happened then flags should be cleared already and mask should
+ # default to fully enabled, but let's be "belts and braces" sure
+ self._reg_write(_REG_IRQ_FLAGS, 0xFF)
+ self._reg_write(_REG_IRQ_FLAGS_MASK, 0) # do IRQ masking in software for now
+
+ # Update MODEM_CONFIG1
+ modem_config1 = 0x0
+ update_mask = 0x0
+ if "bw" in lora_cfg:
+ bw = str(lora_cfg["bw"])
+ bw_reg_val, self._bw_hz = {
+ "7.8": (_MODEM_CONFIG1_BW7_8, 7800),
+ "10.4": (_MODEM_CONFIG1_BW10_4, 10400),
+ "15.6": (_MODEM_CONFIG1_BW15_6, 15600),
+ "20.8": (_MODEM_CONFIG1_BW20_8, 20800),
+ "31.25": (_MODEM_CONFIG1_BW31_25, 31250),
+ "41.7": (_MODEM_CONFIG1_BW41_7, 41700),
+ "62.5": (_MODEM_CONFIG1_BW62_5, 62500),
+ "125": (_MODEM_CONFIG1_BW125, 125000),
+ "250": (_MODEM_CONFIG1_BW250, 250000),
+ "500": (_MODEM_CONFIG1_BW500, 500000),
+ }[bw]
+ modem_config1 |= bw_reg_val << _MODEM_CONFIG1_BW_SHIFT
+ update_mask |= _MODEM_CONFIG1_BW_MASK << _MODEM_CONFIG1_BW_SHIFT
+
+ if "freq_khz" in lora_cfg or "bw" in lora_cfg:
+ # Workaround for Errata Note 2.1 "Sensitivity Optimization with a 500 kHz bandwidth"
+ if self._bw_hz == 500000 and 862_000_000 <= self._rf_freq_hz <= 1020_000_000:
+ self._reg_write(0x36, 0x02)
+ self._reg_write(0x3A, 0x64)
+ elif self._bw_hz == 500000 and 410_000_000 <= self._rf_freq_hz <= 525_000_000:
+ self._reg_write(0x36, 0x02)
+ self._reg_write(0x3A, 0x7F)
+ else:
+ # "For all other combinations of bandiwdth/frequencies, register at address 0x36
+ # should be re-set to value 0x03 and the value at address 0x3a will be
+ # automatically selected by the chip"
+ self._reg_write(0x36, 0x03)
+
+ if "coding_rate" in lora_cfg:
+ self._coding_rate = int(lora_cfg["coding_rate"])
+ if self._coding_rate < 5 or self._coding_rate > 8:
+ raise ConfigError("coding_rate")
+ # _MODEM_CONFIG1_CODING_RATE_45 == value 5 == 1
+ modem_config1 |= (self._coding_rate - 4) << _MODEM_CONFIG1_CODING_RATE_SHIFT
+ update_mask |= _MODEM_CONFIG1_CODING_RATE_MASK << _MODEM_CONFIG1_CODING_RATE_SHIFT
+
+ self._reg_update(_REG_MODEM_CONFIG1, update_mask, modem_config1)
+
+ if "implicit_header" in lora_cfg:
+ self._implicit_header = lora_cfg["implicit_header"]
+ modem_config1 |= _flag(_MODEM_CONFIG1_IMPLICIT_HEADER_MODE_ON, self._implicit_header)
+ update_mask |= _MODEM_CONFIG1_IMPLICIT_HEADER_MODE_ON
+
+ # Update MODEM_CONFIG2, for any fields that changed
+ modem_config2 = 0
+ update_mask = 0
+ if "sf" in lora_cfg:
+ sf = self._sf = int(lora_cfg["sf"])
+
+ if sf < _MODEM_CONFIG2_SF_MIN or sf > _MODEM_CONFIG2_SF_MAX:
+ raise ConfigError("sf")
+ if sf == 6 and not self._implicit_header:
+ # DS 4.1.12 "Spreading Factor"
+ raise ConfigError("SF6 requires implicit_header mode")
+
+ # Update these registers when writing 'SF'
+ self._reg_write(
+ _REG_DETECTION_THRESHOLD,
+ _DETECTION_THRESHOLD_SF6 if sf == 6 else _DETECTION_THRESHOLD_OTHER,
+ )
+ # This field has a reserved non-zero field, so do a read-modify-write update
+ self._reg_update(
+ _REG_DETECT_OPTIMIZE,
+ _DETECT_OPTIMIZE_AUTOMATIC_IF_ON | _DETECT_OPTIMIZE_MASK,
+ _DETECT_OPTIMIZE_SF6 if sf == 6 else _DETECT_OPTIMIZE_OTHER,
+ )
+
+ modem_config2 |= sf << _MODEM_CONFIG2_SF_SHIFT
+ update_mask |= _MODEM_CONFIG2_SF_MASK << _MODEM_CONFIG2_SF_SHIFT
+
+ if "crc_en" in lora_cfg:
+ self._crc_en = lora_cfg["crc_en"]
+ # I had to double-check the datasheet about this point:
+ # 1. In implicit header mode, this bit is used on both RX & TX and
+ # should be set to get CRC generation on TX and/or checking on RX.
+ # 2. In explicit header mode, this bit is only used on TX (should CRC
+ # be added and CRC flag set in header) and ignored on RX (CRC flag
+ # read from header instead).
+ modem_config2 |= _flag(_MODEM_CONFIG2_RX_PAYLOAD_CRC_ON, self._crc_en)
+ update_mask |= _MODEM_CONFIG2_RX_PAYLOAD_CRC_ON
+
+ self._reg_update(_REG_MODEM_CONFIG2, update_mask, modem_config2)
+
+ # Update _REG_INVERT_IQ
+ #
+ # See comment about this register's undocumented weirdness at top of
+ # file above _REG_INVERT_IQ constant.
+ #
+ # Note also there is a second register invert_iq2 which may be set differently
+ # for transmit vs receive, see _set_invert_iq2() for that one.
+ invert_iq = 0x0
+ update_mask = 0x0
+ if "invert_iq_rx" in lora_cfg:
+ self._invert_iq[0] = lora_cfg["invert_iq_rx"]
+ invert_iq |= _flag(_INVERT_IQ_RX, lora_cfg["invert_iq_rx"])
+ update_mask |= _INVERT_IQ_RX
+ if "invert_iq_tx" in lora_cfg:
+ self._invert_iq[1] = lora_cfg["invert_iq_tx"]
+ invert_iq |= _flag(_INVERT_IQ_TX_OFF, not lora_cfg["invert_iq_tx"]) # Inverted
+ update_mask |= _INVERT_IQ_TX_OFF
+ self._reg_update(_REG_INVERT_IQ, update_mask, invert_iq)
+
+ if "preamble_len" in lora_cfg:
+ self._preamble_len = lora_cfg["preamble_len"]
+ self._reg_write(_REG_PREAMBLE_LEN_MSB, struct.pack(">H", self._preamble_len))
+
+ # Update MODEM_CONFIG3, for any fields that have changed
+ modem_config3 = 0
+ update_mask = 0
+
+ if "sf" in lora_cfg or "bw" in lora_cfg:
+ # Changing either SF or BW means the Low Data Rate Optimization may need to be changed
+ #
+ # note: BaseModem.get_n_symbols_x4() assumes this value is set automatically
+ # as follows.
+ modem_config3 |= _flag(_MODEM_CONFIG3_LOW_DATA_RATE_OPTIMIZE, self._get_ldr_en())
+ update_mask |= _MODEM_CONFIG3_LOW_DATA_RATE_OPTIMIZE
+
+ if "lna_gain" in lora_cfg:
+ lna_gain = lora_cfg["lna_gain"]
+ update_mask |= _MODEM_CONFIG3_AGC_ON
+ if lna_gain is None: # Setting 'None' means 'Auto'
+ modem_config3 |= _MODEM_CONFIG3_AGC_ON
+ else: # numeric register value
+ # Clear the _MODEM_CONFIG3_AGC_ON bit, and write the manual LNA gain level 1-6
+ # to the register
+ self._reg_update(
+ _REG_LNA, _LNA_GAIN_MASK << _LNA_GAIN_SHIFT, lna_gain << _LNA_GAIN_SHIFT
+ )
+
+ if "rx_boost" in lora_cfg:
+ self._reg_update(
+ _REG_LNA,
+ _LNA_BOOST_HF_MASK << _LNA_BOOST_HF_SHIFT,
+ _flag(0x3, lora_cfg["lna_boost_hf"]),
+ )
+
+ self._reg_update(_REG_MODEM_CONFIG3, update_mask, modem_config3)
+
+ def _reg_write(self, reg, value):
+ self._cs(0)
+ if isinstance(value, int):
+ self._buf2[0] = reg | _WRITE_REG_BIT
+ self._buf2[1] = value
+ self._spi.write(self._buf2)
+ if _DEBUG:
+ dbg = hex(value)
+ else: # value is a buffer
+ self._buf1[0] = reg | _WRITE_REG_BIT
+ self._spi.write(self._buf1)
+ self._spi.write(value)
+ if _DEBUG:
+ dbg = value.hex()
+ self._cs(1)
+
+ if _DEBUG:
+ print("W {:#x} ==> {}".format(reg, dbg))
+ self._reg_read(reg) # log the readback as well
+
+ def _reg_update(self, reg, update_mask, new_value):
+ # Update register address 'reg' with byte value new_value, as masked by
+ # bit mask update_mask. Bits not set in update_mask will be kept at
+ # their pre-existing values in the register.
+ #
+ # If update_mask is zero, this function is a no-op and returns None.
+ # If update_mask is not zero, this function updates 'reg' and returns
+ # the previous complete value of 'reg' as a result.
+ #
+ # Note: this function has no way of detecting a race condition if the
+ # modem updates any bits in 'reg' that are unset in update_mask, at the
+ # same time a read/modify/write is occurring. Any such changes are
+ # overwritten with the original values.
+
+ if not update_mask: # short-circuit if nothing to change
+ if _DEBUG:
+ # Log the current value if DEBUG is on
+ # (Note the compiler will optimize this out otherwise)
+ self._reg_read(reg)
+ return
+ old_value = self._reg_read(reg)
+ value = ((old_value & ~update_mask) & 0xFF) | (new_value & update_mask)
+ if old_value != value:
+ self._reg_write(reg, value)
+ return old_value
+
+ def _reg_read(self, reg):
+ # Read and return a single register value at address 'reg'
+ self._buf2[0] = reg
+ self._buf2[1] = 0xFF
+ self._cs(0)
+ self._spi.write_readinto(self._buf2, self._buf2)
+ self._cs(1)
+ if _DEBUG:
+ print("R {:#x} <== {:#x}".format(reg, self._buf2[1]))
+ return self._buf2[1]
+
+ def _reg_readinto(self, reg, buf):
+ # Read and return one or more register values starting at address 'reg',
+ # into buffer 'buf'.
+ self._cs(0)
+ self._spi.readinto(self._buf1, reg)
+ self._spi.readinto(buf)
+ if _DEBUG:
+ print("R {:#x} <== {}".format(reg, buf.hex()))
+ self._cs(1)
+
+ def _get_mode(self):
+ # Return the current 'Mode' field in RegOpMode
+ return self._reg_read(_REG_OPMODE) & _OPMODE_MODE_MASK
+
+ def _set_mode(self, mode, lora_en=True):
+ # Set the 'Mode' and 'LongRangeMode' fields in RegOpMode
+ # according to 'mode' and 'lora_en', respectively.
+ #
+ # If enabling or disabling LoRa mode, the radio is automatically
+ # switched into Sleep mode as required and then the requested mode is
+ # set (if not sleep mode).
+ #
+ # Returns the previous value of the RegOpMode register (unmasked).
+ mask = _OPMODE_LONGRANGEMODE_LORA | _OPMODE_MODE_MASK
+ lora_val = _flag(_OPMODE_LONGRANGEMODE_LORA, lora_en)
+ old_value = self._reg_read(_REG_OPMODE)
+ new_value = (old_value & ~mask) | lora_val | mode
+
+ if lora_val != (old_value & _OPMODE_LONGRANGEMODE_LORA):
+ # Need to switch into Sleep mode in order to change LongRangeMode flag
+ self._reg_write(_REG_OPMODE, _OPMODE_MODE_SLEEP | lora_val)
+
+ if new_value != old_value:
+ self._reg_write(_REG_OPMODE, new_value)
+
+ if _DEBUG:
+ print(
+ "Mode {} -> {} ({:#x})".format(
+ old_value & _OPMODE_MODE_MASK, mode, self._reg_read(_REG_OPMODE)
+ )
+ )
+
+ return old_value
+
+ def _set_invert_iq2(self, val):
+ # Set the InvertIQ2 register on/off as needed, unless it is already set to the correct
+ # level
+ if self._invert_iq[2] == val:
+ return # already set to the level we want
+ self._reg_write(_REG_INVERT_IQ2, _INVERT_IQ2_ON if val else _INVERT_IQ2_OFF)
+ self._invert_iq[2] = val
+
+ def _standby(self):
+ # Send the command for standby mode.
+ #
+ # **Don't call this function directly, call standby() instead.**
+ #
+ # (This private version doesn't update the driver's internal state.)
+ old_mode = self._set_mode(_OPMODE_MODE_STDBY) & _OPMODE_MODE_MASK
+ if old_mode not in (_OPMODE_MODE_STDBY, _OPMODE_MODE_SLEEP):
+ # If we just cancelled sending or receiving, clear any pending IRQs
+ self._reg_write(_REG_IRQ_FLAGS, 0xFF)
+
+ def sleep(self):
+ # Put the modem into sleep mode. Modem will wake automatically the next
+ # time host asks it for something, or call standby() to wake it manually.
+ self.standby() # save some code size, this clears driver state for us
+ self._set_mode(_OPMODE_MODE_SLEEP)
+
+ def is_idle(self):
+ # Returns True if the modem is idle (either in standby or in sleep).
+ #
+ # Note this function can return True in the case where the modem has temporarily gone to
+ # standby, but there's a receive configured in software that will resume receiving the
+ # next time poll_recv() or poll_send() is called.
+ return self._get_mode() in (_OPMODE_MODE_STDBY, _OPMODE_MODE_SLEEP)
+
+ def calibrate_image(self):
+ # Run the modem Image & RSSI calibration process to improve receive performance.
+ #
+ # calibration will be run in the HF or LF band automatically, depending on the
+ # current radio configuration.
+ #
+ # See DS 2.1.3.8 Image and RSSI Calibration. Idea to disable TX power
+ # comes from Semtech's sx1276 driver which does this.
+
+ pa_config = self._reg_update(_REG_PA_CONFIG, 0xFF, 0) # disable TX power
+
+ self._set_mode(_OPMODE_MODE_STDBY, False) # Switch to FSK/OOK mode to expose RegImageCal
+
+ self._reg_update(_REG_FSKOOK_IMAGE_CAL, _IMAGE_CAL_START, _IMAGE_CAL_START)
+ while self._reg_read(_REG_FSKOOK_IMAGE_CAL) & _IMAGE_CAL_RUNNING:
+ time.sleep_ms(1)
+
+ self._set_mode(_OPMODE_MODE_STDBY) # restore LoRA mode
+
+ self._reg_write(_REG_PA_CONFIG, pa_config) # restore previous TX power
+
+ def calibrate(self):
+ # Run a full calibration.
+ #
+ # For SX1276, this means just the image & RSSI calibration as no other runtime
+ # calibration is implemented in the modem.
+ self.calibrate_image()
+
+ def start_recv(self, timeout_ms=None, continuous=False, rx_length=0xFF):
+ # Start receiving.
+ #
+ # Part of common low-level modem API, see README.md for usage.
+ super().start_recv(timeout_ms, continuous, rx_length) # sets self._rx
+
+ # will_irq if DIO0 and DIO1 both hooked up, or DIO0 and no timeout
+ will_irq = self._dio0 and (self._dio1 or timeout_ms is None)
+
+ if self._tx:
+ # Send is in progress and has priority, _check_recv() will start receive
+ # once send finishes (caller needs to call poll_send() for this to happen.)
+ if _DEBUG:
+ print("Delaying receive until send completes")
+ return will_irq
+
+ # Put the modem in a known state. It's possible a different
+ # receive was in progress, this prevent anything changing while
+ # we set up the new receive
+ self._standby() # calling private version to keep driver state as-is
+
+ # Update the InvertIQ2 setting for RX
+ self._set_invert_iq2(self._invert_iq[0])
+
+ if self._implicit_header:
+ # Payload length only needs to be set in implicit header mode
+ self._reg_write(_REG_PAYLOAD_LEN, rx_length)
+
+ if self._dio0:
+ # Field value is 0, for DIO0 = RXDone
+ update_mask = _DIO0_MAPPING_MASK << _DIO0_MAPPING_SHIFT
+ if self._dio1:
+ # Field value also 0, for DIO1 = RXTimeout
+ update_mask |= _DIO1_MAPPING_MASK << _DIO1_MAPPING_SHIFT
+ self._reg_update(_REG_DIO_MAPPING1, update_mask, 0)
+
+ if not continuous:
+ # Unlike SX1262, SX1276 doesn't have a "single RX no timeout" mode. So we set the
+ # maximum hardware timeout and resume RX in software if needed.
+ if timeout_ms is None:
+ timeout_syms = 1023
+ else:
+ t_sym_us = self._get_t_sym_us()
+ timeout_syms = (timeout_ms * 1000 + t_sym_us - 1) // t_sym_us # round up
+
+ # if the timeout is too long for the modem, the host will
+ # automatically resume it in software. If the timeout is too
+ # short for the modem, round it silently up to the minimum
+ # timeout.
+ timeout_syms = _clamp(timeout_syms, 4, 1023)
+ self._reg_update(
+ _REG_MODEM_CONFIG2,
+ _MODEM_CONFIG2_SYMB_TIMEOUT_MSB_MASK,
+ timeout_syms >> 8,
+ )
+ self._reg_write(_REG_SYMB_TIMEOUT_LSB, timeout_syms & 0xFF)
+
+ # Allocate the full FIFO for RX
+ self._reg_write(_REG_FIFO_ADDR_PTR, 0)
+ self._reg_write(_REG_FIFO_RX_BASE_ADDR, 0)
+
+ self._set_mode(_OPMODE_MODE_RX_CONTINUOUS if continuous else _OPMODE_MODE_RX_SINGLE)
+
+ return will_irq
+
+ def _rx_flags_success(self, flags):
+ # Returns True if IRQ flags indicate successful receive.
+ # Specifically, from the bits in _IRQ_DRIVER_RX_MASK:
+ # - _IRQ_RX_DONE must be set
+ # - _IRQ_RX_TIMEOUT must not be set
+ # - _IRQ_PAYLOAD_CRC_ERROR must not be set
+ # - _IRQ_VALID_HEADER must be set if we're using explicit packet mode, ignored otherwise
+ return flags & _IRQ_DRIVER_RX_MASK == _IRQ_RX_DONE | _flag(
+ _IRQ_VALID_HEADER, not self._implicit_header
+ )
+
+ def _get_irq(self):
+ return self._reg_read(_REG_IRQ_FLAGS)
+
+ def _clear_irq(self, to_clear=0xFF):
+ return self._reg_write(_REG_IRQ_FLAGS, to_clear)
+
+ def _read_packet(self, rx_packet, flags):
+ # Private function to read received packet (RxPacket object) from the
+ # modem, if there is one.
+ #
+ # Called from poll_recv() function, which has already checked the IRQ flags
+ # and verified a valid receive happened.
+
+ ticks_ms = self._get_last_irq() # IRQ timestamp for the receive
+
+ rx_payload_len = self._reg_read(_REG_RX_NB_BYTES)
+
+ if rx_packet is None or len(rx_packet) != rx_payload_len:
+ rx_packet = RxPacket(rx_payload_len)
+
+ self._reg_readinto(_REG_FIFO, rx_packet)
+
+ rx_packet.ticks_ms = ticks_ms
+ # units: dB*4
+ rx_packet.snr = self._reg_read(_REG_PKT_SNR_VAL)
+ if rx_packet.snr & 0x80: # Signed 8-bit integer
+ # (avoiding using struct here to skip a heap allocation)
+ rx_packet.snr -= 0x100
+ # units: dBm
+ rx_packet.rssi = self._reg_read(_REG_PKT_RSSI_VAL) - (157 if self._pa_boost else 164)
+ rx_packet.crc_error = flags & _IRQ_PAYLOAD_CRC_ERROR != 0
+ return rx_packet
+
+ def prepare_send(self, packet):
+ # Prepare modem to start sending. Should be followed by a call to start_send()
+ #
+ # Part of common low-level modem API, see README.md for usage.
+ if len(packet) > 255:
+ raise ValueError("packet too long")
+
+ # Put the modem in a known state. Any current receive is suspended at this point,
+ # but calling _check_recv() will resume it later.
+ self._standby() # calling private version to keep driver state as-is
+
+ if self._ant_sw:
+ self._ant_sw.tx(self._pa_boost)
+
+ self._last_irq = None
+
+ if self._dio0:
+ self._reg_update(
+ _REG_DIO_MAPPING1,
+ _DIO0_MAPPING_MASK << _DIO0_MAPPING_SHIFT,
+ 1 << _DIO0_MAPPING_SHIFT,
+ ) # DIO0 = TXDone
+
+ # Update the InvertIQ2 setting for TX
+ self._set_invert_iq2(self._invert_iq[1])
+
+ # Allocate the full FIFO for TX
+ self._reg_write(_REG_FIFO_ADDR_PTR, 0)
+ self._reg_write(_REG_FIFO_TX_BASE_ADDR, 0)
+
+ self._reg_write(_REG_PAYLOAD_LEN, len(packet))
+
+ self._reg_write(_REG_FIFO, packet)
+
+ # clear the TX Done flag in case a previous call left it set
+ # (won't happen unless poll_send() was not called)
+ self._reg_write(_REG_IRQ_FLAGS, _IRQ_TX_DONE)
+
+ def start_send(self):
+ # Actually start a send that was loaded by calling prepare_send().
+ #
+ # This is split into a separate function to allow more precise timing.
+ #
+ # The driver doesn't verify the caller has done the right thing here, the
+ # modem will no doubt do something weird if prepare_send() was not called!
+ #
+ # Part of common low-level modem API, see README.md for usage.
+ self._set_mode(_OPMODE_MODE_TX)
+
+ self._tx = True
+
+ return self._dio0 is not None # will_irq if dio0 is set
+
+ def _irq_flag_tx_done(self):
+ return _IRQ_TX_DONE
+
+
+# Define the actual modem classes that use the SyncModem & AsyncModem "mixin-like" classes
+# to create sync and async variants.
+
+try:
+ from .sync_modem import SyncModem
+
+ class SX1276(_SX127x, SyncModem):
+ pass
+
+ # Implementation note: Currently the classes SX1276, SX1277, SX1278 and
+ # SX1279 are actually all SX1276. Perhaps in the future some subclasses with
+ # software enforced limits can be added to this driver, but the differences
+ # appear very minor:
+ #
+ # - SX1276 seems like "baseline" with max freq.
+ # - SX1277 supports max SF level of 9.
+ # - SX1278 supports max freq 525MHz, therefore has no RFO_HF and RFI_HF pins.
+ # - SX1279 supports max freq 960MHz.
+ #
+ # There also appears to be no difference in silicon interface or register values to determine
+ # which model is connected.
+ SX1277 = SX1278 = SX1279 = SX1276
+
+except ImportError:
+ pass
+
+try:
+ from .async_modem import AsyncModem
+
+ class AsyncSX1276(_SX127x, AsyncModem):
+ pass
+
+ # See comment above about currently identical implementations
+ AsyncSX1277 = AsyncSX1278 = AsyncSX1279 = AsyncSX1276
+
+except ImportError:
+ pass
diff --git a/micropython/lora/lora-sx127x/manifest.py b/micropython/lora/lora-sx127x/manifest.py
new file mode 100644
index 00000000..ebd66533
--- /dev/null
+++ b/micropython/lora/lora-sx127x/manifest.py
@@ -0,0 +1,3 @@
+metadata(version="0.1")
+require("lora")
+package("lora")
diff --git a/micropython/lora/lora-sync/lora/sync_modem.py b/micropython/lora/lora-sync/lora/sync_modem.py
new file mode 100644
index 00000000..27c2f19d
--- /dev/null
+++ b/micropython/lora/lora-sync/lora/sync_modem.py
@@ -0,0 +1,86 @@
+# MicroPython LoRa synchronous modem driver
+# MIT license; Copyright (c) 2023 Angus Gratton
+#
+# LoRa is a registered trademark or service mark of Semtech Corporation or its affiliates.
+
+import machine
+import time
+
+
+class SyncModem:
+ # Mixin-like base class that provides synchronous modem send and recv
+ # functions
+ #
+ #
+ # Don't instantiate this class directly, instantiate one of the 'AsyncXYZ'
+ # modem classes defined in the lora module.
+ #
+ # These are intended for simple applications. They block the caller until
+ # the modem operation is complete, and don't support interleaving send
+ # and receive.
+
+ def _after_init(self):
+ pass # Needed for AsyncModem but not SyncModem
+
+ def send(self, packet, tx_at_ms=None):
+ # Send the given packet (byte sequence),
+ # and return once transmission of the packet is complete.
+ #
+ # Returns a timestamp (result of time.ticks_ms()) when the packet
+ # finished sending.
+ self.prepare_send(packet)
+
+ # If the caller specified a timestamp to start transmission at, wait until
+ # that time before triggering the send
+ if tx_at_ms is not None:
+ time.sleep_ms(max(0, time.ticks_diff(tx_at_ms, time.ticks_ms())))
+
+ will_irq = self.start_send() # ... and go!
+
+ # sleep for the expected send time before checking if send has ended
+ time.sleep_ms(self.get_time_on_air_us(len(packet)) // 1000)
+
+ tx = True
+ while tx is True:
+ tx = self.poll_send()
+ self._sync_wait(will_irq)
+ return tx
+
+ def recv(self, timeout_ms=None, rx_length=0xFF, rx_packet=None):
+ # Attempt to a receive a single LoRa packet, timeout after timeout_ms milliseconds
+ # or wait indefinitely if no timeout is supplied (default).
+ #
+ # Returns an instance of RxPacket or None if the radio timed out while receiving.
+ #
+ # Optional rx_length argument is only used if lora_cfg["implict_header"] == True
+ # (not the default) and holds the length of the payload to receive.
+ #
+ # Optional rx_packet argument can be an existing instance of RxPacket
+ # which will be reused to save allocations, but only if the received packet
+ # is the same length as the rx_packet packet. If the length is different, a
+ # new RxPacket instance is allocated and returned.
+ will_irq = self.start_recv(timeout_ms, False, rx_length)
+ rx = True
+ while rx is True:
+ self._sync_wait(will_irq)
+ rx = self.poll_recv(rx_packet)
+ return rx or None
+
+ def _sync_wait(self, will_irq):
+ # For synchronous usage, block until an interrupt occurs or we time out
+ if will_irq:
+ for n in range(100):
+ machine.idle()
+ # machine.idle() wakes up very often, so don't actually return
+ # unless _radio_isr ran already. The outer for loop is so the
+ # modem is still polled occasionally to
+ # avoid the possibility an IRQ was lost somewhere.
+ #
+ # None of this is very efficient, power users should either use
+ # async or call the low-level API manually with better
+ # port-specific sleep configurations, in order to get the best
+ # efficiency.
+ if self.irq_triggered():
+ break
+ else:
+ time.sleep_ms(1)
diff --git a/micropython/lora/lora-sync/manifest.py b/micropython/lora/lora-sync/manifest.py
new file mode 100644
index 00000000..ebd66533
--- /dev/null
+++ b/micropython/lora/lora-sync/manifest.py
@@ -0,0 +1,3 @@
+metadata(version="0.1")
+require("lora")
+package("lora")
diff --git a/micropython/lora/lora/lora/__init__.py b/micropython/lora/lora/lora/__init__.py
new file mode 100644
index 00000000..a12ec45d
--- /dev/null
+++ b/micropython/lora/lora/lora/__init__.py
@@ -0,0 +1,29 @@
+# MicroPython lora module
+# MIT license; Copyright (c) 2023 Angus Gratton
+
+from .modem import RxPacket # noqa: F401
+
+ok = False # Flag if at least one modem driver package is installed
+
+# Various lora "sub-packages"
+
+try:
+ from .sx126x import * # noqa: F401
+
+ ok = True
+except ImportError as e:
+ if "no module named 'lora." not in str(e):
+ raise
+
+try:
+ from .sx127x import * # noqa: F401
+
+ ok = True
+except ImportError as e:
+ if "no module named 'lora." not in str(e):
+ raise
+
+if not ok:
+ raise ImportError(
+ "Incomplete lora installation. Need at least one of lora-sync, lora-async and one of lora-sx126x, lora-sx127x"
+ )
diff --git a/micropython/lora/lora/lora/modem.py b/micropython/lora/lora/lora/modem.py
new file mode 100644
index 00000000..62313dc0
--- /dev/null
+++ b/micropython/lora/lora/lora/modem.py
@@ -0,0 +1,483 @@
+# MicroPython LoRa modem driver base class
+# MIT license; Copyright (c) 2023 Angus Gratton
+#
+# LoRa is a registered trademark or service mark of Semtech Corporation or its affiliates.
+import time
+from micropython import const, schedule
+
+# Set to True to get some additional printed debug output.
+_DEBUG = const(False)
+
+
+def _clamp(v, vmin, vmax):
+ # Small utility function to clamp a value 'v' between 'vmin' and 'vmax', inclusive.
+ return min(max(vmin, v), vmax)
+
+
+def _flag(value, condition):
+ # Small utility function for returning a bit 'value' or not, based on a
+ # boolean condition. Can help make expressions to build register values more
+ # readable.
+ #
+ # Note that for value==1, can also rely on int(bool(x)) with one or both
+ # conversions being implicit, as int(True)==1 and int(False)==0
+ #
+ # There is also (condition and value) but this is (IMO) confusing to read.
+ return value if condition else 0
+
+
+class ConfigError(ValueError):
+ # Raise if there is an error in lora_cfg, saves some duplicated strings
+ def __init__(self, field):
+ super().__init__("Invalid lora_cfg {}".format(field))
+
+
+class BaseModem:
+ def __init__(self, ant_sw):
+ self._ant_sw = ant_sw
+ self._irq_callback = None
+
+ # Common configuration settings that need to be tracked by all modem drivers
+ # (Note that subclasses may set these to other values in their constructors, to match
+ # the power-on-reset configuration of a particular modem.)
+ #
+ self._rf_freq_hz = 0 # Needs to be set via configure()
+ self._sf = 7 # Spreading factor
+ self._bw_hz = 125000 # Reset value
+ self._coding_rate = 5
+ self._crc_en = True # use packet CRCs
+ self._implicit_header = False # implict vs explicit header mode
+ self._preamble_len = 12
+ self._coding_rate = 5
+
+ # CRC error counter
+ self.crc_errors = 0
+ self.rx_crc_error = False
+
+ # Current state of the modem
+
+ # _rx holds radio recv state:
+ #
+ # - False if the radio is not receiving
+ # - True if the radio is continuously receiving, or performing a single receive with
+ # no timeout.
+ # - An int if there is a timeout set, in which case it is the is the receive deadline
+ # (as a time.ticks_ms() timestamp).
+ #
+ # Note that self._rx can be not-False even when the radio hardware is not actually
+ # receiving, if self._tx is True (send always pauses recv.)
+ self._rx = False
+
+ # _rx_continuous is True if the modem is in continuous receive mode
+ # (this value is only valid when self._rx is also True).
+ self._rx_continuous = False
+
+ # This argument is stored from the parameter of the same name, as set in
+ # the last call to start_recv()
+ self._rx_length = None
+
+ # _tx holds radio send state and is simpler, True means sending and
+ # False means not sending.
+ self._tx = False
+
+ # timestamp (as time.ticks_ms() result) of last IRQ event
+ self._last_irq = None
+
+ # values are:
+ # - lora_cfg["invert_iq_rx"]
+ # - lora_cfg["invert_iq_tx"]
+ # - Current modem Invert setting
+ self._invert_iq = [False, False, False]
+
+ # This hook exists to allow the SyncModem & AsyncModem "mixin-like"
+ # classes to have some of their own state, without needing to manage the
+ # fuss of multiple constructor paths.
+ try:
+ self._after_init()
+ except AttributeError:
+ # If this exception happens here then one of the modem classes without a SyncModem or AsyncModem "mixin-like" class
+ # has been instantiated.
+ raise NotImplementedError(
+ "Don't instantiate this class directly, "
+ "instantiate a class from the 'lora' package"
+ )
+
+ def standby(self):
+ # Put the modem into standby. Can be used to cancel a continuous recv,
+ # or cancel a send before it completes.
+ #
+ # Calls the private function which actually sets the mode to standby, and then
+ # clears all the driver's state flags.
+ #
+ # Note this is also called before going to sleep(), to save on duplicated code.
+ self._standby()
+ self._rx = False
+ self._tx = False
+ self._last_irq = None
+ if self._ant_sw:
+ self._ant_sw.idle()
+ self._radio_isr(None) # "soft ISR"
+
+ def _get_t_sym_us(self):
+ # Return length of a symbol in microseconds
+ return 1000_000 * (1 << self._sf) // self._bw_hz
+
+ def _get_ldr_en(self):
+ # Return true if Low Data Rate should be enabled
+ #
+ # The calculation in get_n_symbols_x4() relies on this being the same logic applied
+ # in the modem configuration routines.
+ return self._get_t_sym_us() >= 16000
+
+ def _get_pa_ramp_val(self, lora_cfg, supported):
+ # Return the PA ramp register index from the list of supported PA ramp
+ # values. If the requested ramp time is supported by the modem, round up
+ # to the next supported value.
+ #
+ # 'supported' is the list of supported ramp times, must be sorted
+ # already.
+ us = int(lora_cfg["pa_ramp_us"])
+
+ # Find the index of the lowest supported ramp time that is longer or the
+ # same value as 'us'
+ for i, v in enumerate(supported):
+ if v >= us:
+ return i
+ # The request ramp time is longer than all this modem's supported ramp times
+ raise ConfigError("pa_ramp_us")
+
+ def _symbol_offsets(self):
+ # Called from get_time_on_air_us().
+ #
+ # This function provides a way to implement the different SF5 and SF6 in SX126x,
+ # by returning two offsets: one for the overall number of symbols, and one for the
+ # number of bits used to calculate the symbol length of the payload.
+ return (0, 0)
+
+ def get_n_symbols_x4(self, payload_len):
+ # Get the number of symbols in a packet (Time-on-Air) for the current
+ # configured modem settings and the provided payload length in bytes.
+ #
+ # Result is in units of "symbols times 4" as there is a fractional term
+ # in the equation, and we want to limit ourselves to integer arithmetic.
+ #
+ # References are:
+ # - SX1261/2 DS 6.1.4 "LoRa Time-on-Air"
+ # - SX1276 DS 4.1.1 "Time on air"
+ #
+ # Note the two datasheets give the same information in different
+ # ways. SX1261/62 DS is (IMO) clearer, so this function is based on that
+ # formula. The result is equivalent to the datasheet value "Nsymbol",
+ # times 4.
+ #
+ # Note also there are unit tests for this function in tests/test_time_on_air.py,
+ # and that it's been optimised a bit for code size (with impact on readability)
+
+ # Account for a minor difference between SX126x and SX127x: they have
+ # incompatible SF 5 & 6 modes.
+ #
+ # In SX126x when using SF5 or SF6, we apply an offset of +2 symbols to
+ # the overall preamble symbol count (s_o), and an offset of -8 to the
+ # payload bit length (b_o).
+ s_o, b_o = self._symbol_offsets()
+
+ # calculate the bit length of the payload
+ #
+ # This is the part inside the max(...,0) in the datasheet
+ bits = (
+ # payload_bytes
+ 8 * payload_len
+ # N_bit_crc
+ + (16 if self._crc_en else 0)
+ # (4 * SF)
+ - (4 * self._sf)
+ # +8 for most modes, except SF5/6 on SX126x where b_o == -8 so these two cancel out
+ + 8
+ + b_o
+ # N_symbol_header
+ + (0 if self._implicit_header else 20)
+ )
+ bits = max(bits, 0)
+
+ # "Bits per symbol" denominator is either (4 * SF) or (4 * (SF -2))
+ # depending on Low Data Rate Optimization
+ bps = (self._sf - (2 * self._get_ldr_en())) * 4
+
+ return (
+ # Fixed preamble portion (4.25), times 4
+ 17
+ # Remainder of equation is an integer number of symbols, times 4
+ + 4
+ * (
+ # configured preamble length
+ self._preamble_len
+ +
+ # optional extra preamble symbols (4.25+2=6.25 for SX1262 SF5,SF6)
+ s_o
+ +
+ # 8 symbol constant overhead
+ 8
+ +
+ # Payload symbol length
+ # (this is the term "ceil(bits / 4 * SF) * (CR + 4)" in the datasheet
+ ((bits + bps - 1) // bps) * self._coding_rate
+ )
+ )
+
+ def get_time_on_air_us(self, payload_len):
+ # Return the "Time on Air" in microseconds for a particular
+ # payload length and the current configured modem settings.
+ return self._get_t_sym_us() * self.get_n_symbols_x4(payload_len) // 4
+
+ # Modem ISR routines
+ #
+ # ISR implementation is relatively simple, just exists to signal an optional
+ # callback, record a timestamp, and wake up the hardware if
+ # needed. ppplication code is expected to call poll_send() or
+ # poll_recv() as applicable in order to confirm the modem state.
+ #
+ # This is a MP hard irq in some configurations, meaning no memory allocation is possible.
+ #
+ # 'pin' may also be None if this is a "soft" IRQ triggered after a receive
+ # timed out during a send (meaning no receive IRQ will fire, but the
+ # receiver should wake up and move on anyhow.)
+ def _radio_isr(self, pin):
+ self._last_irq = time.ticks_ms()
+ if self._irq_callback:
+ self._irq_callback(pin)
+ if _DEBUG:
+ # Note: this may cause a MemoryError and fail if _DEBUG is enabled in this base class
+ # but disabled in the subclass, meaning this is a hard irq handler
+ try:
+ print("_radio_isr pin={}".format(pin))
+ except MemoryError:
+ pass
+
+ def irq_triggered(self):
+ # Returns True if the ISR has executed since the last time a send or a receive
+ # started
+ return self._last_irq is not None
+
+ def set_irq_callback(self, callback):
+ # Set a function to be called from the radio ISR
+ #
+ # This is used by the AsyncModem implementation, but can be called in
+ # other circumstances to implement custom ISR logic.
+ #
+ # Note that callback may be called in hard ISR context, meaning no
+ # memory allocation is possible.
+ self._irq_callback = callback
+
+ def _get_last_irq(self):
+ # Return the _last_irq timestamp if set by an ISR, or the
+ # current time.time_ms() timestamp otherwise.
+ if self._last_irq is None:
+ return time.ticks_ms()
+ return self._last_irq
+
+ # Common parts of receive API
+
+ def start_recv(self, timeout_ms=None, continuous=False, rx_length=0xFF):
+ # Start receiving.
+ #
+ # Part of common low-level modem API, see README.md for usage.
+ if continuous and timeout_ms is not None:
+ raise ValueError() # these two options are mutually exclusive
+
+ if timeout_ms is not None:
+ self._rx = time.ticks_add(time.ticks_ms(), timeout_ms)
+ else:
+ self._rx = True
+
+ self._rx_continuous = continuous
+ self._rx_length = rx_length
+
+ if self._ant_sw and not self._tx:
+ # this is guarded on 'not self._tx' as the subclass will not immediately
+ # start receiving if a send is in progress.
+ self._ant_sw.rx()
+
+ def poll_recv(self, rx_packet=None):
+ # Should be called while a receive is in progress:
+ #
+ # Part of common low-level modem API, see README.md for usage.
+ #
+ # This function may alter the state of the modem - it will clear
+ # RX interrupts, it may read out a packet from the FIFO, and it
+ # may resume receiving if the modem has gone to standby but receive
+ # should resume.
+
+ if self._rx is False:
+ # Not actually receiving...
+ return False
+
+ if self._tx:
+ # Actually sending, this has to complete before we
+ # resume receiving, but we'll indicate that we are still receiving.
+ #
+ # (It's not harmful to fall through here and check flags anyhow, but
+ # it is a little wasteful if an interrupt has just triggered
+ # poll_send() as well.)
+ return True
+
+ packet = None
+
+ flags = self._get_irq()
+
+ if _DEBUG and flags:
+ print("RX flags {:#x}".format(flags))
+ if flags & self._IRQ_RX_COMPLETE:
+ # There is a small potential for race conditions here in continuous
+ # RX mode. If packets are received rapidly and the call to this
+ # function delayed, then a ValidHeader interrupt (for example) might
+ # have already set for a second packet which is being received now,
+ # and clearing it will mark the second packet as invalid.
+ #
+ # However it's necessary in continuous mode as interrupt flags don't
+ # self-clear in the modem otherwise (for example, if a CRC error IRQ
+ # bit sets then it stays set on the next packet, even if that packet
+ # has a valid CRC.)
+ self._clear_irq(flags)
+ ok = self._rx_flags_success(flags)
+ if not ok:
+ # If a non-valid receive happened, increment the CRC error counter
+ self.crc_errors += 1
+ if ok or self.rx_crc_error:
+ # Successfully received a valid packet (or configured to return all packets)
+ packet = self._read_packet(rx_packet, flags)
+ if not self._rx_continuous:
+ # Done receiving now
+ self._end_recv()
+
+ # _check_recv() will return True if a receive is ongoing and hasn't timed out,
+ # and also manages resuming any modem receive if needed
+ #
+ # We need to always call check_recv(), but if we received a packet then this is what
+ # we should return to the caller.
+ res = self._check_recv()
+ return packet or res
+
+ def _end_recv(self):
+ # Utility function to clear the receive state
+ self._rx = False
+ if self._ant_sw:
+ self._ant_sw.idle()
+
+ def _check_recv(self):
+ # Internal function to automatically call start_recv()
+ # again if a receive has been interrupted and the host
+ # needs to start it again.
+ #
+ # Return True if modem is still receiving (or sending, but will
+ # resume receiving after send finishes).
+
+ if not self._rx:
+ return False # Not receiving, nothing to do
+
+ if not self.is_idle():
+ return True # Radio is already sending or receiving
+
+ rx = self._rx
+
+ timeout_ms = None
+ if isinstance(rx, int): # timeout is set
+ timeout_ms = time.ticks_diff(rx, time.ticks_ms())
+ if timeout_ms <= 0:
+ # Timed out in software, nothing to resume
+ self._end_recv()
+ if _DEBUG:
+ print("Timed out in software timeout_ms={}".format(timeout_ms))
+ schedule(
+ self._radio_isr, None
+ ) # "soft irq" to unblock anything waiting on the interrupt event
+ return False
+
+ if _DEBUG:
+ print(
+ "Resuming receive timeout_ms={} continuous={} rx_length={}".format(
+ timeout_ms, self._rx_continuous, self._rx_length
+ )
+ )
+
+ self.start_recv(timeout_ms, self._rx_continuous, self._rx_length)
+
+ # restore the previous version of _rx so ticks_ms deadline can't
+ # slowly creep forward each time this happens
+ self._rx = rx
+
+ return True
+
+ # Common parts of send API
+
+ def poll_send(self):
+ # Check the ongoing send state.
+ #
+ # Returns one of:
+ #
+ # - True if a send is ongoing and the caller
+ # should call again.
+ # - False if no send is ongoing.
+ # - An int value exactly one time per transmission, the first time
+ # poll_send() is called after a send ends. In this case it
+ # is the time.ticks_ms() timestamp of the time that the send completed.
+ #
+ # Note this function only returns an int value one time (the first time it
+ # is called after send completes).
+ #
+ # Part of common low-level modem API, see README.md for usage.
+ if not self._tx:
+ return False
+
+ ticks_ms = self._get_last_irq()
+
+ if not (self._get_irq() & self._IRQ_TX_COMPLETE):
+ # Not done. If the host and modem get out
+ # of sync here, or the caller doesn't follow the sequence of
+ # send operations exactly, then can end up in a situation here
+ # where the modem has stopped sending and has gone to Standby,
+ # so _IRQ_TX_DONE is never set.
+ #
+ # For now, leaving this for the caller to do correctly. But if it becomes an issue then
+ # we can call _get_mode() here as well and check the modem is still in a TX mode.
+ return True
+
+ self._clear_irq()
+
+ self._tx = False
+
+ if self._ant_sw:
+ self._ant_sw.idle()
+
+ # The modem just finished sending, so start receiving again if needed
+ self._check_recv()
+
+ return ticks_ms
+
+
+class RxPacket(bytearray):
+ # A class to hold a packet received from a LoRa modem.
+ #
+ # The base class is bytearray, which represents the packet payload,
+ # allowing RxPacket objects to be passed anywhere that bytearrays are
+ # accepted.
+ #
+ # Some additional properties are set on the object to store metadata about
+ # the received packet.
+ def __init__(self, payload, ticks_ms=None, snr=None, rssi=None, valid_crc=True):
+ super().__init__(payload)
+ self.ticks_ms = ticks_ms
+ self.snr = snr
+ self.rssi = rssi
+ self.valid_crc = valid_crc
+
+ def __repr__(self):
+ return "{}({}, {}, {}, {}, {})".format(
+ "RxPacket",
+ repr(
+ bytes(self)
+ ), # This is a bit wasteful, but gets us b'XYZ' rather than "bytearray(b'XYZ')"
+ self.ticks_ms,
+ self.snr,
+ self.rssi,
+ self.valid_crc,
+ )
diff --git a/micropython/lora/lora/manifest.py b/micropython/lora/lora/manifest.py
new file mode 100644
index 00000000..cb9c5d5a
--- /dev/null
+++ b/micropython/lora/lora/manifest.py
@@ -0,0 +1,2 @@
+metadata(version="0.1")
+package("lora")
diff --git a/micropython/lora/tests/test_time_on_air.py b/micropython/lora/tests/test_time_on_air.py
new file mode 100644
index 00000000..56fa1ad8
--- /dev/null
+++ b/micropython/lora/tests/test_time_on_air.py
@@ -0,0 +1,310 @@
+# MicroPython LoRa modem driver time on air tests
+# MIT license; Copyright (c) 2023 Angus Gratton
+#
+# LoRa is a registered trademark or service mark of Semtech Corporation or its affiliates.
+#
+# ## What is this?
+#
+# Host tests for the BaseModem.get_time_on_air_us() function. Theses against
+# dummy test values produced by the Semtech "SX1261 LoRa Calculator" software,
+# as downloaded from
+# https://lora-developers.semtech.com/documentation/product-documents/
+#
+# The app notes for SX1276 (AN1200.3) suggest a similar calculator exists for that
+# modem, but it doesn't appear to be available for download any more. I couldn't find
+# an accurate calculator for SX1276, so manually calculated the SF5 & SF6 test cases below
+# (other values should be the same as SX1262).
+#
+# ## Instructions
+#
+# These tests are intended to be run on a host PC via micropython unix port:
+#
+# cd /path/to/micropython-lib/micropython/lora
+# micropython -m tests.test_time_on_air
+#
+# Note: Using the working directory shown above is easiest way to ensure 'lora' files are imported.
+#
+from lora import SX1262, SX1276
+
+# Allow time calculations to deviate by up to this much as a ratio
+# of the expected value (due to floating point, etc.)
+TIME_ERROR_RATIO = 0.00001 # 0.001%
+
+
+def main():
+ sx1262 = SX1262(spi=DummySPI(), cs=DummyPin(), busy=DummyPin())
+ sx1276 = SX1276(spi=DummySPI(0x12), cs=DummyPin())
+
+ # Test case format is based on the layout of the Semtech Calculator UI:
+ #
+ # (modem_instance,
+ # (modem settings),
+ # [
+ # ((packet config), (output values)),
+ # ...
+ # ],
+ # ),
+ #
+ # where each set of modem settings maps to zero or more packet config / output pairs
+ #
+ # - modem instance is sx1262 or sx1276 (SF5 & SF6 are different between these modems)
+ # - (modem settings) is (sf, bw (in khz), coding_rate, low_datarate_optimize)
+ # - (packet config) is (preamble_len, payload_len, explicit_header, crc_en)
+ # - (output values) is (total_symbols_excl, symbol_time in ms, time_on_air in ms)
+ #
+ # NOTE: total_symbols_excl is the value shown in the calculator output,
+ # which doesn't include 8 symbols of constant overhead between preamble and
+ # header+payload+crc. I think this is a bug in the Semtech calculator(!).
+ # These 8 symbols are included when the calculator derives the total time on
+ # air.
+ #
+ # NOTE ALSO: The "symbol_time" only depends on the modem settings so is
+ # repeated each group of test cases, and the "time_on_air" is the previous
+ # two output values multiplied (after accounting for the 8 symbols noted
+ # above). This repetition is deliberate to make the cases easier to read
+ # line-by-line when comparing to the calculator window.
+ CASES = [
+ (
+ sx1262,
+ (12, 500, 5, False), # Calculator defaults when launching calculator
+ [
+ ((8, 1, True, True), (17.25, 8.192, 206.848)), # Calculator defaults
+ ((12, 64, True, True), (71.25, 8.192, 649.216)),
+ ((8, 1, True, False), (12.25, 8.192, 165.888)),
+ ((8, 192, True, True), (172.25, 8.192, 1476.608)),
+ ((12, 16, False, False), (26.25, 8.192, 280.576)),
+ ],
+ ),
+ (
+ sx1262,
+ (8, 125, 6, False),
+ [
+ ((8, 1, True, True), (18.25, 2.048, 53.760)),
+ ((8, 2, True, True), (18.25, 2.048, 53.760)),
+ ((8, 2, True, False), (18.25, 2.048, 53.760)),
+ ((8, 3, True, True), (24.25, 2.048, 66.048)),
+ ((8, 3, True, False), (18.25, 2.048, 53.760)),
+ ((8, 4, True, True), (24.25, 2.048, 66.048)),
+ ((8, 4, True, False), (18.25, 2.048, 53.760)),
+ ((8, 5, True, True), (24.25, 2.048, 66.048)),
+ ((8, 5, True, False), (24.25, 2.048, 66.048)),
+ ((8, 253, True, True), (396.25, 2.048, 827.904)),
+ ((8, 253, True, False), (396.25, 2.048, 827.904)),
+ ((12, 5, False, True), (22.25, 2.048, 61.952)),
+ ((12, 5, False, False), (22.25, 2.048, 61.952)),
+ ((12, 10, False, True), (34.25, 2.048, 86.528)),
+ ((12, 253, False, True), (394.25, 2.048, 823.808)),
+ ],
+ ),
+ # quick check that sx1276 is the same as sx1262 for SF>6
+ (
+ sx1276,
+ (8, 125, 6, False),
+ [
+ ((8, 1, True, True), (18.25, 2.048, 53.760)),
+ ((8, 2, True, True), (18.25, 2.048, 53.760)),
+ ((12, 5, False, True), (22.25, 2.048, 61.952)),
+ ((12, 5, False, False), (22.25, 2.048, 61.952)),
+ ],
+ ),
+ # SF5 on SX1262
+ (
+ sx1262,
+ (5, 500, 5, False),
+ [
+ (
+ (2, 1, True, False),
+ (13.25, 0.064, 1.360),
+ ), # Shortest possible LoRa packet?
+ ((2, 1, True, True), (18.25, 0.064, 1.680)),
+ ((12, 1, False, False), (18.25, 0.064, 1.680)),
+ ((12, 253, False, True), (523.25, 0.064, 34.000)),
+ ],
+ ),
+ (
+ sx1262,
+ (5, 125, 8, False),
+ [
+ ((12, 253, False, True), (826.25, 0.256, 213.568)),
+ ],
+ ),
+ # SF5 on SX1276
+ #
+ # Note: SF5 & SF6 settings are different between SX1262 & SX1276.
+ #
+ # There's no Semtech official calculator available for SX1276, so the
+ # symbol length is calculated by copying the formula from the datasheet
+ # "Time on air" section. Symbol time is the same as SX1262. Then the
+ # time on air is manually calculated by multiplying the two together.
+ #
+ # see the functions sx1276_num_payload and sx1276_num_symbols at end of this module
+ # for the actual functions used.
+ (
+ sx1276,
+ (5, 500, 5, False),
+ [
+ (
+ (2, 1, True, False),
+ (19.25 - 8, 0.064, 1.232),
+ ), # Shortest possible LoRa packet?
+ ((2, 1, True, True), (24.25 - 8, 0.064, 1.552)),
+ ((12, 1, False, False), (24.25 - 8, 0.064, 1.552)),
+ ((12, 253, False, True), (534.25 - 8, 0.064, 34.192)),
+ ],
+ ),
+ (
+ sx1276,
+ (5, 125, 8, False),
+ [
+ ((12, 253, False, True), (840.25 - 8, 0.256, 215.104)),
+ ],
+ ),
+ (
+ sx1262,
+ (12, 7.81, 8, True), # Slowest possible
+ [
+ ((128, 253, True, True), (540.25, 524.456, 287532.907)),
+ ((1000, 253, True, True), (1412.25, 524.456, 744858.387)),
+ ],
+ ),
+ (
+ sx1262,
+ (11, 10.42, 7, True),
+ [
+ ((25, 16, True, True), (57.25, 196.545, 12824.568)),
+ ((25, 16, False, False), (50.25, 196.545, 11448.752)),
+ ],
+ ),
+ ]
+
+ tests = 0
+ failures = set()
+ for modem, modem_settings, packets in CASES:
+ (sf, bw_khz, coding_rate, low_datarate_optimize) = modem_settings
+ print(
+ f"Modem config sf={sf} bw={bw_khz}kHz coding_rate=4/{coding_rate} "
+ + f"low_datarate_optimize={low_datarate_optimize}"
+ )
+
+ # We don't call configure() as the Dummy interfaces won't handle it,
+ # just update the BaseModem fields directly
+ modem._sf = sf
+ modem._bw_hz = int(bw_khz * 1000)
+ modem._coding_rate = coding_rate
+
+ # Low datarate optimize on/off is auto-configured in the current driver,
+ # check the automatic selection matches the test case from the
+ # calculator
+ if modem._get_ldr_en() != low_datarate_optimize:
+ print(
+ f" -- ERROR: Test case has low_datarate_optimize={low_datarate_optimize} "
+ + f"but modem selects {modem._get_ldr_en()}"
+ )
+ failures += 1
+ continue # results will not match so don't run any of the packet test cases
+
+ for packet_config, expected_outputs in packets:
+ preamble_len, payload_len, explicit_header, crc_en = packet_config
+ print(
+ f" -- preamble_len={preamble_len} payload_len={payload_len} "
+ + f"explicit_header={explicit_header} crc_en={crc_en}"
+ )
+ modem._preamble_len = preamble_len
+ modem._implicit_header = not explicit_header # opposite logic to calculator
+ modem._crc_en = crc_en
+
+ # Now calculate the symbol length and times and compare with the expected valuesd
+ (
+ expected_symbols,
+ expected_symbol_time,
+ expected_time_on_air,
+ ) = expected_outputs
+
+ print(f" ---- calculator shows total length {expected_symbols}")
+ expected_symbols += 8 # Account for the calculator bug mentioned in the comment above
+
+ n_symbols = modem.get_n_symbols_x4(payload_len) / 4.0
+ symbol_time_us = modem._get_t_sym_us()
+ time_on_air_us = modem.get_time_on_air_us(payload_len)
+
+ tests += 1
+
+ if n_symbols == expected_symbols:
+ print(f" ---- symbols {n_symbols}")
+ else:
+ print(f" ---- SYMBOL COUNT ERROR expected {expected_symbols} got {n_symbols}")
+ failures.add((modem, modem_settings, packet_config))
+
+ max_error = expected_symbol_time * 1000 * TIME_ERROR_RATIO
+ if abs(int(expected_symbol_time * 1000) - symbol_time_us) <= max_error:
+ print(f" ---- symbol time {expected_symbol_time}ms")
+ else:
+ print(
+ f" ---- SYMBOL TIME ERROR expected {expected_symbol_time}ms "
+ + f"got {symbol_time_us}us"
+ )
+ failures.add((modem, modem_settings, packet_config))
+
+ max_error = expected_time_on_air * 1000 * TIME_ERROR_RATIO
+ if abs(int(expected_time_on_air * 1000) - time_on_air_us) <= max_error:
+ print(f" ---- time on air {expected_time_on_air}ms")
+ else:
+ print(
+ f" ---- TIME ON AIR ERROR expected {expected_time_on_air}ms "
+ + f"got {time_on_air_us}us"
+ )
+ failures.add((modem, modem_settings, packet_config))
+
+ print("************************")
+
+ print(f"\n{len(failures)}/{tests} tests failed")
+ if failures:
+ print("FAILURES:")
+ for f in failures:
+ print(f)
+ raise SystemExit(1)
+ print("SUCCESS")
+
+
+class DummySPI:
+ # Dummy SPI Interface allows us to use normal constructors
+ #
+ # Reading will always return the 'always_read' value
+ def __init__(self, always_read=0x00):
+ self.always_read = always_read
+
+ def write_readinto(self, _wrbuf, rdbuf):
+ for i in range(len(rdbuf)):
+ rdbuf[i] = self.always_read
+
+
+class DummyPin:
+ # Dummy Pin interface allows us to use normal constructors
+ def __init__(self):
+ pass
+
+ def __call__(self, _=None):
+ pass
+
+
+# Copies of the functions used to calculate SX1276 SF5, SF6 test case symbol counts.
+# (see comments above).
+#
+# These are written as closely to the SX1276 datasheet "Time on air" section as
+# possible, quite different from the BaseModem implementation.
+
+
+def sx1276_n_payload(pl, sf, ih, de, cr, crc):
+ import math
+
+ ceil_arg = 8 * pl - 4 * sf + 28 + 16 * crc - 20 * ih
+ ceil_arg /= 4 * (sf - 2 * de)
+ return 8 + max(math.ceil(ceil_arg) * (cr + 4), 0)
+
+
+def sx1276_n_syms(pl, sf, ih, de, cr, crc, n_preamble):
+ return sx1276_n_payload(pl, sf, ih, de, cr, crc) + n_preamble + 4.25
+
+
+if __name__ == "__main__":
+ main()