Using Home Assistant to Balance Humidity for Comfort and Window Condensation Risk

The first winter in our house was a challenge figuring out where to set the house humidifier connected to our furnace. How do you balance comfortable humidity against the risk of window condensation? I really had no idea and it was common to see water or ice on the inside of our windows during the coldest days.

I had wired our house humidifier through the furnace so I could use the Ecobee thermostat to control it. The Ecobee has a setting called “Front Control,” but it does a poor job balancing for comfort.

Screenshot

Remember this recommended humidity level for later.

Screenshot

Going from 36% to 45% is a large difference. From my testing, the humidity reported by Ecobee is 4-5 percentage points too high, which is also part of the problem and makes the air even drier. I wanted to do better and let Home Assistant handle everything.

First I bought a device with good reviews to be my source of truth and set it in the living room. It’s the Govee H5075 Bluetooth Digital Hygrometer and connects to Home Assistant via an ESPHome Bluetooth Proxy.

Since we don’t use the humidifier all year, the first step was to create a Toggle (or Input Boolean) helper. I’ll manually change this when I enable/disable the humidifier in the Ecobee settings.

I installed the Thermal Comfort integration to calculate the indoor dew point and the OpenWeatherMap integration with their One Call API 3.0 for fetching weather forecasts. I use a trigger template in templates.yaml to store the forecast every hour and whenever Home Assistant starts.

- trigger:
    - platform: time_pattern
      minutes: "/59"
    - platform: homeassistant
      event: start
  action:
    - service: weather.get_forecasts
      data:
        type: hourly
      target:
        entity_id: weather.openweathermap
      response_variable: hourly_forecast
  sensor:
    - name: "Weather Forecast Storage"
      unique_id: weather_forecast_storage
      icon: mdi:weather-cloudy
      state: "{{ states('weather.openweathermap') }}"
      attributes:
        forecast: "{{ hourly_forecast['weather.openweathermap'].forecast }}"

The forecast is important because I don’t want to set the humidity too high and not have enough time for the house to dry out when a cold front moves in. I added another trigger template in templates.yaml, which grabs the low temperature over the next 12 hours, estimates window glass temperature, calculates an ideal indoor humidity, and determines an offset between the trusted and Ecobee humidity values. It does all of this every 15 minutes, when Home Assistant starts, and whenever there is a change to various temperature or humidity values.

- trigger:
    - platform: time_pattern
      minutes: "/15"
    - platform: homeassistant
      event: start
    - platform: state
      entity_id: 
        - sensor.living_room_govee_temperature
        - sensor.living_room_govee_humidity
        - sensor.openweathermap_temperature
        - sensor.thermostat_current_temperature
        - sensor.thermostat_current_humidity
      for:
        seconds: 10
  sensor:
    - name: "Calculated Ideal Humidity"
      unique_id: calculated_ideal_humidity
      unit_of_measurement: "%"
      variables:
        forecast: "{{ state_attr('sensor.weather_forecast_storage', 'forecast') }}"
        has_data: "{{ forecast is not none }}"
        outdoor: "{{ (forecast[1:12] | map(attribute='temperature') | min | float(50)) if has_data else 50 }}"
        indoor: "{{ states('sensor.living_room_govee_temperature') | float(70) }}"
        glass: "{{ (outdoor + (indoor - outdoor) * 0.6) | round(1) }}" # 0.6 should be adjusted based on window efficiency
        ideal_raw: "{{ (0.8 * glass) | int(35) }}"
        ideal_final: "{{ ([25, ideal_raw, 50] | sort)[1] }}"
        
        govee_h: "{{ states('sensor.living_room_govee_humidity') | float(30) }}"
        ecobee_h: "{{ state_attr('climate.thermostat', 'current_humidity') | float(30) }}"
        offset: "{{ ecobee_h - govee_h }}"
        comp_target: "{{ ([25, (ideal_final + offset) | int(35), 50] | sort)[1] }}"

      state: "{{ ideal_final if has_data else states('sensor.calculated_ideal_humidity') }}"
      
      attributes:
        glass_temp_estimate: "{{ glass }}"
        predicted_outdoor_temp: "{{ outdoor }}"
        forecast_window: "Next 12 Hours"
        ecobee_compensated_target: "{{ comp_target }}"
        last_calculation: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"

With this data calculated and saved, an automation takes care of updating the humidity setpoint on the Ecobee thermostat. Whenever I have the Humidifier Enabled toggle on, it’ll update the setpoint every 30 minutes and whenever the Calculated Ideal Humidity changes.

alias: Set Thermostat Humidity
triggers:
  - entity_id: sensor.calculated_ideal_humidity
    trigger: state
  - minutes: /30
    trigger: time_pattern
conditions:
  - condition: and
    conditions:
      - condition: state
        entity_id: input_boolean.humidifier_enabled
        state:
          - "on"
      - condition: template
        value_template: >-
          {{ state_attr('sensor.calculated_ideal_humidity',
          'ecobee_compensated_target') is not none }}
        alias: Ecobee Compensated Target is not None
actions:
  - target:
      entity_id: climate.thermostat
    data:
      humidity: >-
        {{ state_attr('sensor.calculated_ideal_humidity',
        'ecobee_compensated_target') | int(35) }}
    action: climate.set_humidity
mode: restart

I added a card on my dashboard to see the status of everything. This was invaluable when I was testing and tweaking for several weeks.

Remember the Ecobee’s recommended 36%?! If I went by that, the actual humidity in the house at this time would have about 32%, a whole 10 percentage points dryer! Here’s the YAML for the dashboard card.

type: conditional
conditions:
  - condition: state
    entity: input_boolean.humidifier_enabled
    state: "on"
card:
  type: markdown
  content: |2-
      {% set glass_temp = state_attr('sensor.calculated_ideal_humidity', 'glass_temp_estimate') | float(0) | round (0) %}
      {% set dew_point = states('sensor.thermal_comfort_dew_point') | float(0) | round(0) %}
      {% set ideal_hum = states('sensor.calculated_ideal_humidity') | float(0) | round(0) %}
      {% set actual_hum = states('sensor.living_room_govee_humidity') | float(0) | round(0) %}
      {% set thermostat_hum = state_attr( 'climate.thermostat', 'current_humidity' ) | float(0) | round(0) %}
      {% set target_hum = state_attr( 'sensor.calculated_ideal_humidity', 'ecobee_compensated_target' ) | float(0) | round(0) %}
      {% set future_temp = state_attr('sensor.calculated_ideal_humidity', 'predicted_outdoor_temp') | round (0) %}

      ## Window Condensation Status
      {% if ( glass_temp > dew_point ) %}
      #### ✅ No Risk
      {% else %}
      #### ⚠️ Risk
      {% endif %}

      **Glass:** {{ glass_temp }}°
      **Dew Point:** {{ dew_point }}°

      **12hr Forecast Low:** {{ future_temp }}°

      ---

      ### Humidity
      {% if ( actual_hum > ideal_hum ) %}
      ⚠️ Too Humid
      {% elif ( actual_hum < ideal_hum ) %}
      💧 Too Dry
      {% else %}
      ✅ Good
      {% endif %}

      | | Current | Target |
      | :--- | :---: | :---: |
      | **Actual** | {{ actual_hum }}% | {{ ideal_hum }}% |
      | **Ecobee** | {{ thermostat_hum }}% | {{ target_hum }}% |

The windows are so much better this winter and our static shocks are limited. If you have any suggested improvements, leave a comment.

Tracking Cat Litter Cleaning with ESPHome and Home Assistant

One of our daily tasks is to clean out the cat litter boxes and we (on maybe I just wanted an excuse to automate part of the process!) run in to a couple of small problems:

  1. We usually don’t know if the other has already cleaned them.
  2. We obviously don’t want to forget.
  3. Since I primarily took over this job, I’ve been using a daily reminder, but it’s on my iPhone nagging me all day, even though it’s set for noon. I only need a reminder when it doesn’t get done.

I thought of a solution with an ESPHome based device and Home Assistant. I used a WEMOS D1 Mini Lite, WEMOS OLED Shield, button, red LED, green LED, and 220 Ohm resistors. I connected everything on a breadboard for testing and then made it more permanent.

Key functionality:

  • During the day, the green LED will be lit if the litter boxes have been cleaned and the red LED if they need to be cleaned. If we’re walking by, green means keep going and red means stop here.
  • At night (8pm to 8am), the LEDs are off, unless the device is woken up.
  • Press the button to turn on the display and status LED if at night. The display shows the last time the litter was cleaned in one of three formats, depending on how long it’s been.
    • Today
      1:23 PM
    • Yesterday
      2:48 PM
    • 2 days ago
      11:00 AM
  • Press the button when the display is on to update the litter box last cleaned date and time. The LEDs flash to signify something is happening. The new date and time gets shown on the display.
  • After 30 seconds the display turns off.
  • At 4pm send a reminder to our phones if the litter boxes need to be cleaned.
  • In Home Assistant a toggle can disable the reminders. Useful when we’re on vacation.

Here’s the ESPHome device YAML:

substitutions:
  device_name: cat-litter
  api_key: !secret catlitter_api
  ota_password: !secret catlitter_ota
  ip_address: !secret catlitter_ip

packages:
  base: !include z_package_base.yaml

esphome:
  name: ${device_name}
  friendly_name: Cat Litter
  on_boot:
    priority: 800 
    then:
      - lambda: |-
          id(cat_litter_display).turn_on();
          id(screen_is_active) = true;
      - component.update: cat_litter_display
      - script.execute: display_timer

esp8266:
  board: d1_mini_lite

globals:
  - id: screen_is_active
    type: bool
    restore_value: no
    initial_value: 'true'

# WEMOS OLED Shield
i2c:
  sda: GPIO4 
  scl: GPIO5 
  scan: true
  id: bus_a
  frequency: 100kHz

time:
  - platform: homeassistant
    id: esptime
    on_time:
      - seconds: 1
        minutes: 0
        hours: 8
        then:
          - script.execute: update_led_logic
      - seconds: 1
        minutes: 0
        hours: 20
        then:
          - script.execute: update_led_logic

font:
  - file: "fonts/Roboto-Regular.ttf"
    id: roboto_font
    size: 12

text_sensor:
  - platform: homeassistant
    id: litter_text
    entity_id: sensor.cat_litter_status_formatted
    on_value:
      then:
        - component.update: cat_litter_display
        - script.execute: update_led_logic

binary_sensor:
  - platform: homeassistant
    id: remote_litter_status
    entity_id: binary_sensor.litter_needs_cleaning
    on_state:
      then:
        - script.execute: update_led_logic

  # Physical button
  - platform: gpio
    pin: 
      number: GPIO0 
      inverted: true
      mode: INPUT_PULLUP
    name: "Cleaned Button"
    filters:
      - delayed_on: 50ms
    on_press:
      then:
        - if:
            condition:
              lambda: 'return !id(screen_is_active);'
            then:
              # WAKE UP
              - lambda: |-
                  id(cat_litter_display).turn_on();
                  id(screen_is_active) = true;
              - component.update: cat_litter_display
              - script.execute: update_led_logic
              - script.execute: display_timer
            else:
              # TRIGGER CLEANED
              - delay: 50ms 
              - homeassistant.service:
                  service: script.cat_litter_cleaned
              - script.execute: led_flash_animation
              - script.execute: display_timer

output:
  - platform: esp8266_pwm
    pin: GPIO14 
    id: led_green
  - platform: esp8266_pwm
    pin: GPIO12 
    id: led_red

script:
  - id: update_led_logic
    then:
      - lambda: |-
          if (!id(remote_litter_status).has_state()) return;

          auto time = id(esptime).now();
          bool is_night = false;
          if (time.is_valid()) {
            is_night = (time.hour >= 20 || time.hour < 8);
          }

          if (id(cat_litter_display).is_on()) { is_night = false; }

          if (is_night) {
            id(led_red).turn_off();
            id(led_green).turn_off();
          } else {
            if (id(remote_litter_status).state) {
              id(led_red).set_level(0.2); 
              id(led_green).turn_off();
            } else {
              id(led_red).turn_off();
              id(led_green).set_level(0.2);
            }
          }

  - id: display_timer
    mode: restart
    then:
      - delay: 30s
      - lambda: |-
          id(cat_litter_display).turn_off();
          id(screen_is_active) = false;
      - script.execute: update_led_logic

  - id: led_flash_animation
    mode: restart
    then:
      - repeat:
          count: 5
          then:
            - output.set_level: { id: led_green, level: 0.3 }
            - output.turn_off: led_red
            - delay: 150ms
            - output.turn_off: led_green
            - output.set_level: { id: led_red, level: 0.3 }
            - delay: 150ms
      - script.execute: update_led_logic

display:
  - platform: ssd1306_i2c
    id: cat_litter_display
    model: "SSD1306 64x48"
    address: 0x3C
    rotation: 180° 
    lambda: |-
      if (id(litter_text).has_state()) {
        std::string full_text = id(litter_text).state;
        size_t pos = full_text.find("@");
        if (pos != std::string::npos) {
          std::string day = full_text.substr(0, pos);
          std::string time_str = full_text.substr(pos + 1);
          it.printf(32, 8, id(font_main), TextAlign::TOP_CENTER, "%s", day.c_str());
          it.printf(32, 26, id(font_main), TextAlign::TOP_CENTER, "%s", time_str.c_str());
        } else {
          it.printf(32, 24, id(font_main), TextAlign::CENTER, "%s", full_text.c_str());
        }
      } else {
        it.printf(32, 24, id(font_main), TextAlign::CENTER, "Syncing...");
      }

Some helpers in configuration.yaml:

template:
- binary_sensor:
  - name: "Litter Needs Cleaning"
    unique_id: litter_needs_cleaning
    # This turns ON (Red LED) if the date is not today
    state: >
      {% set last = states('input_datetime.cat_litter_last_cleaned') | as_datetime %}
      {% if last is none %} true {% else %}
        {{ last.date() < now().date() }}
      {% endif %}
- sensor:
  - name: "Cat Litter Status Formatted"
    unique_id: cat_litter_status_formatted
    state: >
        {% set last = states('input_datetime.cat_litter_last_cleaned') | as_datetime %}
        {% if last is none %}
            No Data @ --:--
        {% else %}
            {% set diff = (now().date() - last.date()).days %}
            {% set time = last.strftime('%-I:%M %p').lower() %}
            {% if diff == 0 %}
                Today @ {{ time }}
            {% elif diff == 1 %}
                Yesterday @ {{ time }}
            {% else %}
                {{ diff }} days ago @ {{ time }}
            {% endif %}
        {% endif %}

notify:
  - name: "momrik_phones"
    platform: group
    services:
      - service: mobile_app_nick
      - service: mobile_app_brandi

Automations in automations.yaml:

- id: '1768843528106'
  alias: Notify - Cat Litter @ 4PM
  description: ''
  triggers:
  - trigger: time
    at: '16:00:00'
  conditions:
  - condition: state
    entity_id: binary_sensor.litter_needs_cleaning
    state:
    - 'on'
  - condition: state
    entity_id: input_boolean.cat_litter_reminders
    state:
    - 'on'
  actions:
  - action: notify.momrik_phones
    metadata: {}
    data:
      title: "Cat Litter \U0001F408 \U0001F4A9"
      message: The litter hasn't been cleaned yet today!
      data:
        tag: cat-litter-alert
        actions:
        - action: MARK_LITTER_CLEANED
          title: I did it
  mode: single
- id: '1768843827814'
  alias: Notify Clear - Cat litter
  description: ''
  triggers:
  - trigger: event
    event_type: mobile_app_notification_action
    event_data:
      action: MARK_LITTER_CLEANED
  conditions: []
  actions:
  - action: script.cat_litter_cleaned
    data: {}
  - action: notify.momrik_phones
    data:
      message: clear_notification
      data:
        tag: cat-litter-alert
  mode: single

One script in scripts.yaml:

cat_litter_cleaned:
  alias: "Cat Litter Cleaned"
  sequence:
    - service: input_datetime.set_datetime
      target:
        entity_id: input_datetime.cat_litter_last_cleaned
      data:
        datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"
    - service: notify.momrik_phones
      data:
        message: "clear_notification"
        data:
          tag: "cat-litter-alert"

I also added a couple of simpler helpers via Settings -> Devices & services -> Helpers:

  • Cat Litter Last Cleaned – Date and time
  • Cat Litter Reminders – Toggle

Finally, I added cards to a Home Assistant dashboard. See screenshots of the different states of each card below.

Here’s the dashboard YAML if you want it.

type: horizontal-stack
title: Cat Litter
cards:
  - type: custom:mushroom-template-card
    primary: >-
      {% set last = states('input_datetime.cat_litter_last_cleaned') |
      as_datetime %}

      {% if last is none %}
        Unknown State
      {% else %}
        {% set diff = (now().date() - last.date()).days %}
        {% if diff == 0 %}
        Cleaned Today
        {% else %}
        Needs Cleaning
        {% endif %}
      {% endif %}
    icon: mdi:emoticon-poop
    features_position: bottom
    secondary: >-
      {% set last = states('input_datetime.cat_litter_last_cleaned') |
      as_datetime %}

      {% if last is none %}

      ???

      {% else %}
        {% set diff = (now().date() - last.date()).days %}
        {% if diff > 0 %}
          {{ last.strftime('%b %-d') }} -
        {% endif %}
        {{ last.strftime('%-I:%M %p') }}
      {% endif %}
    color: |-
      {% if is_state('binary_sensor.litter_needs_cleaning', 'on') %}
        red
      {% else %}
        green
      {% endif %}
    tap_action:
      action: more-info
    icon_tap_action:
      action: perform-action
      perform_action: script.cat_litter_cleaned
      target: {}
      confirmation:
        text: Are you sure you want to update the last cleaned date/time to now?
    entity: input_datetime.cat_litter_last_cleaned
  - type: custom:mushroom-entity-card
    entity: input_boolean.cat_litter_reminders
    name: Reminders
    tap_action:
      action: toggle
    hold_action:
      action: more-info
    icon_color: amber

If we get a reminder, it looks like this. We can click I did it to update the date/time to now if we forgot to press the button on the physical device. With the automation set to run at 4pm, we should rarely see this though.

The final device lives right next to the waste bag dispenser I made. This way it’s easy to see the state and hit the button when cleaning out the litter. Here are photos in place with a few different states.

This project was so fun. Using Google Gemini for stuff like this makes it so much faster. I’ve had some of these microcontrollers and other parts for almost a decade so it’s nice to finally put them to use.

What other features or automations would you add to this? Have you built anything similar?

Using PyPortal to Display Air Quality Data and Interact with Home Assistant

I got AdaBox 011 in March of 2019 and wrote this about the PyPortal (buy at Adafruit):

I’ll likely turn this into something that interfaces with my Home Assistant server to control different devices around my house.

The PyPortal has been sitting on a shelf ever since. Way back in February, it caught my eye, and I picked it up, not remembering what it’s capabilities were. Then I started upgrading IKEA air quality monitors and even made my own. Since I’m at the desk in my office a large portion of the week I thought I would make that 2019 prediction come true.

I could show a bunch of data on the screen and the PyPortal has a touchscreen, so I could display buttons for triggering things around the house. The device also has connectors for doing GPIO, so I got the idea of adding an LED strip, which I could use for notifications. I even had a meter long strip of Adafruit Mini Skinny NeoPixels I had bought in 2017 and never touched that would be perfect. I needed to buy a 2.0mm JST PH Connector kit in order to make a wire that would connect to the pack of the PyPortal. I ended up using a piece of Cat6 cable, even though I only needed 3 of the 8 wires inside.

I used light strip clips to mount the LEDs to the back of my desk.

I also mounted a power strip under the desk and cleaned up all of the cables.

The code I’m running on the PyPortal was heavily inspired by Adafruit’s PyPortal MQTT Sensor Node/Control Pad for Home Assistant and PyPortal NeoPixel Color Picker. It’s done with CircuitPython and I’ve added my code to pyportal-home-assistant-display on GitHub.

All of this was done back in March. I quickly began having issues with the ethernet cable and the small JST connectors, so I put this post on pause. Figured it was time to finally fix this before the end of the year. While testing, I determined the LED strip got fried up at some point. It was probably some kind of short from the janky wire.

Here’s what my display looks like.

My favorite aspect of the project and code is being able to publish MQTT messages from Home Assistant, which the PyPortal listens for and reacts to. I can send various commands, such as fill:blue, which turns all of the LEDs blue, or whatever color I set. I have commands to chase a color from one side to the other, bounce a color from left to right and back to the left, pulse the entire strip, animate a rainbow, or set the brightness. Since I don’t have another strip of Neopixels, in order to create a demo video, I wired up a 24 LED circle. You’ll have to imagine the effects on the back of my desk, lighting up the wall.

I can manually send these MQTT messages as shown in the demo, but the real power comes from automations. For example, the LEDs automatically pulse blue when the washing machine is done and pink when the dryer is done.

With the different effects and color combinations, the possibilities are endless. What kind of automations would you run?

Update: I got a new LED strip, made a new demo video, and improved a bunch of stuff. See Updates to my PyPortal Home Assistant Display Project.

Building a Shop Air Filter with Box Fans

Over the years, I’ve seen many versions of a shop air filter, made from box fans and 20×20 inch furnace filters. A few years ago I picked up some old box fans on Facebook Marketplace and bought a pack of filters from Sam’s Club. They’ve been stacked in the corner.

It was finally time to build my air filter. I removed the back covers, feet, handles, and knobs from the fans. I got my first look at the switches inside, which are nearly identical.

I’d easily be able to wire the fans together, so I removed the switches and power cords.

I put together a frame from OSB, cut slots to feed the wires through, and screwed the box fans in.

Then I grabbed wood that had been salvaged from a pallet to construct a door.

On the back side, I used glue and brad nails to attach plywood rails. I also made tabs to hold the filters secure.

I attached the door with a couple hinges and made some notched tabs to hold the door shut.

Then it was time to work on the wiring and electronics. I had recently watched a YouTube video showing how to make an old fan smart and his code with ESPHome and Home Assistant gave my a great start. I bought a 4 channel relay board to use with a an ESP8266 development board, a button, and some LEDs. I tore open an old USB power plug and was originally going to tie in the 120 volt line, but decided against it. First, I tested out the circuit and code on a breadboard and then soldering things up more permanently.

A plastic screw container was a good side, so I used hot glue to secure the boards and then wired up all of the fan connections.

I’m not sure if I’ll ever use the button, but it allows me to cycle between the three speeds and turn it off. The three LEDs show which speed is currently running. The only thing I got wrong was reversing the low and high speeds, which was a quick fix in the ESPHome code. Speaking of the code, here’s mine.

esphome:
  name: shop-air-filter
  friendly_name: Shop Air Filter

esp8266:
  board: d1_mini

logger:
  level: WARN

api:
  encryption:
    key: "input_yours"

ota:
  - platform: esphome
    password: "input_yours"

wifi:
  min_auth_mode: WPA2
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:
    static_ip: 192.168.1.2
    gateway: 192.168.1.1
    subnet: 255.255.255.0

time:
  - platform: homeassistant
    id: home_time

binary_sensor:
  - platform: gpio
    pin: {number: D5, mode: INPUT_PULLUP, inverted: true}
    name: "Speed Button"
    on_press:
      then:
        - script.execute: cycle_fan_speed

  - platform: template
    id: active
    lambda: 'return id( fan_speed ).state > 0;'

switch:
  # Relays
  - platform: gpio
    pin: D3
    id: speed_1
    inverted: true
    interlock: &fan_interlock [speed_1, speed_2, speed_3]
    internal: true
  - platform: gpio
    pin: D2
    id: speed_2
    inverted: true
    interlock: *fan_interlock
    internal: true
  - platform: gpio
    pin: D1
    id: speed_3
    inverted: true
    interlock: *fan_interlock
    internal: true

  # LEDs
  - platform: gpio
    pin: D6
    id: led_1
    internal: true
  - platform: gpio
    pin: D7
    id: led_2
    internal: true
  - platform: gpio
    pin: D8
    id: led_3
    internal: true

number:
  - platform: template
    name: "Fan Speed"
    id: fan_speed
    min_value: 0
    max_value: 3
    step: 1
    optimistic: true
    restore_value: true
    on_value:
      then:
        - script.execute: set_shop_filter_speed

text_sensor:
  - platform: template
    name: "Current State"
    id: current_state

sensor:
  - platform: duty_time
    name: "Filter Runtime"
    id: shop_filter_usage
    sensor: active
    restore: true
    unit_of_measurement: h
    accuracy_decimals: 1
    filters:
      - multiply: 0.000277778 # Convert seconds to hours

button:
  - platform: template
    name: "Reset Filter Timer"
    icon: "mdi:timer-off"
    on_press:
      then:
        - sensor.duty_time.reset: shop_filter_usage

script:
  - id: set_shop_filter_speed
    mode: restart
    then:
      - switch.turn_off: speed_1
      - switch.turn_off: speed_2
      - switch.turn_off: speed_3
      - switch.turn_off: led_1 
      - switch.turn_off: led_2
      - switch.turn_off: led_3
      - delay: 300ms 
      - lambda: |-
          if ( id( fan_speed ).state == 0 ) {
            id( current_state ).publish_state( "Off" );
          } else if ( id( fan_speed ).state == 1 ) {
            id( speed_1 ).turn_on();
            id( led_1 ).turn_on();
            id( current_state ).publish_state( "Low" );
          } else if ( id( fan_speed ).state == 2 ) {
            id( speed_2 ).turn_on();
            id( led_1 ).turn_on(); id( led_2 ).turn_on();
            id( current_state ).publish_state( "Medium" );
          } else if ( id( fan_speed ).state == 3 ) {
            id( speed_3 ).turn_on();
            id( led_1 ).turn_on(); id( led_2 ).turn_on(); id( led_3 ).turn_on();
            id( current_state ).publish_state( "High" );
          }
  - id: cycle_fan_speed
    then:
      - lambda: |-
          int next_speed = id( fan_speed ).state + 1;
          if ( next_speed > 3 ) next_speed = 0;
          id( fan_speed ).publish_state( next_speed );

I used Google Gemini to help and it had a great suggestion to track the run time and add a maintenance reminder when it was time to replace the filters.

In Home Assistant I created some automations. My dust collector uses a smart plug, so when it draws electricity, the air filter automatically turns on at high speed. When the dust collector turns off, the air filter continues to run for 15 minutes before turning off. If I had to remember to turn on the air filter all the time, it would rarely happen, so this is amazing.

I’m still on lifting restrictions for several weeks so Brandi helped me install the air filter on the ceiling.

I wish I hadn’t waited so long to build this!

DIY Gate Sensor for Home Assistant

In our basement we have a baby gate, which surprisingly keeps our cat out of the gym and golf sim areas.

Sometimes we forget to close the gate, so I needed a sensor to monitor its state. I still had the breadboard from the air quality monitor project, so it was quick to add a magnetic door switch and test things out with the D1 Mini clone.

I have extra sensors, so those were kept in the project and allowed me to get rid of the shitty DHT22 I added to the golf remote. Everything worked, but I want to save my last two D1 minis and use them for something with the screens I have for them. So I swapped in an Adafruit Feather HUZZAH ESP8266, which I got with AdaBox 3 or 4 in 2017 and made minor changes to the code.

Parts:

References:

ESPHome YAML code:

substitutions:
  slug: gate
  friendly: Gate

esphome:
  name: ${slug}
  friendly_name: ${friendly}

esp8266:
  board: huzzah

logger:
  level: WARN

api:
  encryption:
    key: 'xxx'

ota:
  - platform: esphome
    password: "xxx"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:
    static_ip: x.x.x.x
    gateway: x.x.x.x
    subnet: 255.255.255.0

i2c:

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO14
      mode:
        input: true
        pullup: true
    name: ${friendly}
    device_class: door

sensor:
  - platform: htu21d
    model: SI7021
    temperature:
      name: Temperature
      id: ${slug}_temp
    humidity:
      name: Humidity
      id: ${slug}_humid

  - platform: aht10
    variant: AHT20
    temperature:
      name: AHT21 Temperature
      id: ${slug}_aht21_temp
    humidity:
      name: AHT21 Humidity
      id: ${slug}_aht21_humid

  - platform: ens160_i2c
    address: 0x53
    eco2:
      name: CO²
    tvoc:
      name: VOC
    aqi:
      id: demo_aqi
      name: AQI
    compensation:
      temperature: ${slug}_aht21_temp
      humidity: ${slug}_aht21_humid

text_sensor:
  - platform: template
    name: AQI Rating
    lambda: |-
      switch ( (int) ( id( ${slug}_aqi ).state ) ) {
        case 1: return {"Excellent"};
        case 2: return {"Good"};
        case 3: return {"Moderate"};
        case 4: return {"Poor"};
        case 5: return {"Unhealthy"};
        default: return {"N/A"};
      }

I also added this to my configuration.yaml because I wanted a gate icon instead of the door, due to the device class of the binary sensor:

template:
 - binary_sensor:
    - name: Gate
      unique_id: gate_template
      device_class: door
      state: "{{ is_state( 'binary_sensor.basementgate_gate', 'on' ) }}"
      icon: |
        {% if is_state( 'binary_sensor.basementgate_gate', 'on' ) %}
        mdi:gate-open
        {% else %}
        mdi:gate
        {% endif %}

I figured I might as well use one of the fancy Adafruit Perma-Proto boards I had, which makes soldering all of the connections much easier. As a bonus it was nearly a perfect fit for the case.

The magnetic switch and Si7021 will live outside the box, so those couldn’t get soldered yet. After connecting power I checked the ESPHome logs to make sure everything was working.

I cut holes in a project box, finished soldering, and used hot glue to secure the board..

I reversed the swing of the gate, placed my device, and attached the two sides of the magnetic switch to the gate.

In Home Assistant an automation runs whenever the stairs light is turned off to check the state of the gate. If it’s open, a notification is sent to our phones.

I’m enjoying these little electronics projects, and it feels good to finally put various parts to use.

Set Sound Output via AppleScript

When playing music I usually change my office MacBook’s sound output to a Sonos speaker, which is an AirPlay device. Sometimes the connection freezes and I have to reset output my default device and back to the office speaker. I wanted to automate both of these processes, so I found an AppleScript as a starting point. I modified it and created an Alfred Workflow with a keyword trigger. Here’s my version of the AppleScript. Feel free to modify it for your own use.

	-- This script can be used to set/reset the sound output

	-- Two devices because sometimes the AirPlay device loses connection
	set myDevices to {"LG UltraFine Display Audio", "Office"}

	tell application "System Settings"
		-- sometimes it is already open to Sound, which causes an error
		quit
		delay 0.2
		activate
		delay 0.2

		tell application "System Events"
			tell process "System Settings"
				delay 0.4
				set theWindow to first window
				delay 0.4
			end tell

			keystroke "Sound"
			delay 0.5

			tell application process "System Settings"
				tell its group 2 of scroll area 1 of group 1 of group 3 of splitter group 1 of group 1 of window "Sound"
					tell its radio button 1 of tab group 1
						click
					end tell

					delay 0.3

					tell its scroll area 1
						try
							set theRows to (every row of outline 1)
						on error error_message number error_number
							--display dialog "Error: " & the error_number & ": " & the error_message buttons {"OK"} default button 1
						end try

						repeat with myDevice in myDevices
							set device to myDevice as string

							set found to false

							-- Sometimes the device isn't listed yet, so delay and retry
							repeat 10 times
								repeat with aRow in theRows
									try
										if name of static text 1 of group 1 of UI element 1 of aRow is equal to device then
											set selected of aRow to true
											set found to true

											set volume without output muted
											set volume output volume 10 --100%
										
											exit repeat
										end if
									on error
										--display dialog "Error setting output sound to " & device
									end try
								end repeat

								if found = true then
									exit repeat
								end if
							
								delay 0.5
							end repeat
						end repeat
					end tell
				end tell
			end tell
		end tell

		quit
	end tell

Updated on November 28, 2023 to work with macOS Sonoma 14.1.1.

Updated on March 13, 2024 to retry multiple times if device isn’t listed yet.

Updated on March 21, 2024 to reset volume per device, since the OS remembers the last volume of each device.

Updated on October 8, 2025 to work with MacOS Tahoe 26.0.1.

Automated Closet Lighting

After building a rack for my workout shoes a couple of weeks ago, I wanted to tackle another thing about the broom closet that has been bugging me for years. It never had a light! I put together a rough video of the entire process.

I’m really happy with how it turned out, especially since I was able to use parts I had in my electronics collection. The whole thing uses a simple circuit, cost less than $10, and doesn’t require WiFi or any fancy connections. The Working of Transistor as a Switch page on Electronics Hub was a big help. I ended up using a PNP transistor in my circuit without resistors because the LEDs were dimming and I wanted maximum brightness.

Updates to Home Assistant Projects

My garage temp sensor, running home-assistant-temperature-monitor stopped working several months ago. I didn’t have time to figure it out and then summer hit, when it’s not important since I don’t heat up the garage before I workout. This weekend I finally got around to troubleshooting the problem.

Turned out I needed to install Adafruit_Python_GPIO. I must have updated my code at some point without fully testing, otherwise I’m not sure how any of it worked before. I didn’t investigate that though; I was more concerned with fixing it and doing some improvements. I updated the OS and everything on the Raspberry Pi since it hadn’t been turned on in quite some time.

Earlier this year, another Pi on my network, the one running Home Assistant and Pi-hole, ran out of disk space without warning. I’ve wanted to put in a notification system so it never happens again, so I updated home-assistant-pi to report the disk use % to HA. I added an automation to notify me whenever it’s above 90% for one of my Pis. I also reworked all of the automations in home-assistant-pi to make it easier to configure each time I get a new Pi.

img_9705

That all took much longer than I expected. Most of the trouble was trying to understand the Jinja template system used in HA and where it can be applied to configurations. I think I’m finally getting the hang of it.

While writing this post, I found an old draft with some other updates to home-assistant-pi I never published. Maybe I never finished and that’s why everything stopped working! Here’s a list of some previous updates:

  • Fixed errors causing program to crash.
  • It wasn’t reconnecting very well, especially if Home Assistant went away (ex. for a restart after an upgrade). Rewrote how the MQTT connection works.
  • Switch from PushBullet to iOS notifications.
  • Changed show/hide Home Assistant group automations.

Now that this stuff is running again and I have a better understanding of the Home Assistant automation capabilities, I need to continue the series of posts I planned on home automation. It’s been five and a half months since I published Part 1!

Set Mac Volume to a Specific Percentage with an Alfred Workflow

The Touch Bar on the MacBook is a pain in the ass. I’ve been getting sick of fighting with it to adjust volume and wanted an alternative to using the icon in the Mac OS menu bar. I already had some AppleScript code I use to reset volume to start my work day, so I ran with it to make a simple Alfred Workflow.

I didn’t realize how awesome this workflow would be. I’m using it all the time, even on my other Mac, which has the keyboard volume control buttons.

mac-vol-alfred-workflow-screenshot.png

Get it on GitHub.

Home Assistant Pi

With all of the Raspberry Pis I have (now up to 6 after adding “flapper”), I wanted to get a bunch of data in Home Assistant (yes, I’m still working on a larger home automation post) and have an easy way to reboot or shutdown each computer.

I wrote a little app which runs as a service on each Pi. Here’s an example of what shows up in Home Assistant.

home-assistant-pi-groups.png

The Python app and sample Home Assistant configurations are in my home-assistant-pi project on GitHub. Of course it’s all Open Source.