We recently remodeled our main and kids bathrooms. I already have many z-wave devices in the home, mostly from Zooz, and I wanted to replace our existing Home Depot fan timer with a Zooz ZEN32. I know HASS has a first-party timer helper, so this should all be fairly straightforward, right?
Narrator: It wasn’t.
Finished look is pretty slick with a custom faceplate from Domotinc:
My requirements:
- The big button (relay) operates as a traditional on/off switch for the fan. When pressed, it should also disable any timers, regardless of state.
- LEDs for all buttons should remain on at all times. Default to white color for each while inactive. Turn the color to green when active.
- Anytime the fan is running (by manual control or automation), indicate this by changing the relay button’s LED to green.
- The four scene control buttons should each run the bath fan for a pre-defined amount of time: 5, 10, 15, or 30 minutes.
- When a scene control button is pressed, its LED color should change to green, letting the user know how long the fan will be running.
- As the fan timer counts down, LEDs of the shorter timer buttons should update to let the user know roughly how much longer the fan will run. (Example below)
- A button press should immediately override an existing timer.
- When the timer finishes and the fan turns off, all LEDs should be white again.
Example:
- When the 15m button is pressed, its LED should change to green and the fan turns on. Relay LED should also be green because the fan is now on.
- When there’s 10 minutes left on this timer, turn the 10m LED green and set the 15m LED back to white.
- When there’s 5 minutes left, turn the 5m LED green and set the 10m LED back to white.
- When the timer finishes, turn off the fan and set all LEDs back to white.
As I started playing with the timer helper, I was informed of a critical limitation: the timer’s remaining
attribute is only updated when the timer is paused, canceled, or finishes. This is gonna get complicated.
This new limitation calls for some technical updates to the requirements:
- No time delays in automations. This ensures reliability when someone inevitably presses multiple scene buttons in rapid succession.
- All automations must be triggered by events or state changes. Daisy-chaining automations together can introduce race conditions, which leads to inconsistent behavior and is difficult to troubleshoot (among other things).
- The solution must survive a HASS restart. Tick that “Restore?” checkbox in the
timer
helper config!
I noodled on the design for awhile and tinkered with some ideas, eventually landing on what you see below. Since my desired timer presets are all divisible by 5, I decided the best approach was to exclusively use 5-minute timers and track the number of cycles remaining using a counter
helper. My solution uses the following four automations, plus one script:
1. Scene Buttons Handler
alias: Main Bath Fan Scene Buttons Handler
description: >-
Turns on the bath fan, starts a 5-minute timer, and specifies how may 5-minute
timer cycles to run based on which button was pressed.
trigger:
- platform: event
event_type: zwave_js_value_notification
event_data:
node_id: 78
label: Scene 001
value: KeyPressed
id: 5m
- platform: event
event_type: zwave_js_value_notification
event_data:
node_id: 78
label: Scene 002
value: KeyPressed
id: 10m
- platform: event
event_type: zwave_js_value_notification
event_data:
node_id: 78
label: Scene 003
value: KeyPressed
id: 15m
- platform: event
event_type: zwave_js_value_notification
event_data:
node_id: 78
label: Scene 004
value: KeyPressed
id: 30m
condition: []
action:
- service: switch.turn_on
metadata: {}
data: {}
target:
entity_id: switch.main_bath_fan_controller_zsc03
enabled: true
- delay:
hours: 0
minutes: 0
seconds: 0
milliseconds: 100
- service: timer.start
target:
entity_id: timer.main_bath_fan_timer
data:
duration: "0:05:00"
- choose:
- conditions:
- condition: trigger
id:
- 5m
sequence:
- service: counter.set_value
target:
entity_id: counter.main_bath_fan_timer_cycles_remaining
data:
value: 1
enabled: true
- conditions:
- condition: trigger
id:
- 10m
sequence:
- service: counter.set_value
target:
entity_id: counter.main_bath_fan_timer_cycles_remaining
data:
value: 2
enabled: true
- conditions:
- condition: trigger
id:
- 15m
sequence:
- service: counter.set_value
target:
entity_id: counter.main_bath_fan_timer_cycles_remaining
data:
value: 3
enabled: true
- conditions:
- condition: trigger
id:
- 30m
sequence:
- service: counter.set_value
target:
entity_id: counter.main_bath_fan_timer_cycles_remaining
data:
value: 6
enabled: true
mode: single
2. Iterate Cycles
I should probably add some if/then conditionals to the actions here so we only update the zwave parameters that actually need updating. A task for another day, perhaps.
alias: "Main Bath Fan Timer: Iterate Cycles"
description: >-
Iterates through the specified number of 5-minute timer cycles to keep the
bath fan running for the desired amount of time.
trigger:
- platform: state
entity_id:
- counter.main_bath_fan_timer_cycles_remaining
to: null
enabled: true
condition: []
action:
- choose:
- conditions:
- condition: template
value_template: >-
{{ states.counter.main_bath_fan_timer_cycles_remaining.state | int
== 1 }}
sequence:
- service: timer.start
target:
entity_id:
- timer.main_bath_fan_timer
data:
duration: "00:05:00"
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "7"
value: "2"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "8"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "9"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "10"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- conditions:
- condition: template
value_template: >-
{{ states.counter.main_bath_fan_timer_cycles_remaining.state | int
== 2 }}
sequence:
- service: timer.start
target:
entity_id:
- timer.main_bath_fan_timer
data:
duration: "00:05:00"
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "7"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "8"
value: "2"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "9"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "10"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- conditions:
- condition: template
value_template: >-
{{ states.counter.main_bath_fan_timer_cycles_remaining.state | int
== 3 }}
sequence:
- service: timer.start
target:
entity_id:
- timer.main_bath_fan_timer
data:
duration: "00:05:00"
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "7"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "8"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "9"
value: "2"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "10"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- conditions:
- condition: template
value_template: >-
{{ states.counter.main_bath_fan_timer_cycles_remaining.state | int
== 6 }}
sequence:
- service: timer.start
target:
entity_id:
- timer.main_bath_fan_timer
data:
duration: "00:05:00"
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "7"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "8"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "9"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "10"
value: "2"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- conditions:
- condition: template
value_template: >-
{{ states.counter.main_bath_fan_timer_cycles_remaining.state | int
in (4,5) }}
sequence:
- service: timer.start
target:
entity_id:
- timer.main_bath_fan_timer
data:
duration: "00:05:00"
- conditions:
- condition: template
value_template: >-
{{ states.counter.main_bath_fan_timer_cycles_remaining.state | int
== 0 }}
sequence:
- if:
- condition: state
entity_id: switch.main_bath_fan_controller_zsc03
state: "on"
then:
- service: switch.turn_off
metadata: {}
data: {}
target:
entity_id: switch.main_bath_fan_controller_zsc03
mode: single
3. Timer Reset
alias: "Main Bath Fan Timer: Reset"
description: Decrements the bath fan cycle counter when the 5-minute timer completes.
trigger:
- platform: event
event_type: timer.finished
event_data:
entity_id: timer.main_bath_fan_timer
condition: []
action:
- service: counter.decrement
metadata: {}
data: {}
target:
entity_id:
- counter.main_bath_fan_timer_cycles_remaining
mode: single
4. Relay Toggle & LEDs
alias: "Main Bath Fan: Relay Toggle & LEDs"
description: >-
Clears the fan timer and cycle counter, then resets LED colors when the fan is turned on or off. Note that this automation will fire in parallel to the Scene Buttons Handler automation (#1), which is why that automation has a 100ms delay built in.
trigger:
- platform: state
entity_id:
- switch.main_bath_fan_controller_zsc03
from: "on"
to: "off"
id: Fan Off
- platform: state
entity_id:
- switch.main_bath_fan_controller_zsc03
from: "off"
to: "on"
id: Fan On
condition: []
action:
- if:
- condition: state
entity_id: timer.main_bath_fan_timer
state: active
then:
- service: timer.cancel
metadata: {}
data: {}
target:
entity_id:
- timer.main_bath_fan_timer
- if:
- condition: template
value_template: >-
{{ states.counter.main_bath_fan_timer_cycles_remaining.state | int > 0
}}
then:
- service: counter.set_value
metadata: {}
data:
value: 0
target:
entity_id:
- counter.main_bath_fan_timer_cycles_remaining
- choose:
- conditions:
- condition: trigger
id:
- Fan On
sequence:
- service: zwave_js.set_config_parameter
target:
device_id:
- 7bfff8817706360248b0f81cd46f805b
data:
endpoint: "0"
parameter: "6"
value: "2"
- conditions:
- condition: trigger
id:
- Fan Off
sequence:
- service: script.reset_main_bath_fan_leds
metadata: {}
data: {}
mode: single
5. Script to Reset LEDs
Resets all LED colors on the ZEN32 to white. I moved this into a separate script to avoid having to insert all these commands in multiple places. Also makes it easier to maintain.
alias: Reset Main Bath Fan LEDs
sequence:
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "6"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "7"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "8"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "9"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "10"
value: "0"
endpoint: "0"
target:
device_id: 7bfff8817706360248b0f81cd46f805b
mode: single
icon: mdi:led-on
ZEN32 Config Parameters
Here’s the config parameters I use while the fan isn’t running:
- p1,2,3,4,5 (LED indicator) = Always On
- p6,7,8,9,10 (LED color) = White
- p23 (LED settings indicator) = Disable. (Enabling this creates a cool wavy animation with the scene button LEDs whenever they are updated. Was fun for a few days but I quickly grew tired of it.)
Whew! Not nearly as simple as expected, but it works reliably and really shouldn’t require maintenance. Hopefully this is helpful in some way for others; this should work well for most use cases. And if you want to be extra fancy, check out the update below. Happy config-ing
----------- Aug 2024 Update -----------
I refactored a few of original automations and was able to consolidate two of them. But that’s the simple part of this update. We’re about to get fancy.
After living with the initial implementation for a few weeks, I noticed that the humidity wasn’t always fully cleared by the selected fan cycle. When this happens, I want the fan to run for another 5-minute cycle and continue adding 5-minute cycles until the humidity clears.
Unfortunately, I found my humidity sensor’s readings are unreliable while the fan is running, likely due to its wall placement almost directly below the fan itself. The flowing air is less humid than the stagnant air, so I need to wait a few minutes after the fan stops before re-evaluating the humidity. (I’m using the humidity sensor in our heated floor’s thermostat, so I can’t easily relocate it.)
How do we know if enough humidity was cleared? We go deeper! So in this next section, we will:
- Find the median humidity from a rolling 48-hour window
- Define an acceptable threshold above that level
- Compare the current humidity to that value
Calculate Median Humidity
HASS’ integrated statistics platform has everything we need. I ended up using 50 hours instead of 48 to allow for some variance in daily routines. But that probably doesn’t matter since we’re finding the median. (You definitely don’t want to use the average.)
sensor:
- platform: statistics
name: "Main Bath Median Humidity"
entity_id: sensor.main_bath_heated_floor_thermostat_het02_humidity
unique_id: main_bath_median_humidity
state_characteristic: median
max_age:
hours: 50
sampling_size: 1000
precision: 1
Define A Threshold
I chose a 15% buffer above the median humidity as my threshold. I’m storing this value as a custom variable to make my solution scalable. This way I can quickly make adjustments without digging through a bunch of automation yaml.
var:
bathroom_median_humidity_threshold:
friendly_name: "Bathroom Median Humidity Threshold"
unique_id: bathroom_median_humidity_threshold
initial_value: 1.15
icon: mdi:numeric
restore: true
Compare to Current Humidity
One final automation to build. Although I could configure this as a numeric state trigger using our new threshold, I’d rather trigger this after the fan’s been off for a few minutes to avoid interrupting a long bath or shower. YMMV.
You only need one instance of this automation, even if you have multiple ZEN32s as bath fan timers. Just add an additional trigger, condition, and action to the below automation for each ZEN32.
6. Re-Run Bath Fan When Humidity Wasn’t Fully Cleared
alias: Re-run bath fan when humidity is abnormally high
description: >-
Turns the bath fan back on when humidity wasn't fully cleared by the previous
fan cycle. Changes the LED color to red to differentiate it from the regular cycle process.
trigger:
- platform: state
entity_id:
- switch.kids_bath_fan_controller_zsc02
from: "on"
to: "off"
for:
hours: 0
minutes: 5
seconds: 0
id: Kids Bath Fan
- platform: state
entity_id:
- switch.main_bath_fan_controller_zsc03
from: "on"
to: "off"
for:
hours: 0
minutes: 5
seconds: 0
id: Main Bath Fan
condition:
- condition: or
conditions:
- condition: and
conditions:
- condition: trigger
id:
- Kids Bath Fan
- condition: template
value_template: >-
{{
(states('sensor.kids_bath_heated_floor_thermostat_het01_humidity')
| float) >= ((states('sensor.kids_bath_median_humidity') | float)
* (states('var.bathroom_median_humidity_threshold') | float)) }}
- condition: and
conditions:
- condition: trigger
id:
- Main Bath Fan
- condition: template
value_template: >-
{{
(states('sensor.main_bath_heated_floor_thermostat_het02_humidity')
| float) >= ((states('sensor.main_bath_median_humidity') | float)
* (states('var.bathroom_median_humidity_threshold') | float)) }}
enabled: true
action:
- choose:
- conditions:
- condition: trigger
id:
- Kids Bath Fan
sequence:
- service: switch.turn_on
metadata: {}
data: {}
target:
entity_id: switch.kids_bath_fan_controller_zsc02
- delay:
hours: 0
minutes: 0
seconds: 0
milliseconds: 100
- service: counter.set_value
metadata: {}
data:
value: 1
target:
entity_id: counter.kids_bath_fan_timer_cycles_remaining
- delay:
hours: 0
minutes: 0
seconds: 0
milliseconds: 100
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "6"
endpoint: "0"
value: "4"
target:
device_id: 7ae2389f85f7fd07ac99547c324a3ff8
- conditions:
- condition: trigger
id:
- Main Bath Fan
sequence:
- service: switch.turn_on
metadata: {}
data: {}
target:
entity_id: switch.main_bath_fan_controller_zsc03
- delay:
hours: 0
minutes: 0
seconds: 0
milliseconds: 100
- service: counter.set_value
metadata: {}
data:
value: 1
target:
entity_id: counter.main_bath_fan_timer_cycles_remaining
- delay:
hours: 0
minutes: 0
seconds: 0
milliseconds: 100
- service: zwave_js.set_config_parameter
metadata: {}
data:
parameter: "6"
endpoint: "0"
value: "4"
target:
device_id:
- 7bfff8817706360248b0f81cd46f805b
mode: single
Dashboard Card
Bring it all together with a Lovelace card, enhanced by the template entity row package:
type: vertical-stack
cards:
- type: entities
state_color: true
show_header_toggle: false
entities:
- entity: switch.main_bath_fan_controller_zsc03
secondary_info: last-updated
- entity: timer.main_bath_fan_timer
secondary_info: last-updated
- entity: counter.main_bath_fan_timer_cycles_remaining
secondary_info: last-updated
- entity: sensor.main_bath_median_humidity
name: Main Bath Median Humidity
- entity: sensor.main_bath_heated_floor_thermostat_het02_humidity
name: Main Bath Humidity
- type: custom:template-entity-row
name: Main Bath Humidity Threshold
icon: mdi:border-top
state: >-
{{ ((states('sensor.main_bath_median_humidity') | float) *
(states('var.bathroom_median_humidity_threshold') | float)) | round(1)
}}%
color: >-
{% if
states('sensor.main_bath_heated_floor_thermostat_het02_humidity') |
float >= states('sensor.main_bath_median_humidity') | float *
states('var.bathroom_median_humidity_threshold') | float %} red {%
else %} green {% endif %}
secondary: 'Threshold: {{states(''var.bathroom_median_humidity_threshold'')}}x'
title: Main Bath