Pluviometro LoRaWan con open hardware y open software
Componentes de Hardware
Pluviómetro de cubo basculante
El componente principal es el colector de lluvia con cubo basculante. El colector adquirir solo o en kit con componentes para velocidad y dirección de viento..
Sensor digital de Presión atmosférica y temperatura BMP180. Comunicación tipo I2C, compatible con Arduino con la librería
#include "BMP180.h"
Placa de desarrollo LoRa
Placa de desarrollo LoRa que incluye controlador ARM y módulo de Radio, conexión USB, puerto para batería y panel solar. Por normativa para Ecuador se usa la versión con US915.
El desarrollo es compatible con otros módulos Lora de la serie CubeCell, se ha probado con el modelo "capsule" para la primera versión.
Las instrucciones para interpretar la trama en el gestor de gateways Chirpstack se realizan en JavaScript, siguiendo el mismo orden de bytes transmitido desde el dispositivo.
Para el caso de valores de temperatura se usan números enteros de 2 bytes con signo con dos decimales, según lo permite el sensor usado. El valor antes de transmitir se multiplica por 100. En la recepción se restaura el formato del número al dividirlo para 100.
Al final se añade el acumulado del pluviómetro de los últimos 5 minutos como pluviosum para fines generales.
Con esta parte se habilita la lectura de los parámetros para gestionar los datos en HomeAssistant mediante un mensaje Mqtt.
ChirpStack
// Decode decodes an array of bytes into an object.
// - fPort contains the LoRaWAN fPort number
// - bytes is an array of bytes, e.g. [225, 230, 255, 0]
// - variables contains the device variables e.g. {"calibration": "3.5"} (both the key / value are of type string)
// The function must return an object, e.g. {"temperature": 22.5}
function Decode(fPort, bytes, variables) {
var Down_rssi = -1*parseInt(bytes[0]);
var Down_snr = bytes[1];
var Down_datarate = bytes[2];
// usando entero sin signo
var bateria = (bytes[4] << 8) |(bytes[3]);
bateria = (bateria/1000);
bateria = +bateria.toFixed(2);
// usando entero con signo
var unalectura = (bytes[6] << 8) |(bytes[5]); var unsigno = (unalectura>>>15 === 0) ? 1.0 : -1.0;
var unvalor = unalectura & 0x7fff;
if (unsigno<0){unvalor = unvalor - 32768;}
var ht_temp = unvalor/100;
ht_temp = +ht_temp.toFixed(2);
var ht_humi = (bytes[8] << 8) |(bytes[7]);
ht_humi = ht_humi/100;
ht_humi = +ht_humi.toFixed(2);
var unalectura = (bytes[10] << 8) |(bytes[9]); var unsigno = (unalectura>>>15 === 0) ? 1.0 : -1.0;
var unvalor = unalectura & 0x7fff;
if (unsigno<0){unvalor = unvalor - 32768;}
var bar_temp = unvalor/100;
bar_temp = +bar_temp.toFixed(2);
var unalectura = (bytes[14] << 24) | (bytes[13]<< 16) | (bytes[12] << 8) |(bytes[11]); var unsigno = (unalectura>>>31 === 0) ? 1.0 : -1.0;
var exponente = unalectura>>>23 & 0xff;
var mantisa = (exponente === 0) ? (unalectura & 0x7fffff)<<1 : (unalectura & 0x7fffff) | 0x800000;
var NumReal = unsigno * mantisa * Math.pow(2, exponente - 150);
var bar_press = NumReal;
var pluvi0 = bytes[15];
var pluvi1 = bytes[16];
var pluvi2 = bytes[17];
var pluvi3 = bytes[18];
var pluvi4 = bytes[19];
var pluviosum = pluvi0 + pluvi1 + pluvi2 + pluvi3 + pluvi4;
var appData = {'Down_rssi':Down_rssi,'Down_snr':Down_snr,
'Down_datarate':Down_datarate,
'bateria_V':bateria,
'ht_temp':ht_temp,'ht_humi':ht_humi,
'bar_temp':bar_temp,'bar_press':bar_press,
'pluvi0':pluvi0,'pluvi1':pluvi1,'pluvi2':pluvi2,'pluvi3':pluvi3,'pluvi4':pluvi4,
'pluviosum':pluviosum,
}
return appData;
}
El bloque de sensores se simplifica por partes de control y comunicación acorde al tipo de sensor:
pulsos: Pluviómetro
Analógicos: batería
I2C: humedad, temperatura y presión atmosférica
Pulsos: pluviómetro
El pluviómetro se centra en medir el vaciado de la "doble cubeta basculante".
Para la detección se usa un interruptor magnético que marca el cambio de estado mediante un pulso (pulse) conectado al pin asignado (pluvioPin). Para que la detección sea independiente del estado del dispositivo se usa una interrupción de instrucciones (pulse_tip) que cuenta el pulso (pulse) en flanco de subida (RISING)y marca el evento (pulse_flag).
attachInterrupt(pluvioPin, pulse_tip, RISING);
El dispositivo cuenta los pulsos generados durante un minuto (sample_period) y los almacena en una lista (pulse_list) hasta la transmisión de los datos hacia el gateway LoRa.
Analógicos: Batería
El sensor de estado de batería es un ADC que toma una lectura del estado de la batería antes de cada transmisión LoRa.
I2C: humedad, temperatura y presión atmosférica
Los sensores de humedad, temperatura y presión son de tipo I2C que se activan solo al ser leídos en cada ciclo de transmisión mediante el pin Vext, Posterior a la lectura apagan para optimizar el ahorro de energía.
Antes de cada lectura, se realiza una verificación de estado de conexión al sensor optimizando el tiempo de éste proceso en caso de fallas del sensor expuesto a condiciones ambientales.
// sensors block
// pluviometer pulse counter at interrupt
void pulse_tip(){
if (pulse<=255){pulse = pulse + 1;}
pulse_flag = true;
}
void pulse_check(){
if (pulse_flag==true){
if (pulse>255){pulse=255;}
pulse_flag = false;
tipfree = 0;
if (serial_msg){
Serial.print("_|_: ");Serial.println(pulse);
}
turnOnRGB(128,200);turnOffRGB();
}
}
void minute_check(){
int i = 0;
pulse_list[i_minute] = pulse;
if (i_minute>=n_minute){
// pluviometer update
for (i = 0; i < n_minute; i = i + 1) {
pluviometer[i] = pulse_list[i];
pulse_list[i] = 0;
}
i_minute = 0;
loop_0 = false;
sensor_flag = true;
tx_done = false;
}
if (serial_msg){
Serial.print("i_minute:");Serial.print(i_minute);
Serial.print(" ; pulse_list:");
for (i = 0; i < n_minute; i = i + 1) {
Serial.print(" "); Serial.print(pulse_list[i]);
}
Serial.print(" ; pluviometer:");
for (i = 0; i < n_minute; i = i + 1) {
Serial.print(" "); Serial.print(pluviometer[i]);
}
Serial.print(" ; tipfree "); Serial.println(tipfree);
}
}
void read_sensors(){
batteryVoltage = getBatteryVoltage();
// sensors check i2c on
byte sensor_error, address;
digitalWrite(Vext, LOW); delay(300);
Wire.begin();
humitemp_active = false;
Wire.beginTransmission(0x40);
sensor_error = Wire.endTransmission();
if (sensor_error == 0){humitemp_active = true;}
prestemp_active = false;
Wire.beginTransmission(0x77);
sensor_error = Wire.endTransmission();
if (sensor_error == 0){prestemp_active = true;}
if (humitemp_active){hdc1080.begin(0x40);}
if (prestemp_active){bmp.begin();}
if (humitemp_active){
ht_temp = hdc1080.readTemperature();
ht_humi = hdc1080.readHumidity();
}
float bar_alti = 0, bar_sea_pres = 0, bar_sea_alt = 0;
if (prestemp_active){
bar_temp = bmp.readTemperature();
bar_pres = bmp.readPressure();
bar_alti = bmp.readAltitude();
bar_sea_pres = bmp.readSealevelPressure();
bar_sea_alt = bmp.readAltitude(100800);
}
// sensors I2C off
Wire.end();
digitalWrite(Vext, HIGH);
if (serial_msg){
Serial.print("Battery = ");Serial.print(batteryVoltage);
Serial.print(" ; --- sensor ");
Serial.print("hum_temp: "); Serial.print(humitemp_active);
Serial.print(" ; bar_temp: "); Serial.println(prestemp_active);
if (humitemp_active){
Serial.print("HDC1080 Temperature = ");
Serial.print(ht_temp); Serial.print(" C ; Humidity = ");
Serial.print(ht_humi);Serial.println(" %");
}
if (prestemp_active){
Serial.print("BMP180 Temperature = ");
Serial.print(bar_temp);Serial.println(" C");
Serial.print(" Pressure = ");
Serial.print(bar_pres);Serial.print(" Pa ; Altitude = ");
Serial.print(bar_alti);Serial.println(" meters");
Serial.print(" SeaLevel(calc)= ");
Serial.print(bar_sea_pres); Serial.print(" Pa ; Altitude = ");
Serial.print(bar_sea_alt); Serial.println(" meters");
}
Serial.println("");
}
}
void onSleep(){
Serial.print("\n i_minute:");Serial.print(i_minute);
Serial.printf(" ;Going into lowpower mode, %d ms later wake up.\r\n",timetillwakeup);
lowpower = 1;
//timetillwakeup ms later wake up;
timetillwakeup = sample_period*1000 - (millis()-sample_ti);
TimerSetValue( &wakeUp, timetillwakeup );
TimerStart( &wakeUp );
}
void onWakeUp(){
Serial.println(" ... Woke up by time");//, %d ms later into lowpower mode.\r\n",timetillsleep);
lowpower = 0;
sleep_flag = false;
sleep_done = true;
//timetillsleep ms later into lowpower mode;
//TimerSetValue( &sleep, timetillsleep );
//TimerStart( &sleep );
}
Adicionalmente existen dos procedimientos usados para el control del modo de ahorro de energía: onSleep() y onWakeup(), que junto a la instrucción lowPowerHandler() permiten un mayor ahorro de energía en los ciclos donde no se han producido pulsos del pluviómetro. Esto minimiza el uso de batería ante los periodos sin lluvia y manteniendo aún el registro por minuto durante largos periodos.
Básicamente, se usa el modo de ahorro de energía si no han ocurrido eventos de conteo del pluviómetro durante al menos un ciclo de n_minute, caso contrario se mantiene el controlador activo.
El bloque principal se basa en el esquema básico de LoRaWan usado para sensores, cambiando la parte de ahorro de energía entre transmisiones DEVICE_STATE_SLEEP a una controlada por periodos de muestreo del pluviómetro con los procedimientos onSleep() y onWakeUp() descritos en la parte de sensores. Se controla las actividades de Lectura de sensores y modos de ahorro de energía por la sucesión de cada evento.
Las instrucciones para interpretar la trama en el gestor de gateways se realizan en JavaScript, siguiendo el mismo orden de bytes realizado en el dispositivo.
Con esta parte se habilita la lectura de los parámetros para gestionar los datos en HomeAssistant mediante un mensaje Mqtt.
function Decode(fPort, bytes, variables) {
var Down_rssi = -1*parseInt(bytes[0]);
var Down_snr = bytes[1];
var Down_datarate = bytes[2];
// usando entero
var unalectura = (bytes[4] << 8) |(bytes[3]);
unalectura = (unalectura/1000)
unalectura = +unalectura.toFixed(2);
var appData = {'Down_rssi':Down_rssi,
'Down_snr':Down_snr,
'Down_datarate':Down_datarate,
'bateria_V': unalectura}
return appData;
}
función de codificación hacia el dispositivo, Reenvía al dispositivo el Rssi de Uplink del paquete anterior
function Encode(fPort, obj) {
var data = new Array();
data[0] = -1*parseInt(obj["Up_rssi"])
return data;
}
Automatización en Home-Assistant
Para reenviar el Rssi de UpLink hacia el dispositivo para registrar valores en el punto de muestra:
Para realizar pruebas de conectividad y calidad del enlace inalámbrico, se propone usar dispositivos con una configuración básica que registre los parámetros del enlace de subida y bajada.
Los parámetros para subida (Uplink) se obtienen desde el gateway, de los parámetros regulares que registra el gateway.
Los parámetros de bajada (Downlink) se obtienen de la trama de confirmación (Acknowlegment) que son Rssi, Snr y datarate. Estos parámetros se usan en el próximo envío de datos (Uplink) por lo que presentan un atraso equivalente al intervalo de lectura de datos del dispositivo.
El dispositivo para la prueba se implementa con un módulo de desarrollo HELTEC Cubecell Board-Plus HTCC-AB02.
Preparación de trama
La trama se configura usando los parámetros obtenidos en el dispositivo con una trama de confirmación de subida (Ack). El primer valor enviado será cero, puesto que no se dispone de parámetros iniciales.
Rssi Downlink (1 byte)
Snr Downlink (1 byte)
Datarate Downlink (1 byte)
Voltaje de bateria (2 bytes)
// Ack parametros de recepciónuint8_tconfirmaRssi=0;uint8_tconfirmaSnr=0;uint8_tconfirmaDatarate=0;
A Septiembre del 2021, se usan las librerías publicadas para el módulo HELTEC Cubecell Board-Plus HTCC-AB02. Para obtener los parámetros de la trama de confirmación de recibido (Ack) el gateway para una trama de subida de datos (Uplink) se sustituye el procedimiento:
La función Encode se encarga de preparar los valores para ser enviados por la red LoRaWan. El ejemplo muestra el uso de las variables estado y duración:
// Encode encodes the given object into an array of bytes.
// - fPort contains the LoRaWAN fPort number
// - obj is an object, e.g. {"temperature": 22.5}
// - variables contains the device variables e.g. {"calibration": "3.5"} (both the key / value are of type string)
// The function must return an array of bytes, e.g. [225, 230, 255, 0]
function Encode(fPort, obj, variables) {
var estado = obj["estado"];
var duracion = obj["duracion"];
var mensaje = [50,20];
if (estado == "ON") {
mensaje = [49,duracion];
}
if (estado == "OFF") {
mensaje = [50,20];
}
return mensaje;
}
Mensaje MQTT
El mensaje Mqtt que activa la instrucción tiene un formato que requiere para el tema o tópico: identificador de aplicación del ChirpStack, deviceEUI,
el mensaje contiene si se confirma la trama, el puerto de la aplicación y un diccionario "object" con las variables sus valores correspondientes en texto o numérico.
El ejemplo de la instruccion para el dispositivo es:
Se realiza combinando una variable numérica (input_number) para la duración en segundos, y se envía como una instrucción de control usando una plantilla de foco dim (light).
Un dispositivo como actuador requiere recibir un mensaje de control desde el broker para establecer el estado del artefacto o cosa. En el caso de LoRaWan el mensaje debe gestionarse por el gateway para transformar un mensaje Mqtt enviado al formato de bytes usado en la red LoRa, es decir un Encoder.
En el dispositivo LoRa se requiere al menos una función para recibir los mensajes, interpretación de los bytes recibidos y con éstos actualizar el estado de los pines de salidas usados como control.
Datos de control
Lo primordial en el dispositivo es el pin de control. Para el ejemplo inicial se usa el LED incorporado en el módulo de desarrollo Pin25.
Un dato complementario es la duración del actuador en estado encendido, es un factor necesario en el sistema de riego, pues se debería usar solo por periodos cortos de tiempo. Se aprovecha el modo de ahorro de energía del dispositivo en contraste con los largos periodos sin riego.
Intrucciones Principales
A las instrucciones principales se añaden las líneas para la declaración de las variables. El resto de operaciones se las añade como funciones.
/* HelTec Automation(TM) LoRaWAN 1.0.2 OTAA example use OTAA, CLASS A
* Solo ESP32+LoRa series boards con licencia http://www.heltec.cn/search/);
*https://github.com/HelTecAutomation/ESP32_LoRaWAN
*/
#include <ESP32_LoRaWAN.h>
#include "Arduino.h"
/*licencia Heltec ESP32 LoRaWan http://resource.heltec.cn/search */
uint32_t license[4] = { 0xC254CA22, 0xFB5646A9, 0xA23B184F, 0x8F613844};
/* OTAA parametros*/
uint8_t DevEui[] = { 0xbc, 0x55, 0x31, 0x89, 0x12, 0xbf, 0xd0, 0x90 };
uint8_t AppEui<[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
uint8_t AppKey[] = { 0x99, 0x42, 0xd4, 0x4b, 0xfc, 0x59, 0x85, 0x0a, 0xad, 0x76, 0x15, 0x3b, 0x7a, 0xdb, 0x50, 0x32 };
/* ABP parametros*/
uint8_t NwkSKey[] = { 0xd6, 0x28, 0x15, 0x0c, 0x99, 0x79, 0xbc, 0xec, 0xed, 0x11, 0x67, 0x75, 0x0b, 0x37, 0xb4, 0xe0 };
uint8_t AppSKey[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
uint32_t DevAddr = ( uint32_t )0x00052815;
//LoraWan channelsmask, default channels 0-7/
uint16_t userChannelsMask[6]={ 0x00FF,0x0000,0x0000,0x0000,0x0000,0x0000 };
DeviceClass_t loraWanClass = CLASS_A; /*Soporte de A and C*/
uint32_t appTxDutyCycle = 30000; /*15000; en [ms]*/
bool overTheAirActivation = true; /*OTAA or ABP*/
bool loraWanAdr = true; /*ADR enable*/
bool isTxConfirmed = true; /*confirmed or unconfirmed messages */
uint8_t appPort = 3; /* Application port */
/* reintentos de transmisión, en caso de no recibir ack */
uint8_t confirmedNbTrials = 8;
/* Seleccionado de Arduino IDE tools */
uint8_t debugLevel = LoRaWAN_DEBUG_LEVEL;
LoRaMacRegion_t loraWanRegion = ACTIVE_REGION;
// Mensajes por Puerto Serial
volatile boolean serial_msg = true;
// actuador
#define LEDPin 25 //LED light
boolean estado = false; //estado dispositivo
// Sensor de estado de bateria, revisar modelo de módulo
// PowerDetection 13 en Wireless Stick lite, otros 37
#define Vext 21
#define PowerDetection 13
void setup(){
Serial.begin(115200);
while (!Serial);
SPI.begin(SCK,MISO,MOSI,SS);
Mcu.init(SS,RST_LoRa,DIO0,DIO1,license);
deviceState = DEVICE_STATE_INIT;
}
void loop(){
switch( deviceState ) {
case DEVICE_STATE_INIT: {
LoRaWAN.init(loraWanClass,loraWanRegion);
break;
}
case DEVICE_STATE_JOIN: {
LoRaWAN.join();
break;
}
case DEVICE_STATE_SEND: {
prepareTxFrame( appPort );
LoRaWAN.send(loraWanClass);
deviceState = DEVICE_STATE_CYCLE;
if (serial_msg){
Serial.print(" estado: ");
Serial.println(estado);
}
break;
}
case DEVICE_STATE_CYCLE: {
// Schedule next packet transmission
txDutyCycleTime = appTxDutyCycle + randr( -APP_TX_DUTYCYCLE_RND,
APP_TX_DUTYCYCLE_RND );
LoRaWAN.cycle(txDutyCycleTime);
deviceState = DEVICE_STATE_SLEEP;
break;
}
case DEVICE_STATE_SLEEP: {
LoRaWAN.sleep(loraWanClass,debugLevel);
break;
}
default: {
deviceState = DEVICE_STATE_INIT;
break;
}
}
}
Recibe trama
La recepción de los datos en la trana, "downlink", se realiza en la variable Buffer que es un arreglo de Bytes. La instrucción de control requiere dos valores: estado y duración , por lo que se usa el primer y segundo Byte. Este detalle se debe ajustar en cada situación para la cantidad de bytes que se requieran.
Recibidos los datos, se invoca a la función para control del actuador "actuador_pin()".
Un actuador con red LoRaWan puede usarse con varios elementos, por ejemplo para el control de una válvula de paso para riego.
El esquema básico del circuito empieza con tener un pin de salida o control. El primer paso sería controlar el LED del módulo de desarrollo que para el ejemplo es el pin 25. Con esto se puede realizar las pruebas de conectividad de LoRaWan, Mqtt y Home Assistant.
El pin de salida puede ser conectado aun circuito para usar un relé para adaptarlo a diferentes niveles de voltaje o controlar otros circuitos de mayor voltaje y corriente.