Automating your garden hose for fun and profit

Building a custom self-hosted MQTT water shutoff and controlling it with HomeAssistant

DIY, Home Automation

Table of Contents

I love gardening - over the last couple years it’s become a great summer pasttime for me. And after a backyard revamp, I’m planning a massive flower garden to create my own little oasis.

One of the parts of gardening that I’m not the biggest fan of is watering. Don’t get me wrong - it’s relaxing. But in small doses. And a big garden demands a lot of watering on a set schedule, which is often hard to do. The first part of the solution was to lay down some soaker hose throughout the garden, which works wonderfully, with no more having to point a hose for several minutes at each part of the garden. Once the whole thing is finalized I’ll install some permanent watering solutions, but for now this does the job.

But turning it on and off at the tap was hardly satisfying. I’ve already got HomeAssistant set up to automate various lights around my house and implement voice control, so why not build something to automate watering the garden?

The first thing I did is check for any premade solutions. And while there’s plenty of controllers out there, it doesn’t look like any of them support HomeAssistant. So I endeavoured to build my own instead - and the controller can be used both for a temporary setup or a permanent one.

Getting some parts

I knew I’d need a couple things for this setup to work:

  • A powered water valve/solenoid of some kind.
  • A controller board.
  • 24/7 off-grid power.
  • Connectivity to HomeAssistant.

The first option was actually the hardest to figure out, but I did find the following on Amazon: Water Solenoid Valve. It’s a little pricey, but it’s the cheapest one.

The remaining items were fairly standard: a standard Arduino-compatible 12-volt relay to control the valve, a NodeMCU for control allowing me to use MQTT to connect to HomeAssistant, and a 4.7AH 12-volt lead-acid battery and solar panel allows for continuous off-grid operation. I needed a 12-volt to 5-volt converter to power the NodeMCU (and relays) off the 12-volt battery as well which was fairly easy to come by.

Building the water distributor

The solenoid has 1/2" diameter threads, so that also meant I needed some adapters from the standard hose ends to match up with the valve. Luckily, my local Home Depot had (almost) all the parts I needed. The resulting loop is laid out as follows:

_______________
|  ___   ___  |
| |   | |   | |
| |   | |   | |
|V|   | |   |V|
| |   | |   | |
| |   | |   | |
 B     S     A

Where S is the incoming water source, V is a solenoid valve, and A/B are the outputs to hoses.

I did have to improvise a bit since 1/2" threaded female T connectors weren’t available for me, but the 1/2" 90° male-to-female elbows, as well as hose reducing pieces were. Putting it all together worked perfectly with no leaks allowing the next stage to begin.

Setting up the circuitry

The circuit itself is fairly simple. The NodeMCU digital 1 and 2 pins connect to the two relay controllers, with 12-volt lines coming from the battery into each relay and out to the solenoid valves, wired with the “signal applies power” poles of the relay. Connecting it all to the battery and solar panel gave the full circuit.

Power-on testing was a great success, and the next step was programming the Node MCU.

NodeMCU programming

The code for the NodeMCU I wrote originally for a door lock device that never succeeded, but I was able to quickly repurpose it for this task. The main input comes via MQTT, with one topic per relay, and output goes out a similar set of topics to return state.

The full code is as follows and can be easily scaled to many more valves if needed. The onboard LED is used as a quick debug for operations.

HoseController.ino

#include <PubSubClient.h>
#include <ESP8266WiFi.h>

/*
 * Garden hose WiFi control module
 */

// D0 - 16
#define BUILTIN_LED 16
// D1 - 5
#define RELAY_A_PIN 5
// D2 - 4
#define RELAY_B_PIN 4
// D3 - 0
// D5 - 14
// D6 - 12

// Control constants
const char* mqtt_server = "my.mqtt.server";
const int mqtt_port = 1883;
const char* mqtt_user = "myuser";
const char* mqtt_password = "mypass";
const char mqtt_control_topic[16] = "hose-control";
const char mqtt_state_topic[16] = "hose-state";
const char* wifi_ssid = "myssid";
const char* wifi_psk = "mypsk";

char mqtt_topic[32];
char topic_A[32];
char topic_B[32];

int getRelayPin(char* relay_string) {
  char relay = relay_string[0];
  int pin;
  switch (relay) {
    case 'a':
      pin = RELAY_A_PIN;
      break;
    case 'b':
      pin = RELAY_B_PIN;
      break;
    default:
      pin = BUILTIN_LED;
      break;
  }
  return pin;
}

WiFiClient espClient;
PubSubClient client(espClient);

long lastMsg = 0;
char msg[50];
int value = 0;
int state = 0; // 0 = unlocked, 1 = locked
int last_state = 0; // 0 = unlocked, 1 = locked

void setup() {
  pinMode(RELAY_A_PIN, OUTPUT);  // RELAY A pin
  pinMode(RELAY_B_PIN, OUTPUT);  // RELAY B pin
  pinMode(BUILTIN_LED, OUTPUT);  // LED pin
  digitalWrite(RELAY_A_PIN, HIGH); // Turn off relay
  digitalWrite(RELAY_B_PIN, HIGH); // Turn off relay
  digitalWrite(BUILTIN_LED, LOW); // Turn on LED

  Serial.begin(9600); // Start serial console
  
  setup_wifi(); // Connect to WiFi
  
  client.setServer(mqtt_server, mqtt_port); // Connect to MQTT broker
  client.setCallback(callback);
}

void relay_on(char* relay) {
    int pin = getRelayPin(relay);
    digitalWrite(pin, LOW);
}
void relay_off(char* relay) {
    int pin = getRelayPin(relay);
    digitalWrite(pin, HIGH);
}

void setup_wifi() {
  delay(10);
  // We start by connecting to a WiFi network
  Serial.println();
  Serial.print("Connecting to ");
  Serial.println(wifi_ssid);

  WiFi.begin(wifi_ssid, wifi_psk);

  while (WiFi.status() != WL_CONNECTED) {
    digitalWrite(BUILTIN_LED, HIGH); // Turn off LED
    delay(250);
    digitalWrite(BUILTIN_LED, LOW); // Turn on LED
    delay(250);
  }

  Serial.println("");
  Serial.println("WiFi connected");
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
}

void callback(char* topic, byte* payload, unsigned int length) {
  Serial.print("Message arrived [");
  Serial.print(topic);
  Serial.print("] ");
  String command;
  for (int i = 0; i < length; i++) {
    command.concat((char)payload[i]);
  }

  Serial.print(command);
  Serial.println();

  // Get the specific topic
  String relay_str = getValue(topic, '/', 1);
  char relay[8];
  relay_str.toCharArray(relay, 8);
  strcpy(mqtt_topic, mqtt_state_topic);
  strcat(mqtt_topic, "/");
  strcat(mqtt_topic, relay);

  // Blink LED for debugging
  digitalWrite(BUILTIN_LED, HIGH); // Turn off LED
  delay(250);
  digitalWrite(BUILTIN_LED, LOW); // Turn on LED
  
  // Either enable or disable the relay

  if ( command == "on" ) {
    relay_on(relay);
    Serial.println(String(relay) + ": ON");
    client.publish(mqtt_topic, "on");
  } else {
    relay_off(relay);
    Serial.println(String(relay) + ": OFF");
    client.publish(mqtt_topic, "off");
  }
}

void reconnect() {
  // Loop until we're reconnected
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    // Attempt to connect
    if (client.connect("hose", mqtt_user, mqtt_password)) {
      Serial.println("connected");
      digitalWrite(BUILTIN_LED, HIGH); // Turn on LED
      // ... and resubscribe
      strcpy(topic_A, mqtt_control_topic);
      strcat(topic_A, "/");
      strcat(topic_A, "a");
      strcpy(topic_B, mqtt_control_topic);
      strcat(topic_B, "/");
      strcat(topic_B, "b");

      client.subscribe(topic_A);
      client.subscribe(topic_B);
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 4 seconds");
      // Wait 4 seconds before retrying
      digitalWrite(BUILTIN_LED, HIGH); // Turn off LED
      delay(1000);
      digitalWrite(BUILTIN_LED, LOW); // Turn on LED
      delay(1000);
      digitalWrite(BUILTIN_LED, HIGH); // Turn off LED
      delay(1000);
      digitalWrite(BUILTIN_LED, LOW); // Turn on LED
      delay(1000);
    }
  }
}
void loop() {
  if (!client.connected()) {
    reconnect();
    digitalWrite(BUILTIN_LED, LOW); // Turn on LED
  }
  client.loop();
  delay(1000);
}

String getValue(String data, char separator, int index)
{
    int found = 0;
    int strIndex[] = { 0, -1 };
    int maxIndex = data.length() - 1;

    for (int i = 0; i <= maxIndex && found <= index; i++) {
        if (data.charAt(i) == separator || i == maxIndex) {
            found++;
            strIndex[0] = strIndex[1] + 1;
            strIndex[1] = (i == maxIndex) ? i+1 : i;
        }
    }
    String string = found > index ? data.substring(strIndex[0], strIndex[1]) : "";
    return string;
}

Controlling the hose with HomeAssistant

With the outdoor box portion completed and running as expected in response to MQTT messages, the next step was configuring HomeAssistant to talk to it. I ended up wasting a bunch of time trying to get a useful UI set up before realizing that HomeAssistant has an awesome feature: the MQTT switch component, which makes adding a switch UI element for the hose that actually works easy! Here is the configuration:

configuration.yaml

switch:
  - platform: mqtt
    name: "Hose A"
    state_topic: "hose-state/a"
    command_topic: "hose-control/a"
    payload_on: "on"
    payload_off: "off"
    state_on: "on"
    state_off: "off"
    optimistic: false
    qos: 0
    retain: true
  - platform: mqtt
    name: "Hose B"
    state_topic: "hose-state/b"
    command_topic: "hose-control/b"
    payload_on: "on"
    payload_off: "off"
    state_on: "on"
    state_off: "off"
    optimistic: false
    qos: 0
    retain: true

Has it rained recently?

The next step in the automtion was to set up a timer to turn on and off the water for me automatically. The easiest solution is simply to run it every night, but that’s not ideal if we’ve had rain recently! I thought about a number of ways to get this information within HomeAssistant, and came up with the following:

  • The built-in yr component provides excellent weather information from yr.no, including exposing a number of optional conditions. I enabled it with all of them:

configuration.yaml

sensor:
  # Weather for Burlington (a.k.a. home)
  - name: Burlington
    platform: yr
    monitored_conditions:
      - temperature
      - symbol
      - precipitation
      - windSpeed
      - pressure
      - windDirection
      - humidity
      - fog
      - cloudiness
      - lowClouds
      - mediumClouds
      - highClouds
      - dewpointTemperature
  • From this basic weather information, I built up a set of statistics to obtain 24-hour and 48-hour information about precipitation:

configuration.yaml

sensor:
  # Statistics sensors for precipitation history means
  - name: "tfh precipitation stats"
    platform: statistics
    entity_id: sensor.burlington_precipitation
    max_age:
      hours: 24
  - name: "feh precipitation stats"
    platform: statistics
    entity_id: sensor.burlington_precipitation
    max_age:
      hours: 48
  • From the statistics, I built a sensor template to display the maximum amount of rain that was forecast over the last 24- and 48- hour periods, effectively telling me “has it rained at all, a little, or a lot?” Note that I use the names tfh and feh since the constructs 24h and 48h break the sensor template!

configuration.yaml

sensor:
  # Template sensors to display the max
  - name: "Precipitation history"
    platform: template
    sensors:
      24h_precipitation_history:
        friendly_name: "24h precipitation history"
        unit_of_measurement: "mm"
        entity_id: sensor.tfh_precipitation_stats_mean
        value_template: >-
          {% if states.sensor.tfh_precipitation_stats_mean.attributes.max_value <= 0.1 %}
          0.0
          {% elif states.sensor.tfh_precipitation_stats_mean.attributes.max_value < 0.5 %}
          <0.5
          {% elif states.sensor.tfh_precipitation_stats_mean.attributes.max_value >= 0.5 %}
          >0.5
          {% endif %}
      48h_precipitation_history:
        friendly_name: "48h precipitation history"
        unit_of_measurement: "mm"
        entity_id: sensor.feh_precipitation_stats_mean
        value_template: >-
          {% if states.sensor.feh_precipitation_stats_mean.attributes.max_value <= 0.1 %}
          0.0
          {% elif states.sensor.feh_precipitation_stats_mean.attributes.max_value < 0.5 %}
          <0.5
          {% elif states.sensor.feh_precipitation_stats_mean.attributes.max_value >= 0.5 %}
          >0.5
          {% endif %}
  • These histories are used in a set of automations that turn on the hose at 2:00AM if it hasn’t rained today, and keeps it on for a set amount of time based on the previous day’s precipitation:

automations/hose_control.yaml

---
#
# Hose control structures
#
# Basic idea:
#  If it rained <0.5mm in the last 48h, run for 1h
#  If it rained >0.5mm in the last 48h, but 0.0mm in the last 24h, run for 30m
#  If it rained <0.5mm in the last 24h, run for 15m
#  If it rained >0.5mm in the last 24h, don't run tonight

# Turn on the hose at 02:00 if there's been <0.5mm of rain in the last 24h
- alias: 'Hose - 02:00 Timer - turn on'
  trigger:
    platform: time
    at: '02:00:00'
  condition:
    condition: or
    conditions:
      - condition: state
        entity_id: sensor.24h_precipitation_history
        state: '<0.5'
      - condition: state
        entity_id: sensor.24h_precipitation_history
        state: '0.0'
  action:
    service: homeassistant.turn_on
    entity_id: switch.hose_a

# Turn off the hose at 02:15 if there's been <0.5mm but >0.0mm of rain in the last 24h
- alias: 'Hose - 02:15 Timer - turn off (<0.5mm/24h)'
  trigger:
    platform: time
    at: '02:15:00'
  condition:
    condition: and
    conditions:
      - condition: state
        entity_id: switch.hose_a
        state: 'on'
      - condition: state
        entity_id: sensor.24h_precipitation_history
        state: '<0.5'
  action:
    service: homeassistant.turn_off
    entity_id: switch.hose_a

# Turn off the hose at 02:30 if there's been >0.5mm in the last 48h but 0.0 in the last 24h
- alias: 'Hose - 02:30 Timer - turn off (>0.5mm/48h + 0.0mm/24h)'
  trigger:
    platform: time
    at: '02:30:00'
  condition:
    condition: and
    conditions:
      - condition: state
        entity_id: switch.hose_a
        state: 'on'
      - condition: state
        entity_id: sensor.24h_precipitation_history
        state: '0.0'
      - condition: state
        entity_id: sensor.48h_precipitation_history
        state: '>0.5'
  action:
    service: homeassistant.turn_off
    entity_id: switch.hose_a

# Turn off the hose at 03:00 otherwise
- alias: 'Hose - 03:00 Timer - turn off'
  trigger:
    platform: time
    at: '03:00:00'
  condition:
    condition: state
    entity_id: switch.hose_a
    state: 'on'
  action:
    service: homeassistant.turn_off
    entity_id: switch.hose_a

Tada! Automated watering based on the rainfall!

Conclusion

This was a fun little staycation project that I’m certain has great expandability. Next year once the garden is arranged I’ll probably start work on a larger, multi-zone version to better support the huge garden. But for today I love knowing that my hose will turn itself on and water the garden every night if it needs it, no involvement from me! I hope you find this useful to you, and of course, I’m open to suggestions for improvement or questions - just send me an email!

Errata

2018-10-03: I realized that 4.0mm of rain as an automation value was quite high. Even over a few days of light rain, it never reported above 1.0mm at any single ~2h interval, let alone 4.0mm. So 0.5mm seems like a much nicer value than 4.0mm for the “it’s just lightly showered”/“it’s actually rained” distinction I was going for. The code and descriptions above have been updated. One could also modify the templates to add up the 24h/48h total and base the condition off the total, which is a little more clunky but would be more accurate if that matters in your usecase.