Skip to content

Commit 1957f24

Browse files
projectgusdpgeorge
authored andcommitted
lora: Add lora modem drivers for SX127x and SX126x.
Includes: - component oriented driver, to only install the parts that are needed - synchronous operation - async wrapper class for asynchronous operation - two examples with async & synchronous versions - documentation This work was funded through GitHub Sponsors. Signed-off-by: Angus Gratton <[email protected]>
1 parent 7128d42 commit 1957f24

File tree

22 files changed

+4955
-0
lines changed

22 files changed

+4955
-0
lines changed

micropython/lora/README.md

Lines changed: 1156 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# LoRa Reliable Delivery Example
2+
3+
This example shows a basic custom protocol for reliable one way communication
4+
from low-power remote devices to a central base device:
5+
6+
- A single "receiver" device, running on mains power, listens continuously for
7+
messages from one or more "sender" devices. Messages are payloads inside LoRa packets,
8+
with some additional framing and address in the LoRa packet payload.
9+
- "Sender" devices are remote sensor nodes, possibly battery powered. These wake
10+
up periodically, read some data from a sensor, and send it in a message to the receiver.
11+
- Messages are transmitted "reliably" with some custom header information,
12+
meaning the receiver will acknowledge it received each message and the sender
13+
will retry sending if it doesn't receive the acknowledgement.
14+
15+
## Source Files
16+
17+
* `lora_rd_settings.py` contains some common settings that are imported by
18+
sender and receiver. These settings will need to be modified for the correct
19+
frequency and other settings, before running the examples.
20+
* `receiver.py` and `receiver_async.py` contain a synchronous (low-level API)
21+
and asynchronous (iterator API) implementation of the same receiver program,
22+
respectively. These two programs should work the same, they are intended show
23+
different ways the driver can be used.
24+
* `sender.py` and `sender_async.py` contain a synchronous (simple API) and
25+
asynchronous (async API) implementation of the same sender program,
26+
respectively. Because the standard async API resembles the Simple API, these
27+
implementations are *very* similar. The two programs should work the same,
28+
they are intended to show different ways the driver can be used.
29+
30+
## Running the examples
31+
32+
One way to run this example interactively:
33+
34+
1. Install or "freeze in" the necessary lora modem driver package (`lora-sx127x`
35+
or `lora-sx126x`) and optionally the `lora-async` package if using the async
36+
examples (see main lora `README.md` in the above directory for details).
37+
2. Edit the `lora_rd_settings.py` file to set the frequency and other protocol
38+
settings for your region and hardware (see main lora `README.md`).
39+
3. Edit the program you plan to run and fill in the `get_modem()` function with
40+
the correct modem type, pin assignments, etc. for your board (see top-level
41+
README). Note the `get_modem()` function should use the existing `lora_cfg`
42+
variable, which holds the settings imported from `lora_rd_settings.py`.
43+
4. Change to this directory in a terminal.
44+
5. Run `mpremote mount . exec receiver.py` on one board and `mpremote mount
45+
. exec sender.py` on another (or swap in `receiver_async.py` and/or
46+
`sender_async.py` as desired).
47+
48+
Consult the [mpremote
49+
documentation](https://docs.micropython.org/en/latest/reference/mpremote.html)
50+
for an explanation of these commands and the options needed to run two copies of
51+
`mpremote` on different serial ports at the same time.
52+
53+
## Automatic Performance Tuning
54+
55+
- When sending an ACK, the receiver includes the RSSI of the received
56+
packet. Senders will automatically modify their output_power to minimize the
57+
power consumption required to reach the receiver. Similarly, if no ACK is
58+
received then they will increase their output power and also re-run Image
59+
calibration in order to maximize RX performance.
60+
61+
## Message payloads
62+
63+
Messages are LoRa packets, set up as follows:
64+
65+
LoRA implicit header mode, CRCs enabled.
66+
67+
* Each remote device has a unique sixteen-bit ID (range 00x0000 to 0xFFFE). ID
68+
0xFFFF is reserved for the single receiver device.
69+
* An eight-bit message counter is used to identify duplicate messages
70+
71+
* Data message format is:
72+
- Sender ID (two bytes, little endian)
73+
- Counter byte (incremented on each new message, not incremented on retry).
74+
- Message length (1 byte)
75+
- Message (variable length)
76+
- Checksum byte (sum of all proceeding bytes in message, modulo 256). The LoRa
77+
packet has its own 16-bit CRC, this is included as an additional way to
78+
disambiguate other LoRa packets that might appear the same.
79+
80+
* After receiving a valid data message, the receiver device should send
81+
an acknowledgement message 25ms after the modem receive completed.
82+
83+
Acknowledgement message format:
84+
- 0xFFFF (receiver station ID as two bytes)
85+
- Sender's Device ID from received message (two bytes, little endian)
86+
- Counter byte from received message
87+
- Checksum byte from received message
88+
- RSSI value as received by radio (one signed byte)
89+
90+
* If the remote device doesn't receive a packet with the acknowledgement
91+
message, it retries up to a configurable number of times (default 4) with a
92+
basic exponential backoff formula.
93+
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# MicroPython lora reliable_delivery example - common protocol settings
2+
# MIT license; Copyright (c) 2023 Angus Gratton
3+
4+
#
5+
######
6+
# To be able to be able to communicate, most of these settings need to match on both radios.
7+
# Consult the example README for more information about how to use the example.
8+
######
9+
10+
# LoRa protocol configuration
11+
#
12+
# Currently configured for relatively slow & low bandwidth settings, which
13+
# gives more link budget and possible range.
14+
#
15+
# These settings should match on receiver.
16+
#
17+
# Check the README and local regulations to know what configuration settings
18+
# are available.
19+
lora_cfg = {
20+
"freq_khz": 916000,
21+
"sf": 10,
22+
"bw": "62.5", # kHz
23+
"coding_rate": 8,
24+
"preamble_len": 12,
25+
"output_power": 10, # dBm
26+
}
27+
28+
# Single receiver has a fixed 16-bit ID value (senders each have a unique value).
29+
RECEIVER_ID = 0xFFFF
30+
31+
# Length of an ACK message in bytes.
32+
ACK_LENGTH = 7
33+
34+
# Send the ACK this many milliseconds after receiving a valid message
35+
#
36+
# This can be quite a bit lower (25ms or so) if wakeup times are short
37+
# and _DEBUG is turned off on the modems (logging to UART delays everything).
38+
ACK_DELAY_MS = 100
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# MicroPython lora reliable_delivery example - synchronous receiver program
2+
# MIT license; Copyright (c) 2023 Angus Gratton
3+
import struct
4+
import time
5+
import machine
6+
from machine import SPI, Pin
7+
from micropython import const
8+
from lora import RxPacket
9+
10+
from lora_rd_settings import RECEIVER_ID, ACK_LENGTH, ACK_DELAY_MS, lora_cfg
11+
12+
# Change _DEBUG to const(True) to get some additional debugging output
13+
# about timing, RSSI, etc.
14+
#
15+
# For a lot more debugging detail, go to the modem driver and set _DEBUG there to const(True)
16+
_DEBUG = const(False)
17+
18+
# Keep track of the last counter value we got from each known sender
19+
# this allows us to tell if packets are being lost
20+
last_counters = {}
21+
22+
23+
def get_modem():
24+
# from lora import SX1276
25+
# return SX1276(
26+
# spi=SPI(1, baudrate=2000_000, polarity=0, phase=0,
27+
# miso=Pin(19), mosi=Pin(27), sck=Pin(5)),
28+
# cs=Pin(18),
29+
# dio0=Pin(26),
30+
# dio1=Pin(35),
31+
# reset=Pin(14),
32+
# lora_cfg=lora_cfg,
33+
# )
34+
raise NotImplementedError("Replace this function with one that returns a lora modem instance")
35+
36+
37+
def main():
38+
print("Initializing...")
39+
modem = get_modem()
40+
41+
print("Main loop started")
42+
receiver = Receiver(modem)
43+
44+
while True:
45+
# With wait=True, this function blocks until something is received and always
46+
# returns non-None
47+
sender_id, data = receiver.recv(wait=True)
48+
49+
# Do something with the data!
50+
print(f"Received {data} from {sender_id:#x}")
51+
52+
53+
class Receiver:
54+
def __init__(self, modem):
55+
self.modem = modem
56+
self.last_counters = {} # Track the last counter value we got from each sender ID
57+
self.rx_packet = None # Reuse RxPacket object when possible, save allocation
58+
self.ack_buffer = bytearray(ACK_LENGTH) # reuse the same buffer for ACK packets
59+
self.skipped_packets = 0 # Counter of skipped packets
60+
61+
modem.calibrate()
62+
63+
# Start receiving immediately. We expect the modem to receive continuously
64+
self.will_irq = modem.start_recv(continuous=True)
65+
print("Modem initialized and started receive...")
66+
67+
def recv(self, wait=True):
68+
# Receive a packet from the sender, including sending an ACK.
69+
#
70+
# Returns a tuple of the 16-bit sender id and the sensor data payload.
71+
#
72+
# This function should be called very frequently from the main loop (at
73+
# least every ACK_DELAY_MS milliseconds), to avoid not sending ACKs in time.
74+
#
75+
# If 'wait' argument is True (default), the function blocks indefinitely
76+
# until a packet is received. If False then it will return None
77+
# if no packet is available.
78+
#
79+
# Note that because we called start_recv(continuous=True), the modem
80+
# will keep receiving on its own - even if when we call send() to
81+
# send an ACK.
82+
while True:
83+
rx = self.modem.poll_recv(rx_packet=self.rx_packet)
84+
85+
if isinstance(rx, RxPacket): # value will be True or an RxPacket instance
86+
decoded = self._handle_rx(rx)
87+
if decoded:
88+
return decoded # valid LoRa packet and valid for this application
89+
90+
if not wait:
91+
return None
92+
93+
# Otherwise, wait for an IRQ (or have a short sleep) and then poll recv again
94+
# (receiver is not a low power node, so don't bother with sleep modes.)
95+
if self.will_irq:
96+
while not self.modem.irq_triggered():
97+
machine.idle()
98+
else:
99+
time.sleep_ms(1)
100+
101+
def _handle_rx(self, rx):
102+
# Internal function to handle a received packet and either send an ACK
103+
# and return the sender and the payload, or return None if packet
104+
# payload is invalid or a duplicate.
105+
106+
if len(rx) < 5: # 4 byte header plus 1 byte checksum
107+
print("Invalid packet length")
108+
return None
109+
110+
sender_id, counter, data_len = struct.unpack("<HBB", rx)
111+
csum = rx[-1]
112+
113+
if len(rx) != data_len + 5:
114+
print("Invalid length in payload header")
115+
return None
116+
117+
calc_csum = sum(b for b in rx[:-1]) & 0xFF
118+
if csum != calc_csum:
119+
print(f"Invalid checksum. calc={calc_csum:#x} received={csum:#x}")
120+
return None
121+
122+
# Packet is valid!
123+
124+
if _DEBUG:
125+
print(f"RX {data_len} byte message RSSI {rx.rssi} at timestamp {rx.ticks_ms}")
126+
127+
# Send the ACK
128+
struct.pack_into(
129+
"<HHBBb", self.ack_buffer, 0, RECEIVER_ID, sender_id, counter, csum, rx.rssi
130+
)
131+
132+
# Time send to start as close to ACK_DELAY_MS after message was received as possible
133+
tx_at_ms = time.ticks_add(rx.ticks_ms, ACK_DELAY_MS)
134+
tx_done = self.modem.send(self.ack_buffer, tx_at_ms=tx_at_ms)
135+
136+
if _DEBUG:
137+
tx_time = time.ticks_diff(tx_done, tx_at_ms)
138+
expected = self.modem.get_time_on_air_us(ACK_LENGTH) / 1000
139+
print(f"ACK TX {tx_at_ms}ms -> {tx_done}ms took {tx_time}ms expected {expected}")
140+
141+
# Check if the data we received is fresh or stale
142+
if sender_id not in self.last_counters:
143+
print(f"New device id {sender_id:#x}")
144+
elif self.last_counters[sender_id] == counter:
145+
print(f"Duplicate packet received from {sender_id:#x}")
146+
return None
147+
elif counter != 1:
148+
# If the counter from this sender has gone up by more than 1 since
149+
# last time we got a packet, we know there is some packet loss.
150+
#
151+
# (ignore the case where the new counter is 1, as this probably
152+
# means a reset.)
153+
delta = (counter - 1 - self.last_counters[sender_id]) & 0xFF
154+
if delta:
155+
print(f"Skipped/lost {delta} packets from {sender_id:#x}")
156+
self.skipped_packets += delta
157+
158+
self.last_counters[sender_id] = counter
159+
return sender_id, rx[4:-1]
160+
161+
162+
if __name__ == "__main__":
163+
main()

0 commit comments

Comments
 (0)