# Adaptive Fan Speed Control # Version: 1.3.0 # https://github.com/bferd/homeassistant-blueprints/tree/main/adaptive_fan_speed_control blueprint: name: Adaptive Fan Speed Control description: >- Adjusts fan speed based on one or more temperature, humidity or air quality sensors and/or thermostats. Each entity is evaluated on its own and mapped onto your fan speed range. The highest result wins, so a satisfied sensor never dilutes a more urgent one. The fan turns off only when every entity is satisfied. **Sensor type** is auto-detected from device class. Anything that is not humidity or air quality is treated as temperature. Thermostats use their deviation from setpoint, and the direction flips with HVAC mode (too hot in Cool, too cold in Heat). **Air quality** sensors bypass the cooldown and respond on every check. **Unavailable sensors** are skipped, not treated as zero. The fan holds its speed only if every sensor is down. Each sensor type has its own collapsed section below. Only open the ones you use. Requires Home Assistant 2024.6 or newer. domain: automation input: basic: name: 💨 Basic Setup icon: mdi:fan collapsed: false input: sensor_entities: name: 🌡 Sensors or Thermostat(s) description: >- Temperature, humidity or air quality sensors and/or single-setpoint thermostats. The highest computed fan speed wins. Sensors of the same type share the same threshold sliders. Dual-setpoint thermostats are not supported. default: [] selector: entity: filter: - domain: sensor - domain: climate multiple: true fan_switch: name: 💨 Fan description: The fan to control. selector: entity: domain: - fan multiple: false min_fan_speed: name: 💨 Minimum Fan Speed description: Lowest speed while the fan is on. default: 16 selector: number: min: 1.0 max: 100.0 mode: slider step: 1.0 unit_of_measurement: '%' max_fan_speed: name: 💨 Maximum Fan Speed description: Highest speed. default: 100 selector: number: min: 1.0 max: 100.0 mode: slider step: 1.0 unit_of_measurement: '%' temperature_section: name: 🌡️ Temperature Sensors icon: mdi:thermometer description: >- Used for plain sensors with no humidity or air quality device class. Use the same unit (°C or °F) as the sensor. collapsed: true input: max_temp: name: 📈 Maximum Temperature description: Temperature at which the fan runs at max speed. default: 40 selector: number: min: 10.0 max: 120.0 step: 1.0 mode: slider min_temp: name: 📉 Minimum Temperature description: Temperature at which the fan runs at min speed. default: 23 selector: number: min: 10.0 max: 120.0 step: 1.0 mode: slider off_temp: name: 🛑 Off Temperature description: >- Fan turns off at or below this, and back on above it (if auto fan on is enabled). default: 22 selector: number: min: 10.0 max: 120.0 step: 1.0 mode: slider thermostat_section: name: 🌡️ Thermostats icon: mdi:thermostat description: >- Used for climate entities. Speed is based on how far the room is from the setpoint. collapsed: true input: deviation_amount: name: 📈 Deviation Amount description: Degrees from setpoint that give max fan speed. default: 5 selector: number: min: 0.0 max: 30.0 step: 0.5 mode: slider deviation_off: name: 🛑 Deviation Off description: >- Degrees from setpoint below which the fan turns off. Also where min speed begins. Must be smaller than Deviation Amount. default: 1 selector: number: min: 0.0 max: 30.0 step: 0.5 mode: slider humidity_section: name: 💧 Humidity Sensors icon: mdi:water-percent description: Used for sensors with the humidity device class. collapsed: true input: humidity_direction: name: 🔁 Humidity Direction description: >- High Mode ramps up as humidity rises (e.g. a bathroom fan). Low Mode ramps up as humidity falls. default: high selector: select: mode: dropdown options: - value: high label: High Mode (more fan as humidity rises) - value: low label: Low Mode (more fan as humidity falls) max_humidity: name: 📈 Maximum Humidity description: >- Humidity % for max speed in High Mode (min speed in Low Mode). Must be greater than Minimum Humidity. default: 65 selector: number: min: 0.0 max: 100.0 step: 1.0 mode: slider unit_of_measurement: '%' min_humidity: name: 📉 Minimum Humidity description: >- Humidity % for min speed in High Mode (max speed in Low Mode). Must be smaller than Maximum Humidity. default: 50 selector: number: min: 0.0 max: 100.0 step: 1.0 mode: slider unit_of_measurement: '%' off_humidity: name: 🛑 Off Humidity description: >- High Mode: fan turns off below this (keep it under Minimum Humidity). Low Mode: fan turns off above this (keep it over Maximum Humidity). default: 45 selector: number: min: 0.0 max: 100.0 step: 1.0 mode: slider unit_of_measurement: '%' air_quality_section: name: 🌫️ Air Quality Sensors icon: mdi:air-filter description: >- Used for CO2, PM, VOC, AQI and similar device classes (aqi, carbon_dioxide, carbon_monoxide, nitrogen_dioxide, nitrogen_monoxide, nitrous_oxide, ozone, pm1, pm25, pm4, pm10, sulphur_dioxide, volatile_organic_compounds, volatile_organic_compounds_parts). Enter values in your sensor's own unit. More pollutant always means more fan. collapsed: true input: max_air_quality: name: 📈 Maximum Air Quality description: Reading at which the fan reaches max speed. default: 800 selector: number: min: 0.0 max: 2000.0 step: 1.0 mode: slider min_air_quality: name: 📉 Minimum Air Quality description: Reading at which the fan reaches min speed. default: 500 selector: number: min: 0.0 max: 2000.0 step: 1.0 mode: slider off_air_quality: name: 🛑 Off Air Quality description: Fan turns off below this reading. default: 400 selector: number: min: 0.0 max: 2000.0 step: 1.0 mode: slider behavior_section: name: ⚙️ Timing and Behavior icon: mdi:timer-cog collapsed: true input: auto_turn_on_enabled: name: ✅ Enable auto fan on description: Turn the fan back on when a sensor rises above its off threshold. default: true selector: boolean: {} update_frequency: name: 🔁 Update Check Frequency / Cooldown description: >- How often sensors are re-checked. Also the cooldown between speed changes. default: "0" selector: select: mode: dropdown options: - value: "/5" label: Every 5 seconds - value: "/10" label: Every 10 seconds - value: "/15" label: Every 15 seconds - value: "/30" label: Every 30 seconds - value: "0" label: Every 60 seconds (1 minute) custom_trigger_override: name: 🎛️ (OPTIONAL) Custom Trigger Override description: >- Extra triggers that run the same logic, e.g. a state trigger on a sensor to react on every update. Adds to the frequency above, does not replace it. default: [] selector: trigger: {} change_time_enabled: name: ⏱️ Enable Cooldown description: >- Throttle speed changes to the Update Check Frequency. Turn off for testing. Air quality sensors always bypass the cooldown. default: true selector: boolean: {} change_threshold: name: Minimum percentage change description: Smallest speed change worth applying. default: 1 selector: number: min: 1.0 max: 100.0 mode: slider step: 1.0 unit_of_measurement: '%' max_change: name: Maximum percentage change description: Largest speed change allowed per adjustment. default: 50 selector: number: min: 1.0 max: 100.0 mode: slider step: 1.0 unit_of_measurement: '%' blocker_section: name: 🚫 Blocking Entity icon: mdi:cancel collapsed: true input: blocker_entity: name: 🚫 (OPTIONAL) Blocking entity description: >- While this entity is on, the automation pauses or shuts off the fan (see action below). E.g. a sleep or away helper. default: selector: entity: multiple: false blocker_action: name: 🚫 Blocking Entity Action description: >- Pause holds the current speed. Shut off turns the fan off and keeps it off. Shut off takes effect on the next periodic check. For instant shutoff, add a state trigger on the blocker (to: "on") in Custom Trigger Override. default: pause selector: select: mode: dropdown options: - value: pause label: Pause (fan stays at current speed, no adjustments) - value: shutoff label: Shut off (fan turns off immediately) notify_section: name: 📱 Notifications icon: mdi:bell collapsed: true input: notify_device: name: 📱 (OPTIONAL) Notify Device description: >- Companion app device to notify when a sensor is unavailable or unknown. Without a Notify State Helper it notifies on every check. default: selector: device: filter: integration: mobile_app multiple: false notify_state_helper: name: 🔁 (OPTIONAL) Notify State Helper description: >- An input_boolean (Toggle helper) that limits notifications to once per outage and adds a one-time "recovered" notice. default: selector: entity: filter: - domain: input_boolean multiple: false ambient_section: name: 🌍 Ambient Reference icon: mdi:home-thermometer collapsed: true input: ambient_sensor: name: 🌍 (OPTIONAL) Ambient Reference Sensor description: >- A temperature or humidity sensor for conditions the fan cannot beat (e.g. home average). The off threshold is raised to ambient so the fan stops instead of chasing an unreachable setpoint. A temperature sensor corrects temperature and thermostat sensors. A humidity sensor corrects humidity sensors. Air quality is not affected. default: selector: entity: filter: - domain: sensor device_class: temperature - domain: sensor device_class: humidity multiple: false source_url: https://community.home-assistant.io/t/adaptive-fan-speed-control-based-on-temperature-and-speed-range/678152 variables: fan_switch: !input fan_switch auto_turn_on_enabled: !input 'auto_turn_on_enabled' min_fan_speed: !input min_fan_speed max_fan_speed: !input max_fan_speed max_temp: !input max_temp min_temp: !input min_temp off_temp: !input off_temp humidity_direction: !input humidity_direction max_humidity: !input max_humidity min_humidity: !input min_humidity off_humidity: !input off_humidity max_air_quality: !input max_air_quality min_air_quality: !input min_air_quality off_air_quality: !input off_air_quality deviation_amount: !input deviation_amount deviation_off: !input deviation_off change_time_enabled: !input change_time_enabled update_frequency: !input update_frequency check_interval_seconds: "{{ update_frequency.lstrip('/') | int if update_frequency != '0' else 60 }}" max_change: !input max_change change_threshold: !input change_threshold blocker_entity: !input blocker_entity blocker_action: !input blocker_action notify_device: !input notify_device notify_state_helper: !input notify_state_helper already_notified: "{{ notify_state_helper not in [none, ''] and is_state(notify_state_helper, 'on') }}" ambient_sensor: !input ambient_sensor sensor_entities: !input sensor_entities fan_speed: '{{ state_attr(fan_switch,''percentage'') | float(0)}}' sensor_eval: >- {% set ns = namespace(contributions=[], unavailable=[], aq_active=false) %} {% set aq_classes = ['aqi', 'carbon_dioxide', 'carbon_monoxide', 'nitrogen_dioxide', 'nitrogen_monoxide', 'nitrous_oxide', 'ozone', 'pm1', 'pm25', 'pm4', 'pm10', 'sulphur_dioxide', 'volatile_organic_compounds', 'volatile_organic_compounds_parts'] %} {% set use_ambient = ambient_sensor not in [none, ''] and states(ambient_sensor) not in ['unavailable', 'unknown', 'none'] %} {% set ambient_dc = state_attr(ambient_sensor, 'device_class') if use_ambient else '' %} {% set ambient_t = states(ambient_sensor) | float(0) if (use_ambient and ambient_dc == 'temperature') else none %} {% set ambient_h = states(ambient_sensor) | float(0) if (use_ambient and ambient_dc == 'humidity') else none %} {% for entity in sensor_entities %} {% set is_climate = entity.split('.')[0] == 'climate' %} {% set dc = state_attr(entity, 'device_class') %} {% set is_hum = dc == 'humidity' %} {% set is_aq = dc in aq_classes %} {% if is_climate %} {% set unavail = states(entity) in ['unavailable', 'unknown', 'none', 'off'] or state_attr(entity, 'current_temperature') is none or state_attr(entity, 'temperature') is none %} {% else %} {% set unavail = states(entity) in ['unavailable', 'unknown', 'none'] %} {% endif %} {% if unavail %} {% set ns.unavailable = ns.unavailable + [entity] %} {% else %} {% if is_climate %} {% set setpoint = state_attr(entity, 'temperature') | float(0) %} {% set actual = state_attr(entity, 'current_temperature') | float(0) %} {% set is_heat = states(entity) == 'heat' %} {% set cur = (setpoint - actual) if is_heat else (actual - setpoint) %} {% set emin = deviation_off | float(0) %} {% set emax = deviation_amount | float(0) %} {% set eoff = deviation_off | float(0) %} {% if ambient_t is not none %} {% if is_heat %} {% set eoff = [eoff, setpoint - ambient_t] | max %} {% else %} {% set eoff = [eoff, ambient_t - setpoint] | max %} {% endif %} {% endif %} {% elif is_hum %} {% if humidity_direction == 'low' %} {% set cur = -1 * (states(entity) | float(0)) %} {% set emin = -1 * (max_humidity | float(0)) %} {% set emax = -1 * (min_humidity | float(0)) %} {% set eoff = -1 * (off_humidity | float(0)) %} {% if ambient_h is not none %} {% set eoff = [eoff, -ambient_h] | max %} {% endif %} {% else %} {% set cur = states(entity) | float(0) %} {% set emin = min_humidity | float(0) %} {% set emax = max_humidity | float(0) %} {% set eoff = off_humidity | float(0) %} {% if ambient_h is not none %} {% set eoff = [eoff, ambient_h] | max %} {% endif %} {% endif %} {% elif is_aq %} {% set cur = states(entity) | float(0) %} {% set emin = min_air_quality | float(0) %} {% set emax = max_air_quality | float(0) %} {% set eoff = off_air_quality | float(0) %} {% else %} {% set cur = states(entity) | float(0) %} {% set emin = min_temp | float(0) %} {% set emax = max_temp | float(0) %} {% set eoff = off_temp | float(0) %} {% if ambient_t is not none %} {% set eoff = [eoff, ambient_t] | max %} {% endif %} {% endif %} {% set vrange = emax - emin %} {% set eslope = ((max_fan_speed | float(0) - min_fan_speed | float(0)) / vrange) if vrange != 0 else 0 %} {% set active = cur > eoff %} {% set contrib = ([[eslope * (cur - emin) + min_fan_speed | float(0), min_fan_speed | float(0)] | max, max_fan_speed | float(0)] | min) if active else 0 %} {% set ns.contributions = ns.contributions + [contrib] %} {% if is_aq and active %} {% set ns.aq_active = true %} {% endif %} {% endif %} {% endfor %} {{ {'contributions': ns.contributions, 'unavailable': ns.unavailable, 'aq_active': ns.aq_active} }} unavailable_entities: "{{ sensor_eval.unavailable }}" any_unavailable: "{{ unavailable_entities | length > 0 }}" all_unavailable: "{{ sensor_entities | length > 0 and unavailable_entities | length == sensor_entities | length }}" aq_active: "{{ sensor_eval.aq_active }}" initial_set_fan_speed: "{{ sensor_eval.contributions | max if sensor_eval.contributions | length > 0 else 0 }}" any_active: "{{ initial_set_fan_speed | float(0) > 0 }}" adjusted_set_fan_speed: > {% set diff = initial_set_fan_speed - fan_speed %} {% if diff > max_change|float(0) %} {{ fan_speed + max_change|float(0) }} {% elif diff < -max_change|float(0) %} {{ fan_speed - max_change|float(0) }} {% else %} {{ initial_set_fan_speed }} {% endif %} set_fan_speed: '{{ adjusted_set_fan_speed | round(0) | int }}' speed_diff: '{{(fan_speed - set_fan_speed)|abs}}' trigger: - triggers: !input custom_trigger_override - platform: state entity_id: - !input fan_switch id: fanon from: 'off' to: 'on' - platform: time_pattern id: temp_state_change seconds: !input update_frequency condition: [] action: - if: - condition: template alias: Blocker entity is active value_template: "{{ blocker_entity not in [none, ''] and is_state(blocker_entity, 'on') }}" then: - if: - condition: template value_template: "{{ blocker_action == 'shutoff' }}" then: - service: homeassistant.turn_off target: entity_id: "{{ fan_switch }}" - condition: template alias: Stop here if blocker entity is active, prevents all further logic regardless of mode value_template: "{{ blocker_entity in [none, ''] or is_state(blocker_entity, 'off') }}" - if: - condition: template alias: All previously-down sensors have recovered value_template: "{{ (not any_unavailable) and already_notified }}" then: - service: input_boolean.turn_off target: entity_id: "{{ notify_state_helper }}" - condition: template alias: Only continue if a notify device was configured value_template: "{{ notify_device not in [none, ''] }}" - service: "notify.mobile_app_{{ device_attr(notify_device, 'name') | slugify }}" data: title: "✅ {{ state_attr(fan_switch, 'friendly_name') or fan_switch }} fan control" message: >- All previously unavailable sensors are reporting again. - if: - condition: template alias: At least one configured sensor is currently unavailable or unknown value_template: "{{ any_unavailable }}" then: - if: - condition: template value_template: "{{ notify_state_helper not in [none, ''] }}" then: - service: input_boolean.turn_on target: entity_id: "{{ notify_state_helper }}" - condition: template alias: Only notify if a device is configured and we haven't already notified for this outage value_template: "{{ notify_device not in [none, ''] and not already_notified }}" - service: "notify.mobile_app_{{ device_attr(notify_device, 'name') | slugify }}" data: title: "🌡️ {{ state_attr(fan_switch, 'friendly_name') or fan_switch }} fan control" message: >- {{ unavailable_entities | join(', ') }} {{ 'is' if unavailable_entities | length == 1 else 'are' }} unavailable or unknown. Still operating off whichever configured sensors are working, fully stopped only if all of them go down. - choose: - conditions: - condition: template alias: Every configured sensor is unavailable or unknown, nothing to act on value_template: "{{ all_unavailable }}" sequence: [] - conditions: - condition: trigger alias: Only run if fan was switched on id: fanon - condition: template alias: Is the percentage change great enough? value_template: '{{ speed_diff | float(0) > change_threshold | float(0) }}' sequence: - service: homeassistant.turn_on data: percentage: '{{set_fan_speed}}' target: entity_id: '{{fan_switch}}' - if: - condition: template value_template: "{{ change_time_enabled and not aq_active }}" then: - wait_template: >- {{ blocker_entity not in [none, ''] and is_state(blocker_entity, 'on') }} timeout: seconds: "{{ check_interval_seconds }}" continue_on_timeout: true - conditions: - condition: template value_template: '{{states(fan_switch) == ''on''}}' alias: Make sure the fan is already on (ignore if it's been switched off) - condition: template alias: Is the percentage change great enough? value_template: '{{ speed_diff | float(0) > change_threshold | float(0) }}' - condition: template alias: Does at least one sensor still want the fan on? value_template: '{{ any_active }}' sequence: - service: homeassistant.turn_on target: entity_id: '{{fan_switch}}' data: percentage: '{{set_fan_speed}}' - if: - condition: template value_template: "{{ change_time_enabled and not aq_active }}" then: - wait_template: >- {{ blocker_entity not in [none, ''] and is_state(blocker_entity, 'on') }} timeout: seconds: "{{ check_interval_seconds }}" continue_on_timeout: true - conditions: - condition: not alias: Make sure the fan hasn't just been turned on conditions: - condition: trigger id: fanon - condition: template alias: Does every sensor agree it's satisfied? value_template: '{{ not any_active }}' sequence: - service: homeassistant.turn_off target: entity_id: '{{fan_switch}}' - conditions: - '{{ auto_turn_on_enabled }}' - condition: template value_template: '{{states(fan_switch) == ''off''}}' alias: Is the fan currently off? - condition: template alias: Does at least one sensor want the fan on? value_template: '{{ any_active }}' sequence: - service: homeassistant.turn_on data: percentage: '{{set_fan_speed}}' target: entity_id: '{{fan_switch}}' - if: - condition: template value_template: "{{ change_time_enabled and not aq_active }}" then: - wait_template: >- {{ blocker_entity not in [none, ''] and is_state(blocker_entity, 'on') }} timeout: seconds: "{{ check_interval_seconds }}" continue_on_timeout: true mode: queued max: 2