Makerfabs Soil Moisture Sensor v3, LoRaWAN TTN v3 and HA Integration

MAJOR UPDATES

  1. October 4th 2023: “APPENDIX #4: Battery & Voltage Note” added.
  2. September 16th 2023:
    .- New sensor sketch that compiles within program storage space limits (95%).
    .- Soil humidity measurement method aligned with Makerfabs LoRaWAN sensor sketch.
    .- Calibration code provided.
    .- Information on bandgap reference for accurate battery voltage measurement.
    .- Sketch enriched with comments for better understanding.
    .- I2C_AHT10 library provided ad-hoc with the sketch.
    .- Code cosmetics.
    .- Enriched post instructions.
    .- Appendix on hacked Makerfab soil sensor.

(due to post size limitations this post has been split and so it continues on post#11)

  1. June 27th 2023: “Appendix #1: Recover a bricked Sensor” added.

DISCLAIMER

  1. All information herein (post #1 and post #11) is for documentation purposes and not meant to be used in any device. Deviating from this is to be under your responsibility.
  2. Makerfabs LoRa Soil moisture sensor is full Open Source (What Upgraded on Lora Soil Moisture Sensor V3?). All hardware and software open at GitHub.
  3. AG March (LORAWAN SOIL MOISTURE SENSOR | Hackaday.io) is credited for adapting Makerfabs code to LoRaWAN.

ASSUMPTIONS

  1. You are familiarized with The Things Network (https://www.thethingsnetwork.org/).
  2. You know how to create an application and devices in The Things Network (TTN).
  3. You have a LoRaWAN gateway in the nearby (or you have your own).
  4. You know how to upload a sketch into Arduino or similar microprocessor.
  5. You have HACS installed on your Home Assistant and the HACS TTN v3 adapter installed.
  6. You have followed the instructions on how to configure HACS TTN v3 adapter to receive data from TTN.
  7. You have a Zigbee setup with a power control outlet, e.g IKEA Trådfri Control Outlet.

CAVEAT

  1. The sketch presented below makes use of LoRaWAN Over-the-Air Activation (OTAA).

THE STORY
I bought two LoRa soil moisture V3 sensors from Makerfabs (Lora Temperature/ Humidity/ Soil Moisture Sensor V3 | Makerfabs) to monitor and water my tomato plants.

The device is featured with LoRa (GitHub - Makerfabs/Lora-Soil-Moisture-Sensor: Lora Soil Moisture Sensor) although I needed LoRaWAN to get the sensor engaged with The Things Network (TTN).

As mentioned in the disclaimer above, I found that AG March developed a LoRaWAN sketch but for the V2 sensor.

The major difference between V1/V2 and V3 is that the latest generates a square wave by the MCU replacing the 555 IC found in version V1/V2. Since all three versions feature a capacitive moisture sensor, it means that the code to calculate the moisture got changed on V3.

I merged the two codes by replacing the soil moisture calculation found in AG March sketch by the one in Makerfab sketch.

THE SETUP

PICTURES
LoRaSensor
SensorValues
SoilChart
Note: the increase in moisture before reaching 50 is because slight rain.

HA CONFIGURATION
HA receives the values from TTN as string therefore they need to get converted into numbers to get the chronological graph representation of the values. In configuration.yaml you need to add:

  - platform: template
    sensors:
      soil_moist:
        friendly_name: "Soil Moisture"
        unit_of_measurement: '%'
        value_template: "{{ int(states('sensor.YOUR_SENSOR')) }}"

To trigger the watering:

# Watering during the day only
  - alias: "Watering ON when soil humidity level reached"
    trigger:
      platform: template
      value_template: "{{ (states('sensor.soil_hum') | int(0) < 50) }}"
    condition:
      condition: sun
      after: sunrise
      before: sunset
      before_offset: "-03:00:00"
    action:
      service: switch.turn_on
      entity_id: switch.YOUR_ZIGBEE_CONTROL_OUTLET
# Sunrise watering
  - alias: "Morning watering"
    trigger:
      platform: sun
      event: sunrise
    condition:
      condition: template
      value_template: "{{ (states('sensor.soil_hum') | int(0) < 50) }}"
    action:
      service: switch.turn_on
      entity_id: switch.YOUR_ZIGBEE_CONTROL_OUTLET
  - alias: "Watering OFF after 5 minutes"
    trigger:
      platform: state
      entity_id: switch.YOUR_SENSOR
      to: 'on'
      for: 
        minutes: 5
    action:
      - service: switch.turn_off
        entity_id: switch.YOUR_ZIGBEE_CONTROL_OUTLET  

SENSOR CALIBRATION
This step is not strictly necessary although recommended specially for consistency in soil moisture measurements when using multiple sensors. By skipping this step a default value is already set in the sketch.

Calibration Steps
After loading the code to the sensor and by making use of the serial monitor, do as follows:

  1. Leave the sensor on the top of the table without touching it with your hands.
  2. Take note of the value shown as “SOIL ADC”. We are to call this value SOIL ADC AIR.
  3. Immerse the sensor in water until the indicated line “put this part to soil”
  4. Take note of the value shown as “SOIL ADC”. We are to call this value SOIL ADC WATER.
  5. Now you can replace values SOIL_ADC_AIR and SOIL_ADC_WATER in this same sketch and upload it again.
    After uploading the sketch again, but now with updated SOIL_ADC_AIR and SOIL_ADC_WATER, you should see that “SOIL MOIST (%)”, properly shows 100% when the sensor is immerse in water and 0% when it is outside (and dry).

For even a more accurate soil moisture calibration, please google for soil water holding capacity and calibrate accordingly.

Caveat: refer to post #11 for the I2C_AHT10 library necessary for this sketch.

#include <avr/wdt.h>
#include "I2C_AHT10.h"

// SoftwareSerial lorawan_serial(LORAWAN_TX, LORAWAN_RX);
AHT10 aht;

// Sensor Calibration Parameters
#define SOIL_ADC_WATER 583    // Raw read when sensor probe submerged in water. See SOIL_ADC in serial monitor.
#define SOIL_ADC_AIR 888      // Raw read when sensor probe is on air. See SOIL_ADC in serial monitor.
#define SOIL_ADC_UNIT (100.0 / (SOIL_ADC_AIR - SOIL_ADC_WATER))

#define VOLTAGE_PIN A3
#define PWM_OUT_PIN 9
#define SENSOR_POWER_PIN 5
#define ADC_PIN A2

bool readSensorStatus = false;
int soil_adc = 0; // variable to store the value coming from the sensor
int soil_percent = 0;
int bat_adc = 0; // the voltage of battery
int bat_vol = 0;
int tx_count = 0;
int InternalReferenceVoltage = 1101L;  // Adjust this value to your boards specific internal BG voltage x1000

float temperature = 0.0;
float humidity = 0.0;

int count = 0; // WDT count

void setup() {
  pinMode(SENSOR_POWER_PIN, OUTPUT);
  digitalWrite(SENSOR_POWER_PIN, HIGH); // Sensor Power ON

  log_init();

  log_out("---------------- Start----------------");

  pinMode(PWM_OUT_PIN, OUTPUT);    // digitalWrite(PWM_OUT_PIN, LOW);
  TCCR1A = bit(COM1A0);            // toggle OC1A on Compare Match
  TCCR1B = bit(WGM12) | bit(CS10); // CTC, scale to clock
  OCR1A = 1;                       // compare A register value (5000 * clock speed / 1024).When OCR1A == 1, PWM is 2MHz
}

void loop() {
  log_out("[Loop Start]");
  read_sensor();
  sensor_log();
  log_out("[Loop Over]");
  delay(800);
}

// ---------------------- Task -----------------------------

void read_sensor() {

  // ----- Read Vcc Voltage -----
  // REFS1 REFS0          --> 0 1, AVcc internal ref.
  // MUX3 MUX2 MUX1 MUX0  --> Mux configuration:1110 for 1.1V (VBG: Voltage Bandgap)
  // Configuring ADMUX
  ADMUX = (0<<REFS1) | (1<<REFS0) | (0<<ADLAR) | (1<<MUX3) | (1<<MUX2) | (1<<MUX1) | (0<<MUX0);
  delay(50);  // Wait for stabilization, otherwise first value can be wrong. In principle, the ADMUX does not need to be configure every time but it gets reconfigured to read soil humidity.
  // Start a conversion  
  ADCSRA |= _BV( ADSC );
  // Wait for it to complete
  while( ( (ADCSRA & (1<<ADSC)) != 0 ) );
  // Scale the value
  bat_adc = ADC;
  bat_vol = (((InternalReferenceVoltage * 1024L) / ADC) + 5L) / 10L;

  // ----- Read Soil Moisture -----
  // Soil sensor is on ADC2  
  ADMUX = (0<<REFS1) | (1<<REFS0) | (0<<ADLAR) | (0<<MUX3) | (0<<MUX2) | (1<<MUX1) | (0<<MUX0);
  delay(50);  // Wait for stabilization, otherwise first value can be wrong. In principle, the ADMUX does not need to be configure every time but it gets reconfigured to read soil humidity.
  // Start a conversion  
  ADCSRA |= _BV( ADSC );
  // Wait for it to complete
  while( ( (ADCSRA & (1<<ADSC)) != 0 ) );
  // Scale the value
  soil_adc = ADC;
  soil_percent = (int)((SOIL_ADC_AIR - soil_adc) * SOIL_ADC_UNIT);
  if (soil_percent >= 100) {
    soil_percent = 100;
  }
  else if (soil_percent <= 0) {
    soil_percent = 0;
  }

  // ----- Read AHT10 -----
  read_AHT10();
}


// switch VCC on (sensors on)
void sensorPowerOn(void) {
  digitalWrite(SENSOR_POWER_PIN, HIGH);//Sensor power on 
}

// switch VCC off (sensor off)
void sensorPowerOff(void) {
  digitalWrite(SENSOR_POWER_PIN, LOW);//Sensor power off 
}


bool read_AHT10() {
  bool acquired = false;
  digitalWrite(SENSOR_POWER_PIN, HIGH);   // Power on AHT10
  delay(20);                              // Manufacture notice: Wait 20ms, at most, to enter idle state
     
  if (aht.begin() == false) {
//    log_out("AHT10 not detected. Please check wiring. Freezing.");
  } else {
    int x = 0;
    while (x < 10 && !acquired) {
      if (aht.available() == true) {
        temperature = aht.getTemperature();
        humidity = aht.getHumidity();
        acquired = true;
        // log_out("AHT10 Read Success.");
      } else {
        delay(50);
        x++;
      }
    }
  }
  digitalWrite(SENSOR_POWER_PIN, LOW);   // Power off ATH10 
  return acquired;
}

bool aht_init() {
  bool ret = false;
  Wire.begin();
  delay(100);

  sensorPowerOff();
  delay(100);
  sensorPowerOn();
  delay(300);

  if (aht.begin() == false)
    log_out("AHT10 not detected. Please check wiring. Freezing.");
  else {
    int x=0;
    while (x<10) {
      if (aht.available() == true) {
        temperature = aht.getTemperature();
        humidity = aht.getHumidity();
        ret = true;
        log_out("AHT10 Read Success.");
        break;
      } else {
        delay(1000);
        x++;
      }
    }
  }
  Wire.end();
  return ret;
}

void pwm_init() {
  pinMode(PWM_OUT_PIN, OUTPUT);    // digitalWrite(PWM_OUT_PIN, LOW);
  TCCR1A = bit(COM1A0);            // toggle OC1A on Compare Match
  TCCR1B = bit(WGM12) | bit(CS10); // CTC, scale to clock
  OCR1A = 1;                       // compare A register value (5000 * clock speed / 1024).When OCR1A == 1, PWM is 2MHz
}

// ---------------------- Log ---------------------------

void log_init() {
  Serial.begin(9600);
}

void log_out(const char *log) {
  Serial.println(log);
}

void log_out_num(const char *log, int num) {
  Serial.print(log);
  Serial.println(num);
}

void sensor_log() {

  log_out("");
  log_out_num("BAT ADC       :", bat_adc);
  log_out_num("BAT VOL       :", bat_vol);
  log_out_num("SOIL ADC      :", soil_adc);
  log_out_num("SOIL MOIST (%):", soil_percent);
  log_out_num("TEMPERAUTRE   :", (int)temperature);
  log_out_num("HUMIDITY      :", (int)humidity);
  log_out("");
}

THE SENSOR SKETCH
Caveat:

  1. Refer to post #11 for the I2C_AHT10 library necessary for this sketch.
  2. If previous sketch was used please beware the TTN payload formatter has been updated!
/*******************************************************************************
 * Copyright (c) 2015 Thomas Telkamp and Matthijs Kooijman
 * Copyright (c) 2018 Terry Moore, MCCI
 *
 * Permission is hereby granted, free of charge, to anyone
 * obtaining a copy of this document and accompanying files,
 * to do whatever they want with them without any restriction,
 * including, but not limited to, copying, modification and redistribution.
 * NO WARRANTY OF ANY KIND IS PROVIDED.
 *
 * This example sends a valid LoRaWAN packet with payload "Hello,
 * world!", using frequency and encryption settings matching those of
 * the The Things Network.
 *
 * This uses OTAA (Over-the-air activation), where where a DevEUI and
 * application key is configured, which are used in an over-the-air
 * activation procedure where a DevAddr and session keys are
 * assigned/generated for use with all further communication.
 *
 * Note: LoRaWAN per sub-band duty-cycle limitation is enforced (1% in
 * g1, 0.1% in g2), but not the TTN fair usage policy (which is probably
 * violated by this sketch when left running for longer)!

 * To use this sketch, first register your application and device with
 * the things network, to set or generate an AppEUI, DevEUI and AppKey.
 * Multiple devices can use the same AppEUI, but each device has its own
 * DevEUI and AppKey.
 * 
 *
 * Do not forget to define the radio type correctly in
 * arduino-lmic/project_config/lmic_project_config.h or from your BOARDS.txt.
 *
 *******************************************************************************/

 /*
 A3: Battery voltage      -> Not used. It is instead read via register configuration amd bandgap reference voltage. See ADMUX
 A2: Soil moisture signal -> Read via register configuration. See ADMUX
 D5: Control power for AHT10 (and Battery Voltage measurement via A3 although not used)
 */

// MCCI LoRaWAN LMIC library, Version: 4.0.0
#include <lmic.h>

// aht10 library, Date: 03-01-2020 
// https://github.com/Makerfabs/Project_IoT-Irrigation-System/tree/master/LoraTransmitterADCAHT10
#include "I2C_AHT10.h"

// Lightweight low power library for Arduino, Version: 1.81, Date: 21-01-2020 
#include <LowPower.h>

// standard libraries
#include <hal/hal.h>

# define sketchVersion "Tamadite_20230903"

AHT10 aht; 

// This EUI must be in little-endian format, so least-significant-byte
// first. When copying an EUI from ttnctl output, this means to reverse
// the bytes. For TTN issued EUIs the last bytes should be 0xD5, 0xB3,
// 0x70.
// APPEUI can be ceros
static const u1_t PROGMEM APPEUI[8]={ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
void os_getArtEui (u1_t* buf) { memcpy_P(buf, APPEUI, 8);}

// This should also be in little endian format, see above.
static const u1_t PROGMEM DEVEUI[8]={ 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB };
void os_getDevEui (u1_t* buf) { memcpy_P(buf, DEVEUI, 8);}

// This key should be in big endian format (or, since it is not really a
// number but a block of memory, endianness does not really apply). In
// practice, a key taken from ttnctl can be copied as-is.
static const u1_t PROGMEM APPKEY[16] = { 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB };
void os_getDevKey (u1_t* buf) {  memcpy_P(buf, APPKEY, 16);}

// payload to send to TTN gateway
static osjob_t sendjob;

// Schedule TX every this many seconds (might become longer due to duty
// cycle limitations).
const unsigned TX_INTERVAL = 1200;  // 1200sec = 20min

// sensors pin mapping
int sensorPin = A2;         // select the input pin for the potentiometer
int bat_vol = 0;
#define SENSOR_POWER_PIN 5     // AHT
// Sensor Calibration Parameters
#define SOIL_ADC_WATER 583    // Raw read when sensor probe submerged in water. See SOIL_ADC in serial monitor.
#define SOIL_ADC_AIR 888      // Raw read when sensor probe is on air. See SOIL_ADC in serial monitor.
#define SOIL_ADC_UNIT (100.0 / (SOIL_ADC_AIR - SOIL_ADC_WATER))
#define PWM_OUT_PIN 9

// RFM95 pin mapping
const lmic_pinmap lmic_pins = {
    .nss = 10,
    .rxtx = LMIC_UNUSED_PIN,
    .rst = 4,
    .dio = {2, 6, 7},
};

int InternalReferenceVoltage = 1101L;   // This value is crucial for accuracy. Refer to instructions on how to get it.

bool readSensorStatus = false;
int soil_adc = 0; // variable to store the value coming from the sensor
int soil_percent = 0;

float temperature = 0.0;
float humidity = 0.0;

void onEvent (ev_t ev) {
/*
EV_SCAN_TIMEOUT: 1
EV_BEACON_FOUND: 2
EV_BEACON_MISSED: 3
EV_BEACON_TRACKED: 4
EV_JOINING: 5
EV_JOINED: 6
EV_JOIN_FAILED: 8
EV_REJOIN_FAILED: 9
EV_TXCOMPLETE: 10
EV_LOST_TSYNC: 11
EV_RESET: 12
EV_RXCOMPLETE: 13
EV_LINK_DEAD: 14
EV_LINK_ALIVE: 15
EV_TXSTART: 17
EV_TXCANCELED: 18
EV_RXSTART: 19
EV_JOIN_TXCOMPLETE: 20
*/
  Serial.println(ev);
#if DEBUG_OUT_ENABLE
    Serial.print(os_getTime());
    Serial.print(": ");
#endif
  switch(ev) {

    case EV_TXCOMPLETE: //ev: 10
    //  Serial.println(F("EV_TXCOMPLETE"));
    //  Serial.println(F("EV_TXCOMPLETE (includes waiting for RX windows)"));
      if (LMIC.txrxFlags & TXRX_ACK)
        //  Serial.println(F("Received ack"));
        //  Schedule next transmission
        os_setTimedCallback(&sendjob, os_getTime()+sec2osticks(TX_INTERVAL), do_send);
      // Use library from https://github.com/rocketscream/Low-Power
      for (int i=0; i<int(TX_INTERVAL/8); i++) {
        // low power sleep mode
        LowPower.powerDown(SLEEP_8S, ADC_OFF, BOD_OFF);
       }
       do_send(&sendjob);
       break;
      
    case EV_JOINED: //ev: 6
      u4_t netid = 0;
      devaddr_t devaddr = 0;
      u1_t nwkKey[16];
      u1_t artKey[16];
      LMIC_getSessionKeys(&netid, &devaddr, nwkKey, artKey);
      LMIC_setLinkCheckMode(0);
      break;          

    default:
    //  Serial.print(F("Unknown event: "));
    //  Serial.println((unsigned) ev);
      break;
  }
}

void do_send(osjob_t* j) {

  uint8_t payload[8];             //payload for TX

  // Check if there is not a current TX/RX job running
  if (LMIC.opmode & OP_TXRXPEND) {
  //  Serial.println(F("OP_TXRXPEND, not sending"));
  } else {
    read_sensor();
    
    // prepare payload for TX
    // place the bytes into the payload
    payload[0] = lowByte(soil_percent);
    payload[1] = highByte(soil_percent);

    // note: this uses the sflt16 datum (https://github.com/mcci-catena/arduino-lmic#sflt16)
    // used range for mapping type float to int:  -1...+1, -> value/100
    uint16_t payloadTemp = 0;
    if (temperature != 0) 
      payloadTemp = LMIC_f2sflt16(temperature/100);
    // int -> bytes
    // place the bytes into the payload
    payload[2] = lowByte(payloadTemp);
    payload[3] = highByte(payloadTemp);
       
   // used range for mapping type float to int:  -1...+1, -> value/100
    uint16_t payloadHumid = 0;
    if(humidity !=0) 
      payloadHumid = LMIC_f2sflt16(humidity/100);
    // int -> bytes
    // place the bytes into the payload
    payload[4] = lowByte(payloadHumid);
    payload[5] = highByte(payloadHumid);   

    // int -> bytes
    // byte battLow = lowByte(bat_vol);
    // byte battHigh = highByte(bat_vol);
    payload[6] = lowByte(bat_vol);
    payload[7] = highByte(bat_vol);

    // Prepare upstream data transmission at the next possible time.
    LMIC_setTxData2(1, payload, sizeof(payload), 0);
    // Serial.println(F("Packet queued"));
  }
  // Next TX is scheduled after TX_COMPLETE event.
}

void read_sensor()  {
  // ADC initialization
  ADCSRA = _BV(ADEN) | _BV(ADPS1) | _BV(ADPS0);  // Enable and set prescaler division factor to 8. Division factor between the system clock frequency and the input clock to the ADC

  // ----- Read Soil Moisture -----
  // Soil sensor is on ADC2  
  pinMode(PWM_OUT_PIN, OUTPUT);                 // configuring signal to feed soil sensor

  TCCR1A = (1<<COM1A0) | (0<<COM1A1);           // toggle OC1A on Compare Match
  TCCR1B = bit(WGM12) | bit(CS10); // Set CTC mode (clear timer on compare match) making TOP OCR1A and immediate update. Scale to clock (no prescaling). Freq. to sensor is 2 MHz.
  OCR1A = 1;  
  ADCSRA |= (0<<ADEN);
  ADMUX = (0<<REFS1) | (1<<REFS0) | (0<<ADLAR) | (0<<MUX3) | (0<<MUX2) | (1<<MUX1) | (0<<MUX0);
  delay(100);  // Wait for stabilization of the soil sensor return signal. Based on oscilloscope, 12 ms is the minimum, 20 ms should be more than enough.
  
  // Start a conversion  
  ADCSRA |= _BV( ADSC );
  // Wait for it to complete
  while ( ! (ADCSRA & (1<<ADIF)) );
  soil_adc = ADC;

  TCCR1A = (1<<COM1A1) | (0<<COM1A0);     // Clear OC1A on compare match
  digitalWrite(9, LOW);                   // Secure signal is low

  // Scale the value
  soil_percent = (int)((SOIL_ADC_AIR - soil_adc) * SOIL_ADC_UNIT);
  if (soil_percent >= 100) {
    soil_percent = 100;
  }
  else if (soil_percent <= 0) {
    soil_percent = 0;
  }

  // ----- Read Vcc Voltage -----
  // REFS1 REFS0          --> 0 1, Use internal AVcc at AREF
  // MUX3 MUX2 MUX1 MUX0  --> Mux configuration:1110 for 1.1V (VBG: Voltage Bandgap)
  // Configuring ADMUX
  ADMUX = (0<<REFS1) | (1<<REFS0) | (0<<ADLAR) | (1<<MUX3) | (1<<MUX2) | (1<<MUX1) | (0<<MUX0);
  delay(10);  // Wait for stabilization. From the datasheet:  after setting the ACBG bit or enabling the ADC, the user must always allow the reference to start up before the output from the analog comparator or ADC is used. 
  
  // Start a conversion. As in datasheet, first value has to be discarded; we take the third value. This is only for AVcc at AREF.
  for (int i = 0; i < 3; i++) { 
    ADCSRA |= _BV( ADSC );
    // Wait for it to complete
    while( ( (ADCSRA & (1<<ADSC)) != 0 ) );
  }
  
  // Start conversion  
  bat_vol = (((InternalReferenceVoltage * 1024L) / ADC) + 5L) / 10L;

  // ----- Read AHT10 -----
  read_AHT10(); 
}

bool read_AHT10() {
  bool acquired = false;
  digitalWrite(SENSOR_POWER_PIN, HIGH);   // Power on AHT10
  delay(20);                              // Manufacture notice: Wait 20ms, at most, to enter idle stat

  if (aht.begin() == false) {
  //  Serial.println("AHT10 not detected. Please check wiring. Freezing.");
  }
  else {
    int x = 0;
    while (x < 10 && !acquired) {
      if (aht.available() == true) {
        temperature = aht.getTemperature();
        humidity = aht.getHumidity();
        acquired = true;
        // Serial.println("AHT10 Read Success.");
      }
      else {
        delay(50);
        x++;
      }
    }
  }
  digitalWrite(SENSOR_POWER_PIN, LOW);   // Power off ATH10 
  return acquired;
}

void setup() {
    Serial.begin(9600);
    Serial.print(F("Sketch version: "));
    Serial.println(sketchVersion);

    Wire.begin(); //Join I2C bus
    pinMode(SENSOR_POWER_PIN, OUTPUT);   // configuring power on/off switching pin
    
    // LMIC init
    os_init();
    // Reset the MAC state. Session and pending data transfers will be discarded.
    LMIC_reset();

    LMIC_setClockError(MAX_CLOCK_ERROR * 1 / 100);
    // Start job (sending automatically starts OTAA too)
    do_send(&sendjob);
}

void loop() {
  os_runloop_once();
}

LoRaWAN PAYLOAD FORMAT
Custom Javascript formatter

// TTNV3 Payload Formatter Uplink V0.1
function decodeUplink(input) {

if ((input.fPort > 0) && (input.fPort < 223))
{
  var decodedTemp = 0;
  var decodedHumi = 0;
  var decodedBatt = 0;

// separate raw data from payload
  var rawSoil = input.bytes[0] + input.bytes[1] * 256;
  var rawTemp = input.bytes[2] + input.bytes[3] * 256;
  var rawHumi = input.bytes[4] + input.bytes[5] * 256;
  var rawBatt = input.bytes[6] + input.bytes[7] * 256;

// decode raw data to values
  decodedTemp = sflt162f(rawTemp) * 100; // value calculated to range -1..x..+1 by dividing /100
  decodedHumi =  sflt162f(rawHumi) * 100; // value calculated to range -1..x..+1 by dividing /100
  if (rawBatt !== 0) decodedBatt =   rawBatt / 100; // battery voltage is transmitted in mV, recalculate in V

// definition of the decimal places
  decodedTemp = decodedTemp.toFixed(2);
  decodedHumi = decodedHumi.toFixed(2);
  decodedBatt = decodedBatt.toFixed(2);

// return values
  return {
    data: {
      field1: rawSoil,
      field2: decodedTemp,
      field3: decodedHumi,
      field4: decodedBatt
          },
    warnings: [],
    errors: []
  };
}
else {
    return {
      data: {},
      warnings: [],
      errors: ["Invalid data received"]
    };
  
}
}

function sflt162f(rawSflt16)
	{
	// rawSflt16 is the 2-byte number decoded from wherever;
	// it's in range 0..0xFFFF
	// bit 15 is the sign bit
	// bits 14..11 are the exponent
	// bits 10..0 are the the mantissa. Unlike IEEE format, 
	// 	the msb is transmitted; this means that numbers
	//	might not be normalized, but makes coding for
	//	underflow easier.
	// As with IEEE format, negative zero is possible, so
	// we special-case that in hopes that JavaScript will
	// also cooperate.
	//
	// The result is a number in the open interval (-1.0, 1.0);
	// 
	
	// throw away high bits for repeatability.
	rawSflt16 &= 0xFFFF;

	// special case minus zero:
	if (rawSflt16 == 0x8000)
		return -0.0;

	// extract the sign.
	var sSign = ((rawSflt16 & 0x8000) !== 0) ? -1 : 1;
	
	// extract the exponent
	var exp1 = (rawSflt16 >> 11) & 0xF;

	// extract the "mantissa" (the fractional part)
	var mant1 = (rawSflt16 & 0x7FF) / 2048.0;

	// convert back to a floating point number. We hope 
	// that Math.pow(2, k) is handled efficiently by
	// the JS interpreter! If this is time critical code,
	// you can replace by a suitable shift and divide.
	var f_unscaled = sSign * mant1 * Math.pow(2, exp1 - 15);

	return f_unscaled;
	}

continues on post#11

2 Likes

Hi, May I ask what LoRa WAN Gateway did you use?
Thanks.

As you well guess, unfortunately I do not have any open LoRaWAN close enough where I live so I had to get my own one. I decided to go and build my own one based on the link posted on the FAQ-#2 (just updated because the page got moved)

I have been thinking of building a new one but due to the scarce of R-Pis I think that I would go for a R-Pi Zero approach, see here: GitHub - OpenIotNetwork/ic880a-adapter-raspberry: Adapter board to connect IMST ic880a to Raspberry Pi + Zero as Open Hardware

Beautiful, exactly what I want to build, could you tell me how you managed to register the sensor on TTN?
Do you register the device as an arduino?
thank you very much if you can spare me some time.

Not sure what you mean registering a device as an arduino, I haven’t come across that.

Hi,
Thanks for your work!
Trying to get my sensors working, but after flashing them the serial monitor doesn`t write any activity. No idea what i´m doing wrong. Board and prozessor are set right, flashing works fine. Also reloaded the bootloader but still not working.

Thanks for your help!

Is the IDE serial monitor set to “9600 baud”?

I’ll try! Thanks a lot!

I have been able to get most of your description done but my compiler keeps giving me an error based on the lmic pins. Do you get this as well or did you manage to get rid of this?

Copying and pasting the sketch I published above do not return any lmic pin error however, it seems I have updated my libraries since the compiled sketch now takes 102% of the program storage. I think it took 97% by the time I published it with the libraries I had then. I was lucky enough to run it stable. Actually, one of the sensors has been running continuously ever since.

I have tweaked the sketch so it takes 85% although I am still testing it. I could pass it onto you so you can try it if you want.

My plan is to publish it inhere once I feel confident with it.

Update: new sketch published on September 16th 2023

resume from post #1

I2C AHT LIBRARY
Caveat: The below instructions assume you are using the Arduino IDE.

i2c_aht10.cpp
Create a new tab and call it I2C_AHT10.cpp. Paste the following in it.

/****************************************************************

 ***************************************************************/

#include "I2C_AHT10.h"

/*--------------------------- Device Status ------------------------------*/
bool AHT10::begin(TwoWire &wirePort)
{
    _i2cPort = &wirePort; //Grab the port the user wants to communicate on

    _deviceAddress = AHT10_DEFAULT_ADDRESS; //We had hoped the AHT10 would support two addresses but it doesn't seem to

    if (isConnected() == false)
        return false;

    //Wait 40 ms after power-on before reading temp or humidity. Datasheet pg 8
    delay(40);

    //Check if the calibrated bit is set. If not, init the sensor.
    if (isCalibrated() == false)
    {
        //Send 0xBE0800
        initialize();

        //Immediately trigger a measurement. Send 0xAC3300
        triggerMeasurement();

        delay(75); //Wait for measurement to complete

        uint8_t counter = 0;
        while (isBusy())
        {
            delay(1);
            if (counter++ > 100)
                return (false); //Give up after 100ms
        }

        //This calibration sequence is not completely proven. It's not clear how and when the cal bit clears
        //This seems to work but it's not easily testable
        if (isCalibrated() == false)
        {
            return (false);
        }
    }

    //Check that the cal bit has been set
    if (isCalibrated() == false)
        return false;

    //Mark all datums as fresh (not read before)
    sensorQueried.temperature = true;
    sensorQueried.humidity = true;

    return true;
}

//Ping the AHT10's I2C address
//If we get a response, we are correctly communicating with the AHT10
bool AHT10::isConnected()
{
    _i2cPort->beginTransmission(_deviceAddress);
    if (_i2cPort->endTransmission() == 0)
        return true;

    //If IC failed to respond, give it 20ms more for Power On Startup
    //Datasheet pg 7
    delay(20);

    _i2cPort->beginTransmission(_deviceAddress);
    if (_i2cPort->endTransmission() == 0)
        return true;

    return false;
}

/*------------------------ Measurement Helpers ---------------------------*/

uint8_t AHT10::getStatus()
{
    _i2cPort->requestFrom(_deviceAddress, (uint8_t)1);
    if (_i2cPort->available())
        return (_i2cPort->read());
    return (0);
}

//Returns the state of the cal bit in the status byte
bool AHT10::isCalibrated()
{
    return (getStatus() & (1 << 3));
}

//Returns the state of the busy bit in the status byte
bool AHT10::isBusy()
{
    return (getStatus() & (1 << 7));
}

bool AHT10::initialize()
{
    _i2cPort->beginTransmission(_deviceAddress);
    _i2cPort->write(sfe_aht10_reg_initialize);
    _i2cPort->write(0x80);
    _i2cPort->write(0x00);
    if (_i2cPort->endTransmission() == 0)
        return true;
    return false;
}

bool AHT10::triggerMeasurement()
{
    _i2cPort->beginTransmission(_deviceAddress);
    _i2cPort->write(sfe_aht10_reg_measure);
    _i2cPort->write(0x33);
    _i2cPort->write(0x00);
    if (_i2cPort->endTransmission() == 0)
        return true;
    return false;
}

//Loads the
void AHT10::readData()
{
    //Clear previous data
    sensorData.temperature = 0;
    sensorData.humidity = 0;

    if (_i2cPort->requestFrom(_deviceAddress, (uint8_t)6) > 0)
    {
        uint8_t state = _i2cPort->read();

        uint32_t incoming = 0;
        incoming |= (uint32_t)_i2cPort->read() << (8 * 2);
        incoming |= (uint32_t)_i2cPort->read() << (8 * 1);
        uint8_t midByte = _i2cPort->read();

        incoming |= midByte;
        sensorData.humidity = incoming >> 4;

        sensorData.temperature = (uint32_t)midByte << (8 * 2);
        sensorData.temperature |= (uint32_t)_i2cPort->read() << (8 * 1);
        sensorData.temperature |= (uint32_t)_i2cPort->read() << (8 * 0);

        //Need to get rid of data in bits > 20
        sensorData.temperature = sensorData.temperature & ~(0xFFF00000);

        //Mark data as fresh
        sensorQueried.temperature = false;
        sensorQueried.humidity = false;
    }
}

//Triggers a measurement if one has not been previously started, then returns false
//If measurement has been started, checks to see if complete.
//If not complete, returns false
//If complete, readData(), mark measurement as not started, return true
bool AHT10::available()
{
    if (measurementStarted == false)
    {
        triggerMeasurement();
        measurementStarted = true;
        return (false);
    }

    if (isBusy() == true)
    {
        return (false);
    }

    readData();
    measurementStarted = false;
    return (true);
}

bool AHT10::softReset()
{
    _i2cPort->beginTransmission(_deviceAddress);
    _i2cPort->write(sfe_aht10_reg_reset);
    if (_i2cPort->endTransmission() == 0)
        return true;
    return false;
}

/*------------------------- Make Measurements ----------------------------*/

float AHT10::getTemperature()
{
    if (sensorQueried.temperature == true)
    {
        //We've got old data so trigger new measurement
        triggerMeasurement();

        delay(75); //Wait for measurement to complete

        uint8_t counter = 0;
        while (isBusy())
        {
            delay(1);
            if (counter++ > 100)
                return (false); //Give up after 100ms
        }

        readData();
    }

    //From datasheet pg 8
    float tempCelsius = ((float)sensorData.temperature / 1048576) * 200 - 50;

    //Mark data as old
    sensorQueried.temperature = true;

    return tempCelsius;
}

float AHT10::getHumidity()
{
    if (sensorQueried.humidity == true)
    {
        //We've got old data so trigger new measurement
        triggerMeasurement();

        delay(75); //Wait for measurement to complete

        uint8_t counter = 0;
        while (isBusy())
        {
            delay(1);
            if (counter++ > 100)
                return (false); //Give up after 100ms
        }

        readData();
    }

    //From datasheet pg 8
    float relHumidity = ((float)sensorData.humidity / 1048576) * 100;

    //Mark data as old
    sensorQueried.humidity = true;

    return relHumidity;
}

i2c_AHT10.h
Create a new tab and call it I2C_AHT10.h. Paste the following in it.

/****************************************************************
 * 
 ******************************************************************/

#ifndef __I2C_AHT10_H__
#define __I2C_AHT10_H__

#include <Arduino.h>
#include <Wire.h>

#define AHT10_DEFAULT_ADDRESS 0x38

enum registers
{
    sfe_aht10_reg_reset = 0xBA,
    sfe_aht10_reg_initialize = 0xBE,
    sfe_aht10_reg_measure = 0xAC,
};

class AHT10
{
private:
    TwoWire *_i2cPort; //The generic connection to user's chosen I2C hardware
    uint8_t _deviceAddress;
    bool measurementStarted = false;

    struct
    {
        uint32_t humidity;
        uint32_t temperature;
    } sensorData;

    struct
    {
        uint8_t temperature : 1;
        uint8_t humidity : 1;
    } sensorQueried;

public:
    //Device status
    bool begin(TwoWire &wirePort = Wire); //Sets the address of the device and opens the I2C bus
    bool isConnected();                   //Checks if the AHT10 is connected to the I2C bus
    bool available();                     //Returns true if new data is available

    //Measurement helper functions
    uint8_t getStatus();       //Returns the status byte
    bool isCalibrated();       //Returns true if the cal bit is set, false otherwise
    bool isBusy();             //Returns true if the busy bit is set, false otherwise
    bool initialize();         //Initialize for taking measurement
    bool triggerMeasurement(); //Trigger the AHT10 to take a measurement
    void readData();           //Read and parse the 6 bytes of data into raw humidity and temp
    bool softReset();          //Restart the sensor system without turning power off and on

    //Make measurements
    float getTemperature(); //Goes through the measurement sequence and returns temperature in degrees celcius
    float getHumidity();    //Goes through the measurement sequence and returns humidity in % RH
};
#endif

WORTHY OF NOTE

  1. If you order a soil moisture LoRa sensor from Makerfabs, I highly recommend you ordering theirs CP2104 USB to Serial Converter Arduino Programmer (CP2104 USB to Serial Converter Arduino Programmer | Makerfabs). This is unless you have a CH340G or similar at hand. Just note that it may not have the DTR pin/signal. In that case you need to reset the device once the Arduino IDE goes into uploading mode after compilation.
    You also might need to cross TX/RX connection between devices that’s, RX on the sensor to TX on the USB device and TX on the sensor to RX on the USB device.
  2. Follow the sketch uploading instructions found on Makerfabs wiki (Lora Soil Moisture Sensor V3 - MakerFabsWiki)
  3. Advantage of using a water tank:
    a. Limit possible water damage to the content of the tank.
    b. The water can be enriched with fertilizers.
  4. Water tank with water valve: I experienced water pressure problems at the end of the watering line caused by diverse circumstances. The lack of water pressure was not observed if no valve was used that is, having the watering hose connected directly to the water tank therefore, a water pump is used instead.

FAQ

  1. Why don’t you post the sensor code in GitHub?
    That’s a good question… next question.

  2. Why don’t you explain how you build the LoRaWAN gateway?
    Reference: Building a gateway with Raspberry Pi and IC880A | The Things Stack for LoRaWAN

  3. Would you recommend Makerfabs and/or this LoRa Soil Moisture product?
    Based on my personal experience, yes.

  4. Would you suggest any improvement to this Makerfabs LoRa Soil Moisture product?
    I don’t want to spin out on this topic because the product is very affordable and perfectly fit for purpose for aficionados and hobbyists however, I wonder if there is a possible “no cost” improvement of the PCB capacitance of the sensor by using the design published by acolomitchi here: https://www.instructables.com/Automatic-Watering-System-With-Capacitive-Probe-an/
    Update Jan-2023: The circuit shows it would not make sense changing the design of the sensor as it attends to voltage (analog DC voltage), not to changes in frequency. IMG_0312
    D9: 2 MHz input
    A2: Analog output

APPENDIX #1: Measuring bandgap voltage for accurate battery voltage measurement
DISCLAIMER
• This step is not strictly necessary and, if not properly done, can worsen the results.
• The usage of a well calibrated multimeter is a must otherwise worsen results are to be provided.

BACKGROUND
The ATmega328 bandgap reference voltage is expected to be 1.1v however unexpected small variations may happen from chip to chip. The guide here provides instructions to find the bandgap reference value of your chip and so to configure the sketch accordingly.

Bandgap Voltage Sketch. Directions
1 Load the following sketch in the sensor
2 Make use of a multimeter and measure voltage on pin 20 of the ATmega328 processor with reference to ground.
3 Take note of value and multiply it by 1000, e.g. 1.101v would be 1101
4 Set this value in variable InternalReferenceVoltage in the calibration sketch and upload the sketch to the sensor.
5 Check “BAT VOL” value accuracy.
6 If satisfied, set this same value in the same variable found in the main sketch.

void setup( void ) {
  Serial.begin(9600);
  Serial.println("\r\n\r\n");
  analogReference(INTERNAL);
}

void loop( void ) {
  Serial.println(analogRead(0));
  delay(1000);
}

APPENDIX #2: Testing another probe on Makerfabs sensor
Even when the resolution of the original probe provides a downscaling of 3:1 I wondered how a change (improvement?) of its design could affect the accuracy of the device. For this, I made use of the design provided by acolomitchi. I cut the original probe off the sensor and glued and solder the new one:

I found out the downscaling improved to 4:1.

My conclusion based on my own observations is:

  • This design could be useful if high resolution is needed.
  • I observe the values are more stable (less fluctuation) than with the other sensors (probe).
  • The current Makerfabs probe design is just fine.

APPENDIX #3: Recover a bricked sensor
DISCLAIMER

  • The information herein is provided as-is that means “I got it to work following the below directions and so no comprehensive documentation about drivers, versions, etc. are provided”.
  • These directions shall be read as “a possible way to solve a situation” rather than “the way to solve the problem I have”.
  • The below procedure makes use of an USBasp programmer.

ACKNOWLEDGMENT
I want to thank Makerfab for providing me with the bootloader file as well as the fuse configuration. Without Makerfabs help this appendix would not be possible as well as I would have an inoperative sensor at home.

DIRECTIONS
0) Solder a six pin connector to the sensor as shown in the picture and plug the USBasp device to it.
Connector:


USBasp:

  1. Install avrdude
  2. Download file bootloader.rar from here: Dropbox - bootloader.rar - Simplify your life
  3. Open the command prompt and execute the following commands:

Configure Low fuse

avrdude -c usbasp-clone -p m328p -U lfuse:w:0xFF:m

Configure High fuse

avrdude -c usbasp-clone -p m328p -U hfuse:w:0xDA:m

Configure Extended fuse

avrdude -c usbasp-clone -p m328p -U efuse:w:0xFE:m

Flash bootloader

avrdude -c usbasp -p m328p -U flash:w:m328p.hex:i  

m328p.hex is the name of the bootloader file that should be on same folder as avrdude.exe

Check status

avrdude -c usbasp -p m328p -b 19200 -v

BRICKED. HOW IT HAPPENED
Under the Worthy of Note section above I wrote: I highly recommend you ordering theirs CP2104 USB to Serial Converter Arduino Programmer (CP2104 USB to Serial Converter Arduino Programmer | Makerfabs 8). This is unless you have a CH340G […] In that case you need to reset the device once the Arduino IDE goes into uploading mode after compilation… well, I make use of a CH340G. I got it bricked because I pressed the reset button one too many times at the wrong moment, just during initial negotiation period. Moral of the story: either to press&hold the reset button before compiling and then release it when Uploading... shows up or even better, buy Makerfabs CP2104 USB to Serial Converter Arduino Programmer, then you don’t need to hassle with the reset button procedure.

APPENDIX #4: Battery & Voltage Note
The ATmega328p operating voltage goes from 2.7v to 5.5v. This means the following:

  1. AAA NiMH (1.2v) batteries should not be used. The device would work but the soil humidity value would go up around 17 percent points (too low voltage).
  2. Alkaline batteries should be replaced when the supplied voltage (both batteries) is 2.7v. This means, replacing the batteries when the cell voltage is about 1.35v that is, circa 30% of the battery capacity has been used.
  3. Using three AAA alkaline batteries, as in the “red” LoRaWAN model, would lead to replacing the batteries when the cell voltage is 0.9v (0.9 x 3 = 2.7v). An alkaline cell is considered to be depleted when its voltage is 1.0v.

Hi, i try to use the lorawan soil moisture sensor with millesight ug67 gateway. The gateway recive the package of information, but i don’t undestenda how to interparetate it. Do you have same information or sameone have try with this gateway before?

I myself never pursued LoRaWAN, so I don’t really know.

The Lorawan packet is correctly sent and received on the gateway, the problem is that this packet consists of a 14 character hexadecimal number, of which
the first is added by the gateway’s Milesight software.
To correctly read the packet and send it to other devices in Modbus TCP or RTU, we need to remove this first character in order to read the
bytes correctly because if we develop the payload to read individual bytes, the initial character offsets all the others.

I’ve just bought exactly the same sensor myself, and plan to test it this weekend.

After your gateway, what’s your network server? Are you using TheThingsNetwork? If so, can you not edit the payload formatter to tell it to ignore the bytes from your gateway? (The suggested code for the payload formatter is in the wiki: LoRaWAN Temperature/ Humidity Soil Moisture Sensor - Makerfabs Wiki)

PS. For others who may stumble on this thread:

I initially had some trouble uploading the sketches. I was using a CP2012 board for the programming, rather than the recommended CP2014, and received errors that “avrdude: stk500_getsync(): not in sync: resp=0x00”, which I understand is a fairly generic Arduino error. For me the solution was about timing: I had to hold down the reset button on the sensor, click ‘Upload’ on the Arduino IDE window, and then release the reset button at the exact moment that it said ‘Uploading’, rather than compiling. (I think the recommended CP2014 programming module resolves this, as the OP suggests, but I didn’t have one to hand).

I’m not sure how relevant it is, but FWIW, I’m using TheThingsStack and set the parameters to Lorawan 1.0.3 and SF9.

In addition, it may be worth noting that, before I uploaded the sketch, the device reported various Lorawan data (DevEui, AppEui, AppKey) when connected via the programming board to Arduino IDE, but this was a bit of a red herring: I initially attempted to create a new device within TTN/TTS using this, but it was not successful, nor was any Join attempt reported via the IDE.

OK, I think I was encountering the same issue as @Gaserma, or a similar one: the data was received by the gateway and forwarded to TTN stack, but somewhat garbled – although when it was connected via serial cable, the output looked correct.

IMO the issue is not a problem with the gateway, but an error in the moisture_lorawan.ino sketch (as per the Github repository, a week prior to this post), in line ~283, where the Lorawan package is defined using:

sprintf(data_str, "%02x%02x%02x%02x%04x", (int)temperature, (int)humidity, soil_adc, bat_vol, tx_count)

The problem, I think, is that the “%02x” code tells the system to format the decimal integers as at least two hex characters (with the exception of the transaction count, which is allocated 4). However, soil_adc value often runs to into the 700-800 range in decimal, which requires three hex characters. And then because the system (either sensor or receiver, I’m not sure) expects pairs of characters, so that the message is always an even number of hex characters, an extra 0 was sometimes being added to the front of the string.

My solution was as follows:

Using Arduino IDE, open the moisture_lorawan.ino sketch and edit the line which defines the data string. Change “%02x%02x%02x%02x%04x” to “%02x%02x%04x%02x%04x”, so that it allocates 4 characters to the ADC variable, rather than 2. Then do Sketch>Upload (remembering to hold down the reset button on the sensor until the Arduino IDE changes from ‘compiling’ to ‘uploading’, if you’re not using the recommended CP2104 serial-to-usb module, else the sensor won’t properly receive the data). If using TTN, you probably also need to go to Device > Payload Formatters and edit the Formatter code so that it now recognises that the moisture data is two bytes, not one. I edited it with the following (since the first two digits will be 16x16=256 times larger than the second):

var adc = ((input.bytes[2]*256) + input.bytes[3])

Hope this helps someone!