Home LoRa: Writing our own Protocol - Part 2 - Hardware & Hello World
Post
Cancel

LoRa: Writing our own Protocol - Part 2 - Hardware & Hello World

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

LilyGo TTGO T-Beam v1.1

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”.

MikroTik wAP LR8 kit LoRa 868MHz

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

MikroTik -> LoRa -> Devices

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.

MikroTik -> LoRa -> Servers

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.

Sources

LoRa: Writing our own Protocol - Part 1 - Introduction

PlatformIO: Self-destructing Arduino ESP32 Firmware