Make Your Ceiling Fans Smart with Home Assistant - Round 2

Victor ChangVictor Chang
11 min read

Following up on my post about controlling ceiling fans, let’s address some of the shortcomings, which are:

  1. ESPHome 2025.2 removed support for dbuezas’ CC1101 custom component. We need a way forward.

  2. IoT controls generally assume you can specifically set a dimmer/speed setting for things (such as “30 percent”), but how do you map that to a fan with only up/down buttons?

  3. Using Google Home (or Alexa, etc.) is more convenient, but can we expose these fans to them without an additional hop to the cloud?

  4. What if the fan is switched off at the wall or via its original remote? How can Home Assistant learn its new state?

Caveat: The solutions below fit my hardware and use-case; YMMV.

And with that said, here is how I solved each of the shortcomings!

1. Using the newer CC1101 implementation

Since the previous CC1101 implementation is no longer supported as of February 2025, I switched to using an unmerged PR (lol). But the configuration syntax is very different, and the documentation does not explain how to broadcast a timing array (the format that I have for my signals). Here is the syntax that works for me.

(Optional) Add a sub-device:

This is a new feature added in ESPHome 2025.7.

  devices:
    - id: radio_control_device
      name: "Radio Control"

Use the PR’s external_component:

external_components:
  - source: github://pr#6300
    components: [ cc1101 ]

Set up with the GPIO pins that were used

spi:
  clk_pin: GPIO18
  miso_pin: GPIO19
  mosi_pin: GPIO23

cc1101:
  id: transceiver
  cs_pin: GPIO5

remote_transmitter:
  id: cc1101_transmitter
  pin: GPIO33 # This is GDO0
  carrier_duty_percent: 100%
  on_transmit:
    then:
      - cc1101.begin_tx: transceiver
  on_complete:
    then:
      - cc1101.end_tx: transceiver

Allow setting the broadcast frequency per broadcast

I need to broadcast at both 433.92MHz and 350MHz, so I can’t let it default to 433.92MHz. The way you can set the frequency on-demand is to create a number mapping to a field defined by the CC1101 implementation:

number:
  - platform: cc1101
    tuner:
      frequency:
        id: tuner_frequency
        name: "Tuner Frequency (kHz)"

I then create a wrapper script to handle all command broadcasts:

script:
  - id: radio_tx
    mode: queued  # Enforce that transmissions don't overlap.
    parameters:
      freq_khz: int  # The frequency to broadcast at.
      code: int[]  # The signal.
      trailing_delay: bool  # Whether to have a gap before broadcasting the next signal.
    then:
      - number.set:  # Set the broadcast frequency
          id: tuner_frequency
          value: !lambda 'return freq_khz;'
      - delay: 3ms
      - lambda: |-
          esphome::remote_base::RawTimings timings;
          timings.reserve(code.size());
          for (auto v : code)
            timings.push_back(static_cast<int32_t>(v));
          auto call = id(cc1101_transmitter).transmit();
          call.get_data()->set_data(timings);
          call.set_send_times(2);
          call.set_send_wait(10);
          call.perform();
      - delay: !lambda 'return trailing_delay ? 700 : 0;'

The mode: queued means I can send any number of commands and the script will make sure each signal is sent in the correct order, with an adequate trailing delay for the receiver to not mix it in with the next signal.

Unfortunately, the script mechanism only supports sending bool, int, float, string, and array variations of each. The passed in code is not technically the data type that set_data() expects, so we need to first cast the entire array into a new array.

Individual signal commands

The rest is to just define the actual signals that the fan supports. For example:

  - platform: template
    id: office_light
    icon: "mdi:ceiling-fan-light"
    name: Office Light
    on_press:
      - lambda: |-
          std::vector<int> timings{461,-356,425,-356,469,-352,441,-354,441,-350,441,-354,445,-354,445,-356,443,-356,443,-352,447,-356,445,-5176,843,-346,425,-788,417,-780,837,-364,835,-346,447,-742,869,-324,843,-386,419,-752,477,-744,415,-788,829,-384,401,-766,859,-348,439,-758,855,-346,835,-378,429,-754,441,-778,431,-746,427,-776,437,-746,441,-766,449,-750,841,-374,831,-348,841,-386,817,-350,467,-744,443,-750,849,-350,439,-774,837,-350,795,-454,341,-852,385,-812,377,-820,383,-826,343,-832,387,-824,377,-798,421,-782,409,-808,819,-380,793,-384,425,-778,411,-804,415,-760,415,-802,413,-776,433,-778,419,-746,441,-778,849,-324,847,-384,425,-772,443,-740,839,-362,849,-352,847,-354,851,-352,823,-380,837,-352,823,-386,835,-354,429,-25634,429,-352,463,-350,439,-350,409,-420,407,-390,375,-424,373,-422,409,-392,373,-424,409,-390,411,-390,409,-5184,805,-380,423,-792,411,-798,821,-380,797,-362,453,-776,801,-386,847,-352,457,-752,431,-754,425,-774,831,-382,429,-774,823,-380,415,-780,815,-372,839,-364,413,-784,443,-744,439,-778,411,-782,445,-744,451,-752,445,-740,873,-332,855,-354,853,-326,841,-386,421,-780,441,-744,843,-358,449,-750,841,-366,839,-344,427,-768,441,-780,431,-746,449,-752,459,-744,449,-752,425,-782,427,-776,431,-772,815,-356,839,-384,423,-752,475,-744,417,-782,445,-748,415,-786,447,-750,451,-754,433,-746,853,-356,859,-346,445,-746,439,-774,839,-356,849,-352,825,-358,861,-352,823,-362,845,-384,823,-364,805,-378,449};
          id(radio_tx).execute(433920, timings, true);
    device_id: radio_control_device

Note: The vector of positive and negative numbers denotes the amount of time it should broadcast nothing (negative values) and the amount of time it should broadcast a pulse (positive values).

2. Create a wrapper around the up/down buttons

Most trivially, you could create virtual buttons for Levels 1/2/3 (let’s assume you have 3 levels, but you can expand this to N levels) by just sending the relevant number of “up” signals. But the main problem is not knowing what the current fan level is.

Entering a known state

We need to first tell the fan to turn off before we can send the appropriate number of “up” commands. At least for my fan, if you spam “down”, it should lower the fan to the off state. But for a 3-level fan, that means you need to send three “down” signals just to turn the fan off. We can reduce this down to 2 commands by taking advantage of the fan on/off “toggle” button as well, which will turn the fan off if it was on, or on if it was off. It kind of has the same issue where we don’t know if the fan was on or not, but sending an “up” will always turn the fan on. So, we can create a virtual “off” button by making it send “up” and then “toggle”. This is acceptable because the toggle turns the fan off before the fan can really react to the extraneous “up” command.

ESPHome YAML

Here are the virtual buttons for setting my office fan to off and to speeds 1-3. Note for the 3rd/highest level, you can skip the “off” procedure and just spam “up”.

button:
  - platform: template
    id: office_fan_off
    name: Office Fan Off
    icon: "mdi:fan-off"
    on_press:
      - button.press: office_fan_up # We don't know the state. Turn it on beforehand.
      - button.press: office_fan    # Now toggle it off.
    device_id: radio_control_device
  - platform: template
    id: office_fan_speed_1
    icon: "mdi:fan-speed-1"
    name: Office Fan Speed 1
    on_press:
      - button.press: office_fan_off
      - delay: 10ms # This yields to the system to reduce perceived runtime according to "Component web_server took a long time for an operation" warnings.
      - button.press: office_fan_up
    device_id: radio_control_device
  - platform: template
    id: office_fan_speed_2
    name: Office Fan Speed 2
    icon: "mdi:fan-speed-2"
    on_press:
      - button.press: office_fan_off
      - delay: 10ms
      - button.press: office_fan_up
      - delay: 10ms
      - button.press: office_fan_up
    device_id: radio_control_device
  - platform: template
    id: office_fan_speed_3
    name: Office Fan Speed 3
    icon: "mdi:fan-speed-3"
    on_press:
      - button.press: office_fan_up
      - delay: 10ms
      - button.press: office_fan_up
      - delay: 10ms
      - button.press: office_fan_up

3. Expose Home Assistant entities as Matter devices

We’ll use tobst4r’s Matter Hub to expose the fans as Matter devices to services like Google Home and Alexa. But first, we need to create a HA-native fan entity out of the signals that we can send.

Fan Configuration

This goes into Home Assistant’s configuration.yaml:

fan:
  - platform: template
    fans:
      office_fan:
        unique_id: 42febbf9-da5d-4e2b-8001-9a10bf2e12b5
        friendly_name: "Office Fan"
        turn_on:
          service: script.turn_on # Does not block on the script returning
          target:
            entity_id: script.set_office_fan_speed
          data:
            variables:
              percentage: 50
        turn_off:
          action: button.press
          target:
            entity_id: button.office_fan_off
        set_percentage:
          service: script.turn_on
          target:
            entity_id: script.set_office_fan_speed
          data:
            variables:
              percentage: "{{ percentage }}"

This maps the fan’s “turn on” function to setting the fan to a 50% fan level, which maps to level 2 in the script below. The “turn off” function will call the office_fan_off wrapper that I defined in part 1.

Note that both turn_on and set_percentage will call script.set_office_fan_speed in a non-blocking manner, which is necessary because of some debouncing we have to do. More on that later.

And then this script goes into scripts.yaml:

set_office_fan_speed:
  alias: Office Fan Speed Setting
  mode: restart
  sequence:
    - delay:
        milliseconds: 500
    - service: button.press
      target:
        entity_id: >
          {% if percentage <= 5 %}
            button.office_fan_off
          {% elif percentage <= 40 %}
            button.office_fan_speed_1
          {% elif percentage <= 70 %}
            button.office_fan_speed_2
          {% elif percentage <= 100 %}
            button.office_fan_speed_3
          {% endif %}

The script must include the mode: restart and the delay because there are two quirks we need to handle:

  1. Google Home (don’t know about others) lets you drag a slider to set the fan percentage, but it will incessantly send dozens of intermediate percentages while you’re actively sliding. Each of these events will cause the script to be called and signals to be sent.

  2. Home Assistant will first call turn_on and then set_percentage immediately after. This also results in duplicate calls to the script.

In both cases, we are sending too many signals to the fan. We need to somehow ignore all the previous calls and just send out the signal for the very last call. So, mode: restart says that the script will throw out its current run if another call comes in before it finishes. We then add a 500ms delay to the script execution so that it takes long enough for the extraneous calls to come in during the execution. The result is the script gets restarted a bunch of times before finally executing only the last call.

The reason the fan configuration uses service: script.turn_on syntax is because this syntax makes the calls non-blocking. Otherwise, in the second case, turn_on's call to the script will wait until the script returns (including the delay) before it calls set_percentage. And this means the fan will first be set to 50% before being set to the actual level you wanted.

Exposing to Matter

Go to the Home-Assistant-Matter-Hub web UI and edit your Matter bridge to include fans.office_fan like so:

It will then be exposed through Matter as a Fan type:

Provided you paired the Matter Hub “device” to your smart home provider of choice (Google, Alexa, etc.), the fan should show up on their platform as well.

4. Knowing the fan state at all times

If you control the fan outside of Home Assistant/Matter, then HA doesn’t know the fan’s current state. It could be that the fan was turned off but we still thought it was set to level 2. This isn’t a huge issue, but it means that if you want to turn the fan back on, you first have to tell HA to turn the fan “off” (from “level 2”) and then back on. What if we could add something to let us glean the fan’s state?

Enter the Shelly PM Mini. It sits between the fan and the house wiring and measures the wattage of whatever it’s hooked up to. This can be installed in the base (the part that touches the ceiling) of the ceiling fan, and has excellent integration with Home Assistant.

In this case, we have to be lucky that each possible fan state has a unique power usage. For me, there are 7 (8 including Off) states that need to not overlap:

Fan LevelLightPower usage
OffOff0 W
1Off11 W
2Off30 W
3Off70 W
OffOn21 W
1On32 W
2On50 W
3On90 W

Fortunately, my fan does not have any overlapping power usage between the possible states. However, the fan supports dimming the light, which will ruin our ability to make sense of anything purely from wattage. I have never bothered to use the light in a dimmed state though, so this does not matter to me, but YMMV.

All we need now is to put some logic into Home Assistant to set the fan and light states using the wattage reading from the power meter. Here are the additional config to add to the configuration.yaml:

Fan entity

fan:
  - platform: template
    fans:
      office_fan:

        ... the config we added earlier ...

        availability_template: >-
          {% set s = states('sensor.shellypm_282c_power') %}
          {{ s not in ['unavailable', 'unknown', 'none'] }}

        value_template: >
          {% set p = (states('input_number.stable_shellypm_282c_power') | float) %}
          {{ (p > 0 and (p < 20 or p > 22)) }}

        percentage_template: >
          {% set p = states('input_number.stable_shellypm_282c_power') | float %}
          {% if (p > 9 and p < 13) or (p > 31 and p < 34) %}
            33
          {% elif (p > 28 and p <= 31) or (p > 48 and p < 52) %}
            66
          {% elif (p > 68 and p < 72) or (p > 88 and p < 100) %}
            100
          {% elif p <= 9 %}
            0
          {% else %}
            100
          {% endif %}

Regardless of the currently-set percentage, we will detect fan levels 1-3 and map them to specific percentages. In this way, the reported percentage will eventually settle at 0, 33, 66, or 100%.

You can see that I added +/- 2W (where possible) to account for variance in the measured usage, and that each case is handling the expected wattage range with the light being either off or on.

availability_template is there to notice when the power meter is offline. Since the power meter is wired in-line with the fan, it is safe to assume that if the power meter is offline, so is the fan.

Light entity

The fan has a light, so let’s also create a light entity as well.

light:
  - platform: template
    lights:
      office_fan_light:
        unique_id: 621b19dc-2927-4978-a322-f18753139cf2
        friendly_name: "Office Fan Light"
        icon_template: mdi:ceiling-fan-light
        availability_template: >-
          {% set s = states('sensor.shellypm_282c_power') %}
          {{ s not in ['unavailable', 'unknown', 'none'] }}
        turn_on:
          action: button.press
          target:
            entity_id: button.office_light
        turn_off:
          action: button.press
          target:
            entity_id: button.office_light
        value_template: >
          {% set p = (states('input_number.stable_shellypm_282c_power') | float) %}
          {{ (p > 20 and p < 22) or
             (p > 31 and p < 34) or
             (p > 48 and p < 52) or
             (p > 88 and p < 100) }}

Ignoring intermediate readings

You’ll notice that the configurations are reading the wattage value from input_number.stable_shellypm_282c_power, note the stable in the name.

This is because of a quirk that creates finicky behavior where the power meter will report intermediary wattages before settling into the new wattage. For example, if I turn the fan from level 1 (11W) to level 2 (30W), the meter will momentarily read something like 25W before settling into 30W. The intermediate readings should be thrown out, which is the role of this automation I put into automations.yaml:

- id: "1752985898000"
  alias: "Check office fan wattage"
  trigger:
    - platform: state
      entity_id: sensor.shellypm_282c_power
      for:
        seconds: 2
  action:
    - service: input_number.set_value
      target:
        entity_id: input_number.stable_shellypm_282c_power
      data:
        value: "{{ states('sensor.shellypm_282c_power')|float }}"

It takes the raw readings from the power meter, and requires that the reading stay the same for 2 seconds before saving that value to input_number.stable_shellypm_282c_power. That’s a variable I declare in configuration.yaml:

input_number:
  stable_shellypm_282c_power:
    name: Established stable power reading from the Shelly PM 282C
    min: 0
    max: 2000
    step: 0.1
    initial: 0

And with all that, you will not see finicky behavior where the wattage reading momentarily falls outside of the if condition ranges.

Enjoy your controllable fan!

In Home Assistant:

… and in Google Home!

And it’ll still update to the real state of the fan even if you use the original remote, or turn off the switch on the wall!

0
Subscribe to my newsletter

Read articles from Victor Chang directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Victor Chang
Victor Chang