Skip to main content

Command Palette

Search for a command to run...

Make Your Ceiling Fans Smart with Home Assistant - Round 2

Updated
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!

M
Matt6mo ago

Do you have a Github repository for all of this code? I have the same old 350MHz fan (same exact remote) and would love to do something similar here. I just went to be able to control the fan speed and off buttons (no lights). I made something with an esp32 and optocouplers just to press the buttons but it is not quite as reliable as I'd like.

Thanks!

V

I haven't created a repo for this since it's just a single YAML file and the relevant parts are in this post.

What are you looking for exactly? If you want my recorded signal codes, I can provide that, but note that the remote has DIP switches to set its channel so my recordings might not be for the same channel as yours.

M
Matt6mo ago

Victor Chang I'll take whatever you're offering. On the off chance that our DIP switches are in the same config it might be useful. And if you have the single YAML so I don't have to do a bunch of copy and paste I would really appreciate it.

Thank you so much!

V

Matt Okay, here you go: https://pastebin.com/AKMRxeVa

Good luck!

M
Matt6mo ago

Victor Chang Thank you for doing this. I really appreciate it! I'll follow the other post to try to figure out the codes from the remote and then use your YAML to see if I can make it work.

Appreciate your help!

M
Matt6mo ago

Victor Chang I don't suppose you have a Discord you use to chat with folks? Would love to pick your brain.

I can get your config to load on the ESP32 but I need to capture the codes from my 350MHz remote. No matter what I try, I can't seem to get a config working that would allow me to use this tool: https://github.com/dbuezas/esphome-remote_receiver-oscilloscope/tree/main

Did you ever get it working or did you just use your Flipper Zero?

V

Matt Hi, sorry been busy. To avoid spam and such, I don't typically provide ways for the public to chat with me. But if the solution below doesn't work then we can figure out a way to get a chat going.

I did exclusively use the Flipper Zero for signal-gathering.

I'm not sure how to receive signals using https://github.com/esphome/esphome/pull/6300, but in the replies it seems like some mechanisms exist and you could dive into that. But...

What might be the most straight forward for you (aside from buying additional hardware) is to downgrade your ESPHome version temporarily to 2025.1.x or lower since those are compatible with https://github.com/dbuezas/esphome-cc1101.

Then you can use their example yaml, particularly the remote_receiver section with the dump: - raw, and then use that tool.

V

Oh right, remote_receiver doesn't allow setting the frequency to 350MHz... this is paving new road from anything I've done then.

From the #6300 PR, maybe it is still possible to receive at 350MHz by doing

cc1101:
  id: transceiver
  cs_pin: GPIO5
  output_power: 11
  tuner:
    frequency: 350000
  ...

and then:

remote_receiver:
  pin: GPIO33  # GDO2
  dump:
    - raw
  tolerance: 50%
  buffer_size: 2kb
  filter: 250us
  idle: 10ms
M
Matt6mo ago

Victor Chang Hey Victor, thanks so much for getting back to me. Your suggestions are actually exactly what I did. I downgraded to 2024.12.2 and was able set it up so i can receive my 350MHz signals. I'm now at the point where I'm trying to make transmitting the signals back to the fan work. It's odd that your Flipper Zero captured a much longer 350MHz signal than mine. The raw output I captured is shorter than yours and I haven't even tried to trim it yet. So far, transmitting that signal doesn't seem to work.

If I use your code on the latest ESPhome, I get an error that 350000 isn't supported in the ESPhome logs when pressing the button. If I go back to 2024.12.2 I no longer get that error but the fan isn't responding.

Bottom line is I'm still going back and forth on it but feel like I'm close. Any other tips you've got would be greatly appreciated!

E

https://www.fluxkrea.ai/ from fluxkrea.ai offers unparalleled text-to-image results. It adheres to prompts closely, with diverse styles from photographic to artistic. Its model variants suit all needs, and high resolution enhances output. https://www.fluxkrea.ai/ is accessible via API and open weights for non-commercial use.