blueprint: name: Ceiling Fan Climate Control Pro v2.2.7 description: > HVAC-aware ceiling fan automation with adaptive comfort control. Uses the EN 16798 / ASHRAE 55 adaptive comfort model to dynamically adjust fan behavior based on outdoor temperature, indoor humidity, and occupancy. Coordinates with your thermostat to optimize comfort: turns fans off during heating (to avoid drafts), runs them during cooling (to distribute cool air), and uses adaptive comfort bands when HVAC is idle. Supports fans with variable speed and optional direction control. domain: automation author: Jeremy Carter source_url: https://github.com/schoolboyqueue/home-assistant-blueprints/blob/main/blueprints/adaptive-fan-control/adaptive_fan_control_pro_blueprint.yaml input: core: name: Fan & Sensors icon: mdi:fan input: fan_entity: name: Ceiling fan description: > REQUIRED. Select the ceiling fan entity to control. selector: entity: domain: - fan multiple: false temp_sensor: name: Indoor temperature sensor description: > REQUIRED. Select the temperature sensor for this room. Used to determine when to turn the fan on/off and at what speed. selector: entity: domain: - sensor device_class: temperature multiple: false humidity_sensor: name: Indoor humidity sensor description: > Optional. Select a humidity sensor for this room. When provided, humidity is factored into comfort calculations using heat index. Leave empty to use temperature only. default: {} selector: entity: domain: - sensor device_class: humidity multiple: false outdoor_temp_sensor: name: Outdoor temperature sensor description: > Optional. Select an outdoor temperature sensor or weather entity. When provided, enables adaptive comfort mode where the comfort band shifts based on outdoor conditions (EN 16798 / ASHRAE 55). Leave empty to use fixed thresholds. default: {} selector: entity: domain: - sensor - weather multiple: false presence_sensor: name: Presence/occupancy sensor description: > REQUIRED. Select a binary sensor that indicates room occupancy. Fan will only run when the room is occupied. selector: entity: domain: - binary_sensor multiple: false climate_entity: name: Climate/thermostat entity description: > REQUIRED. Select your thermostat. The blueprint monitors hvac_action to coordinate fan behavior with heating/cooling cycles. selector: entity: domain: - climate multiple: false fan_capabilities: name: Fan Capabilities icon: mdi:cog input: supports_direction: name: Fan supports direction control description: > Enable if your fan supports direction/reverse mode (e.g., Bond, some smart fans). When enabled, direction will be set based on season or HVAC mode. default: false selector: boolean: {} reverse_when_heating: name: Run in reverse when heating description: > When enabled AND the fan supports direction, the fan will run in REVERSE at low speed during heating to push warm air down from the ceiling. When disabled, fan turns off during heating. default: false selector: boolean: {} heating_speed_pct: name: Fan speed when heating (%) description: > Speed to run the fan when HVAC is heating (only applies if "Run in reverse when heating" is enabled). Lower speeds are recommended to gently circulate warm air without creating drafts. default: 25 selector: number: min: 10 max: 50 step: 5 unit_of_measurement: "%" mode: slider comfort: name: Comfort Settings icon: mdi:thermometer-lines input: comfort_mode: name: Comfort mode description: > Fixed: Uses static temperature thresholds (legacy behavior). Adaptive: Uses EN 16798 adaptive comfort model where the comfort band shifts based on outdoor temperature. Warmer outdoor temps = higher acceptable indoor temps. default: adaptive selector: select: mode: dropdown options: - label: "Fixed thresholds" value: "fixed" - label: "Adaptive comfort (EN 16798)" value: "adaptive" sort: false multiple: false custom_value: false comfort_category: name: Comfort category description: > Controls how strict the comfort band is (adaptive mode only). Category I (±2°C): Strict - for sensitive occupants. Category II (±3°C): Normal - typical residential. Category III (±4°C): Relaxed - maximum energy savings. default: "II" selector: select: mode: dropdown options: - label: "I - Strict (±2°C / ±3.6°F)" value: "I" - label: "II - Normal (±3°C / ±5.4°F)" value: "II" - label: "III - Relaxed (±4°C / ±7.2°F)" value: "III" sort: false multiple: false custom_value: false units: name: Temperature units description: > Select your preferred temperature units. Auto will detect from your indoor sensor's unit_of_measurement attribute. default: "auto" selector: select: mode: dropdown options: - label: "Auto-detect" value: "auto" - label: "Fahrenheit (°F)" value: "f" - label: "Celsius (°C)" value: "c" sort: false multiple: false custom_value: false temperature: name: Fixed Thresholds (when not using adaptive mode) icon: mdi:thermometer input: temp_on_threshold: name: Turn on temperature description: > Fan turns on when room temperature exceeds this value (fixed mode only, or as absolute maximum in adaptive mode). Set in your preferred units. default: 74 selector: number: min: 60 max: 90 step: 1 unit_of_measurement: "°" mode: slider temp_off_threshold: name: Turn off temperature description: > Fan turns off when room temperature drops below this value (fixed mode only, or as absolute minimum in adaptive mode). Should be lower than the "on" threshold to prevent rapid cycling. default: 72 selector: number: min: 55 max: 85 step: 1 unit_of_measurement: "°" mode: slider speed_tiers: name: Speed Tiers icon: mdi:speedometer input: speed_mode: name: Speed calculation mode description: > Fixed thresholds: Uses static temperature values for speed tiers. Comfort deviation: Calculates speed based on how far above the comfort band the temperature is (more intelligent). default: "deviation" selector: select: mode: dropdown options: - label: "Fixed temperature thresholds" value: "fixed" - label: "Comfort band deviation" value: "deviation" sort: false multiple: false custom_value: false speed_tier_1_threshold: name: Medium speed threshold description: > Fixed mode: Temperature above which fan runs at medium speed. Deviation mode: Degrees above comfort band for medium speed. default: 2 selector: number: min: 1 max: 10 step: 0.5 unit_of_measurement: "°" mode: slider speed_tier_2_threshold: name: High speed threshold description: > Fixed mode: Temperature above which fan runs at high speed. Deviation mode: Degrees above comfort band for high speed. default: 4 selector: number: min: 2 max: 15 step: 0.5 unit_of_measurement: "°" mode: slider speed_low_pct: name: Low speed (%) description: Fan speed percentage for temperatures just above comfort. default: 33 selector: number: min: 15 max: 50 step: 1 unit_of_measurement: "%" mode: slider speed_medium_pct: name: Medium speed (%) description: Fan speed percentage for moderate discomfort. default: 66 selector: number: min: 40 max: 80 step: 1 unit_of_measurement: "%" mode: slider speed_high_pct: name: High speed (%) description: Fan speed percentage for high discomfort. default: 100 selector: number: min: 70 max: 100 step: 1 unit_of_measurement: "%" mode: slider presence: name: Presence Settings icon: mdi:account input: presence_off_delay_min: name: Presence off delay (minutes) description: > How long to wait after presence clears before turning off the fan. Prevents the fan from turning off during brief absences. default: 30 selector: number: min: 5 max: 120 step: 5 unit_of_measurement: "min" mode: slider diagnostics: name: Diagnostics icon: mdi:bug-outline input: debug_level: name: Debug level description: "off: no logging, basic: state changes only, verbose: all decisions and calculations." default: "off" selector: select: mode: dropdown options: - "off" - basic - verbose sort: false multiple: false custom_value: false variables: blueprint_version: "2.2.7" fan_entity: !input fan_entity temp_sensor: !input temp_sensor humidity_sensor_input: !input humidity_sensor outdoor_temp_sensor_input: !input outdoor_temp_sensor presence_sensor: !input presence_sensor climate_entity: !input climate_entity supports_direction_input: !input supports_direction reverse_when_heating_input: !input reverse_when_heating heating_speed_pct_input: !input heating_speed_pct comfort_mode_input: !input comfort_mode comfort_category_input: !input comfort_category units_input: !input units temp_on_threshold_input: !input temp_on_threshold temp_off_threshold_input: !input temp_off_threshold speed_mode_input: !input speed_mode speed_tier_1_threshold_input: !input speed_tier_1_threshold speed_tier_2_threshold_input: !input speed_tier_2_threshold speed_low_pct_input: !input speed_low_pct speed_medium_pct_input: !input speed_medium_pct speed_high_pct_input: !input speed_high_pct presence_off_delay_min_input: !input presence_off_delay_min debug_level_v: !input debug_level # Resolve optional sensors humidity_sensor: "{{ humidity_sensor_input if humidity_sensor_input else none }}" outdoor_temp_sensor: "{{ outdoor_temp_sensor_input if outdoor_temp_sensor_input else none }}" has_humidity: "{{ humidity_sensor is not none and humidity_sensor != '' }}" has_outdoor_temp: "{{ outdoor_temp_sensor is not none and outdoor_temp_sensor != '' }}" # Basic settings supports_direction: "{{ supports_direction_input | default(false) }}" reverse_when_heating: "{{ reverse_when_heating_input | default(false) }}" heating_speed_pct: "{{ heating_speed_pct_input | int(25) }}" comfort_mode: "{{ comfort_mode_input | default('adaptive') }}" comfort_category: "{{ comfort_category_input | default('II') }}" speed_mode: "{{ speed_mode_input | default('deviation') }}" speed_tier_1: "{{ speed_tier_1_threshold_input | float(2) }}" speed_tier_2: "{{ speed_tier_2_threshold_input | float(4) }}" speed_low_pct: "{{ speed_low_pct_input | int(33) }}" speed_medium_pct: "{{ speed_medium_pct_input | int(66) }}" speed_high_pct: "{{ speed_high_pct_input | int(100) }}" presence_off_delay_min: "{{ presence_off_delay_min_input | int(30) }}" # Unit detection and conversion _sensor_unit: "{{ state_attr(temp_sensor, 'unit_of_measurement') | default('°F', true) | string }}" is_imperial: > {% if units_input == 'f' %} {{ true }} {% elif units_input == 'c' %} {{ false }} {% else %} {{ _sensor_unit | lower in ['°f', 'f', 'fahrenheit'] }} {% endif %} # Temperature tolerance based on comfort category (in Celsius) tol_c: "{% if comfort_category == 'I' %}2.0{% elif comfort_category == 'II' %}3.0{% else %}4.0{% endif %}" tol_sys: "{{ (tol_c | float * 9/5) if is_imperial else tol_c | float }}" # Fixed thresholds (convert to system units if needed) temp_on_threshold: "{{ temp_on_threshold_input | float(74 if is_imperial else 23.3) }}" temp_off_threshold: "{{ temp_off_threshold_input | float(72 if is_imperial else 22.2) }}" # Get indoor temperature in system units # Rate-of-change filter: reject readings that change too fast (sensor glitch detection) # Max realistic indoor temp change is ~2-3°F/min; we use 10°F as a generous threshold # to catch spikes like 72°F | 212°F while allowing any realistic scenario _t_in_max_delta_f: 10 _t_in_min_valid_f: 40 _t_in_max_valid_f: 120 _t_in_state: "{{ states(temp_sensor) }}" _t_in_unavailable: "{{ _t_in_state in ['unavailable', 'unknown', 'none'] or _t_in_state is none }}" t_in_raw: "{{ _t_in_state | float(70 if is_imperial else 21) }}" _t_in_unit: "{{ state_attr(temp_sensor, 'unit_of_measurement') | default('', true) | string }}" # Get previous state from trigger (only available on temp_changed trigger) _t_in_prev_state: "{{ trigger.from_state.state | default('unknown', true) if trigger is defined and trigger.id == 'temp_changed' else 'unknown' }}" _t_in_prev_unavailable: "{{ _t_in_prev_state in ['unavailable', 'unknown', 'none'] or _t_in_prev_state is none }}" _t_in_prev_raw: "{{ _t_in_prev_state | float(0) if not _t_in_prev_unavailable else 0 }}" # Convert both to Fahrenheit for consistent delta calculation _t_in_raw_f: > {% set v = t_in_raw | float %} {% set u = _t_in_unit | lower %} {% if u in ['°c', 'c', 'celsius'] %} {{ v * 9/5 + 32 }} {% else %} {{ v }} {% endif %} _t_in_prev_f: > {% set v = _t_in_prev_raw | float %} {% set u = _t_in_unit | lower %} {% if u in ['°c', 'c', 'celsius'] %} {{ v * 9/5 + 32 }} {% else %} {{ v }} {% endif %} _t_in_bounds_ok: "{{ _t_in_raw_f | float >= _t_in_min_valid_f and _t_in_raw_f | float <= _t_in_max_valid_f }}" _t_in_delta_f: "{{ (_t_in_raw_f | float - _t_in_prev_f | float) | abs }}" # Validation logic: # - If current reading is unavailable | invalid # - If this is a temp_changed trigger AND we have a valid previous reading: # | Use rate-of-change check (reject if delta > threshold) # - Otherwise (other triggers or no valid previous): # | Accept the reading (no rate check possible, trust the sensor) _t_in_has_prev: "{{ trigger is defined and trigger.id == 'temp_changed' and not _t_in_prev_unavailable }}" _t_in_rate_ok: "{{ not _t_in_has_prev or _t_in_delta_f <= _t_in_max_delta_f }}" t_in_valid: "{{ not _t_in_unavailable and _t_in_rate_ok and _t_in_bounds_ok }}" _t_in_reject_reason: > {% if _t_in_unavailable %} unavailable {% elif not _t_in_bounds_ok %} out_of_bounds {% elif not _t_in_rate_ok %} rate_exceeded {% else %} none {% endif %} t_in_sys: > {% set v = t_in_raw %} {% set u = _t_in_unit | lower %} {% if is_imperial and u in ['°c', 'c', 'celsius'] %} {{ v * 9/5 + 32 }} {% elif not is_imperial and u in ['°f', 'f', 'fahrenheit'] %} {{ (v - 32) * 5/9 }} {% else %} {{ v }} {% endif %} t_in_c: "{{ ((t_in_sys - 32) * 5/9) if is_imperial else t_in_sys }}" # Get indoor humidity rh_in: > {% if has_humidity %} {{ states(humidity_sensor) | float(50) }} {% else %} 50 {% endif %} # Get outdoor temperature (supports weather entities) _is_weather_out: "{{ outdoor_temp_sensor and outdoor_temp_sensor.startswith('weather.') }}" t_out_raw: > {% if has_outdoor_temp %} {% if _is_weather_out %} {{ state_attr(outdoor_temp_sensor, 'temperature') | float(65 if is_imperial else 18) }} {% else %} {{ states(outdoor_temp_sensor) | float(65 if is_imperial else 18) }} {% endif %} {% else %} {{ 65 if is_imperial else 18 }} {% endif %} _t_out_unit: > {% if has_outdoor_temp %} {% if _is_weather_out %} {{ state_attr(outdoor_temp_sensor, 'temperature_unit') | default('', true) | string }} {% else %} {{ state_attr(outdoor_temp_sensor, 'unit_of_measurement') | default('', true) | string }} {% endif %} {% else %} {{ '' }} {% endif %} t_out_c: > {% set v = t_out_raw | float %} {% set u = _t_out_unit | lower %} {% if u in ['°f', 'f', 'fahrenheit'] or (u == '' and is_imperial) %} {{ (v - 32) * 5/9 }} {% else %} {{ v }} {% endif %} # Heat index calculation (Rothfusz regression, simplified) # Only calculated when humidity sensor is available heat_index_c: > {% if has_humidity %} {% set T = t_in_c | float %} {% set R = rh_in | float %} {% if T < 20 or R < 40 %} {{ T }} {% else %} {% set c1 = -8.78469475556 %} {% set c2 = 1.61139411 %} {% set c3 = 2.33854883889 %} {% set c4 = -0.14611605 %} {% set c5 = -0.012308094 %} {% set c6 = -0.0164248277778 %} {% set c7 = 0.002211732 %} {% set c8 = 0.00072546 %} {% set c9 = -0.000003582 %} {% set HI = c1 + c2*T + c3*R + c4*T*R + c5*T*T + c6*R*R + c7*T*T*R + c8*T*R*R + c9*T*T*R*R %} {{ HI }} {% endif %} {% else %} {{ t_in_c }} {% endif %} # Effective indoor temperature (uses heat index when humidity available) t_effective_c: "{{ heat_index_c | float }}" t_effective_sys: "{{ (t_effective_c * 9/5 + 32) if is_imperial else t_effective_c }}" # EN 16798 Adaptive comfort model # Comfort temperature = 0.33 * T_rm + 18.8 (in Celsius) # Where T_rm is running mean outdoor temperature (simplified to current outdoor) # Clamp outdoor temp to 10-30°C range per standard t_rm_c: "{{ [[t_out_c | float, 10] | max, 30] | min }}" comfort_temp_c: "{{ 0.33 * t_rm_c + 18.8 }}" comfort_temp_sys: "{{ (comfort_temp_c * 9/5 + 32) if is_imperial else comfort_temp_c }}" # Comfort band (upper and lower limits) comfort_upper_c: "{{ comfort_temp_c + tol_c | float }}" comfort_lower_c: "{{ comfort_temp_c - tol_c | float }}" comfort_upper_sys: "{{ (comfort_upper_c * 9/5 + 32) if is_imperial else comfort_upper_c }}" comfort_lower_sys: "{{ (comfort_lower_c * 9/5 + 32) if is_imperial else comfort_lower_c }}" # Effective comfort band limits # In adaptive mode: use pure EN 16798 calculation (no fixed threshold caps) # In fixed mode: use user-defined fixed thresholds effective_upper_sys: > {% if comfort_mode == 'adaptive' and has_outdoor_temp %} {{ comfort_upper_sys | float }} {% else %} {{ temp_on_threshold | float }} {% endif %} effective_lower_sys: > {% if comfort_mode == 'adaptive' and has_outdoor_temp %} {{ comfort_lower_sys | float }} {% else %} {{ temp_off_threshold | float }} {% endif %} # Comfort state determination is_above_comfort: "{{ t_effective_sys | float > effective_upper_sys | float }}" is_below_comfort: "{{ t_effective_sys | float < effective_lower_sys | float }}" comfort_deviation: "{{ (t_effective_sys | float - effective_upper_sys | float) | round(1) }}" # HVAC state hvac_action: "{{ state_attr(climate_entity, 'hvac_action') | default('idle') }}" is_heating: "{{ hvac_action == 'heating' }}" is_cooling: "{{ hvac_action == 'cooling' }}" is_occupied: "{{ is_state(presence_sensor, 'on') }}" # Season current_season: "{{ states('sensor.season') | default('summer') }}" is_winter: "{{ current_season == 'winter' }}" # Current fan state (for avoiding redundant commands) fan_is_on: "{{ is_state(fan_entity, 'on') }}" fan_is_off: "{{ not fan_is_on }}" fan_current_pct: "{{ state_attr(fan_entity, 'percentage') | int(0) }}" fan_current_direction: "{{ state_attr(fan_entity, 'direction') | default('forward', true) | lower }}" # Speed calculation (must be defined before desired_speed references it) calculated_speed: > {% if speed_mode == 'deviation' %} {% set dev = comfort_deviation | float %} {% if dev > speed_tier_2 %} {{ speed_high_pct }} {% elif dev > speed_tier_1 %} {{ speed_medium_pct }} {% else %} {{ speed_low_pct }} {% endif %} {% else %} {% set temp = t_effective_sys | float %} {% set tier1 = effective_upper_sys | float + speed_tier_1 | float %} {% set tier2 = effective_upper_sys | float + speed_tier_2 | float %} {% if temp > tier2 %} {{ speed_high_pct }} {% elif temp > tier1 %} {{ speed_medium_pct }} {% else %} {{ speed_low_pct }} {% endif %} {% endif %} # Direction calculation (must be defined before desired_direction references it) calculated_direction: > {% if is_heating and reverse_when_heating %} reverse {% elif is_winter and not is_cooling %} reverse {% else %} forward {% endif %} # Determine desired fan state to enable early-exit optimization # This pre-calculates what the fan SHOULD be doing so we can skip entirely if no change needed desired_fan_state: > {% if not is_occupied and not (is_heating and reverse_when_heating and supports_direction) %} off {% elif is_heating %} {% if reverse_when_heating and supports_direction %} on {% else %} off {% endif %} {% elif is_cooling %} on {% elif is_above_comfort %} on {% else %} off {% endif %} desired_speed: > {% if desired_fan_state == 'off' %} 0 {% elif is_heating and reverse_when_heating %} {{ heating_speed_pct | int }} {% else %} {{ calculated_speed | int }} {% endif %} desired_direction: > {% if desired_fan_state == 'off' %} {{ fan_current_direction }} {% else %} {{ calculated_direction }} {% endif %} # Check if any change is actually needed fan_state_matches: "{{ (desired_fan_state == 'on') == fan_is_on }}" fan_speed_matches: "{{ desired_fan_state == 'off' or fan_current_pct == desired_speed | int }}" fan_direction_matches: "{{ not supports_direction or desired_fan_state == 'off' or fan_current_direction == desired_direction }}" no_change_needed: "{{ fan_state_matches and fan_speed_matches and fan_direction_matches }}" # Speed tolerance for comparison (avoid redundant commands for small differences) speed_tolerance: 5 speed_matches: "{{ (fan_current_pct - calculated_speed | int) | abs <= speed_tolerance }}" heating_speed_matches: "{{ (fan_current_pct - heating_speed_pct | int) | abs <= speed_tolerance }}" mode: single max_exceeded: silent trigger: - platform: state entity_id: !input temp_sensor id: temp_changed - platform: state entity_id: !input presence_sensor to: "off" for: minutes: !input presence_off_delay_min id: presence_cleared - platform: state entity_id: !input presence_sensor to: "on" id: presence_detected - platform: state entity_id: !input climate_entity attribute: hvac_action id: hvac_changed - platform: state entity_id: sensor.season id: season_changed - platform: time_pattern minutes: "/5" id: periodic condition: [] action: # Early exit: Skip if temperature reading is invalid (sensor glitch, unavailable, or out of range) # This prevents reacting to bogus spikes like 212°F from malfunctioning sensors - if: - condition: template value_template: "{{ not t_in_valid }}" then: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: > Skipped: {{ _t_in_reject_reason }} | {% if _t_in_reject_reason == 'out_of_bounds' %}reading={{ _t_in_raw_f | round(1) }}°F | valid_range={{ _t_in_min_valid_f }}°F-{{ _t_in_max_valid_f }}°F{% elif _t_in_reject_reason == 'rate_exceeded' %}reading={{ _t_in_raw_f | round(1) }}°F | prev={{ _t_in_prev_f | round(1) }}°F | delta={{ _t_in_delta_f | round(1) }}°F | max={{ _t_in_max_delta_f }}°F{% else %}state={{ _t_in_state }} | sensor unavailable{% endif %} - stop: "Invalid temperature reading - ignoring" # Early exit: Skip entirely if fan is already in the desired state # This prevents redundant RF commands to devices like Bond that make audible sounds - if: - condition: template value_template: "{{ no_change_needed and trigger.id not in ['presence_cleared', 'season_changed', 'hvac_changed'] }}" then: - if: - condition: template value_template: "{{ debug_level_v == 'verbose' }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: > Skipped: no change needed | fan={{ 'on' if fan_is_on else 'off' }} | current={{ fan_current_pct }}% | desired={{ desired_fan_state }} | desired_speed={{ desired_speed }}% - stop: "No change needed" # Always log on basic or verbose - use logbook for visibility - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: > Trigger: {{ trigger.id | default('unknown') }} | indoor={{ t_in_sys | round(1) }}°{% if has_humidity %} | HI={{ t_effective_sys | round(1) }}° | RH={{ rh_in | round(0) }}%{% endif %}{% if has_outdoor_temp %} | outdoor={{ t_out_c | round(1) }}°C{% endif %} | comfort={{ effective_lower_sys | round(1) }}°-{{ effective_upper_sys | round(1) }}° | above={{ is_above_comfort }} | below={{ is_below_comfort }} | dev={{ comfort_deviation }}° | hvac={{ hvac_action }} | occupied={{ is_occupied }} - choose: # HVAC is heating - conditions: - condition: template value_template: "{{ is_heating }}" sequence: - if: - condition: template value_template: "{{ reverse_when_heating and supports_direction and is_occupied }}" then: - if: - condition: template value_template: "{{ fan_is_off or not heating_speed_matches or fan_current_direction != 'reverse' }}" then: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "Fan ON: HVAC heating + occupied | direction=reverse | speed={{ heating_speed_pct }}%" - if: - condition: template value_template: "{{ fan_current_direction != 'reverse' }}" then: - service: fan.set_direction target: entity_id: "{{ fan_entity }}" data: direction: reverse - if: - condition: template value_template: "{{ fan_is_off or not heating_speed_matches }}" then: - service: fan.turn_on target: entity_id: "{{ fan_entity }}" data: percentage: "{{ heating_speed_pct }}" else: - if: - condition: template value_template: "{{ fan_is_on }}" then: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "Fan OFF: HVAC heating | no drafts" - service: fan.turn_off target: entity_id: "{{ fan_entity }}" # HVAC is cooling - run fan to distribute cool air - conditions: - condition: template value_template: "{{ is_cooling and is_occupied }}" sequence: - if: - condition: template value_template: "{{ fan_is_off or not speed_matches or (supports_direction and fan_current_direction != 'forward') }}" then: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "Fan ON: HVAC cooling + occupied | direction=forward | speed={{ calculated_speed }}%" - if: - condition: template value_template: "{{ supports_direction and fan_current_direction != 'forward' }}" then: - service: fan.set_direction target: entity_id: "{{ fan_entity }}" data: direction: forward - if: - condition: template value_template: "{{ fan_is_off or not speed_matches }}" then: - service: fan.turn_on target: entity_id: "{{ fan_entity }}" data: percentage: "{{ calculated_speed }}" # Season changed while fan is on - update direction - conditions: - condition: template value_template: "{{ trigger.id == 'season_changed' }}" - condition: template value_template: "{{ supports_direction }}" - condition: state entity_id: !input fan_entity state: "on" - condition: template value_template: "{{ not is_heating and not is_cooling }}" sequence: - if: - condition: template value_template: "{{ fan_current_direction != calculated_direction }}" then: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "Direction change: season={{ current_season }} | direction={{ calculated_direction }}" - service: fan.set_direction target: entity_id: "{{ fan_entity }}" data: direction: "{{ calculated_direction }}" # Above comfort band - turn on fan - conditions: - condition: template value_template: "{{ not is_heating }}" - condition: template value_template: "{{ is_occupied }}" - condition: template value_template: "{{ is_above_comfort }}" sequence: - if: - condition: template value_template: "{{ fan_is_off or not speed_matches or (supports_direction and fan_current_direction != calculated_direction) }}" then: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "Fan ON: above comfort | temp={{ t_effective_sys | round(1) }}° | upper={{ effective_upper_sys | round(1) }}° | speed={{ calculated_speed }}% | direction={{ calculated_direction }}" - if: - condition: template value_template: "{{ supports_direction and fan_current_direction != calculated_direction }}" then: - service: fan.set_direction target: entity_id: "{{ fan_entity }}" data: direction: "{{ calculated_direction }}" - if: - condition: template value_template: "{{ fan_is_off or not speed_matches }}" then: - service: fan.turn_on target: entity_id: "{{ fan_entity }}" data: percentage: "{{ calculated_speed }}" # Below comfort band or unoccupied - turn off fan - conditions: - condition: or conditions: - condition: template value_template: "{{ is_below_comfort }}" - condition: template value_template: "{{ trigger.id == 'presence_cleared' }}" - condition: template value_template: "{{ not (is_heating and reverse_when_heating and supports_direction) }}" sequence: - if: - condition: template value_template: "{{ fan_is_on }}" then: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "Fan OFF: {{ 'below comfort | temp=' ~ (t_effective_sys | round(1)) ~ '° | lower=' ~ (effective_lower_sys | round(1)) ~ '°' if is_below_comfort else 'unoccupied' }}" - service: fan.turn_off target: entity_id: "{{ fan_entity }}" # Within comfort band and fan is on - check if we should turn it off - conditions: - condition: template value_template: "{{ not is_above_comfort and not is_below_comfort }}" - condition: state entity_id: !input fan_entity state: "on" - condition: template value_template: "{{ not is_cooling }}" - condition: template value_template: "{{ not (is_heating and reverse_when_heating) }}" sequence: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "Fan OFF: within comfort | range={{ effective_lower_sys | round(1) }}°-{{ effective_upper_sys | round(1) }}°" - service: fan.turn_off target: entity_id: "{{ fan_entity }}" # HVAC stopped - re-evaluate - conditions: - condition: template value_template: "{{ trigger.id == 'hvac_changed' }}" - condition: template value_template: "{{ not is_heating and not is_cooling }}" sequence: - if: - condition: template value_template: "{{ is_above_comfort and is_occupied }}" then: - if: - condition: template value_template: "{{ fan_is_off or not speed_matches or (supports_direction and fan_current_direction != calculated_direction) }}" then: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "Fan ON: HVAC stopped, above comfort | speed={{ calculated_speed }}% | direction={{ calculated_direction }}" - if: - condition: template value_template: "{{ supports_direction and fan_current_direction != calculated_direction }}" then: - service: fan.set_direction target: entity_id: "{{ fan_entity }}" data: direction: "{{ calculated_direction }}" - if: - condition: template value_template: "{{ fan_is_off or not speed_matches }}" then: - service: fan.turn_on target: entity_id: "{{ fan_entity }}" data: percentage: "{{ calculated_speed }}" else: - if: - condition: template value_template: "{{ fan_is_on }}" then: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "Fan OFF: HVAC stopped | comfort=ok or unoccupied" - service: fan.turn_off target: entity_id: "{{ fan_entity }}" # Default case - no action taken default: - if: - condition: template value_template: "{{ debug_level_v in ['basic', 'verbose'] }}" then: - service: logbook.log data: name: "Adaptive Fan Control" entity_id: "{{ fan_entity }}" message: "No action: fan={{ states(fan_entity) }} | comfort={{ 'above' if is_above_comfort else ('below' if is_below_comfort else 'within') }} | hvac={{ hvac_action }} | occupied={{ is_occupied }}"