Make Your Ceiling Fans Smart with Home Assistant - Round 2

Following up on my post about controlling ceiling fans, let’s address some of the shortcomings, which are:
ESPHome 2025.2 removed support for dbuezas’ CC1101 custom component. We need a way forward.
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?
Using Google Home (or Alexa, etc.) is more convenient, but can we expose these fans to them without an additional hop to the cloud?
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:
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.
Home Assistant will first call
turn_on
and thenset_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 Level | Light | Power usage |
Off | Off | 0 W |
1 | Off | 11 W |
2 | Off | 30 W |
3 | Off | 70 W |
Off | On | 21 W |
1 | On | 32 W |
2 | On | 50 W |
3 | On | 90 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!
Subscribe to my newsletter
Read articles from Victor Chang directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
