Connecting long range sensors using LoRa and MQTT

Connecting long range sensors using LoRa and MQTT

This post is written as part of an assignment for the HAN University of Applied Sciences. This is also part of a few other projects I am posting.

Other guides can be found here:
Two Zigbee Networks Joined over a wireguard VPN: Two Zigbee Networks joined over a Wireguard VPN
Controlling Tuya devices locally using Node-Red: Controlling Tuya devices locally using Node-Red

This guide presumes that you have some basic knowledge of using Home-Assistant and that Home-Assistant is already installed. I am not liable for broken devices, broken configuration, thermonuclear war or ANYTHING ELSE. This guide is provided as is. Nonetheless I hope you find it interesting :smiley:


Unlike the other posts I made. This isn’t a guide but more of an exploration of what is possible. At this point with my Home-Assistant installation I could add Zigbee devices and Wi-Fi devices and sensors at my own studio and at my parents place which is connected through a VPN. However I couldn’t put sensors in the yard of my parents or put a device at a long-range without an internet connection or Zigbee Signal from either gateways.
That is why I decided to explore with a Point to Point LoRa connection. I Have used used LoRaWAN with The Things Network, but neither my parents place or my own studio have very good coverage of TTN. So I bought two ESP32 modules with a SX1276 Lora chip and went coding.


LoRa stands for Long Range and is specifically designed to send data over longer distances upto 5km. It is mostly used by LoRaWAN networks like the things network. More information on LoRa can be found here and more information on The Things Network can be found Here

Goal of this project

The point of using LoRa is to get a longer range. So I wanted to see how far I could get in a semi urban area with some lower power modules.


I opted to buy two LilyGO TTGO T3 LoRa32 V1.6.1 ESP32 Modules. With some cheap antenna’s from amazon not for extended range but more for antenna placement.

The Project

A few years ago I made a small joke project in where a traffic light was hooked up to fridge. When the fridge would open the traffic light would go to green. When it closes it would cycle back through orange and eventually to Red.
This was controlled by Arduino Nano which which would constantly read a digital pin to check if the fridge light was activated. If the fridge light turned on the pin would go high and the light would turn green.


The Hardware consist of a Node and Gateway.

The Node

The Board that controlled the traffic light becomes the node and originally looked like this:

The traffic light itself looks like this and is a leftover from a carnavals project a few years ago:


There are two relays to switch live and neutral wires per light. So there are six relays in total. The Red light has inverted logic because that is light that is almost always on since the fridge is closed most of the time. This is so that there is the least amount of wear on the relays since they are off in a default state.
The relays are controlled using BJT transistors. There are debugging LED’s on the board for programming the board if it is removed from the traffic light.

The relays are controlled in a similair way to this schematic:

This meant that the board was almost ready. Only the pins for it had to be mapped to appropriate pins on the LilyGo.
The pinout for this was changed as follows:

Function Arduino Nano Pin LilyGO Pin
Green light D11 GPIO12
Yellow Light D5 GPIO13
Red light (inverted) D2 GPIO15
Fridge Read Pin A2 GPIO2

After some more soldering with protoboard it looked like this:

The Gateway

The Gateway is just the LilyGo module described earlier that is connected to a Wifi Network.
The Node and Gateway are about 1 km apart from each other. The Gateways job is to translate MQTT message into LoRa messages.

Software Design

There are two pieces of different software written. The Node is in control of traffic light and The Gateway is in control of processing MQTT messages and sending them over LoRa.

The Node

The Node can control the traffic light in three Modes: Manual, Automatic and Flicker


In the manual mode you can select a color from the Home Assistant color wheel. Green controls the green stoplight, red Controls the red stoplight, and blue controls the yellow stoplight.
The Reason that blue is controlling the yellow light is so that there is finer control after all. There is no blue light in the traffic light so nothing goes to waist. It also easier to code since each light has its own value from 0 to 255
However the lights are binary. This means that they either on or off. So I mapped that above 200 the light is on and below 200 the light is off


Flicker is essentially the same as the manual mode only that the light flickers at the selected color with an interval of 1 second.


The Automatic mode is the original function of the traffic light. This means when the fridge is opened it turns green and when it closes it turns the light to orange and then red on an interval of 2 seconds.

Counting how many times the fridge has opened

Whenever the fridge is opened it stores it in ringbuffer. When the transmit interval occurs for LoRa it sends back a ping that the fridge has opened. This is then turned back into a MQTT message and registered by node red to the a virtual sensor.

The Gateway

The Gateway checks if there is an MQTT message

Software implementation

Node-RED and Home Assistant

For this to be controlled from Home Assistant I wanted to control it from MQTT. But since I don’t really like the normal MQTT Light implementation I opted to use A virtual light in combination with Node-Red. See my guide on controlling Tuya light were I basically did the same thing. But in short I created a fake light and sensor with the virtual HACS plugin you can find here
The config for the virtual light looks like this:

- platform: virtual
  name: Sociale Leven Stoplicht
  initial_value: "on"
  initial_brightness: 100
  support_color: true
  initial_color: [0, 100]
  support_effect: true
  initial_effect: "Automatic"
  initial_effect_list: ["Automatic", "Flicker", "Manual"]

The config for the virtual sensor looks like this:

- platform: virtual
  name: "Sociale Leven Koelkast"
  initial_value: 0
  initial_availability: true
  unit_of_measurement: "x Opened"

From here I created Node-Red Flow:

This flow can be found in my github here

For example if I set it to the mode to manual and choose green as a color it sends the following JSON:


In the reverse direction a single message is incremented by a counter in Node-RED and that updates the virtual sensor.


The nodes implementation can be found in my github repository here.\

Unpacking LoRa messages

void loop() 
  if (millis() - lastSendTime > transmitIntervalMs) {
    lastSendTime = millis();            // timestamp the message

Unpacking LoRa Messages looks like this:

void CheckForPacket()
  if (LoRa.parsePacket()) 
    unpackedMessage_t unpackedMessage;
    uint8_t message = 0;
    while (LoRa.available()) 
      message = (uint8_t);
      unpackedMessage = ParseLoraMessage(message);
    if(unpackedMessage.mode == AUTOMATIC)
    if(unpackedMessage.mode == FLICKER)
      SetStopLightFlicker(, unpackedMessage.color.yellow,, 1000);
    if(unpackedMessage.mode == MANUAL)
      SetStopLightColors(, unpackedMessage.color.yellow,;

The main loop is checking constantly if a LoRa packet has arrived or if it needs to send a LoRa packet. If a LoRa packed arrived it unpacks it. A LoRa packet consists of a singular byte and looks like the following:\

Bit 8 7 6 5 4 3 2 1
Data Empty Mode Green Yellow Red

All data fits into 5 bits. For every light a 1 represents on and 0 means off.
The modes are encoded as follows:

  • 00 for automatic
  • 01 for manual
  • 10 for flicker
  • 11 is invalid.

Automatic Mode

In the original implementation of the traffic light. The loop would constantly check if the fridge had opened. Since the loop is now fully occupied by checking if there is LoRa packet this is no longer possible.
This functionality is now rewritten on a interrupt bases. And it works like this:
The fridge detection pin is connected to an interrupt service routine that checks when the pin rises. To mitigate noise there is FreeRTOS timer of 20ms. After these 20ms have passed a function is called to check if the Pin is still high. This is done because sometimes there can be noise which will trigger the pin. This noise can for example be created by switching on and off devices which inducts current into the wire that runs from the fridge to the traffic light. It essentially a software debounce function.

A Timing diagram looks like this:

The checking for closing the fridge works in the same way, but there HIGH and LOW states are switched. The Red Light activates after two second timer so there is no debounce implementation for that

Manual Mode

Manual mode sets the corresponding bits for red, yellow and green to the output.


Flicker does the same as manual. But it alternates between off and on states by invoking a FreeRTOS timer which will switch the state when the timer times out.

The Gateway

The Job of the Gateway is to listen for MQTT Messages and Listen For Lora Messages that it can receive. Once it finds an MQTT Message it will translate from the JSON mentioned above to the single byte discussed above.
MQTT message are received on interrupt bases. Once A message is received it will parse it. The parsing function looks like this:

uint8_t parseToByte(mqttMessage_t inComingMessage)
    const static uint8_t redBitMaskOn    = 0b00000001;
    const static uint8_t yellowBitMaskOn = 0b00000010;
    const static uint8_t greenBitMask    = 0b00000100;
    const static uint8_t AutomaticModeBitMaskOn     = 0b00000000;
    const static uint8_t ManualModeBitMaskOn        = 0b00001000;
    const static uint8_t FlickerModeBitMaskOn       = 0b00010000;

    const static uint8_t offMessage = ManualModeBitMaskOn;

    uint8_t DataByte = 0b00000000;
    if(inComingMessage.state == ON)
        if( == true)
            DataByte = DataByte | greenBitMask;
        if( == true)
            DataByte = DataByte | redBitMaskOn;
        if(inComingMessage.color.yellow == true)
            DataByte = DataByte | yellowBitMaskOn;

        switch (inComingMessage.mode)
            case AUTOMATIC:
                DataByte = AutomaticModeBitMaskOn;
            case MANUAL:
                DataByte = DataByte | ManualModeBitMaskOn;
            case FLICKER:
                DataByte = DataByte | FlickerModeBitMaskOn;
                DataByte = offMessage;
        DataByte = offMessage;
    return DataByte;

At the top some bitmasks are seen. These bitmasks are set depending on the data in the JSON that is received. It is then translated to a single byte of data. Since LoRa is very low bandwidth we cannot send entire strings across it
If you want a deeper dive into the code it can be found on my github page. Note that I will probably be updating the code over the following weeks to tweak some things here and there.

Youtube Videos

I Have posted some unlisted youtube videos of it working:
Changing the color of the traffic light
Counting the number of times the fridge opens

LoRa Spectrum and Legality

Since you are sending over a free part of the frequency spectrum there are limitations on how much power you can use on the band. The restrictions in the Netherlands for 868MHz band are a max sending power of 25mW and only sending at 1% duty cycle. This is to ensure that the band is not to crowded. The 1 km was at the edge of what I could reach. Since I couldn’t put the antenna outside of the buildings I had to resort to a spreading factor of 10 at the maximum sending power. However with a standard pre-amble of 12.25 and one byte this means that I could only send 1 Message per 24 seconds. If you copy this project keep this in mind. I am not liable for you breaking broadcasting laws in your country

Short comings/future additions of this Project

Since I didn’t make use of LoRaWAN. There is no encryption and technically anyone can listen or sabotage the message stream. There is also no addressing. So if anyone is using the same frequency band and channel you can get garbage data. This can be mitigated by implementing some form of addressing and maybe even encryption.