The problem
I have one avocado tree, planted far enough from the house that a wired soil moisture sensor isn’t practical, and wireless soil moisture sensors have a reputation (confirmed by my own research) for being unreliable enough that I didn’t want to build irrigation decisions on top of one. The irrigation valve runs off a smart plug, and — this constraint shaped a lot of the design — the pump that pressurizes the line is the same pump that serves the whole house. Any tap or shower running skews a naive “is the pump on” check.
I wanted something that:
Waters enough, but not on a naive fixed timer that ignores rain
Distinguishes “top up what rain didn’t provide” from an occasional deep soak that actually reaches root depth
Doesn’t quietly fail — a stuck valve or a dead pump reading shouldn’t be something I only discover by noticing the tree wilting (or flooding)
Recovers on its own after a fault rather than needing me to babysit it (this runs unattended)
Hardware
Rain gauge: A cheap Misol tipping-bucket gauge, with the community-documented hack of wiring its reed switch into a Zigbee door/window contact sensor. Each tip = one momentary contact pulse, picked up over ZHA as a binary_sensor state change.
Valve control: A smart plug switching the solenoid valve.
Pump monitoring: A power-metering smart plug on the shared house pump — used as a sanity check only, never as a hard gate, given the shared-pump caveat above.
Why the rain gauge needed more than “read the sensor”
The Zigbee contact sensor only gives you a raw tip count — a lifetime counter that only ever increases. To get useful numbers (rain in the last 24h, 4 days, 7 days, 14 days), I initially tried sampling a rolling window into a manual array and summing it — this works, but it’s fragile (string-length limits, self-healing edge cases, a midnight cron dependency).
The cleaner fix, once I had a true monotonically-increasing accumulator: Home Assistant’s statistics platform is built for exactly this, using state_characteristic: sum_differences_nonnegative (not sum, which sums raw values and produces nonsense on an accumulator — a mistake worth calling out explicitly since it’s an easy one to make) with different max_age windows. Four of these — 30min, 24h, 3d/4d, 7d, 14d — feed every decision downstream.
One non-obvious catch: a statistics sensor sourced from something that only updates when it rains will read unknown once the last real data point ages out of the window during a dry spell. Fixed by making the source a trigger-based template sensor with a 15-minute heartbeat trigger alongside the real tip-count trigger, so it always has a fresh sample even when nothing’s happening.
Two watering modes, because they’re physically different problems
Routine irrigation (daily check, heat-adjusted 3–4 day interval): tops up whatever recent rain didn’t provide, scaled to a weekly mm target proportional to the actual interval, using whichever rain window (3d or 4d) matches that interval.
Deep soak (every 14 days): a separate, larger volume delivered as 3 pulsed cycles with 20-minute rest periods between them — pulsing avoids surface runoff and actually lets water reach 40–60cm depth instead of just wetting the topsoil. This mode has its own, longer dry-down memory (8 days vs. 4), since subsoil dries out slower than the surface.
Both modes share:
A mutex lock (input_boolean) so they can never run concurrently and fight over the same valve
A runtime safety cap, checked before the valve opens, that aborts with a notification if the computed watering time looks unreasonable (bad calibration, bad slider value)
A rain dry-down check — after a logged heavy-rain event, both modes back off for their respective holdoff period, so a big storm doesn’t get immediately followed by more watering just because the rolling rain total happens to dip
A last-second “is it actively storming right now” check (>3mm in the last 30 minutes cancels the run) — none of the other gates catch a downpour that starts five minutes before the scheduled run
A pump wattage check that warns and continues rather than aborting — given the shared pump, a low reading isn’t reliable enough evidence to cancel outright, but it’s worth flagging for review
Fault tolerance, independent of the irrigation logic
Two watchdogs run completely separately from the irrigation automations themselves:
If the valve is ever continuously on for 150 minutes (well past any legitimate run), it’s force-closed, active automation delays are cancelled (automation.turn_off with stop_actions: true, then re-armed), and the mutex is released — regardless of why it got stuck (HA restart mid-delay, a bug, anything).
If the mutex lock itself is ever stuck on for 180 minutes (e.g. HA restarted during a rest interval between deep-soak pulses, when the valve was legitimately closed but the lock never got released), it force-clears.
Neither watchdog needs to know anything about irrigation logic — they only care about physical/state facts (valve on too long, lock on too long), which is what makes them trustworthy as a genuine backstop rather than more of the same logic that could share the same bug.
All of it is tunable from the dashboard
Every threshold — target depths, application rate, dry-down days, runtime caps, pump threshold, rain ceilings, mm-per-tip calibration — is an input_number slider, not a value buried in YAML. Recalibrating the gauge or adjusting for a hotter season doesn’t require touching code.
What I deliberately left out, and why
No soil moisture sensor. Wireless options weren’t reliable enough to trust for irrigation decisions; wired wasn’t practical given the tree’s distance from the building. This is a known, accepted gap rather than an oversight — the target-depth math and rain accounting are a substitute, not a full replacement.
No per-zone flow confirmation. The pump wattage check confirms something is drawing water, not that this specific zone is. A flow sensor on the line would close this gap; for one tree, I didn’t think it was worth the added complexity and failure point.
No weather forecast integration — and this one’s deliberate, not a shortcut. I’m on a tropical island in Thailand, where rainfall is highly localized and convective (afternoon buildup, storm cells that form and dump over one specific area) rather than the broad frontal systems most weather models are tuned for. In practice, forecasts here have repeatedly predicted 3–4 days straight of heavy afternoon rain that never produced a single drop. A regional forecast just doesn’t correlate well with what actually happens at one specific tree on one specific island. Reacting to measured rain from my own gauge is simply more trustworthy than predicted rain from an API in this climate — this isn’t a gap I plan to close later, it’s the right architecture for where I live. If you’re in a temperate climate with more predictable frontal weather, forecast-based skip logic probably makes more sense for you than it does for me.
Happy to share the full YAML if anyone’s working with the same Misol hack or a similarly shared-pump setup.