Integrating LoRaWAN sensors into Home Assistant

Sure. Here’s my LoRa_GNSS.ino code.

/*
 * LoRaWAN GNSS tracker
 *
 */

#include "LoRaWan_APP.h"
#include "CayenneLPP.h"

#include <Wire.h>  
#include "HT_SSD1306Wire.h"

/* OTAA para*/
uint8_t devEui[] = { 0x22, 0x32, 0x33, 0x00, 0x00, 0x88, 0x88, 0x02 };
uint8_t appEui[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
uint8_t appKey[] = { 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x66, 0x01 };

/* we're using OTAA, but these still need to be defined to compile successfully
/* ABP para*/
uint8_t nwkSKey[] = { 0x15, 0xb1, 0xd0, 0xef, 0xa4, 0x63, 0xdf, 0xbe, 0x3d, 0x11, 0x18, 0x1e, 0x1e, 0xc7, 0xda,0x85 };
uint8_t appSKey[] = { 0xd7, 0x2c, 0x78, 0x75, 0x8c, 0xdc, 0xca, 0xbf, 0x55, 0xee, 0x4a, 0x77, 0x8d, 0x16, 0xef,0x67 };
uint32_t devAddr =  ( uint32_t )0x007e6ae1;

/*LoraWan channelsmask*/
/* using channels 8-15 */
uint16_t userChannelsMask[6]={ 0xFF00,0x0000,0x0000,0x0000,0x0000,0x0000 };

/*LoraWan region, select in arduino IDE tools*/
LoRaMacRegion_t loraWanRegion = ACTIVE_REGION;

/*LoraWan Class, Class A and Class C are supported*/
DeviceClass_t  loraWanClass = CLASS_A;

/*the application data transmission duty cycle.  value in [ms].*/
uint32_t appTxDutyCycle = 30000;

/*OTAA or ABP*/
bool overTheAirActivation = true;

/*ADR enable*/
bool loraWanAdr = true;


/* Indicates if the node is sending confirmed or unconfirmed messages */
bool isTxConfirmed = true;

/* Application port */
uint8_t appPort = 2;
/*!
* Number of trials to transmit the frame, if the LoRaMAC layer did not
* receive an acknowledgment. The MAC performs a datarate adaptation,
* according to the LoRaWAN Specification V1.0.2, chapter 18.4, according
* to the following table:
*
* Transmission nb | Data Rate
* ----------------|-----------
* 1 (first)       | DR
* 2               | DR
* 3               | max(DR-1,0)
* 4               | max(DR-1,0)
* 5               | max(DR-2,0)
* 6               | max(DR-2,0)
* 7               | max(DR-3,0)
* 8               | max(DR-3,0)
*
* Note, that if NbTrials is set to 1 or 2, the MAC will not decrease
* the datarate, in case the LoRaMAC layer did not receive an acknowledgment
*/
uint8_t confirmedNbTrials = 4;

// GNSS settings

// U-BLOX firmware version
#define   UBLOX_FW_VERSION  7

// Enable Serial debbug on Serial UART to see registers written
#define GNSS_DEBUG Serial

#define TX_PIN  46
#define RX_PIN  45
#define LED_PIN 35

#include "ublox_GNSS.h"

// Use hardware serial support
#define Serial_GNSS Serial1

float lat, lon, alt, acc, dist;

fixType_t fix = NO_FIX;

GNSS gnss( Serial_GNSS, UBLOX_FW_VERSION );
extern SSD1306Wire display;


typedef struct coords_s {
  float lat;
  float lon;
} COORDS;

COORDS home = { 35.90477, -78.83601 };

/* This is the first call to the display after sleep so we have to duplicate
 * LoRaWanClass::displaySending()
 */
void displayAcquiring()
{
	digitalWrite(Vext,LOW);
	display.init();
	display.setFont(ArialMT_Plain_16);
	display.setTextAlignment(TEXT_ALIGN_CENTER);
	display.clear();
	display.drawString(display.getWidth()/2, display.getHeight()/2, "ACQUIRING LOC");
	display.display();
}

void displayLocation()
{
	char temp[30];
	sprintf(temp,"dist: %3.1f km, hAcc %3.0f m",dist, acc);
	display.setFont(ArialMT_Plain_10);
	display.setTextAlignment(TEXT_ALIGN_RIGHT);
	display.drawString(128, 0, temp);
	display.display();
  delay( 2000 );
}

/* Prepares the payload of the frame */

static void prepareTxFrame( uint8_t port )
{
	/*appData size is LORAWAN_APP_DATA_MAX_SIZE which is defined in "commissioning.h".
	*appDataSize max value is LORAWAN_APP_DATA_MAX_SIZE.
	*if enabled AT, don't modify LORAWAN_APP_DATA_MAX_SIZE, it may cause system hanging or failure.
	*if disabled AT, LORAWAN_APP_DATA_MAX_SIZE can be modified, the max value is reference to lorawan region and SF.
	*for example, if use REGION_CN470, 
	*the max value for different DR can be found in MaxPayloadOfDatarateCN470 refer to DataratesCN470 and BandwidthsCN470 in "RegionCN470.h".
	*/
  gnss_acquire();

  CayenneLPP *lpp = new CayenneLPP( LORAWAN_APP_DATA_MAX_SIZE );

  lpp->reset();
  lpp->addGPS( 1, lat, lon, alt/1000 );
  lpp->addDistance( 2, acc );
  lpp->addDistance( 3, dist * 1000 );
  lpp->addDigitalOutput( 4, ( digitalRead( LED_PIN ) == HIGH ? 1 : 0 ));

  appDataSize = lpp->copy( appData );

  delete lpp;
}

void decodeDownlinkMsg( uint8_t *buf, uint8_t bufsize, uint8_t port )
{
    StaticJsonDocument<256> jsonBuffer;
    CayenneLPP lpp(0);

    JsonArray array = jsonBuffer.to<JsonArray>();
    lpp.decode( buf, bufsize, array );
    //serializeJsonPretty(array, Serial);
    //Serial.println();
    for ( JsonObject item : array ) {
    
      int lpp_type = item["type"];
      switch ( lpp_type )
      {
        case LPP_DIGITAL_OUTPUT:

          if ( item["channel"] == 4 )
          {
            int setval = LOW;
            if ( item["value"] == 1 )
            {
              setval = HIGH;
            }
            gpio_hold_dis( (gpio_num_t )LED_PIN );
            gpio_deep_sleep_hold_dis();
            pinMode(LED_PIN, OUTPUT);
            digitalWrite( LED_PIN, setval );
            gpio_deep_sleep_hold_en();
            gpio_hold_en( (gpio_num_t )LED_PIN );
          }
          break;

        default:
          break;
       } 
    }  
}

void downLinkDataHandle(McpsIndication_t *mcpsIndication)
{
	printf("+REV DATA:%s,RXSIZE %d,PORT %d\r\n",mcpsIndication->RxSlot?"RXWIN2":"RXWIN1",mcpsIndication->BufferSize,mcpsIndication->Port);
	printf("+REV DATA:");
	for(uint8_t i=0;i<mcpsIndication->BufferSize;i++)
	{
		printf("%02X",mcpsIndication->Buffer[i]);
	}
	printf("\r\n");
  if ( mcpsIndication->RxData )
  {
    decodeDownlinkMsg( mcpsIndication->Buffer, mcpsIndication->BufferSize, mcpsIndication->Port );
  }
}

RTC_DATA_ATTR bool firstrun = true;

void gnss_setup( void )
{

  Serial_GNSS.begin( 9600, SERIAL_8N1, RX_PIN, TX_PIN );

  #define ACQ_TIMEOUT     40
  #define SEARCH_PERIOD   0
  #define UPDATE_PERIOD   0
  #define ON_TIME         15

  if ( gnss.config( ON_OFF ) )
  {
    Serial.println("\nGNSS configured.");
  }
  else
  {
    Serial.println("\nGNSS configuration failed.");
  }

  /* wait for first fix */
  while ( gnss.getCoodinates(lon, lat, alt, fix, acc, 50) == 0 )
  {
    Serial.println("\nInitial fix failed.");
  }

  float distance = gnss.HaverSine( home.lat, home.lon, lat, lon );
  Serial.println("Distance to home: " + String( distance, 2 ) + " km" );


  if ( gnss.init( ON_OFF, ACQ_TIMEOUT, SEARCH_PERIOD, UPDATE_PERIOD, ON_TIME ) )
  {
    Serial.println("\nGNSS initialized.");
  }
  else
  {
    Serial.println("\nFailed to initialize GNSS module.");
  }

  gnss.conditionalOff();
}

void gnss_acquire( void )
{
  Serial.println("Get location");

  gnss.reinst( Serial_GNSS, UBLOX_FW_VERSION );

  Serial_GNSS.begin( 9600, SERIAL_8N1, RX_PIN, TX_PIN );

  gnss.wake();

  delay( 1000 );

  if ( gnss.getCoodinates(lon, lat, alt, fix, acc, 50) == 0) 
  {
    Serial.println("Failed to get coordinates, check signal, accuracy required or wiring");
  }

  Serial.println("Location lat:" + String(lat, 7) +" lon:" + String(lon, 7) +
                  + " alt: " + String( alt ) + "m horizontal accuracy: " + String(acc) + "m");

  //Serial.println("\nOr try the following link to see on google maps:");
  //Serial.println(String("https://www.google.com/maps/search/?api=1&query=") + String(lat,7) + "," + String(lon,7));

  dist = gnss.HaverSine( home.lat, home.lon, lat, lon );
  Serial.println("Distance to home: " + String( dist, 2 ) + " km" );

  gnss.conditionalOff();

}

void setup() {

  Serial.begin(115200);
  Mcu.begin();
  if(firstrun)
  {
    LoRaWAN.displayMcuInit();
    firstrun = false;

    gnss_setup();

 }

	deviceState = DEVICE_STATE_INIT;
}

void loop()
{

  switch( deviceState )
	{
		case DEVICE_STATE_INIT:
		{
#if(LORAWAN_DEVEUI_AUTO)
			LoRaWAN.generateDeveuiByChipID();
#endif
			LoRaWAN.init(loraWanClass,loraWanRegion);

      break;
		}
		case DEVICE_STATE_JOIN:
		{
      LoRaWAN.displayJoining();
			LoRaWAN.join();
			break;
		}
		case DEVICE_STATE_SEND:
		{
      displayAcquiring();
			prepareTxFrame( appPort );
      displayLocation();

      LoRaWAN.displaySending();

			LoRaWAN.send();
			deviceState = DEVICE_STATE_CYCLE;
			break;
		}
		case DEVICE_STATE_CYCLE:
		{
			// Schedule next packet transmission
			txDutyCycleTime = appTxDutyCycle + randr( 0, APP_TX_DUTYCYCLE_RND );
			LoRaWAN.cycle(txDutyCycleTime);
			deviceState = DEVICE_STATE_SLEEP;
			break;
		}
		case DEVICE_STATE_SLEEP:
		{
      LoRaWAN.displayAck();
			LoRaWAN.sleep(loraWanClass);

			break;
		}
		default:
		{
			deviceState = DEVICE_STATE_INIT;
			break;
		}
	}
}

The contents of the Arduino sketch.yaml file.

default_fqbn: heltec:esp32:WIFI_LoRa_32_V3:UploadSpeed=921600,CPUFreq=240,DebugLevel=none,LORAWAN_REGION=3,LoRaWanDebugLevel=0,LoopCore=1,EventsCore=1,LORAWAN_PREAMBLE_LENGTH=0,LORAWAN_DEVEUI=1
default_port: COM5

And the decoder.js file.

/**
 * @reference https://github.com/myDevicesIoT/cayenne-docs/blob/ff6da3d400d406a3cbc6b380abe3eeaa7009bbb9/docs/LORA.md
 * @reference http://openmobilealliance.org/wp/OMNA/LwM2M/LwM2MRegistry.html#extlabel
 *
 * Adapted for lora-app-server from https://gist.github.com/iPAS/e24970a91463a4a8177f9806d1ef14b8
 *
 * Type                 IPSO    LPP     Hex     Data Size   Data Resolution per bit
 *  Digital Input       3200    0       0       1           1
 *  Digital Output      3201    1       1       1           1
 *  Analog Input        3202    2       2       2           0.01 Signed
 *  Analog Output       3203    3       3       2           0.01 Signed
 *  Illuminance Sensor  3301    101     65      2           1 Lux Unsigned MSB
 *  Presence Sensor     3302    102     66      1           1
 *  Temperature Sensor  3303    103     67      2           0.1 °C Signed MSB
 *  Humidity Sensor     3304    104     68      1           0.5 % Unsigned
 *  Accelerometer       3313    113     71      6           0.001 G Signed MSB per axis
 *  Barometer           3315    115     73      2           0.1 hPa Unsigned MSB
 *  Time                3333    133     85      4           Unix time MSB
 *  Gyrometer           3334    134     86      6           0.01 °/s Signed MSB per axis
 *  GPS Location        3336    136     88      9           Latitude  : 0.0001 ° Signed MSB
 *                                                          Longitude : 0.0001 ° Signed MSB
 *                                                          Altitude  : 0.01 meter Signed MSB
 *
 * Additional types
 *  Generic Sensor      3300    100     64      4           Unsigned integer MSB
 *  Voltage             3316    116     74      2           0.01 V Unsigned MSB
 *  Current             3317    117     75      2           0.001 A Unsigned MSB
 *  Frequency           3318    118     76      4           1 Hz Unsigned MSB
 *  Percentage          3320    120     78      1           1% Unsigned
 *  Altitude            3321    121     79      2           1m Signed MSB
 *  Concentration       3325    125     7D      2           1 PPM unsigned : 1pmm = 1 * 10 ^-6 = 0.000 001
 *  Power               3328    128     80      2           1 W Unsigned MSB
 *  Distance            3330    130     82      4           0.001m Unsigned MSB
 *  Energy              3331    131     83      4           0.001kWh Unsigned MSB
 *  Colour              3335    135     87      3           R: 255 G: 255 B: 255
 *  Direction           3332    132     84      2           1º Unsigned MSB
 *  Switch              3342    142     8E      1           0/1

 * 
 */

// lppDecode decodes an array of bytes into an array of ojects, 
// each one with the channel, the data type and the value.
function lppDecode(bytes) {
    
    var sensor_types = {
        0  : {'size': 1, 'name': 'digital_in', 'signed': false, 'divisor': 1},
        1  : {'size': 1, 'name': 'digital_out', 'signed': false, 'divisor': 1},
        2  : {'size': 2, 'name': 'analog_in', 'signed': true , 'divisor': 100},
        3  : {'size': 2, 'name': 'analog_out', 'signed': true , 'divisor': 100},
        100: {'size': 4, 'name': 'generic', 'signed': false, 'divisor': 1},
        101: {'size': 2, 'name': 'illuminance', 'signed': false, 'divisor': 1},
        102: {'size': 1, 'name': 'presence', 'signed': false, 'divisor': 1},
        103: {'size': 2, 'name': 'temperature', 'signed': true , 'divisor': 10},
        104: {'size': 1, 'name': 'humidity', 'signed': false, 'divisor': 2},
        113: {'size': 6, 'name': 'accelerometer', 'signed': true , 'divisor': 1000},
        115: {'size': 2, 'name': 'barometer', 'signed': false, 'divisor': 10},
        116: {'size': 2, 'name': 'voltage', 'signed': false, 'divisor': 100},
        117: {'size': 2, 'name': 'current', 'signed': false, 'divisor': 1000},
        118: {'size': 4, 'name': 'frequency', 'signed': false, 'divisor': 1},
        120: {'size': 1, 'name': 'percentage', 'signed': false, 'divisor': 1},
        121: {'size': 2, 'name': 'altitude', 'signed': true, 'divisor': 1},
        125: {'size': 2, 'name': 'concentration', 'signed': false, 'divisor': 1},
        128: {'size': 2, 'name': 'power', 'signed': false, 'divisor': 1},
        130: {'size': 4, 'name': 'distance', 'signed': false, 'divisor': 1000},
        131: {'size': 4, 'name': 'energy', 'signed': false, 'divisor': 1000},
        132: {'size': 2, 'name': 'direction', 'signed': false, 'divisor': 1},
        133: {'size': 4, 'name': 'time', 'signed': false, 'divisor': 1},
        134: {'size': 6, 'name': 'gyrometer', 'signed': true , 'divisor': 100},
        135: {'size': 3, 'name': 'colour', 'signed': false, 'divisor': 1},
        136: {'size': 9, 'name': 'gps', 'signed': true, 'divisor': [10000,10000,100]},
        142: {'size': 1, 'name': 'switch', 'signed': false, 'divisor': 1},
    };

    function arrayToDecimal(stream, is_signed, divisor) {

        var value = 0;
        for (var i = 0; i < stream.length; i++) {
            if (stream[i] > 0xFF)
                throw 'Byte value overflow!';
            value = (value << 8) | stream[i];
        }

        if (is_signed) {
            var edge = 1 << (stream.length) * 8;  // 0x1000..
            var max = (edge - 1) >> 1;             // 0x0FFF.. >> 1
            value = (value > max) ? value - edge : value;
        }

        value /= divisor;

        return value;

    }

    var sensors = [];
    var i = 0;
    while (i < bytes.length) {

        var s_no   = bytes[i++];
        var s_type = bytes[i++];
        if (typeof sensor_types[s_type] == 'undefined') {
            throw 'Sensor type error!: ' + s_type;
        }

        var s_value = 0;
        var type = sensor_types[s_type];
        switch (s_type) {

            case 113:   // Accelerometer
            case 134:   // Gyrometer
                s_value = {
                    'x': arrayToDecimal(bytes.slice(i+0, i+2), type.signed, type.divisor),
                    'y': arrayToDecimal(bytes.slice(i+2, i+4), type.signed, type.divisor),
                    'z': arrayToDecimal(bytes.slice(i+4, i+6), type.signed, type.divisor)
                };
                break;
            
            case 136:   // GPS Location
                s_value = {
                    'latitude': arrayToDecimal(bytes.slice(i+0, i+3), type.signed, type.divisor[0]),
                    'longitude': arrayToDecimal(bytes.slice(i+3, i+6), type.signed, type.divisor[1]),
                    'altitude': arrayToDecimal(bytes.slice(i+6, i+9), type.signed, type.divisor[2])
                };
                break;
			case 135:   // Colour
				s_value = {
                    'r': arrayToDecimal(bytes.slice(i+0, i+1), type.signed, type.divisor),
                    'g': arrayToDecimal(bytes.slice(i+1, i+2), type.signed, type.divisor),
                    'b': arrayToDecimal(bytes.slice(i+2, i+3), type.signed, type.divisor)
                };
                break;

            default:    // All the rest
                s_value = arrayToDecimal(bytes.slice(i, i + type.size), type.signed, type.divisor);
                break;
        }
        
        sensors.push({
            'channel': s_no,
            'type': s_type,
            'name': type.name,
            'value': s_value
        });

        i += type.size;

    }

    return sensors;

}

// To use with TTN
function decodeUplink(input) {

    bytes = input.bytes;
    fPort = input.fPort;

    // flat output (like original decoder):
    var response = {};
    lppDecode(bytes, 1).forEach(function(field) {
        response[field['name'] + '_' + field['channel']] = field['value'];
    });
    return {
       data: response
    };

    // field output
    //return {'fields': lppDecode(bytes, fPort)};

}

// To use with NodeRED
// Assuming msg.payload contains the LPP-encoded byte array
/*
msg.fields = lppDecode(msg.payload);
return msg;
*/
// Encode downlink function.
//
// Input is an object with the following fields:
// - data = Object representing the payload that must be encoded.
// - variables = Object containing the configured device variables.
//
// Output must be an object with the following fields:
// - bytes = Byte array containing the downlink payload.
// 
// our data looks like: {"array":[{"channel":4,"name":"digital_out","value":1}]}

function encodeDownlink(input) {

  var arr = parseExtJSON( input.data );
  var i = 0;
  var output = [];

  arr.array.forEach(( item ) => {

    switch ( item.name ) {
      case "digital_out":
        item.type = 1;
        break;
      default:
        item.type = 255;
        break;
    }

    output[i++] = item.channel;
    output[i++] = item.type;
    output[i++] = item.value;

  });

  return {
    bytes: output
  };
}

I recommend getting the LoRaWan_OLED example running on the Heltec before tackling this.

Greate,
Thanks a lot :upside_down_face:

Hi,
I am searching a „remote Lorawan button“. I want to use the button to reliably trigger a light. I do want to use a small physical button, I can carry around, I know I could use my mobile with HA, but fiddling out the mobile on my bike is not very pleasant, my idea is to press this button about 200m before my garage door, so that it starts opening, and I just drive directly in.
I found MClimate Multipurpose Button LoRaWAN | MClimate LoRaWAN Devices e.g but do not understand to to integrate it in my dragino gateway. The setup is otaa, but I have no clue how to use the dragino with otaa. All sensors I use I setup with ABP. So does anyone has a remote button locally integrated into the dragino, or succsessfully setup otaa on the dragino server? I am googling without luck since about over 8 month.

Many thanks for a pointer to something that could do this job
Jürgen

You could use zone (with conditions) and then simply trigger the garage door to open when your mobile phone enters the zone.

Whilst your use case is feasible on LoRWAN, it is not 100% suited because:

  1. LoRaWAN is better for stationary sensors

  2. Latency can sometimes be an issue

Also, check your Dragino gateway is LoRaWAN (8 channels) and not a single channel gateway. OTAA does not work on single channel gateways

Hello community,
using the TTN integration I have integrated a LoraWAN water level sensor (Dragino PS-LB), which sends the water level in cm. I can see the sensor values as an entity of the TTN integration, the values correctly arrive at HA. Unfortunately
I cannot view the historical values as a curve or bar chart, but only get a single horizontal bar with different colors:
Screenshot 2024-11-10 154235

According to my research, I have to enter the unit_of_measurement somewhere for other display variants, but I don’t know where and how. Can any of you help me?

Thank you very much!
Franz Josef

Hi, Franz. You should first check the settings for the device in the integration panel for TTN. Typically the developer has set the sensor’s attributes correctly. I do not use that integration so I cannot help you there. I do have an MQTT depth sensor that measures in “ft” as the unit_of_measurement and will display a history graph. You can also use a “customize” entry to set the unit_of_measurement for the sensor. This thread discusses a situation similar to yours with suggestions: Unit of Measurement - 2022.4.

Thank you for your fast reply. I wanted to avoid MQTT thinking it´s too complicated - but maybe I´m wrong an the TTN-integration is even more…
The customization possibility is exactly missing the “Show as” input like we can see it in the following example:

So, I have to write anything in the configuration.yaml (but don´t know what…) or deal myself with the “mqtt topic”.