LoRa Rain Gauge and Weather Server

So it rains here in Arkansas a bit and I have always wanted an automatic rain gauge and weather station but I am too cheap to buy a decent one. Why not build one myself and learn something along the way?

I started out with a simple off the shelf tip bucket rain gauge made for a cheap weather station setup. The Oregon Scientific PCR800 (which is no longer available).

I gutted it of it’s electronics since it was supposed to talk to a proprietary hub which was no longer available. The tip bucket method of measuring rain has been around for a long time. The basic concept is that rain is directed to a funnel located on the top of the sensor and then falls into a bucket that pivots. When the bucket fills, it tips, drains the bucket and a hall effect sensor is triggered to note the tip. It’s important to note that you must know EXACTLY how much water it takes to tip the bucket so your total rain calculations are accurate. We will get to my failings momentarily….

Given that we need a low power way to transmit a notification of a bucket tip and store that data along with the correct measurement of a bucket tip to calculate total rain, LoRa to the rescue! I created a sensor board with a Raspberry Pi Pico and a RFM95 Lora Module just as I had for my other sensors (Such as the gate sensor).

I programmed the rain gauge sensor using micropython and the u-lora package. I gave the sensor a unique address (5) and the server address of 2. The code essentially does nothing until a bucket tip is activated by the hall effect sensor which is connected between pin 7 and ground. When the hall effect is engaged, it causes pin 7 to go from 3.3v to zero volts as the magnet passes pass the sensor. The code is waiting for that pin to be triggered in an if statement. When triggered it sends the date, “1” to the server address (2) via LoRa. Here is the sensor Micropython code.

from time import sleep
from ulora import LoRa, ModemConfig, SPIConfig
from machine import Pin

# Lora Parameters
RFM95_RST = 9
RFM95_SPIBUS = SPIConfig.rp2_0
RFM95_CS = 8
RFM95_INT = 10
RF95_FREQ = 915.0
RF95_POW = 20
CLIENT_ADDRESS = 5
SERVER_ADDRESS = 2

#Weather gauge sensor pin
wg = Pin(7, Pin.IN, Pin.PULL_UP)

# Setup LEDs
led1 = Pin(12, Pin.OUT)
led2 = Pin(13, Pin.OUT)

# Turn on power LED
led1.on()
# initialise radio
lora = LoRa(RFM95_SPIBUS, RFM95_INT, CLIENT_ADDRESS, RFM95_CS, reset_pin=RFM95_RST, freq=RF95_FREQ, tx_power=RF95_POW, acks=True)

def send_tip():
    if wg.value() == 0:
        print("trigger")
        lora.send_to_wait("1", SERVER_ADDRESS)
        # blink LED after sending LORA message
        led2.on()
        sleep(.5)
        led2.off()

# loop and send data
while True:
    send_tip()

Once the data is sent, I wrote a section in my LoRa server that collects and processes the data according to the sensor address (5). I created a MariaDB Database in my docker container environment and created a database named sensors and a table named Rain_Data which contained three rows (ID, Date and Tips) ID is autogenerated and is just a serial number that auto increments so that it’s easy to see the order of the tips visually. Date is generated when the data is written to the database and provides a time stamp for reporting. Tips denotes that a tip has occurred and has a number 1 in the field each time a tip is written to the database.

The python code extract from the server looks like this.. payload.header_from is the sensors client ID I set in the Micropython code above. payload.message Is the data in this case it’s “1” to signify the number of tips. This code INSERTS only the data, “1” into the database and the two other fields are autogenerated (date and id).

if payload.header_from == 5:
        conn = connect_to_db()
        cur = conn.cursor()
        ###### Write rain tip data to weather database ######
        insert_state = "INSERT INTO Rain_Data (tips) VALUES (?)"           
        cur.execute(insert_state, [payload.message], None)
        conn.commit()
        conn.close()
        return None
    else:
        logging.info("on_recv - Client Not found in the database. Client = {0} Payload = {1}".format(payload.header_from, payload.message))
        return None

The data in the database looks like this.

After the data is written to the database the “collection” part of the process is complete. To report on the rain data I wrote another python program which is run within a Flask wrapper. It runs a series of SQL statements based on time periods. 5 minute rainfall, last hour rainfall, last 24 hours rainfall, last 7 days rainfall, last 14 days rainfall, last 30 days rainfall, and last 365 days rainfall. Those queries run ever 60 seconds and update MQTT topics that my home automation system can subscribe to. I also have some real time API functions if I ever wanted to get a specific time period report via a REST API call. I run this python program as a micro service in a docker container. You will find there is two separate sections noted in the code API and MQTT with the associated functions. I am using the Background scheduler package to run the MQTT update code every 60 seconds.

One VERY VERY important line in this code is “bucket_size = 0.0787402”. That determines how much water is actually counted in one tip of the bucket. Here is where I screwed up initially. I used 0.0787402 of an inch… Turns out that was supposed to be 0.0787402 mm! Doh! For a while I was seeing huge numbers and couldn’t explain why. After several hours of testing and reading, I figured it out, Millimeters.. Curses! I told you we would get to my failings. 🙂

Here is the code…

import os
from unittest import result
from flask import Flask, request, jsonify
from flask_mqtt import Mqtt
import logging
import mariadb
from datetime import datetime
import sys
import functools
from apscheduler.schedulers.background import BackgroundScheduler
import time
from dotenv import load_dotenv

load_dotenv()

os.environ['TZ'] = 'America/Chicago'

bucket_size = 0.0787402     # The amount of water it takes to tip the bucket

def connect_to_db():
    try:
        conn = mariadb.connect(
            user = os.getenv("DB_USER"),
            password = os.getenv("DB_PASSWORD"),
            host = os.getenv("DB_HOST"),
            port=3306,
            database = os.getenv("DB_NAME"),)
        return conn
    except mariadb.Error as e:
        print(f"Error connecting to MariaDB Platform: {e}")
        sys.exit(1)

app = Flask(__name__)

if __name__ == "__main__":
    app.run(debug=False)

logger = logging.getLogger(__name__)
logging.basicConfig(filename='/app/logs/weather.log', level=logging.DEBUG,
                    format='%(asctime)s    %(levelname)s   %(message)s' ,
                    datefmt='%d-%m-%Y %H:%M:%S') 
logger.info("Started")

# Setup MQTT broker connection
app.config['MQTT_BROKER_URL'] = os.getenv("MQTT_SERVER") # Your MQTT Broker IP
app.config['MQTT_BROKER_PORT'] = 1883 # Change if you are not using the default port number
# app.config['MQTT_USERNAME'] = 'user' # Add your MQTT username or delete the user if no user name is used
# app.config['MQTT_PASSWORD'] = 'password' # Add your MQTT password or delete the user if no password name is used
app.config['MQTT_REFRESH_TIME'] = 1.0  # refresh time in seconds
mqtt = Mqtt(app)

#########################    REST API   #############################

def api_publish(sql_statement):
    conn = connect_to_db()
    cur = conn.cursor()
    cur.execute(sql_statement)
    result = cur.fetchall()
    tips = functools.reduce(lambda N, digit: N * 10 + digit, result)
    quanity = int(tips[0])*bucket_size
    conn.close()
    return quanity

# Rain Gauge 5 minutes
@app.route('/rain_5_min', methods=['GET'])
def rain_5_min():
    sql = "SELECT DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 300 SECOND"
    returned_data = api_publish(sql)
    return jsonify(rain=returned_data)

# Rain Gauge last hour
@app.route('/rain_last_hour', methods=['GET'])
def rain_lasthour():
    sql = "SELECT DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 1 HOUR"
    returned_data = api_publish(sql)
    return jsonify(rain=returned_data)

 # Rain Gauge last 24 hours
@app.route('/rain_last_24_hours', methods=['GET'])
def rain_last_24_hours():
    sql = "SELECT DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 24 HOUR"
    returned_data = api_publish(sql)
    return jsonify(rain=returned_data)

# Rain Gauge last week
@app.route('/rain_last_week', methods=['GET'])
def rain_last_week():
    sql = "SELECT DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 1 WEEK"
    returned_data = api_publish(sql)
    return jsonify(rain=returned_data)

# Rain Gauge last two weeks
@app.route('/rain_last_2_weeks', methods=['GET'])
def rain_last_2_weeks():
    sql = "SELECT DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 2 WEEK"
    returned_data = api_publish(sql)
    return jsonify(rain=returned_data)

# Rain Gauge last month
@app.route('/rain_last_month', methods=['GET'])
def rain_last_month():
    sql = "SELECT DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 1 MONTH"
    returned_data = api_publish(sql)
    return jsonify(rain=returned_data)

# Rain Gauge last year
@app.route('/rain_last_year', methods=['GET'])
def rain_last_year():
    sql = "SELECT DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 1 YEAR"
    returned_data = api_publish(sql)
    return jsonify(rain=returned_data)

# Rain Gauge input
@app.route('/rain_input', methods=['GET'])
def rain_input():
    conn = connect_to_db()
    cur = conn.cursor()
    NumOfTips = 1
    cur.execute("INSERT INTO Rain_Data (tips) VALUES (?)", ([NumOfTips]))
    conn.commit()
    conn.close()
    return jsonify(update="success")

################## MQTT Publish ###################

def publish_rain_data(sql_select, topic_name):
    conn = connect_to_db()
    cur = conn.cursor()
    sel_rain_5_min = sql_select
    cur.execute(sel_rain_5_min)
    result = cur.fetchall()
    tips = functools.reduce(lambda N, digit: N * 10 + digit, result)
    quanity = int(tips[0])*bucket_size
    mqtt.publish(topic_name, round(quanity, 1), qos=0,retain=True)
    conn.close()

# Rain Gauge last 5 minutes
def mqtt_rain_last_5_min():
    sql = "SELECT SQL_NO_CACHE DISTINCT IFNULL (SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 5 MINUTE"
    topic = 'rain/5_min'
    publish_rain_data(sql, topic)

# Rain Gauge last hour
def mqtt_rain_last_hour():
    sql = "SELECT SQL_NO_CACHE DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 1 HOUR"
    topic = 'rain/hour'
    publish_rain_data(sql, topic)

 # Rain Gauge last 24 hours
def mqtt_rain_last_24_hours():
    sql = "SELECT SQL_NO_CACHE DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 24 HOUR"
    topic = 'rain/day'
    publish_rain_data(sql, topic)

# Rain Gauge last week
def mqtt_rain_last_week():
    sql = "SELECT SQL_NO_CACHE DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 1 WEEK"
    topic = 'rain/week'
    publish_rain_data(sql, topic)

# Rain Gauge last two weeks
def mqtt_rain_last_2_weeks():
    sql = "SELECT SQL_NO_CACHE DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 2 WEEK"
    topic = 'rain/2weeks'
    publish_rain_data(sql, topic)

# Rain Gauge last month
def mqtt_rain_last_month():
    sql = "SELECT SQL_NO_CACHE DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 1 MONTH"
    topic = 'rain/month'
    publish_rain_data(sql, topic)

# Rain Gauge last year
def mqtt_rain_last_year():
    sql = "SELECT SQL_NO_CACHE DISTINCT IFNULL(SUM(tips),0) FROM Rain_Data WHERE date >= now() - INTERVAL 1 YEAR"
    topic = 'rain/year'
    publish_rain_data(sql, topic)

def mqtt_update_all():
    time.sleep(.5)
    mqtt_rain_last_5_min()
    time.sleep(.5)
    mqtt_rain_last_hour()
    time.sleep(.5)
    mqtt_rain_last_24_hours()
    time.sleep(.5)
    mqtt_rain_last_week()
    time.sleep(.5)
    mqtt_rain_last_2_weeks()
    time.sleep(.5)
    mqtt_rain_last_month()
    time.sleep(.5)
    mqtt_rain_last_year()

scheduler = BackgroundScheduler()
scheduler.add_job(func=mqtt_update_all, trigger="interval", seconds=60)
scheduler.configure(max_instances=1)
scheduler.start()

Once the info is published to MQTT, the sky is the limit as they say.. My home automation system (Home Assistant) subscribes to the MQTT topic and is displayed on my dashboard. It looks like this… Yeah, it’s accurate. We just had flash floods locally.

Here it is mounted on my fence. I added a liPo battery, charging circuit and solar panel to assure it stays running.

My next plans are to add Temp, Humidity and Pressure. More to come from nerdville.

2 thoughts on “LoRa Rain Gauge and Weather Server

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.