DISCLAIMER
- Makerfabs LoRa Soil moisture sensor is full Open Source (What Upgraded on Lora Soil Moisture Sensor V3?). All hardware and software open at GitHub.
- AG March (LORAWAN SOIL MOISTURE SENSOR | Hackaday.io) is credited for adapting Makerfabs code to LoRaWAN.
ASSUMPTIONS
- You are familiarized with The Things Network (https://www.thethingsnetwork.org/).
- You know how to create an application and devices in The Things Network (TTN).
- You have a LoRaWAN gateway in the nearby (or you have your own).
- You know how to upload a sketch into Arduino or similar microprocessor.
- You have HACS installed on your Home Assistant and the HACS TTN v3 adapter installed.
- You have followed the instructions on how to configure HACS TTN v3 adapter to receive data from TTN.
- You have a Zigbee setup with a power control outlet, e.g IKEA TrĂĽdfri Control Outlet.
CAVEAT
- 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
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
THE SENSOR SKETCH
/*******************************************************************************
* 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.
*
*******************************************************************************/
// 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 <Wire.h>
#include <hal/hal.h>
#include <SPI.h>
# define SKETCH_VERSION "Tamadite: 2022July27_1"
AHT10 humiditySensor;
//
// For normal use, we require that you edit the sketch to replace FILLMEIN
// with values assigned by the TTN console. However, for regression tests,
// we want to be able to compile these scripts. The regression tests define
// COMPILE_REGRESSION_TEST, and in that case we define FILLMEIN to a non-
// working but innocuous value.
//
#ifdef COMPILE_REGRESSION_TEST
# define FILLMEIN 0
#else
# warning "You must replace the values marked FILLMEIN with real values from the TTN control panel!"
# define FILLMEIN (#dont edit this, edit the lines that use FILLMEIN)
#endif
// 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 left with ceros
static const u1_t PROGMEM APPEUI[8]={ 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0 };
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]={ TTN_DEVEUI };
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] = { TTN_APPKEY };
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;
// sensors pin mapping
int sensorPin = A2; // select the input pin for the potentiometer
int sensorPowerCtrlPin = 5; // select control pin for switching VCC (sensors)
#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},
};
// switch VCC on (sensors on)
void sensorPowerOn(void)
{
digitalWrite(sensorPowerCtrlPin, HIGH);//Sensor power on
}
// switch VCC off (sensor off)
void sensorPowerOff(void)
{
digitalWrite(sensorPowerCtrlPin, LOW);//Sensor power off
}
void printHex2(unsigned v) {
v &= 0xff;
if (v < 16)
Serial.print('0');
Serial.print(v, HEX);
}
void onEvent (ev_t ev) {
Serial.print(os_getTime());
Serial.print(": ");
switch(ev) {
case EV_SCAN_TIMEOUT:
Serial.println(F("EV_SCAN_TIMEOUT"));
break;
case EV_BEACON_FOUND:
Serial.println(F("EV_BEACON_FOUND"));
break;
case EV_BEACON_MISSED:
Serial.println(F("EV_BEACON_MISSED"));
break;
case EV_BEACON_TRACKED:
Serial.println(F("EV_BEACON_TRACKED"));
break;
case EV_JOINING:
Serial.println(F("EV_JOINING"));
break;
case EV_JOINED:
Serial.println(F("EV_JOINED"));
{
u4_t netid = 0;
devaddr_t devaddr = 0;
u1_t nwkKey[16];
u1_t artKey[16];
LMIC_getSessionKeys(&netid, &devaddr, nwkKey, artKey);
Serial.print("netid: ");
Serial.println(netid, DEC);
Serial.print("devaddr: ");
Serial.println(devaddr, HEX);
Serial.print("AppSKey: ");
for (size_t i=0; i<sizeof(artKey); ++i) {
if (i != 0)
Serial.print("-");
printHex2(artKey[i]);
}
Serial.println("");
Serial.print("NwkSKey: ");
for (size_t i=0; i<sizeof(nwkKey); ++i) {
if (i != 0)
Serial.print("-");
printHex2(nwkKey[i]);
}
Serial.println();
}
// Disable link check validation (automatically enabled
// during join, but because slow data rates change max TX
// size, we don't use it in this example.
LMIC_setLinkCheckMode(0);
break;
/*
|| This event is defined but not used in the code. No
|| point in wasting codespace on it.
||
|| case EV_RFU1:
|| Serial.println(F("EV_RFU1"));
|| break;
*/
case EV_JOIN_FAILED:
Serial.println(F("EV_JOIN_FAILED"));
break;
case EV_REJOIN_FAILED:
Serial.println(F("EV_REJOIN_FAILED"));
break;
case EV_TXCOMPLETE:
Serial.println(F("EV_TXCOMPLETE (includes waiting for RX windows)"));
if (LMIC.txrxFlags & TXRX_ACK)
Serial.println(F("Received ack"));
if (LMIC.dataLen) {
Serial.print(F("Received "));
Serial.print(LMIC.dataLen);
Serial.println(F(" bytes of payload"));
}
// 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_LOST_TSYNC:
Serial.println(F("EV_LOST_TSYNC"));
break;
case EV_RESET:
Serial.println(F("EV_RESET"));
break;
case EV_RXCOMPLETE:
// data received in ping slot
Serial.println(F("EV_RXCOMPLETE"));
break;
case EV_LINK_DEAD:
Serial.println(F("EV_LINK_DEAD"));
break;
case EV_LINK_ALIVE:
Serial.println(F("EV_LINK_ALIVE"));
break;
/*
|| This event is defined but not used in the code. No
|| point in wasting codespace on it.
||
|| case EV_SCAN_FOUND:
|| Serial.println(F("EV_SCAN_FOUND"));
|| break;
*/
case EV_TXSTART:
Serial.println(F("EV_TXSTART"));
break;
case EV_TXCANCELED:
Serial.println(F("EV_TXCANCELED"));
break;
case EV_RXSTART:
/* do not print anything -- it wrecks timing */
break;
case EV_JOIN_TXCOMPLETE:
Serial.println(F("EV_JOIN_TXCOMPLETE: no JoinAccept"));
break;
default:
Serial.print(F("Unknown event: "));
Serial.println((unsigned) ev);
break;
}
}
void do_send(osjob_t* j){
float temperature = 0.0; //temperature
float humidity = 0.0; //humidity
int soilmoisturepercent=0; //spoil moisture humidity
uint8_t payload[8]; //payload for TX
int AirValue = 880; //capacitive sensor in the value (maximum value)
int WaterValue = 560; //capacitive sensor in water value (minimum value)
int sensorValue = 0; //capacitive sensor
int x = 0;
int ADC_O_1; // ADC Output First 8 bits
int ADC_O_2; // ADC Output Next 2 bits
// Check if there is not a current TX/RX job running
if (LMIC.opmode & OP_TXRXPEND) {
Serial.println(F("OP_TXRXPEND, not sending"));
} else {
// ------------------------------
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
//ADC2 AVCC as reference voltage
ADMUX = _BV(REFS0) | _BV(MUX1);
//ADC2 internal 1.1V as ADC reference voltage
//ADMUX = _BV(REFS1) |_BV(REFS0) | _BV(MUX1);
// 8 ĺé˘
ADCSRA = _BV(ADEN) | _BV(ADPS1) | _BV(ADPS0);
// ------------------------------
// read capacitive sensor value
sensorPowerOn();//
delay(100);
for (int i = 0; i < 3; i++)
{
//start ADC conversion
ADCSRA |= (1 << ADSC);
delay(10);
if ((ADCSRA & 0x40) == 0)
{
ADC_O_1 = ADCL;
ADC_O_2 = ADCH;
sensorValue = (ADC_O_2 << 8) + ADC_O_1;
ADCSRA |= 0x40;
#if DEBUG_OUT_ENABLE
Serial.print("ADC:");
Serial.println(sensorValue);
#endif
//e if (readSensorStatus == false)
//e readSensorStatus = AHT_init();
}
ADCSRA |= (1 << ADIF); //reset as required
delay(50);
}
//e sensorValue = analogRead(sensorPin);
delay(200);
// measure voltage by band gap voltage
unsigned int getVDD = 0;
// set the reference to Vcc and the measurement to the internal 1.1V reference
while (((getVDD == 0)&&(x<=10)) || isnan(getVDD)){
x++;
ADMUX = (1<<REFS0) | (1<<MUX3) | (1<<MUX2) | (1<<MUX1);
delay(50); // Wait for Vref to settle
ADCSRA |= (1<<ADSC); // Start conversion
while (bit_is_set(ADCSRA,ADSC)); // wait until done
getVDD = ADC; // Vcc in millivolts
// mcu dependend calibration
}
getVDD = 1122475UL / (unsigned long)getVDD; //1126400 = 1.1*1024*1000
sensorPowerOff();
delay(100);
sensorPowerOn();
delay(300);
// Get the new temperature and humidity value
while ((humiditySensor.available() == false) && (x<10))
{
x++;
delay(300);
}
temperature = humiditySensor.getTemperature();
humidity = humiditySensor.getHumidity();
if (humidity == 0) Serial.println(F("Failed to read from AHT sensor (zero values)!"));
// Check if any reads failed and exit early (to try again).
if (isnan(humidity) || isnan(temperature)) {
Serial.println(F("Failed to read from AHT sensor (value NaN)!"));
temperature=0.0;
humidity=0.0;
}
soilmoisturepercent = map(sensorValue, AirValue, WaterValue, 0, 100);
if(soilmoisturepercent >= 100)
{
soilmoisturepercent=100;
}
else if(soilmoisturepercent <=0)
{
soilmoisturepercent=0;
}
// measurement completed, power down sensors
sensorPowerOff();
//Print the results
Serial.print(F("Temperature: "));
Serial.print(temperature, 2);
Serial.print(F(" C\t"));
Serial.print(F("Humidity: "));
Serial.print(humidity, 2);
Serial.println(F("% RH\t"));
Serial.print(F("Voltage: "));
Serial.print(getVDD);
Serial.println(F("mV \t"));
Serial.print(F("Moisture ADC : "));
Serial.print(soilmoisturepercent);
Serial.println(F("% \t"));
Serial.print(F("Moisture (raw): "));
Serial.print(sensorValue);
Serial.println(F(" \t"));
// prepare payload for TX
byte csmLow = lowByte(soilmoisturepercent);
byte csmHigh = highByte(soilmoisturepercent);
// place the bytes into the payload
payload[0] = csmLow;
payload[1] = csmHigh;
// float -> int
// 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
byte tempLow = lowByte(payloadTemp);
byte tempHigh = highByte(payloadTemp);
// place the bytes into the payload
payload[2] = tempLow;
payload[3] = tempHigh;
// 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
byte humidLow = lowByte(payloadHumid);
byte humidHigh = highByte(payloadHumid);
payload[4] = humidLow;
payload[5] = humidHigh;
// int -> bytes
byte battLow = lowByte(getVDD);
byte battHigh = highByte(getVDD);
payload[6] = battLow;
payload[7] = battHigh;
// 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 setup() {
Serial.begin(9600);
Serial.println(F("Starting"));
Serial.print(F("Sketch version: "));
Serial.println("SKETCH_VERSION");
// set control pin for VCC as Output
pinMode(sensorPowerCtrlPin, OUTPUT);
sensorPowerOn();
delay(200);
Wire.begin(); //Join I2C bus
//Check if the AHT10 will acknowledge
if (humiditySensor.begin() == false)
{
Serial.println(F("AHT10 not detected. Please check wiring. Freezing."));
//while (1);
}
else
Serial.println(F("AHT10 acknowledged."));
// 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();
}
My notes about the sketch:
- Yes, the code is in need of serious cosmetic arrangements, a sort of âBetty la feaâ right now.
- Please feel free to improve it and enligt us with your wiseness!
- Mind for the LoRa frequency in your region.
- Mind for file
20210715 lmic_project_config.h
available on AG March page.
// project-specific definitions
#define CFG_eu868 1
//define CFG_us915 1
//#define CFG_au915 1
//#define CFG_as923 1
// #define LMIC_COUNTRY_CODE LMIC_COUNTRY_CODE_JP /* for as923-JP */
//#define CFG_kr920 1
//#define CFG_in866 1
#define CFG_sx1276_radio 1
//#define LMIC_USE_INTERRUPTS
#define DISABLE_PING
#define DISABLE_BEACONS
#define LMIC_DEBUG_LEVEL 0
#define USE_IDEETRON_AES
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;
// seperate 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 / 1000; // batterie voltage ist 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;
}
WORTHY OF NOTE
- If you order a soil moisture LoRa sensor from Makerfabs, I highly recommend you order 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. - Follow the sketch uploading instructions found on Makerfabs wiki (Lora Soil Moisture Sensor V3 - MakerFabsWiki)
- 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. - 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
-
Why donât you post the sensor code in GitHub?
Thatâs a good question⌠next question. -
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 -
Would you recommend Makerfabs and/or this LoRa Soil Moisture product?
Based on my personal experience, yes. -
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-2022: 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.
A9: 2 MHz input
A2: Analog output