This is Part 2 of the #LoRa series. If you didn’t read the previous post, check it out here.
Requirements
Before we start, you should have basic knowledge in ESP32 and be familiar with the “Arduino LoRa” Library. I prefer to work with PlatformIO Arduino Framework for ESP32, but Arduino IDE Legacy and Arduino IDE 2 should work as well. If you are new to LoRa, you can quickly catch up with tons of tutorials on YouTube or other Blogs.1 2 3 4 5
Optionally you should be familiar with the configuration of your LoRaWAN Gateway device. If you want to use a MikroTik LoRaWAN Gateway like me, you should have worked with WinBox before. It’s the configuration tool for MikroTik devices.
Hardware - End Nodes
I highly recommend something based on STM32 or ESP32 for LoRa End Nodes. AVR based LoRa nodes are available too and you can easily build something yourself, but when your projects grow and become more complex, it will give you a hard time debugging it. You can not compare your Arduino Uno with the hardware specs of the chips mentioned above.
I prefer LoRa products from LilyGo6, especially TTGO T-Beam v1.1. It is ESP32 based, comes with a decent GPS module (uBlox M8N), has a built-in Battery Management System and Battery Charger via USB and can run of a single 18650 Li-ion battery. Of course, the ESP32 has plenty space for your firmware and comes with Bluetooth and WiFi out-of-the-box.
Hardware - LoRaWAN Gateway
I’m not going into detail how to configure your MikroTik device. But for simplicity, your LoRaWAN Gateway should be in the same network as the Python LoRa server.
I chose MikroTik wAP LR8 Kit7 as LoRaWAN Gateway. It is described on the product page as “outdoor and weatherproof, out-of-the-box solution” with “pre-installed UDP packet forwarder to any public or private LoRa server”.
The suggested price is around $169.00. It is not Open Source like other Hardware but the price is really good for what you get and we are only interested in the Semtech UDP Packet Forwarder anyway. Any LoRaWAN Gateway with legacy UDP Packet Forwarder will work.
The LR8 kit supports 863-870 MHz. For 902-928 MHz you can choose LR9 Kit8. Make sure that your LoRa End Nodes supports the same frequency and it is permissible to use the frequency in your country.
Did you know that you can build your own 1-Channel LoRaWAN Gateway?
Hardware - LoRaWAN Gateway Settings
I assume you have a working network on your MikroTik LoRaWAN Gateway and it can communicate with your computer or server that will run the Python server.
First let’s get rid of the default Servers and add our own. Give your LoRa Server a Name
and set the IP or Hostname under Address
and leave Up port
and Down port
on 1700.
Under Devices, disable your LoRa Device , if it’s enabled. Give your device a Name
and select your Network Server
and your Channel plan
.
To not get spammed by your Gateway, untick everything except Valid under Forward
. Choose under Network
, Public
or Private
. For now we leave our one on Public
. Apply and enable the LoRa Device.
Later on, we can use Log
in the main sidebar of WinBox to check if our LoRaWAN Gateway is connecting succesfully with our Python server.
Software - LoRaWAN UDP Server
The Semtech UDP Packet Forwarder Protocol9 is relatively easy to implement. I wrote the UDP server in Python. You can, of course, choose your favorite programming language.
The protocol consists of 5 identifier PUSH_DATA (0x00)
,PUSH_ACK (0x01)
,PULL_DATA (0x02)
,PULL_RESP (0x03)
,PULL_ACK (0x04)
and TX_ACK (0x05)
to distinguish between the received UDP packets.
Here is a LoRaWAN UDP Server written in Python. It echoes back packets on the same frequency it received.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#!/usr/bin/env python3
# Blog: https://www.technopolis.tv/blog/2023/07/24/LoRa-Writing-our-own-Protocol-Part-2-Hardware/
import socket
import json
import base64
from binascii import hexlify, unhexlify
PROTO_SIZE = 12
PROTO_VERSION = b"\x02"
PUSH_DATA = b"\x00"
PUSH_ACK = b"\x01"
PULL_DATA = b"\x02"
PULL_RESP = b"\x03"
PULL_ACK = b"\x04"
TX_ACK = b"\x05"
local_ip = "0.0.0.0"
local_port = 1700
buffer_size = 1024
downlink = False
tx = {"txpk": {"imme": True, "freq": None, "rfch": 0, "powe": 14, "modu": "LORA", "datr": "SF8BW125", "codr": "4/6", "ipol": True, "prea": 8, "size": 0, "data": None}}
def parse_lorawan(data):
for rx in data["rxpk"]:
if rx["modu"] == "LORA":
if rx["stat"] == 1:
send_downlink(rx)
try:
print(f"[*] LoRaWAN Packet received")
print(f" RX Time: {rx['time']}")
# print(f" RX Timestamp: {rx['tmms']}")
print(f" RX finished: {rx['tmst']}")
print(f" CRC Status: {rx['stat']}")
print(f" Frequency: {rx['freq']}MHz")
print(f" Channel: {rx['chan']}")
print(f" RF Chain: {rx['rfch']}")
print(f" Coding Rate: {rx['codr']}")
print(f" Data Rate: {rx['datr']}")
print(f" RSSI: {rx['rssi']}dBm")
print(f" SNR: {rx['lsnr']}dB")
print(f" Size: {rx['size']} bytes")
print(f" Data: {hexlify(base64.b64decode(rx['data']))}")
except:
print(f"[!] No valid JSON: {rx}")
def parse_stats(gateway, data):
print(f"[*] Gateway Statistics received")
print(f" ID: {hexlify(gateway)}")
print(f" Time: {data['stat']['time']}")
print(f" Packets received: {data['stat']['rxnb']}")
print(f" Packets received (valid): {data['stat']['rxok']}")
print(f" Packets forwarded: {data['stat']['rxfw']}")
print(f" Acknowledged upstream: {data['stat']['ackr']}%")
print(f" Downlink received: {data['stat']['dwnb']}")
print(f" Packets emitted: {data['stat']['txnb']}")
def send_downlink(data):
global downlink
tx["txpk"]["freq"] = data['freq']
tx["txpk"]["data"] = data['data']
tx["txpk"]["size"] = data['size']
downlink = True
if __name__ == "__main__":
server = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
server.bind((local_ip, local_port))
print(f"[*] Starting LoRaWAN UDP Server {local_ip}:{local_port}")
try:
while(True):
data, addr = server.recvfrom(buffer_size)
if len(data) >= PROTO_SIZE:
token = data[1:3]
identifier = data[3].to_bytes(1, "little")
gateway = data[4:12]
# print(f"token: {token} id: {identifier} gateway: {hexlify(gateway)} data: {data[12:]}")
if identifier == PUSH_DATA:
try:
json_payload = json.loads(data[12:])
if "rxpk" in json_payload:
parse_lorawan(json_payload)
else:
parse_stats(gateway, json_payload)
except:
print(f"[!] No valid JSON: {data[12:]}")
# Send PUSH_ACK
payload = PROTO_VERSION
payload += token
payload += PUSH_ACK
server.sendto(payload, addr)
elif identifier == PULL_DATA:
# Send PULL_ACK
payload = PROTO_VERSION
payload += token
payload += PULL_ACK
server.sendto(payload, addr)
if downlink:
print(f"[*] Echoing packet back")
payload = PROTO_VERSION
payload += token
payload += PULL_RESP
server.sendto(payload + json.dumps(tx).encode("utf-8"), addr)
downlink = False
elif identifier == TX_ACK:
if len(data[12:]) == 0:
print(f"[*] Received acknowledge token {hexlify(token)} from {hexlify(gateway)}")
else:
print(f"[*] Received error token {hexlify(token)} from {hexlify(gateway)}")
print(f" Error message: {data[12:]}")
else:
print(f"[!] Unknown UDP Packet: {hexlify(data)}")
else:
print(f"[!] Wrong UDP Packet size: {len(data)}")
except KeyboardInterrupt:
print(" [*] Shutting down LoRaWAN UDP Server...")
finally:
server.close()
Software - End Node Firmware
To be able to talk with a LoRaWAN Gateway, Frequency
, Bandwidth
, Coding Rate
and Spreading Factor
must match on the LoRa End Node and LoRaWAN Gateway.
Additionally few important settings such as SyncWord
, I/Q
, Preamble Length
, Implicit Header
, CRC
and optionally the LoRaWAN header 0x80
to 0xFF
(for Proprietary non-standard message formats) must be set.
Uplink messages are send by our LoRa End Nodes to our Server Backend. Downlink messages are send from our Server Backend to the LoRa End Nodes. LoRaWAN Gateways invert I/Q Signals to avoid other Gateways receiving eachothers downlink messages.
The SyncWord for public LoRaWAN in decimal is 52 (0x34)
and 18 (0x12)
for private.
1
2
3
4
5
6
7
8
9
// necessary settings for LoRaWAN Gateways after calling LoRa.begin() function
LoRa.enableCrc();
LoRa.setPreambleLength(8);
LoRa.setSyncWord(0x34); // LoRaWAN Public SyncWord, use 0x12 for Private
LoRa.disableInvertIQ(); // transmitting (uplink)
// or
LoRa.enableInvertIQ(); // receiving (downlink)
In case we don’t use the proprietary header, the LoRaWAN Gateway tries to interpret our packets and perhaps refuses it.
Did you know that you can use TTGO T-BEAM as TNC and build a IP network via LoRa?
Here is an LoRa Hello World example. It sends every 15 seconds a LoRaWAN compatible packet to the Gateway (uplink) and gets an echo back from it (downlink).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#include <Arduino.h>
#include <SPI.h>
#include <LoRa.h>
#define SCK 5
#define MISO 19
#define MOSI 27
#define SS 18
#define RST 23
#define DI0 26
#define FREQ 867100000
#define BANDWIDTH 125E3
#define TX_SF 12
#define RX_SF 8
#define RATE 4
unsigned long previousMillis = 0;
void txMode() {
LoRa.setFrequency(FREQ);
LoRa.setSpreadingFactor(TX_SF);
LoRa.disableInvertIQ();
LoRa.idle();
}
void rxMode() {
LoRa.setFrequency(FREQ);
LoRa.setSpreadingFactor(RX_SF);
LoRa.enableInvertIQ();
LoRa.receive();
}
void setup() {
SPI.begin(SCK, MISO, MOSI, SS);
LoRa.setPins(SS, RST, DI0);
LoRa.setFrequency(FREQ);
LoRa.setCodingRate4(RATE);
LoRa.setSignalBandwidth(BANDWIDTH);
Serial.begin(115200);
if (!LoRa.begin(FREQ)) {
while (1) {
Serial.println("Starting LoRa failed!");
delay(5000);
}
} else {
Serial.println("LoRa started");
}
// necessary settings for LoRaWAN gateway
LoRa.enableCrc();
LoRa.setPreambleLength(8);
LoRa.setSyncWord(0x34);
rxMode();
}
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= 15000) {
Serial.print("Sending LoRaWAN packet... ");
txMode();
// send Proprietary LoRaWAN packet
LoRa.beginPacket(0);
LoRa.write(0xFF);
LoRa.print(millis());
LoRa.print(" HELLO TECHNOPOLIS CITIZEN");
LoRa.endPacket(false);
rxMode();
Serial.println("done");
previousMillis = currentMillis;
}
// try to parse packet
int packetSize = LoRa.parsePacket();
if (packetSize) {
// received a packet
Serial.print("Received packet '");
LoRa.read(); // get rid of first byte
// read packet
while (LoRa.available()) {
Serial.print((char)LoRa.read());
}
// print RSSI of packet
Serial.print("' with RSSI ");
Serial.println(LoRa.packetRssi());
}
}
If everything worked fine, you should see that the server received the packet and replied back with an echo packet. The response of the LoRaWAN Gateway should appear on your LoRa End Nodes Serial Monitor.
1
2
3
4
Sending LoRaWAN packet... done
Received packet '10000 HELLO TECHNOPOLIS CITIZEN' with RSSI -62
Sending LoRaWAN packet... done
Received packet '20000 HELLO TECHNOPOLIS CITIZEN' with RSSI -63
Future Roadmap
In the upcoming Part 3 of the #LoRa series, we will dive into the (binary) packet structure of our own LoRa protocol and write some code for our LoRa End Nodes.
Shout-out and Thanks
Thank you very much for everyone out there who shares and supports this project and special thanks to LilyGo for the amazing LoRa products.