Skip to content

Instantly share code, notes, and snippets.

@mharizanov
Created September 12, 2026 06:20
Show Gist options
  • Select an option

  • Save mharizanov/fc6d69cb39b339421241601a69c2fd0e to your computer and use it in GitHub Desktop.

Select an option

Save mharizanov/fc6d69cb39b339421241601a69c2fd0e to your computer and use it in GitHub Desktop.
SONOFF SWV zone irrigation from Home Assistant with the timer on the valve (cyclic_timed_irrigation via Zigbee2MQTT) — companion to the harizanov.com irrigation post

SONOFF SWV zone irrigation from Home Assistant, with the timer on the valve

Companion to the harizanov.com post on garden irrigation. Anonymized: replace the Zigbee2MQTT device names and the helper names with yours.

The problem this solves

The obvious way to run a valve for N minutes is switch.turn_on → delay → switch.turn_off. It works until Home Assistant restarts, or you reload scripts/automations from the UI, in the middle of a run. The delay dies with the script and the valve stays open. With six zones and a 03:00 start you find out in the morning.

The SONOFF SWV has its own timer. Zigbee2MQTT exposes it as cyclic_timed_irrigation; send one command and the valve opens, counts the seconds, and closes itself. Home Assistant never has to send "off". If HA is dead ten seconds after the command, the zone still closes on time.

The command

Publish to zigbee2mqtt/<friendly name>/set:

{
  "cyclic_timed_irrigation": {
    "current_count": 0,
    "total_number": 1,
    "irrigation_duration": 1200,
    "irrigation_interval": 0
  }
}
  • total_number: 1 = run once. Higher values cycle (irrigation_interval seconds of pause between runs) — useful for pulsing a drip line, not needed for sprinklers.
  • irrigation_duration is seconds (0–86400).
  • current_count must be present and 0; it is the valve's own progress counter.
  • All four keys are required in the same message. Sending only irrigation_duration does nothing.
  • To stop early, switch.turn_off the valve's switch entity. That cancels the running cycle.
  • The valve reports cyclic_timed_irrigation, irrigation_start_time / irrigation_end_time, real_time_irrigation_duration and daily_irrigation_volume (litres, device-side counter that resets when the run ends) back over MQTT, so you can verify a run without trusting HA's own timer.

Gotchas found the hard way

  1. auto_close_when_water_shortage is ENABLED from the factory. Fine on a zone valve. Fatal on a master valve that sits ahead of closed zone valves: it sees zero flow, decides there is a water shortage, and closes itself about 30 minutes after opening. Disable it on the master. A factory reset or re-pair turns it back on.
  2. Set retain: true for the master valve in Zigbee2MQTT (device options). Without it the switch is unknown after every HA restart and a gate like state: 'on' silently skips the run.
  3. Do not gate on state: 'on' at all. A dead battery makes the entity unavailable, which also fails that condition, and the 03:00 run is skipped for as long as you do not notice. Gate on "not confirmed closed" instead (condition: not + state: 'off'), see the automation.
  4. Zones run one at a time, so each gets full pressure. The automation's delay after each zone is only there to space the zones out; it is not what closes the valve. If the delay dies, the next zone simply starts late (or not at all), the open zone still closes itself.
  5. Use mode: single on the sequence automation so a second trigger cannot interleave zones, and keep the script mode: parallel so a manual tap on the dashboard works while the sequence runs.

Files

  • helpers.yaml — the helpers (put it in packages/, or create them in the UI).
  • scripts.yaml — swv_irrigation (the one that matters) and swv_irrigate_or_stop (dashboard tap: start or cancel).
  • automations.yaml — the nightly sequence for two zones; copy the block per zone.

Tested with Zigbee2MQTT 2.14 and Home Assistant 2026.9, SONOFF SWV-BSP.

# Nightly sequence, zones one at a time. Each zone: fire the valve's own timer, then wait
# (duration + 1 min) before the next zone. The delay only spaces the zones; the valve closes itself.
- id: irrigation_nightly_sequence
alias: Irrigation nightly sequence
mode: single
triggers:
- trigger: time
at: '03:00:00'
conditions:
# Your gates go here: outside temperature, humidity, rain forecast, soil moisture...
# Keep them "fail-open": a missing/stale sensor should water, not silently skip.
- condition: numeric_state
entity_id: sensor.outside_temperature
above: 10
actions:
# ---- zone 1: front lawn ----
- if:
- condition: state
entity_id: input_boolean.irrigation_zone_front
state: 'on'
- condition: template
value_template: >-
{{ (states('input_number.irrigation_time') | int(0))
+ (states('input_number.irrigation_zone_front_adjust_min') | int(0)) > 0 }}
then:
- action: script.swv_irrigation
data:
z2m_name: swv_front_lawn
duration_seconds: >-
{% set g = states('input_number.irrigation_time') | int(0) %}
{% set a = states('input_number.irrigation_zone_front_adjust_min') | int(0) %}
{{ [0, g + a] | max * 60 }}
- delay:
seconds: >-
{% set g = states('input_number.irrigation_time') | int(0) %}
{% set a = states('input_number.irrigation_zone_front_adjust_min') | int(0) %}
{{ ([0, g + a] | max + 1) * 60 }}
# ---- zone 2: back lawn ----
- if:
- condition: state
entity_id: input_boolean.irrigation_zone_back
state: 'on'
- condition: template
value_template: >-
{{ (states('input_number.irrigation_time') | int(0))
+ (states('input_number.irrigation_zone_back_adjust_min') | int(0)) > 0 }}
then:
- action: script.swv_irrigation
data:
z2m_name: swv_back_lawn
duration_seconds: >-
{% set g = states('input_number.irrigation_time') | int(0) %}
{% set a = states('input_number.irrigation_zone_back_adjust_min') | int(0) %}
{{ [0, g + a] | max * 60 }}
- delay:
seconds: >-
{% set g = states('input_number.irrigation_time') | int(0) %}
{% set a = states('input_number.irrigation_zone_back_adjust_min') | int(0) %}
{{ ([0, g + a] | max + 1) * 60 }}
# Package: irrigation helpers. Global run time plus per-zone enable flag and ± adjustment.
input_number:
irrigation_time:
name: Irrigation time (all zones)
min: 5
max: 45
step: 1
unit_of_measurement: min
icon: mdi:timer-outline
irrigation_zone_front_adjust_min:
name: Front lawn · adjust
min: -20
max: 20
step: 1
unit_of_measurement: min
irrigation_zone_back_adjust_min:
name: Back lawn · adjust
min: -20
max: 20
step: 1
unit_of_measurement: min
input_boolean:
irrigation_zone_front:
name: Front lawn · enabled
icon: mdi:sprinkler
irrigation_zone_back:
name: Back lawn · enabled
icon: mdi:sprinkler
# The timer runs ON THE VALVE. One MQTT message; the SWV closes itself after duration_seconds.
swv_irrigation:
alias: 'SWV: single cyclic timed irrigation'
mode: parallel
fields:
z2m_name:
name: Zigbee2MQTT friendly name of the zone valve
required: true
example: swv_front_lawn
duration_seconds:
name: Duration (seconds)
required: true
default: 600
selector:
number:
min: 1
max: 3600
step: 1
unit_of_measurement: s
sequence:
# Optional master-valve gate. Blocks only on a CONFIRMED closed master; `unavailable` /
# `unknown` (dead battery, restart) still waters. Delete this block if you have no master valve.
- alias: Master valve not confirmed CLOSED (fail-open)
condition: not
conditions:
- condition: state
entity_id: switch.swv_master
state: 'off'
- action: mqtt.publish
data:
topic: "zigbee2mqtt/{{ z2m_name }}/set"
qos: 0
retain: false
payload: >-
{{ {
"cyclic_timed_irrigation": {
"current_count": 0,
"total_number": 1,
"irrigation_duration": (duration_seconds | int),
"irrigation_interval": 0
}
} | tojson }}
# Dashboard tap: start the zone for (global time + zone adjust) minutes, or cancel a running zone.
swv_irrigate_or_stop:
alias: 'SWV: irrigate zone or stop (tap toggle)'
mode: parallel
fields:
z2m_name:
required: true
example: swv_front_lawn
switch_entity:
required: true
example: switch.swv_front_lawn
adjust_entity:
required: true
example: input_number.irrigation_zone_front_adjust_min
sequence:
- choose:
- conditions: "{{ is_state(switch_entity, 'on') }}"
sequence:
- action: switch.turn_off # cancels the valve's running cycle
target:
entity_id: "{{ switch_entity }}"
default:
- action: script.swv_irrigation
data:
z2m_name: "{{ z2m_name }}"
duration_seconds: >-
{% set g = states('input_number.irrigation_time') | int(0) %}
{% set a = states(adjust_entity) | int(0) %}
{{ [0, g + a] | max * 60 }}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment