Bathroom fan timer with LED progress indicators using the Zooz ZEN32 scene controller

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. :scream: :disappointed: 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 :slight_smile:

----------- 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! :smiley: 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

2 Likes

Hi Brystmar,

Thanks for posting your configuration to automate the bathroom fan using Zen32. I am trying to use the ZEN32 in the same manner but your control is way better than mine : ) I am a newbie to Automation in Home Assistant. A quick question: do I need to create 5 automation yaml files as you posted under automation folder or can I put all your file files within on big file?

Thanks a lot for your generous help,

Sean

Hi Sean,
Glad you found this useful!

I made a bunch of small tweaks since my original post, so I updated everything above. I was able to consolidate two of the automations, leaving us with 4 per ZEN32. I also moved the config that resets the LED colors into a dedicated script. That way I wouldn’t need to insert all these commands in multiple places, and it makes this easier to maintain.

As to your question: I wouldn’t recommend trying to consolidate this feature into fewer automations. While you might be able to get it down to 2 or 3, doing so would make the entire feature more difficult to maintain. For example, there’s a ton of branching logic in the iterate cycles (#2) automation; it’s hard enough to read already.

Remember: you will probably the person troubleshooting and maintaining this feature, so I suggest making life as easy as possible for your future self. :slight_smile:

Good luck in your HA journey!

Thanks for the additional pointers and insights - Brystmar!

Any chance you could share the latest automation files? I am using your file to learn how to put together a complicated automation… :rofl:

Best,

Sean

I updated all the automations in the original post, so you’ll probably need to go through and update yours too. Also added another section with an additional automation for those who wish to get a bit more fancy. It’s all in the original post above.

Thanks!!! Appreciate the help! Now time to study your automation… : ) BTW, mind sharing the brand and model of the humidity sensor that you use? Would like to use something reliable.

Another thing I noticed is that when I pressed the button on ZEN32, the LED on the button flashed once before it turns into solid, which is a little annoying. I googled and the document said “parameter 23” to disable it. I set the parameter 23 to disable, but the LED light still behaves the same way - flash once before it becomes solid. Have you run into this and wondering how you handled it?

Bets,

Sean

The humidity sensor in my bathroom is built into my heated floor thermostat, so it’s not something you can easily add. I have a Zooz ZSE44 in every room of our house and they’ve been rock solid for me. Recommended.

The single blink doesn’t bother me, so I haven’t tried to get rid of it. In fact, I think it’s a nice confirmation that my input was registered. If the 10-minute button was pressed and is currently illuminated, and I want to reset it to 10 minutes, this blink lets me know that my command was successfully recognized. Your best bet is reading through the config documentation.

Got it. Thanks for the recommendation. Only one thing I am a little hesitant (just me) is ZSE44 uses button battery - prefer AAA when possible : )

Hi Brysmar,

Finally I got all my scene controllers up and running. : ) Also tried the humidity sensor and it’s solid! Appreciate you sharing all the scripts and insights. One quick question: do you have recommendations on any solid presence detection sensors, both indoor and outdoor? I am thinking of mmWAVE, seems that’s the trend but seems people have different opinions. Wondering your take on it and experience.

Best,

Sean

I haven’t implemented any presence sensors in my system. From what I’ve read, mmWave does seem to be the way to go. I’d probably start with the one from Apollo Automation since their AIR-1 AQI sensor has performed well for me, and they are active members of this community.