#:########################################################################################:# # TITLE: IKEA AIR FILTER - BEDROOM 1 # zorruno.com layout v1.1 2026 # This is a conversion of the IKEA FORNUFTIG Air Filter unit to add smart control. # # References: # - https://www.youtube.com/watch?v=WB4xxhgggHQ from 3ATIVE VFX # - https://github.com/3ative/ikea-air-filter from 3ATIVE VFX # - https://github.com/dgalue/Ikea-Air-Purifier-ESP32-Homespan from dgalue #:########################################################################################:# # REPO: # https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-bedrm1airfilter.yaml #:########################################################################################:# # VERSIONS: # V1.3 2026-04-29 Added restored fan speed so the selected fan speed is remembered # and re-applied after reboot. # V1.2 2026-04-27 Converted filter replacement logic from pulse-count based to kRevs based. # Added editable Replacement Threshold number in kRevs. # Added Reset Replacement Threshold button to restore default 19000 kRevs. # Added fan pulse to revolution conversion substitutions. # Updated Filter Age to display accumulated fan use in kRevs. # Updated Clean/Dirty and LED logic to use fan kRevs rather than raw pulses. # V1.1 2026-04-27 Updated yaml to zorruno layout V1.1. # Added D5 panel dirty LED flashing at 500ms on/off. # Decoupled onboard LED from fan MOSFET state. # Reset now clears pulse total, restored age value and both dirty LEDs. # Added fuller hardware, operation and offline notes. # V1.0 2025-07-21 First Setup. #:########################################################################################:# # HARDWARE: # - IKEA FORNUFTIG wall mount air filter conversion. # - Wemos D1 Mini ESP8266. # - D2: Fan MOSFET enable output. # - D4: D1 Mini onboard LED, active low, used as filter dirty indicator. # - D5: IKEA unit panel dirty LED, active high, flashes when filter is dirty. # - D6: Fan pulse counter / tacho input. # - D7: Fan PWM output. # - Fan tacho hardware has been modified so the ESP8266 sees a clean 3.3V signal. #:########################################################################################:# # OPERATION NOTES: # - Fan speed slider is 0 to 6. # - Speed 0 turns the fan MOSFET off and sets PWM frequency to 0. # - Speeds 1 to 6 turn the fan MOSFET on, enable 50 percent duty drive and set # PWM frequency to speed * 50Hz. # - Fan Rate is calculated from raw tacho pulses using fan_revs_per_pulse. # - Filter Age is tracked in kRevs, meaning 1000 fan revolutions. # - Default replacement threshold is 19000 kRevs, stored in substitutions. # - Replacement Threshold is editable from Home Assistant as a number entity. # - Reset Replacement Threshold restores the editable threshold back to the default. # - When Filter Age is greater than or equal to Replacement Threshold, the # onboard LED turns on steady, the panel LED flashes at 500ms on/off, and # Filter State changes to Dirty. # - Reset button clears the filter age counter, restored filter age value, # Filter State, and both dirty LEDs. #:########################################################################################:# # MQTT COMMANDS: # - MQTT common package is included for standard device availability/status. # - No local custom MQTT command topics are currently defined in this file. #:########################################################################################:# # OFFLINE NOTES: # a) HA offline (network and MQTT online) # - Device fan speed and dirty indicator logic continue locally. # - HA entities will not be available until HA/API reconnects. # b) MQTT offline # - No custom MQTT commands are required for the local fan/dirty logic. # - HA API control remains available if HA and WiFi are online. # c) Entire WiFi/Network offline # - Device fan speed and filter age logic continue locally once booted. # - SNTP is not needed; this device does not use accurate timeclocks. #:########################################################################################:# #:########################################################################################:# # SUBSTITUTIONS: Specific device variable substitutions # If NOT using a secrets file, just replace these with the passwords etc (in quotes) #:########################################################################################:# substitutions: # Device Naming device_name: "esp-bedrm1airfilter" friendly_name: "Bedroom 1 Air Filter" description_comment: "Wall Mount Air Filter :: D1 Mini ESP8266 (Layout V1.1)" device_area: "Master Bedroom" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. # Project Naming project_name: "Generic.D1 Mini" # Project Details project_version: "v1.3" # Project V denotes release of yaml file, allowing checking of deployed vs latest version # Passwords and Secrets api_key: !secret esp-api_key ota_pass: !secret esp-ota_pass static_ip_address: !secret esp-bedrm1airfilter_ip # unfortunately you can't use substitutions inside secrets names #mqtt_command_main_topic: !secret mqtt_command_main_topic #mqtt_status_main_topic: !secret mqtt_status_main_topic # If we are changing IP addresses, you must update the current IP address here, otherwise it remains # Don't forget to switch it back when changed. current_ip_address: ${static_ip_address} # Device Settings log_level: "INFO" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE update_interval: "60s" # update time for general sensors etc # MQTT LOCAL Controls #mqtt_device_name: "bedrm1airfilter" #mqtt_local_command_topic: "${mqtt_local_command_main_topic}/${mqtt_device_name}" # Topic used to command this locally without HA #mqtt_local_status_topic: "${mqtt_local_status_main_topic}/${mqtt_device_name}" # Topic used to view status locally without HA # Filter Settings # This is a usage approximation based on fan revolutions, not calendar time. # fan_revs_per_pulse is also used to convert pulse_counter pulses/min to RPM. # fan_krevs_per_pulse converts raw pulse total to 1000 fan revolutions. fan_revs_per_pulse: "0.06" fan_krevs_per_pulse: "0.00006" filter_replacement_default_krevs: "19000" #:########################################################################################:# # PACKAGES: Included Common Packages # https://esphome.io/components/packages.html #:########################################################################################:# packages: #### WIFI, Network (Static/DHCP/IPV6 etc), Fallback AP, Safemode #### common_wifi: !include file: common/network_common.yaml vars: local_device_name: "${device_name}" local_static_ip_address: "${static_ip_address}" local_ota_pass: "${ota_pass}" local_current_ip_address: "${current_ip_address}" #### HOME ASSISTANT API (choose encryption or no encryption options) #### common_api: !include file: common/api_common.yaml #file: common/api_common_noencryption.yaml vars: local_api_key: "${api_key}" #### MQTT #### common_mqtt: !include file: common/mqtt_common.yaml vars: local_device_name: "${device_name}" #### WEB PORTAL #### #common_webportal: !include common/webportal_common.yaml #### SNTP (Only use if you want/need accurate timeclocks) #### #common_sntp: !include common/sntp_common.yaml #### DIAGNOSTICS Sensors #### diag_basic: !include common/include_basic_diag_sensors.yaml diag_more: !include common/include_more_diag_sensors.yaml #diag_debug: !include common/include_debug_diag_sensors.yaml #diag_resetcount: !include common/include_resetcount_diag_sensors.yaml #:########################################################################################:# # ESPHOME: # https://esphome.io/components/esphome.html #:########################################################################################:# esphome: name: "${device_name}" friendly_name: "${friendly_name}" comment: "${description_comment}" # Appears on the esphome page in HA area: "${device_area}" project: name: "${project_name}" version: "${project_version}" on_boot: # Restore the saved filter age counter and dirty warning state on boot. priority: -100 then: - delay: 200ms # Restore the raw pulse total from the saved filter age in kRevs. # raw_pulses = kRevs * 1000 / revs_per_pulse - pulse_counter.set_total_pulses: id: filter_counter value: !lambda |- return static_cast((id(filter_age_krevs) * 1000.0f) / ${fan_revs_per_pulse}); # Restore Clean/Dirty state and warning LEDs. - script.execute: update_filter_state # Re-apply the restored fan speed after boot. # The template number restores its value, but this forces the actual # MOSFET/PWM output state to match the restored Home Assistant value. - delay: 1s - number.set: id: fan_speed value: !lambda |- if (isnan(id(fan_speed).state)) { return 0.0f; } return id(fan_speed).state; #:########################################################################################:# # ESP PLATFORM AND FRAMEWORK: # https://esphome.io/components/esp8266/ #:########################################################################################:# esp8266: board: d1_mini # Generic Board restore_from_flash: true # Restore selected values on reboot. preferences: flash_write_interval: 5min mdns: disabled: false # Disabling will make the build file smaller, and the device is still available via static IP. #:########################################################################################:# # GLOBAL VARIABLES: # https://esphome.io/guides/automations.html#global-variables #:########################################################################################:# globals: # Save the filter age value in kRevs and restore it on boot. # This replaces the old pulse-based/global filtered age value. - id: filter_age_krevs type: float restore_value: true initial_value: "0.0" # Save total fan runtime in seconds and restore it on boot. # This is used to display total runtime hours in Home Assistant. - id: fan_runtime_seconds type: uint32_t restore_value: true initial_value: "0" # Runtime session start/update marker. # This does not need to be restored because it only applies to the current boot. - id: fan_runtime_last_ms type: uint32_t restore_value: false initial_value: "0" #:########################################################################################:# # LOGGING: ESPHome Logging Enable # https://esphome.io/components/logger.html #:########################################################################################:# logger: level: "${log_level}" # INFO suggested, or DEBUG for testing. baud_rate: 0 # Set to 0 for no logging via UART, needed if you are using it for other serial things. #:########################################################################################:# # BUTTON COMPONENT: # https://esphome.io/components/button/ #:########################################################################################:# button: # Reset Button for Filter Age - platform: template name: "${friendly_name} Filter EOL Reset" id: reset_button icon: mdi:restart # Reset the filter age counter and clear the dirty indicators. on_press: - pulse_counter.set_total_pulses: id: filter_counter value: 0 - lambda: |- id(filter_age_krevs) = 0.0f; - script.execute: update_filter_state # Immediately save the reset filter age value to flash. - lambda: |- global_preferences->sync(); # Reset Button for Total Fan Runtime - platform: template name: "${friendly_name} Reset Runhours" id: reset_runtime_button icon: mdi:timer-refresh-outline on_press: - lambda: |- id(fan_runtime_seconds) = 0; id(fan_runtime_last_ms) = id(fan_mosfet).state ? millis() : 0; id(fan_runtime_hours).publish_state(0.0f); - lambda: |- global_preferences->sync(); # Reset Button for Filter Replacement Threshold - platform: template name: "${friendly_name} EOL Threshold = Default" id: reset_replacement_threshold_button icon: mdi:restore on_press: - number.set: id: filter_replacement_krevs value: ${filter_replacement_default_krevs} - script.execute: update_filter_state - lambda: |- global_preferences->sync(); # Test Button for IKEA panel dirty LED on D5. # This is retained for easy hardware testing. #- platform: template # name: "${friendly_name} Test Panel LED" # id: test_panel_led_button # icon: mdi:led-on # on_press: # - output.turn_on: panel_led_output # - delay: 2s # - output.turn_off: panel_led_output #:########################################################################################:# # NUMBER COMPONENT: # https://esphome.io/components/number/ #:########################################################################################:# number: # Slider for Fan Speed 0-6, 0 = Off, 6 = Max - platform: template name: "${friendly_name} Speed Level" id: fan_speed icon: mdi:air-filter update_interval: never optimistic: true restore_value: true min_value: 0 max_value: 6 initial_value: 0 step: 1 set_action: - if: condition: # Is the slider 1 or above? lambda: |- return x >= 1; then: # Turn on the fan MOSFET and enable the PWM drive. - switch.turn_on: fan_mosfet - servo.write: id: fan_motor level: 1 # Change the PWM signal based on the slider value. # 50Hz steps are based on the tested value used by the original IKEA device. - output.esp8266_pwm.set_frequency: id: fan_pwm frequency: !lambda |- return x * 50; else: # Turn off the fan MOSFET and set PWM to 0. - switch.turn_off: fan_mosfet - output.esp8266_pwm.set_frequency: id: fan_pwm frequency: !lambda |- return 0; # Editable filter replacement threshold in 1000 fan revolutions. # Default is 19000 kRevs, which is about 19 million fan revolutions. - platform: template name: "${friendly_name} Filter EOL Threshold" id: filter_replacement_krevs icon: mdi:counter mode: box unit_of_measurement: "kRevs" optimistic: true restore_value: true initial_value: ${filter_replacement_default_krevs} min_value: 1 max_value: 100000 step: 100 set_action: - script.execute: update_filter_state - lambda: |- global_preferences->sync(); #:########################################################################################:# # BINARY SENSOR COMPONENT: # https://esphome.io/components/binary_sensor/ #:########################################################################################:# binary_sensor: # Internal binary state used by ESPHome logic. # The public HA display is handled by the text_sensor below so it can show # Clean / Dirty instead of Off / On. - platform: template id: filter_dirty internal: true lambda: |- float threshold = id(filter_replacement_krevs).state; if (isnan(threshold) || threshold <= 0.0f) { threshold = ${filter_replacement_default_krevs}; } return id(filter_age_krevs) >= threshold; #:########################################################################################:# # TEXT SENSOR COMPONENT: # https://esphome.io/components/text_sensor/ #:########################################################################################:# text_sensor: # Public filter status for Home Assistant. # This avoids Unknown/Off/On wording and always displays Clean or Dirty. - platform: template name: "${friendly_name} Filter EOL State" id: filter_dirty_text icon: mdi:air-filter update_interval: 5s lambda: |- float threshold = id(filter_replacement_krevs).state; if (isnan(threshold) || threshold <= 0.0f) { threshold = ${filter_replacement_default_krevs}; } if (id(filter_age_krevs) >= threshold) { return std::string("Dirty"); } return std::string("Clean"); #:########################################################################################:# # SENSOR COMPONENT: # https://esphome.io/components/sensor/ #:########################################################################################:# sensor: # Total fan runtime in hours. # Counts whenever the fan MOSFET is on, regardless of selected speed. - platform: template name: "${friendly_name} Fan Runhours" id: fan_runtime_hours icon: mdi:timer-outline unit_of_measurement: "h" device_class: duration state_class: total_increasing accuracy_decimals: 1 update_interval: 60s lambda: |- return id(fan_runtime_seconds) / 3600.0f; # Fan speed calculated from the fan tacho pulse rate. # pulse_counter reports pulses/min, then fan_revs_per_pulse converts it to RPM. - platform: pulse_counter name: "${friendly_name} Fan Speed" pin: number: D6 mode: input: true pullup: false id: filter_counter unit_of_measurement: "RPM" accuracy_decimals: 0 count_mode: rising_edge: INCREMENT falling_edge: DISABLE update_interval: 2s filters: - multiply: ${fan_revs_per_pulse} total: # Filter age is displayed in kRevs, meaning 1000 fan revolutions. # raw_pulses * fan_krevs_per_pulse = kRevs name: "${friendly_name} Filter Age" icon: mdi:counter unit_of_measurement: "kRevs" accuracy_decimals: 0 filters: - multiply: ${fan_krevs_per_pulse} on_value: then: - if: condition: switch.is_on: fan_mosfet then: # Only save trusted filter age while the fan is deliberately running. - lambda: |- id(filter_age_krevs) = x; - script.execute: update_filter_state #:########################################################################################:# # SERVO COMPONENT: # https://esphome.io/components/servo/ # Not a servo here, but is the most appropriate component for the required output behaviour. #:########################################################################################:# servo: # Set the PWM signal to 50 percent duty cycle. - id: fan_motor output: fan_pwm max_level: 50% #:########################################################################################:# # OUTPUT COMPONENT: # https://esphome.io/components/output/ #:########################################################################################:# output: # Fan PWM output. - platform: esp8266_pwm id: fan_pwm pin: number: D7 # Filter Dirty LED output on the IKEA unit. # This is active high: output ON means D5 goes high and lights the LED. - platform: gpio id: panel_led_output pin: number: D5 inverted: false #:########################################################################################:# # SWITCH COMPONENT: # https://esphome.io/components/switch/ #:########################################################################################:# switch: # Control the fan motor power MOSFET. # When the fan starts, reset the raw pulse counter total to the last trusted # stored age so idle noise is ignored before real fan use begins. # Runtime is counted whenever this switch is on, regardless of fan speed. - platform: gpio pin: D2 id: fan_mosfet restore_mode: ALWAYS_OFF on_turn_on: - lambda: |- id(fan_runtime_last_ms) = millis(); - pulse_counter.set_total_pulses: id: filter_counter value: !lambda |- return static_cast((id(filter_age_krevs) * 1000.0f) / ${fan_revs_per_pulse}); on_turn_off: - lambda: |- const uint32_t now = millis(); if (id(fan_runtime_last_ms) != 0) { const uint32_t elapsed_seconds = (now - id(fan_runtime_last_ms)) / 1000; if (elapsed_seconds > 0) { id(fan_runtime_seconds) += elapsed_seconds; } id(fan_runtime_last_ms) = 0; id(fan_runtime_hours).publish_state(id(fan_runtime_seconds) / 3600.0f); } # Filter Dirty LED 1: D1 Mini onboard LED. # D4 is active low on most D1 Mini boards. - platform: gpio id: onboard_led pin: number: D4 inverted: true restore_mode: ALWAYS_OFF # Filter Dirty LED 2: IKEA unit panel LED. # This template switch starts/stops the flashing script and mirrors the # warning state to the D1 Mini onboard LED. - platform: template name: "${friendly_name} Filter EOL Reached" id: panel_led optimistic: true restore_mode: ALWAYS_OFF turn_on_action: - switch.turn_on: onboard_led - script.execute: panel_led_flash turn_off_action: - script.stop: panel_led_flash - output.turn_off: panel_led_output - switch.turn_off: onboard_led #:########################################################################################:# # INTERVAL COMPONENT: # https://esphome.io/components/interval/ #:########################################################################################:# interval: # Add runtime while the fan is operating. # This uses millis() so the count is based on elapsed time, not just interval ticks. - interval: 10s then: - if: condition: switch.is_on: fan_mosfet then: - lambda: |- const uint32_t now = millis(); if (id(fan_runtime_last_ms) == 0) { id(fan_runtime_last_ms) = now; return; } const uint32_t elapsed_seconds = (now - id(fan_runtime_last_ms)) / 1000; if (elapsed_seconds > 0) { id(fan_runtime_seconds) += elapsed_seconds; id(fan_runtime_last_ms) += elapsed_seconds * 1000; id(fan_runtime_hours).publish_state(id(fan_runtime_seconds) / 3600.0f); } #:########################################################################################:# # SCRIPT COMPONENT: # https://esphome.io/components/script/ #:########################################################################################:# script: # Update the filter state and warning LEDs using kRevs rather than raw pulses. - id: update_filter_state mode: restart then: - if: condition: lambda: |- float threshold = id(filter_replacement_krevs).state; if (isnan(threshold) || threshold <= 0.0f) { threshold = ${filter_replacement_default_krevs}; } return id(filter_age_krevs) >= threshold; then: - switch.turn_on: onboard_led - switch.turn_on: panel_led - lambda: |- id(filter_dirty_text).publish_state("Dirty"); else: - switch.turn_off: panel_led - switch.turn_off: onboard_led - lambda: |- id(filter_dirty_text).publish_state("Clean"); # Flash the IKEA panel dirty LED continuously at 500ms on / 500ms off. # The loop is intentionally unconditional; switch.turn_off stops the script. - id: panel_led_flash mode: restart then: - while: condition: lambda: |- return true; then: - output.turn_on: panel_led_output - delay: 500ms - output.turn_off: panel_led_output - delay: 500ms