Thermostat to control multi-speed fan

I am trying to create a thermostat that ramps up and down based on how far off it is from the setpoint.
Hot water is sent to the fan coil through an automation input_boolean.living_room_call_for_heat
I have a hot water fan coil called living room fan that has 3 fan speeds. I have an ESP8266 set up to control the fan speeds with 3 relays. This works well.
I currently have a generic thermostat set up that pulls temperature sensor data and turns the input boolean on and off. This is also working

What I can’t figure out is how to add the fan speeds into the thermostat so I can select high/med/low or auto. In auto, it would compare the setpoint with the current temp. If over 6 deg difference, high, 4 degrees med, 2 degrees low. I see no fan control option in generic thermostat.
This has to be a common thing but I can’t find any good documentation on it.

Use a Climate Template as the entity for the Generic Thermostat.

I looked through that but I’m not sure I understand how to do that.

Set up a basic climate template:

climate:
  - platform: climate_template
    name: Climate Control For Fan

    # temperatures 
    min_temp: 16
    max_temp: 30

    # read the current temperature from whichever temperature sensor you want
    current_temperature_template: "{{ states('sensor.[entity of temperature]') }}"
    # use a helper to store the target temperature
    target_temperature_template: "{{ states('input_number.[entity]target_temp') | float }}"
    
    # hvac modes
    modes:
      - "fan_only"
    # use a helper to store the hvac mode
    # I've only put fan_only so it will only have that mode, but you could add an off to disable
    hvac_mode_template: "{{ states('input_select.[entity]hvac_mode') }}"

    fan_modes:
      - "auto"
      - "low"
      - "medium"
      - "high"
    # helper for keeping track of fan mode 
    fan_mode_template: "{{ states('input_select.[entity]_fan_mode') }}"
    # set the fan mode when it is changed
    set_fan_mode:
      - action: input_select.select_option
        target:
          entity_id: input_select.[entity]_fan_mode'
        data:
          option: "{{ fan_mode }}"
      - action: script.set_fan_mode  [should be a script to change your relays]
        data:
          fan_mode: "{{ fan_mode }}"  

Note: I tried to use ‘[’ ‘]’ to indicate bits you should configure according to values suitable to your system.

Then create an automation that triggers on change of current temperature. Pull the current set point value and if the fan mode is ‘auto’ set the speed of the fan accordingly.

This should also allow you to use a Thermostat UI card to control the fan manually.

I cannot get that thermostat to show up as an entity.This is what I have in configuration.yaml:

  - platform: climate_template
    name: Living Room Thermostat
    heater: input_boolean.living_room_call_for_heat
    # temperatures 
    min_temp: 45
    max_temp: 80

    # read the current temperature from whichever temperature sensor you want
    current_temperature_template: "{{ states('sensor.kitchen_temperature) }}"
    # use a helper to store the target temperature
    target_temperature_template: "{{ states('input_number.[climate.living_room_thermostat]target_temp') | float }}"
    
    # hvac modes
    modes:
      - "off"
      - "heat"
      - "fan_only"
    # use a helper to store the hvac mode
    # I've only put fan_only so it will only have that mode, but you could add an off to disable
    hvac_mode_template: "{{ states('input_select.[climate.living_room_thermostat]hvac_mode') }}"

    fan_modes:
      - "auto"
      - "low"
      - "medium"
      - "high"
    # helper for keeping track of fan mode 
    fan_mode_template: "{{ states('input_select.[fan.living_room_fan]_fan_mode') }}"
    # set the fan mode when it is changed
    set_fan_mode:
      - action: input_select.select_option
        target:
          entity_id: input_select.[fan.living_room_fan]_fan_mode'
        data:
          option: "{{ fan_mode }}"
      - action: script.set_fan_mode  [should be a script to change your relays]
        data:
          fan_mode: "{{ fan_mode }}"  

This is how the fan is set up in ESPHome:

switch:
  - platform: gpio
    name: "Living Room Fan Coil High"
    pin: D3
    inverted: true    
    icon: mdi:fan
    id: relay1
    interlock: &interlock_group [relay1, relay2, relay3]
    interlock_wait_time: 300ms
    internal: true    

  - platform: gpio
    name: "Living Room Fan Coil Medium"
    pin: D4
    inverted: true
    icon: mdi:fan
    id: relay2
    interlock: *interlock_group
    interlock_wait_time: 300ms
    internal: true    
    
  - platform: gpio
    name: "Living Room Fan Coil Low"
    pin: D5
    inverted: true
    icon: mdi:fan
    id: relay3 
    interlock: *interlock_group
    interlock_wait_time: 300ms
    internal: true

# Output set up to create a variable fan speed out of 3 or 4 relays
output:
  - platform: template
    id: custom_output
    type: float 
    write_action:
      - if:
          condition:
            lambda: return ((state == 0));
          then:
            - switch.turn_off: relay1
            - switch.turn_off: relay2
            - switch.turn_off: relay3
      - if:
          condition:
            lambda: return ((state > 0) && (state < 0.4));
          then:
            - delay: 500ms
            - switch.turn_on: relay1
      - if:
          condition:
            lambda: return ((state > 0.4) && (state < 0.7));
          then:
            - delay: 500ms
            - switch.turn_on: relay2
      - if:
          condition:
            lambda: return ((state > 0.9));
          then:
            - delay: 500ms
            - switch.turn_on: relay3

# Fan set up to provide an interaction function
fan:
  - platform: speed
    id: living_room_fan
    icon: mdi:fan
    output: custom_output
    name: Living Room Fan
    speed_count: 3
    internal: false

The changes you made to the yaml I posted aren’t correct. If you read the comments I put within, it should provide some direction. For example you have:

 # use a helper to store the target temperature
    target_temperature_template: "{{ states('input_number.[climate.living_room_thermostat]target_temp') | float }}"

I am guessing you have not created a Input Number Helper with the name ‘[climate.living_room_thermostat]target_temp’. This most likely should be:

# use a helper to store the target temperature
    target_temperature_template: "{{ states('input_number.living_room_thermostat_target_temp') | float }}"

You then need to add a Input Number Helper called ‘living_room_thermostat_target_temp’

You also added:

    heater: input_boolean.living_room_call_for_heat

This shouldn’t be necessary as we are only using this ‘climate’ entity to fake the fan. If you have that input_boolean helper to automatically turn the fan on/off then you can use:

 modes:
      - "off"
      - "fan_only"

    # Use the 'call for heat' helper to set the hvac mode
    hvac_mode_template: >
      {% if is_state('input_boolean.living_room_call_for_heat', 'on') %}
        fan_only
      {% else %}
        off
      {% endif %}      

For the fan you could use:


        fan_modes:
          - "off"
          - "low"
          - "medium"
          - "high"

        fan_mode_template: >
          {% set p = state_attr('fan.living_room_fan', 'percentage') | int(0) %}
          {% if p == 0 %}
            off
          {% elif p <= 33 %}
            low
          {% elif p <= 66 %}
            medium
          {% else %}
            high
          {% endif %}

        set_fan_mode:
          service: fan.set_percentage
          target:
            entity_id: fan.living_room_fan
          data:
            percentage: >
              {% if fan_mode == 'off' %}
                0
              {% elif fan_mode == 'low' %}
                33
              {% elif fan_mode == 'medium' %}
                66
              {% else %}
                100
              {% endif %}

I did note that you were setting the current temperature by taking the value from the ‘sensor.kitchen_temperature’ entity. The yaml I have given above will allow a Generic Thermostat to manually control the climate entity created. Once that is working you can look to automate by creating an automation that set’s the fan mode according to the difference in setpoint and current temperature.

EDIT: For changing the fan speed based on the temperatures you could use the following:

        fan_mode_template: >
          {% set current = states('sensor.kitchen_temperature') | float(0) %}
          {% set target  = states('input_number.living_room_thermostat_target_temp') | float(0) %}
          {% set diff = target - current %}

          {# thresholds — adjust as needed #}
          {% if diff <= 0 %}
            off
          {% elif diff < 2 %}
            low
          {% elif diff < 4 %}
            medium
          {% else %}
            high
          {% endif %}

Note: the code above takes the current temperature from the ‘sensor.kitchen_temperature’ as you were. It also relies on the ‘input_number.living_room_thermostat_target_temp’ helper which is used to store the setpoint

I created the input_number.living_room_thermostat_target_temp helper but it just reads zero. How do I link it to the thermostat? Also, I have this but I am still not getting fan speeds in the Living Room Thermostat entity:

  - platform: generic_thermostat
    name: Living Room Thermostat  
    heater: input_boolean.living_room_call_for_heat  
    target_sensor: sensor.kitchen_temperature
    cold_tolerance: 1
    min_temp: 50  
    keep_alive: 30  
  - platform: climate_template
    name: Living Room Fan Template

    # temperatures 
    min_temp: 45
    max_temp: 80

    # read the current temperature from whichever temperature sensor you want
    current_temperature_template: "{{ states('sensor.kitchen_temperature) }}"
    # use a helper to store the target temperature
    target_temperature_template: "{{ states(input_number.living_room_thermostat_target_temp) | float }}"
    
    # hvac modes
    modes:
      - "off"
      - "fan_only"
    # use a helper to store the hvac mode
    # I've only put fan_only so it will only have that mode, but you could add an off to disable
    hvac_mode_template: >
      {% if is_state('input_boolean.living_room_call_for_heat', 'on') %}
        fan_only
      {% else %}
        off
      {% endif %}     

    fan_modes:
      - "auto"
      - "low"
      - "medium"
      - "high"
    # helper for keeping track of fan mode 
    fan_mode_template: >
          {% set current = states('sensor.kitchen_temperature') | float(0) %}
          {% set target  = states('input_number.living_room_thermostat_target_temp') | float(0) %}
          {% set diff = target - current %}

          {# thresholds — adjust as needed #}
          {% if diff <= 0 %}
            off
          {% elif diff < 2 %}
            low
          {% elif diff < 4 %}
            medium
          {% else %}
            high
          {% endif %}

    set_fan_mode:
          service: fan.set_percentage
          target:
            entity_id: fan.living_room_fan
          data:
            percentage: >
              {% if fan_mode == 'off' %}
                0
              {% elif fan_mode == 'low' %}
                33
              {% elif fan_mode == 'medium' %}
                66
              {% else %}
                100
              {% endif %}
    

Try this for the generic thermostat (note: I did use AI to generate this code rather than typing it all out myself).

climate:
  - platform: generic_thermostat
    name: Living Room Thermostat
    unique_id: living_room_generic_thermostat

    # --- Temperature sensor ---
    sensor: sensor.kitchen_temperature

    # --- This is your template climate fan ---
    heater: climate.living_room_fan_template

    # --- When thermostat calls for heat, it sets hvac_mode=fan_only ---
    target_temp: 21
    min_temp: 5
    max_temp: 30

    # --- Optional tuning ---
    cold_tolerance: 0.2
    hot_tolerance: 0.0
    min_cycle_duration:
      seconds: 30

    # --- Prevents thermostat from turning off the fan speed logic ---
    keep_alive:
      minutes: 1

    # --- Safety ---
    initial_hvac_mode: "off"

So I have gotten very close. I built the thermostat totally in ESPHome. It has the fan functions captured properly and will run the fan at the correct level if set to that level. I just cannot nail how to make the Auto mode ramp the fan up and down based on temperature differential. The last few lines just don’t seem to work.

climate:
  - platform: thermostat
    id: living_room_thermostat_esp
    name: Living Room Thermostat ESP
    sensor: kitchen_temperature
    visual:
      temperature_step: 1
    min_idle_time: 3s
    min_heating_off_time: 5s
    min_heating_run_time: 5s
    min_fanning_off_time: 3s
    min_fanning_run_time: 3s
    min_fan_mode_switching_time: 3s
    fan_with_heating: true
    fan_mode_low_action:
     - fan.turn_on: 
        id: living_room_fan
        speed: 1
    fan_mode_medium_action:
     - fan.turn_on: 
        id: living_room_fan
        speed: 2
    fan_mode_high_action:
     - fan.turn_on: 
        id: living_room_fan
        speed: 3
    fan_only_action:
      - delay: 2s
      - fan.turn_on: living_room_fan
    heat_action:
      - switch.turn_on: living_room_call_for_heat
    idle_action:
      - fan.turn_off: living_room_fan
      - switch.turn_off: living_room_call_for_heat
    fan_mode_auto_action:
      - if:
          condition:
           lambda: return (id(living_room_thermostat_esp).current_temperature > id(living_room_thermostat_esp).target_temperature -4) && (id(living_room_thermostat_esp).mode != 0);
          then:
            - fan.turn_on:
                id: living_room_fan
                speed: 3
          condition:

            lambda: return (id(living_room_thermostat_esp).current_temperature > id(living_room_thermostat_esp).target_temperature -3) && (id(living_room_thermostat_esp).current_temperature < id(living_room_thermostat_esp).target_temperature +7) && (id(living_room_thermostat_esp).mode != 0);
          then:
                id: living_room_fan
                speed: 2
      - if:
          condition:
            lambda: return (id(living_room_thermostat_esp).current_temperature > id(living_room_thermostat_esp).target_temperature -1) && (id(living_room_thermostat_esp).current_temperature < id(living_room_thermostat_esp).target_temperature +3) && (id(living_room_thermostat_esp).mode != 0);
          then:
            - fan.turn_on:
                id: living_room_fan
                speed: 1

Firstly, your ‘speed 2’ is missing the the ‘fan.turn_on’. However, your ‘if’ statements fall through, meaning that if multiple win then the last will always end up being set.

Here is what I would use, that defaults the fan to a low speed if the medium or high condition is not met.

fan_mode_auto_action:
      - if:
          condition:
            lambda: return (id(living_room_thermostat_esp).current_temperature > id(living_room_thermostat_esp).target_temperature -4) && (id(living_room_thermostat_esp).mode != 0);
          then:
            - fan.turn_on:
                id: living_room_fan
                speed: 3
          else:
            - if:
                condition:
                  lambda: return (id(living_room_thermostat_esp).current_temperature > id(living_room_thermostat_esp).target_temperature -3) && (id(living_room_thermostat_esp).current_temperature < id(living_room_thermostat_esp).target_temperature +7) && (id(living_room_thermostat_esp).mode != 0);
                then:
                  - fan.turn_on:
                      id: living_room_fan
                      speed: 2
                else:
                  - fan.turn_on:
                      id: living_room_fan
                      speed: 1

I was having issues with the speed updating as I changed the setpoint. I decided to do fan settings based on state. This is working perfectly!

    fan_mode_auto_action:
      - fan.turn_on: living_room_fan
    on_state:
      then:
       - if:
          condition:
           lambda: return (id(living_room_thermostat_esp).current_temperature < id(living_room_thermostat_esp).target_temperature -3) && (id(living_room_call_for_heat).state =true);
          then:
            - fan.turn_on:
                id: living_room_fan
                speed: 3
          else:
          - if:
              condition:
               lambda: return (id(living_room_thermostat_esp).current_temperature < id(living_room_thermostat_esp).target_temperature -1) && (id(living_room_thermostat_esp).current_temperature > id(living_room_thermostat_esp).target_temperature -3) && (id(living_room_call_for_heat).state =true);
              then:
                - fan.turn_on:    
                    id: living_room_fan
                    speed: 2
              else:
                - if:
                    condition:
                     lambda: return (id(living_room_thermostat_esp).current_temperature < id(living_room_thermostat_esp).target_temperature) && (id(living_room_thermostat_esp).current_temperature > id(living_room_thermostat_esp).target_temperature) && (id(living_room_call_for_heat).state =true);
                    then:
                      - fan.turn_on:    
                          id: living_room_fan
                          speed: 1