This post may contain affiliate links. As an Amazon Associate we earn from qualifying purchases. Disclosure.

TL;DR

Commercial smart sprinkler controllers want a subscription and a cloud account to open a valve. I built a 6-zone irrigation controller around a $8 ESP32 instead, with rainwater priority, automatic tank refill, and full Home Assistant integration. Here's the complete build, firmware included.

Quick take: A 6-zone DIY irrigation controller built on an ESP32 with ESPHome costs about $160 in panel parts and needs zero cloud services. Mine waters from a 3000 liter rainwater tank first, falls back to mains automatically below a 12 percent reserve, and refills the tank on its own. Full firmware, wiring pinout, and Home Assistant configs below.

My garden drinks rainwater. A 3000 liter tank collects roof runoff, a submersible pressure pump pushes it to six watering zones, and the water company only gets involved during a long dry spell. The brain of the whole thing? An $8 ESP32 on a DIN rail, running open source firmware I can read line by line.

This guide walks through the complete build: the plumbing layout, the control panel wiring, the full ESPHome firmware, and the Home Assistant side. I've kept every config file complete, so you can copy, adjust the pins and thresholds, and flash.

Why Build Your Own Irrigation Controller?

I priced commercial options first. A 6-zone smart controller with weather logic runs $150 to $250, and the popular ones tie basic features like seasonal adjustment to a phone app that phones home. None of them knew what a rainwater tank was. Not one could switch to a backup source when the tank ran low, let alone refill it.

The DIY route solved all three problems in one weekend of soldering and one evening of YAML. And it removed the part I dislike most about consumer smart hardware: the vendor sitting between me and my own solenoid valves.

If you have never flashed a board before, this is not the project to learn on. Start with a sensor that cannot flood anything, get the toolchain working, then come back. The ESPHome and Home Assistant walkthrough covers that first hour, and everything below assumes you have already done it once.

One honest warning before the parts list. This project mixes 230V mains, 24V AC valve wiring, and plumbing backflow rules. The low-voltage side is beginner friendly. The mains side belongs behind an RCBO, and if wiring a contactor makes you nervous, hand that one section to an electrician and build the rest yourself.

What Parts Go into the Build?

The control panel lives in an IP65 DIN rail enclosure. Everything below, minus the pump and the valve boxes I already had, cost me about $160.

PartRoleApprox. price
ESP32 DevKit (ESP-WROOM-32)controller$8
8-channel optoisolated relay board, 5V + 1 spare relayvalve and pump switching$12
Breve PSS 50 transformer, 230V to 24V AC, 50 VAvalve coil power$30
Mean Well MDR-60-5 DIN supply, 5V 10AESP32 and relay power$25
DFRobot A02YYUW ultrasonic sensortank level$16
2x Rain Bird 100-DV 1 inch valve, 24V ACtank fill + mains bypass$50
2x Gardena V3 valve box, 3 zones eachzone valves 1 to 6(already owned)
Contactor 25A + RCBO B6 30mApump switching, protection$30
IP65 DIN enclosure, fuses, wirehousing$40

Two sizing notes that bit me. First, the 50 VA transformer limit: a Rain Bird 100-DV coil pulls around 0.45 A on inrush, so you get one source valve plus one zone valve at a time, never a free-for-all. The firmware enforces sequential zones for exactly this reason. Second, the pump never hangs off a hobby relay. A 1 kW submersible motor gets a proper contactor; the relay board only switches the contactor coil.

How Does the Water Side Fit Together?

MAINS CHECK VALVE FILL VALVE BYPASS VALVE AIR GAP RAIN TANK 3000 L PUMP 1 inch manifold 2x GARDENA V3 BOX Z1 Z2 Z3 Z4 Z5 Z6
Water path: rainwater is the default source, mains is the buffered backup.

Two rules shape this layout. Rainwater and drinking water must never connect directly, and the pump must never fight the bypass valve. The tank refill line drops water in through a Type AA air gap, an open vertical gap above the tank inlet, so backflow into the mains simply can't happen. The bypass line gets a 1 inch check valve, and my IBO Multi IP 1200 pump ships with an internal check valve on its outlet, which isolates the tank side.

Both sources meet at a shared manifold feeding two Gardena V3 valve boxes, three zones each. Standard 25 mm pipe and 1 inch threads throughout, nothing exotic.

How Do You Wire the Control Panel?

The 230V section is short: RCBO in, transformer and 5V supply out, plus a contactor for the pump. Everything else is low voltage. The relay board switches the 24V AC hot leg to each valve coil, and all coil commons return straight to the transformer's second terminal.

Why so much fuss about which relay closes when? Because most cheap optoisolated boards are active-low: the input pin pulled to ground energizes the relay. In ESPHome that is a one-line fix, inverted: true on every output, but miss it and all nine relays slam on at boot. Ask me how I know.

ESP32 Pin Map

ESP32 pinRelayDrivesVoltage
GPIO16 (RX2)-A02YYUW level sensor TX3.3V UART
GPIO32K1tank fill valve24V AC
GPIO33K2mains bypass valve24V AC
GPIO25K3zone 124V AC
GPIO26K4zone 224V AC
GPIO27K5zone 324V AC
GPIO14K6zone 424V AC
GPIO13K7zone 524V AC
GPIO23K8zone 624V AC
GPIO19K9pump contactor coil230V via contactor

I deliberately avoided GPIO0, GPIO2, and GPIO15. Those are strapping pins that wiggle during boot, and a valve that twitches on every restart is a flooded flower bed waiting to happen. You don't want strapping pins anywhere near a solenoid.

The Complete ESPHome Firmware

The whole controller is one YAML file. I run it on ESPHome 2026.6, and the design goal was simple: every safety rule must work with the router unplugged. Home Assistant is a remote control here, not the brain. If ESPHome is new to you, skim the ESPHome sprinkler component docs first; it does the zone sequencing heavy lifting.

Tank Level Sensing

The A02YYUW is a waterproof ultrasonic sensor that spits distance readings over UART at 9600 baud. Mount it inside the tank lid pointing at the water, at least 3 cm above the highest level to respect its blind zone.

substitutions:
  dist_full_cm: "22"     # sensor to water surface, tank full - measure yours
  dist_empty_cm: "112"   # sensor to water surface, tank empty - measure yours
  tank_liters: "3000"
  reserve_pct: "12"      # below this, irrigation switches to mains bypass
  fill_start_pct: "25"   # refill valve opens
  fill_stop_pct: "85"    # refill valve closes
  fill_timeout: 90min    # refill hard stop - leak protection
  max_source_runtime: 60min

esphome:
  name: irrigation
  friendly_name: Garden Irrigation
  on_boot:
    priority: -100
    then:
      - switch.turn_off: relay_fill
      - switch.turn_off: relay_bypass
      - switch.turn_off: relay_pump
      - switch.turn_off: zone1
      - switch.turn_off: zone2
      - switch.turn_off: zone3
      - switch.turn_off: zone4
      - switch.turn_off: zone5
      - switch.turn_off: zone6

esp32:
  board: esp32dev

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome

logger:

uart:
  id: uart_level
  rx_pin: GPIO16
  baud_rate: 9600

sensor:
  - platform: a02yyuw
    uart_id: uart_level
    id: tank_distance
    name: Tank Distance
    unit_of_measurement: cm
    filters:
      - multiply: 0.1          # sensor reports millimeters
      - median:
          window_size: 7
          send_every: 4
      - clamp:
          min_value: ${dist_full_cm}
          max_value: ${dist_empty_cm}
      - throttle_average: 30s

  - platform: template
    id: tank_percent
    name: Tank Level
    unit_of_measurement: "%"
    accuracy_decimals: 0
    update_interval: 30s
    lambda: |-
      const float full = ${dist_full_cm};
      const float empty = ${dist_empty_cm};
      const float d = id(tank_distance).state;
      if (isnan(d)) { return NAN; }
      return clamp((empty - d) / (empty - full) * 100.0f, 0.0f, 100.0f);

  - platform: template
    id: tank_volume
    name: Tank Volume
    unit_of_measurement: L
    accuracy_decimals: 0
    update_interval: 60s
    lambda: |-
      if (isnan(id(tank_percent).state)) { return NAN; }
      return id(tank_percent).state * ${tank_liters} / 100.0;

The median filter matters more than it looks. When the pump runs, the water surface ripples and raw readings jump 4 to 6 cm. Filtered, my level trace is smooth enough to hang refill decisions on.

Relays, Interlock, and Source Selection

Here is the part no commercial controller offered me. The pump and the bypass valve sit in an ESPHome interlock, so firmware physically refuses to energize both. A template switch called water_source picks between them by tank level every time a cycle starts.

switch:
  - platform: gpio
    id: relay_fill
    name: Tank Fill Valve
    pin: { number: GPIO32, inverted: true }
    restore_mode: ALWAYS_OFF
    on_turn_on:
      - script.execute: fill_watchdog
    on_turn_off:
      - script.stop: fill_watchdog

  - platform: gpio
    id: relay_bypass
    name: Mains Bypass Valve
    pin: { number: GPIO33, inverted: true }
    restore_mode: ALWAYS_OFF
    interlock: [relay_pump]
    on_turn_on:
      - script.execute: source_watchdog

  - platform: gpio
    id: relay_pump
    name: Rain Tank Pump
    pin: { number: GPIO19, inverted: true }
    restore_mode: ALWAYS_OFF
    interlock: [relay_bypass]
    on_turn_on:
      - script.execute: source_watchdog

  - platform: gpio
    id: zone1
    pin: { number: GPIO25, inverted: true }
    restore_mode: ALWAYS_OFF
  - platform: gpio
    id: zone2
    pin: { number: GPIO26, inverted: true }
    restore_mode: ALWAYS_OFF
  - platform: gpio
    id: zone3
    pin: { number: GPIO27, inverted: true }
    restore_mode: ALWAYS_OFF
  - platform: gpio
    id: zone4
    pin: { number: GPIO14, inverted: true }
    restore_mode: ALWAYS_OFF
  - platform: gpio
    id: zone5
    pin: { number: GPIO13, inverted: true }
    restore_mode: ALWAYS_OFF
  - platform: gpio
    id: zone6
    pin: { number: GPIO23, inverted: true }
    restore_mode: ALWAYS_OFF

  - platform: template
    id: water_source
    name: Water Source
    lambda: 'return id(relay_pump).state || id(relay_bypass).state;'
    turn_on_action:
      - if:
          condition:
            lambda: 'return id(tank_percent).state > ${reserve_pct};'
          then:
            - switch.turn_on: relay_pump
          else:
            - switch.turn_on: relay_bypass
    turn_off_action:
      - switch.turn_off: relay_pump
      - switch.turn_off: relay_bypass

Zones, Refill, and Watchdogs

The sprinkler component runs zones one at a time, which protects both the 50 VA transformer and the water pressure. It treats water_source as its pump, so source selection happens automatically at the start of every cycle.

sprinkler:
  - id: irrigation_ctl
    main_switch: Irrigation
    auto_advance_switch: Zones Auto Advance
    pump_switch_id: water_source
    valve_open_delay: 2s
    valves:
      - valve_switch: Zone 1 Front Lawn
        valve_switch_id: zone1
        run_duration_number: { name: Zone 1 Duration, initial_value: 10, min_value: 1, max_value: 60, step: 1, unit_of_measurement: min }
      - valve_switch: Zone 2 Back Lawn
        valve_switch_id: zone2
        run_duration_number: { name: Zone 2 Duration, initial_value: 10, min_value: 1, max_value: 60, step: 1, unit_of_measurement: min }
      - valve_switch: Zone 3 Left Beds
        valve_switch_id: zone3
        run_duration_number: { name: Zone 3 Duration, initial_value: 8, min_value: 1, max_value: 60, step: 1, unit_of_measurement: min }
      - valve_switch: Zone 4 Right Beds
        valve_switch_id: zone4
        run_duration_number: { name: Zone 4 Duration, initial_value: 8, min_value: 1, max_value: 60, step: 1, unit_of_measurement: min }
      - valve_switch: Zone 5 Hedge Drip
        valve_switch_id: zone5
        run_duration_number: { name: Zone 5 Duration, initial_value: 15, min_value: 1, max_value: 60, step: 1, unit_of_measurement: min }
      - valve_switch: Zone 6 Greenhouse
        valve_switch_id: zone6
        run_duration_number: { name: Zone 6 Duration, initial_value: 5, min_value: 1, max_value: 60, step: 1, unit_of_measurement: min }

globals:
  - id: fill_alarm
    type: bool
    initial_value: 'false'

binary_sensor:
  - platform: template
    id: low_water
    name: Low Water
    lambda: 'return id(tank_percent).state < ${reserve_pct};'
  - platform: template
    id: fill_timeout_alarm
    name: Fill Timeout Alarm
    lambda: 'return id(fill_alarm);'

text_sensor:
  - platform: template
    name: Water Source Mode
    update_interval: 5s
    lambda: |-
      if (id(relay_pump).state) { return {"rain tank"}; }
      if (id(relay_bypass).state) { return {"mains"}; }
      return {"idle"};

interval:
  - interval: 60s
    then:
      - if:
          condition:
            and:
              - lambda: 'return !isnan(id(tank_percent).state);'
              - lambda: 'return id(tank_percent).state < ${fill_start_pct};'
              - lambda: 'return !id(fill_alarm);'
              - switch.is_off: relay_fill
          then:
            - switch.turn_on: relay_fill
      - if:
          condition:
            and:
              - switch.is_on: relay_fill
              - lambda: 'return id(tank_percent).state >= ${fill_stop_pct};'
          then:
            - switch.turn_off: relay_fill

script:
  - id: fill_watchdog
    mode: restart
    then:
      - delay: ${fill_timeout}
      - globals.set: { id: fill_alarm, value: 'true' }
      - switch.turn_off: relay_fill
      - logger.log: 'Fill timeout - valve closed, inspect for leaks or a stuck valve'
  - id: source_watchdog
    mode: restart
    then:
      - delay: ${max_source_runtime}
      - sprinkler.shutdown: irrigation_ctl
      - switch.turn_off: relay_pump
      - switch.turn_off: relay_bypass
      - logger.log: 'Watchdog - max source runtime exceeded, all outputs off'

The refill loop runs on the ESP32 every 60 seconds, independent of watering. Open at 25 percent, close at 85 percent, hard stop after 90 minutes. That timeout saved me once already: a fill valve stuck half-open would otherwise have dumped mains water through the overflow all night. As a last mechanical backstop, the tank overflow drains to a soakaway, so even total controller failure can't flood anything.

How Does It Plug into Home Assistant?

The ESPHome integration discovers the device within a minute of it joining the network, and every switch, number, and sensor appears as an entity. Firmware handles safety; Home Assistant handles scheduling and nagging me.

My automations.yaml, trimmed to the three that matter:

- id: irrigation_morning_run
  alias: 'Irrigation: 5:30 run with rain skip'
  triggers:
    - trigger: time
      at: '05:30:00'
  conditions:
    - condition: numeric_state
      entity_id: sensor.forecast_precipitation
      below: 1
  actions:
    - action: switch.turn_on
      target:
        entity_id: switch.irrigation

- id: irrigation_low_water
  alias: 'Irrigation: low tank warning'
  triggers:
    - trigger: state
      entity_id: binary_sensor.low_water
      to: 'on'
      for: '00:05:00'
  actions:
    - action: notify.mobile_app_phone
      data:
        title: Rain tank low
        message: 'Tank at {{ states("sensor.tank_level") }}%. Watering runs on mains until it refills.'

- id: irrigation_fill_alarm
  alias: 'Irrigation: refill timeout alarm'
  triggers:
    - trigger: state
      entity_id: binary_sensor.fill_timeout_alarm
      to: 'on'
  actions:
    - action: notify.mobile_app_phone
      data:
        title: Tank refill timeout
        message: Fill valve ran 90 minutes without reaching 85%. Check for leaks.

And the dashboard card, one gauge plus manual controls:

type: vertical-stack
cards:
  - type: gauge
    entity: sensor.tank_level
    name: Rain Tank
    severity: { green: 40, yellow: 20, red: 0 }
  - type: history-graph
    entities: [sensor.tank_volume]
    hours_to_show: 72
  - type: entities
    title: Irrigation
    entities:
      - switch.irrigation
      - switch.zones_auto_advance
      - sensor.water_source_mode
      - switch.tank_fill_valve
      - switch.mains_bypass_valve
      - switch.rain_tank_pump

Entity ids depend on your device name, so check yours under Settings, Devices and Services, ESPHome before pasting.

First Run and Calibration

Do the first test with the pump breaker off and the manifold's manual ball valve closed. Watch relays, not water.

  • Flash over USB, confirm all nine relays stay silent through three reboots.
  • Measure the sensor-to-water distance at full and near-empty tank, then set dist_full_cm and dist_empty_cm. My flat 3000 liter tank is close enough to linear; a very irregular tank wants a calibration table instead.
  • Toggle each zone from Home Assistant and listen for the correct V3 valve clicking.
  • Force the source logic: with the tank above reserve, starting a cycle must pull in the pump contactor; set reserve_pct to 99 temporarily and the bypass valve must open instead.
  • Let a refill run to completion once while you watch, before you ever leave it unattended.

After that, open the water and let the sprinkler component do its thing. Mine has now run a full season: 41,000 liters through the zones, roughly 80 percent of it rainwater, zero stuck valves, and one troubleshooting session caused by a spider nesting on the ultrasonic sensor. Is it more work than buying a controller off the shelf? Sure, the first weekend. Every weekend since, it's been less.

If you want the deeper background on how ESPHome devices talk to Home Assistant, my earlier ESPHome sensor firmware guide covers the toolchain from install to OTA updates. This build is green living with a soldering iron: the garden stays watered, the water bill stays boring, and the logic stays in my panel, where it belongs.

Frequently Asked Questions

Does the irrigation controller work when Home Assistant is down?

Yes, and that was a hard requirement for my build. All critical logic lives in the ESPHome firmware on the ESP32 itself: zone sequencing, the pump-versus-bypass interlock, source selection by tank level, automatic refill with its timeout, and the runtime watchdog. Home Assistant only adds scheduling, notifications, and a dashboard. If the Wi-Fi router or the Home Assistant server dies mid-cycle, the ESP32 finishes the watering run on its own and the safety rules keep working. A power cut is also safe because every valve is normally closed and the boot sequence forces all nine outputs off before any automation can run.

Why does the build use 24V AC valves instead of 12V DC ones?

Because 24V AC is the de facto standard for irrigation hardware. Rain Bird, Hunter, Gardena, and Orbit all sell 24V AC solenoid valves, so replacements are stocked in every garden center, and the coils tolerate long cable runs to valve boxes better than low-voltage DC. A Rain Bird 100-DV coil draws about 0.45 A at inrush, which is why a 50 VA bell transformer comfortably drives one zone valve plus one source valve at a time. The relay board doesn't care whether it switches AC or DC, so the ESP32 side stays identical.

How accurate is the A02YYUW ultrasonic sensor for tank level?

Good enough for irrigation decisions, not for lab work. The A02YYUW reads 3 to 450 cm with roughly centimeter-level repeatability once you filter it, and it is fully waterproof, which matters inside a condensation-heavy tank. Raw readings bounce around when the water surface ripples during pumping, so the firmware applies a median filter over 7 samples plus a 30 second throttle average. After filtering, my reported level drifts less than 2 percent day to day, and the refill thresholds sit 60 percentage points apart, so noise never causes valve chatter.

What happens when the rainwater tank runs empty mid-cycle?

The controller switches sources instead of running the pump dry. Every time a watering cycle asks for water, the firmware checks the tank percentage against the reserve threshold, 12 percent in my config. Above it, the pump runs; below it, the mains bypass valve opens instead, and an interlock guarantees the two can never be energized together. The pump also has its own dry-run protection as a second layer. Meanwhile the independent refill loop opens the fill valve at 25 percent and closes it at 85 percent, so in practice the tank rarely gets low enough to force a mains cycle.

Sources & References

  • ESPHome - Sprinkler Controller component esphome.io
  • Home Assistant - ESPHome integration home-assistant.io
  • DFRobot - A02YYUW waterproof ultrasonic sensor wiki wiki.dfrobot.com