Photo by @pawel_czerwinski / Unsplash.com

Building an IoT soil moisture monitoring solution using Azure and Ruuvitag sensors

Yet another weekend, and yet another interesting little project I got to work on! I often “scratch my own itch”, and this time it’s no different. I built a simple, yet powerful solution to measure the soil moisture of my home office houseplant.

This is the plant:

I’m not entirely sure what type it is, but it’s green, and it seems to be alive. The challenge for me has been that I never remember to water it. And to be brutally honest, I have no idea how often it needs watering.

I built a solution that measures the moisture of the soil, and if it drops below a given value, I am alerted. The optimal solution for someone like me, who mostly does not pay attention to my surroundings at home.

The solution

First, I needed a sensor. Microsoft partnered with a few companies providing sensors, so I had a look at them. They are Davis, Teralytic, and Metos by Pessl. This Metos single soil probe looks promising:

It’s $1,100 per year. Leased. Obviously, it’s very fancy – but it’s also quite large, perhaps larger than my houseplant. These are industrial-grade sensors, for someone with a large farm with acres of farmland requiring robust and weather-resistant sensors. My needs are simple – measure the soil moisture, and give me a number.

RuuviTag to the rescue! I’ve used these before, and I had one lying around I’m not using at the moment.

Ruuvi - We Love Open-Source | RuuviTag specs

It’s a small Bluetooth beacon, that is capable of giving me readings on temperature, proximity, air pressure among other things – but most importantly, also humidity! The logic board is sealed in an almost air-tight enclosure, which seems to be moisture resistant. I’ve had a few of these on the balcony throughout the year, and even in cold (-25 °C (-13 F)) temperatures, they have been reliable.

The plan thus became this:

  1. Embed a RuuviTag sensor in the soil of the houseplant
  2. Use my Raspberry Pi 4 to ping the sensor a few times a day to get the readings
  3. Push those readings to Azure using Azure IoT Hub
  4. Alert me if the readings are outside preferred boundaries

Let’s walk through these four steps next.

1. Embedding the RuuviTag sensor in the soil

This was the easy part. It took me 1 minute.

I buried the beacon in the soil, but not too deep to avoid blocking the Bluetooth signal too much. It looks like this when in.. ‘production’:

To test that it works, I used the native mobile app for RuuviTag, that gives me a nicely formatted reading:

Soil moisture is 94 %, we are good! I read somewhere – I forgot where, but I did do a lot of creative googling to get to this conclusion – that the optimal soil moisture is between 80 and 95 % for this type of house plant.

2. Use my Raspberry Pi 4 to ping the sensor a few times a day to get the readings

Next, I need to connect my Raspberry Pi 4 with the RuuviTag to get the readings. This is something I’ve already achieved, when I built my hardware cabinet temperature monitoring solution. In a nutshell, the Raspberry Pi 4 uses the RuuviTag-Sensor library in Python to utilizing the Bluetooth connectivity.

I simply created a Python script, that runs twice a day – at 9 in the morning, and at 6 in the evening. The full code is here:

import DeviceClient
import datetime, requests
from ruuvitag_sensor.ruuvi import RuuviTagSensor
import json

# START: Azure IoT Hub settings
KEY = "<KEY-HERE>";
HUB = "<IOT-HUB-NAME-HERE>";
DEVICE_NAME = "rpi4iotplants";
BEACON_NAME = "homeisland-plant1";
# END: Azure IoT Hub settings

macs = ['MAC:ADDRESS:HERE']

timeout_in_sec = 5
datas = RuuviTagSensor.get_data_for_sensors(macs, timeout_in_sec)

device = DeviceClient.DeviceClient(HUB, DEVICE_NAME, KEY)
device.create_sas(600)

plantdata = datas['MAC:ADDRESS:HERE']
encode_plantdata = json.dumps(plantdata, indent=1).encode('utf-8')

print(encode_plantdata)

# Device to Cloud
print(device.send(encode_plantdata))

It’s rather simple and shows the extent of my Python scripting skills, too. But, it works and is easy to debug.

Running this shell script produces me with the readings:

Humidity is 93.5 %, and we still have plenty of battery left, too! When the battery drops below ~2500, it’s time to start thinking of a replacement battery. Plenty of scientific explanations for that here.

3. Push those readings to Azure using Azure IoT Hub

For the next step, we need to provision a new Azure IoT Hub instance in Azure and configure it to accept our values. Azure IoT Hub is the gateway in the cloud, allowing us to work with the data without worrying too much how it arrives.

You can choose a free tier, but as I already had used my free tier for the hardware temperature monitoring solution (that I still use), I provisioned the cheapest tier, which is B1 – Basic. It has a fixed cost of 8.4 € a month, for up to 400,000 messages each day. I will send 48 messages a day, so there is still room for growth in my solution.

Within Azure IoT Hub, I’ll provision my device under IoT Devices:

This is the Raspberry Pi, not the individual sensor of course. Through this, I can get the device ID and the shared access key for pushing my data. I can now update these to my script.

Running my Python script manually, I can now verify that Azure IoT Hub is getting the messages:

A more useful approach, however, is to use Visual Studio Code and look into telemetry for those messages. This is also useful for troubleshooting later if I’m not entirely sure if the values are correct. In Visual Studio Code, you can install the Azure IoT Tools – and this allows you to directly view the Azure IoT Hub messages:

Outputting the messages in Visual Studio Code is now super simple:

I still need to wire-up those messages that land in Azure IoT Hub somewhere where I can process and store them. Otherwise they are ephemeral and simply are discarded.

Thankfully, I can use Event Grid for this. Under Events, I can have Azure IoT Hub push the message through a WebHook. And what’s better than Logic Apps for this purpose?

So I created a quick Logic Apps orchestration to pick up said messages:

Let’s take a closer look at the steps. First, the HTTP request is received. This a JSON payload from Azure IoT Hub, so I trained Logic Apps to craft me the schema based on a sample message:

{
    "properties": {
        "body": {
            "items": {
                "properties": {
                    "data": {
                        "properties": {
                            "body": {
                                "type": "string"
                            },
                            "properties": {
                                "properties": {},
                                "type": "object"
                            },
                            "systemProperties": {
                                "properties": {
                                    "iothub-connection-auth-generation-id": {
                                        "type": "string"
                                    },
                                    "iothub-connection-auth-method": {
                                        "type": "string"
                                    },
                                    "iothub-connection-device-id": {
                                        "type": "string"
                                    },
                                    "iothub-content-encoding": {
                                        "type": "string"
                                    },
                                    "iothub-content-type": {
                                        "type": "string"
                                    },
                                    "iothub-enqueuedtime": {
                                        "type": "string"
                                    },
                                    "iothub-message-source": {
                                        "type": "string"
                                    }
                                },
                                "type": "object"
                            }
                        },
                        "type": "object"
                    },
                    "dataVersion": {
                        "type": "string"
                    },
                    "eventTime": {
                        "type": "string"
                    },
                    "eventType": {
                        "type": "string"
                    },
                    "id": {
                        "type": "string"
                    },
                    "metadataVersion": {
                        "type": "string"
                    },
                    "subject": {
                        "type": "string"
                    },
                    "topic": {
                        "type": "string"
                    }
                },
                "required": [
                    "id",
                    "topic",
                    "subject",
                    "eventType",
                    "data",
                    "dataVersion",
                    "metadataVersion",
                    "eventTime"
                ],
                "type": "object"
            },
            "type": "array"
        },
        "headers": {
            "properties": {
                "Accept-Encoding": {
                    "type": "string"
                },
                "Connection": {
                    "type": "string"
                },
                "Content-Length": {
                    "type": "string"
                },
                "Content-Type": {
                    "type": "string"
                },
                "Host": {
                    "type": "string"
                },
                "aeg-data-version": {
                    "type": "string"
                },
                "aeg-delivery-count": {
                    "type": "string"
                },
                "aeg-event-type": {
                    "type": "string"
                },
                "aeg-metadata-version": {
                    "type": "string"
                },
                "aeg-subscription-name": {
                    "type": "string"
                }
            },
            "type": "object"
        }
    },
    "type": "object"
}

It looks scary, but keep in mind that the message only includes the following data:

{
    "headers": {
        "Connection": "Keep-Alive",
        "Accept-Encoding": "gzip,deflate",
        "Host": "prod-1.westeurope.logic.azure.com",
        "aeg-subscription-name": "FOO",
        "aeg-delivery-count": "0",
        "aeg-data-version": "",
        "aeg-metadata-version": "1",
        "aeg-event-type": "Notification",
        "Content-Length": "1078",
        "Content-Type": "application/json; charset=utf-8"
    },
    "body": [
        {
            "id": "f123
            "topic": "/SUBSCRIPTIONS/ID/RESOURCEGROUPS/RESGROUP/PROVIDERS/MICROSOFT.DEVICES/IOTHUBS/IOTHUB",
            "subject": "devices/device1",
            "eventType": "Microsoft.Devices.DeviceTelemetry",
            "data": {
                "properties": {},
                "systemProperties": {
                    "iothub-content-type": "application/json",
                    "iothub-content-encoding": "",
                    "iothub-connection-device-id": "device1",
                    "iothub-connection-auth-method": "{\"scope\":\"device\",\"type\":\"sas\",\"issuer\":\"iothub\",\"acceptingIpFilterRule\":null}",
                    "iothub-connection-auth-generation-id": "123",
                    "iothub-enqueuedtime": "2020-08-17T15:08:15.6930000Z",
                    "iothub-message-source": "Telemetry"
                },
                "body": "ewogImRhdGFfZm9ybWF0IjogMywKICJodW1pZGl0eSI6IDkzLjUsCiAidGVtcGVyYXR1cmUiOiAyNC43NCwKICJwcmVzc3VyZSI6IDEwMTUuMzQsCiAiYWNjZWxlcmF0aW9uIjogMTAyOC40ODQ4MDc4NjA1NzMsCiAiYWNjZWxlcmF0aW9uX3giOiAtMjM0LAo..."
            },
            "dataVersion": "",
            "metadataVersion": "1",
            "eventTime": "2020-08-17T15:08:15.6930000Z"
        }
    ]
}

I next need to parse all of this to a handy variable as JSON:

And then, a bit of trickery as the body (or one of the bodies, anyway) is Base64 encoded, I have to decode it. I use decodeBase64() which is a built-in function of Logic Apps.

And finally, I’ll add a variable to pick up Humidity from the payload, and store that in an Azure SQL database.

4. Alert me if the readings are outside preferred boundaries

And in the end, I’ll add a simple condition, that if humidity is less than 50 %, I’ll get an email.

Obviously, you could replace this with anything else – such as a text message, or a notification to your smartwatch or phone. Email for now is more than sufficient.

In conclusion

Running through this takes about 2-4 seconds within the Logic Apps. It really is super fast. Perhaps it could be further optimized to move most of the logic to an Azure Function, but then troubleshooting it later would be more pesky.

Once I get an email alert, such as this from my testing:

I know I’ll need to give the plant a bit more water. It takes about 12 hours for the soil to moisture after that, and humidity quickly goes up from 57 % to 95 % then.

The RuuviTag beacons are simply stellar for things like this. Their upcoming RuuviTag Gateway, which they hinted at in late 2019, will allow you to remove the Raspberry Pi from the solution.