Controlling Ceiling Fans with Home Assistant

My ceiling fans are not smart, but let's try to control them using Home Assistant to make them smart! The key is that they have remote controls, so it should be possible to make something that can act like those remotes.

Signal Gathering

These are my remotes:

Both of these are radio-based and use 434 and 350MHz signals respectively. Let's first see what kind of signals they emit and will have to hope it's the same signal every time (no rolling codes). Since I have a Flipper Zero, I'll use that:

From the look of the signal timeline graph, it did detect that I had pressed a button on the remote. And when I press Send to try broadcasting it again, it works! Rinse and repeat for every other button on the remote and we now have a recording of every signal we can know of. Now to look into how we can make a permanent device to broadcast these signals via Home Assistant controls.

Software

On the Home Assistant side, I'll still need some sort of radio device to broadcast these signals, so let's see what ESPHome would let us do. Looks like remote_transmitter is what I want, but it's strange... I don't think it's the right solution for a couple reasons.

  1. The docs specifically say it is for 433MHz with no configurability, but I also need to broadcast 350MHz.

  2. It appears to just use a GPIO pin directly as the transmitter. As neat as that is, further reading reveals that this will create interference at many other frequencies unless we use a low-pass filter.

Is there an alternative? Further searching reveals a popular radio chip, the TI CC1101. It's actually the same radio used in the Flipper Zero! With this, we can do standard SPI communication with the CC1101 and let it deal with the details of actually transmitting my commands. With dbuezas' CC1101 driver for ESPHome, the software side of things is covered.

Hardware

A CC1101 board with antenna can be had for about $6 with shipping. I picked the "CC1101 433MHZ SMA" variant to be specific. For the microcontroller, I went with an ESP32 D1 Mini which is also about $6 with shipping, specifically I picked a microUSB model.

Next is to make a case for it. The D1 Mini is likely pretty standardized in its dimensions, but there are a lot of variations of the CC1101 boards. I modeled something for my particular boards, so YMMV.

Some soldering later...

The two shells can be rubber banded together, or you can print as many of the "Case Wrap" model as you want.

Processing the commands

I used my Flipper Zero to read the commands sent by each button on my remotes, but it's not a requirement. Dbuezas' CC1101 driver includes code to log the signals that it detects straight into ESPHome's log output.

But this is just a raw recording which implies some issues:

  1. There can be noise before and after the desired signal. If not trimmed out, the noise in the beginning will still be broadcasted each time you want to send the command - adding latency.

  2. The remotes like to send the command multiple times in quick succession, but this is often unnecessary and adds significantly more characters into the YAML. I reduce it down to only sending the command once, and if you would prefer to continue sending it a few times then I think it's best to repeat the signal in code rather than with a longer recorded signal.

  3. It's hard to see just from the raw numbers what constitutes as the "command".

To help us identify the specific command from the raw reading, dbueza created a corresponding tool to visualize the signal, which is incredibly helpful! And the only incompatibility it had with my Flipper recordings was it expected comma delimiters instead of spaces. No big deal, find and replace spaces with commas and you can input the Flipper's readings into the visualizer:

It seems to send the signal a couple times, but in reality you can just take a single segment like this:

Hovering the cursor to the left of the left-most rectangle, you can see which number it underlines & highlights in the CSV. In this case, a particular 453. Remember where this specific 453 is.

Do the same for the last rectangle, where it ends with 411:

We will just need to select the entire string between the 453 and the 411 and put that code into the code: [] line as seen in the sample YAML file. It should look like this:

button:
  - platform: template
    name: Office Light
    on_press:
      - lambda: get_cc1101(transceiver).setFreq(433.92);
      - lambda: get_cc1101(transceiver).beginTransmission();
      - remote_transmitter.transmit_raw:
          code: [453,-344,459,-352,403,-414,371,-422,409,-390,375,-424,375,-424,407,-388,409,-388,377,-422,409,-392,411,-5172,803,-414,435,-746,413,-800,837,-356,821,-380,429,-774,825,-380,837,-354,435,-744,443,-772,447,-750,815,-398,413,-756,843,-368,451,-736,847,-368,821,-382,421,-784,415,-778,415,-770,443,-776,429,-784,415,-752,457,-746,837,-354,841,-384,821,-354,841,-382,409,-776,441,-776,811,-358,445,-754,817,-400,841,-342,425,-786,449,-754,411,-770,441,-774,431,-778,391,-808,409,-778,419,-772,445,-754,449,-756,431,-784,389,-806,409,-776,819,-386,447,-752,413,-768,451,-760,837,-366,833,-362,839,-382,831,-326,847,-382,819,-364,839,-382,819,-364,443,-770,805,-386,839,-352,817,-390,445,-748,411,-800,411,-1000000]
      - lambda: get_cc1101(transceiver).endTransmission();

Note, I made two changes compared to the sample:

  1. The lambda: get_cc1101(transceiver).setFreq(433.92); line is added because my two ceiling fans use different frequencies. I simply always set the relevant frequency prior to each transmission.

  2. I added a -1000000 to the end of the code list to ensure it waits for 1 second before moving on to any further broadcasts. This is because there's a cooldown period where the fan ignores follow-up commands if they happen too soon after the first command.

And that's all! Do the same process for each button on the remote(s) and those buttons can now appear in Home Assistant like so:

And like everything else in Home Assistant, it is ripe for automations! 🤩

Bonus Round - Light Dimming

My office light has a light dimming function where you hold on the light button (on the remote) and the light will progressively dim and brighten. You then let go of the button when it reaches the brightness you want. The signal it sends for this is different from the usual light on/off command and has to be handled separately. The problem is finding a clean way to make ESPHome continuously send the command until an arbitrary moment.

My first approach was to make a button that would send the command for an entire second, then I would keep clicking on it approximately every second until it reached the desired brightness. However, it depends on my own timing for each sequential click because each command is queued up to run right after the previous command. Clicking too quickly actually causes it to send for longer than intended as the queue has to work through a backlog of commands.

It's a terrible UX. Can we do better?

What about a toggle switch?

It should be possible to use a toggle switch that sets a corresponding boolean variable, then have a loop that continuously sends the dimming command while the variable is true.

The problem is this doesn't work:

switch:
  - platform: template
    name: Office Light Dimming Hold
    id: office_dimming
    turn_on_action:
      - script.execute: office_dimmer_script

The switch always immediately turned back off. I couldn't figure out why it was behaving differently from all the other toggle switches I had on other devices. Turns out there is what I'd consider to be a bug with the platform: template switches. Michele184 on the Home Assistant forum posted a workaround here.

So the working configuration is:

switch:
  - platform: template
    name: Office Light Dimming Hold
    id: office_dimming
    turn_on_action:
      - switch.template.publish: 
          id: office_dimming
          state: ON
      - script.execute: office_dimmer_script
    turn_off_action:
      - switch.template.publish: 
          id: office_dimming
          state: OFF

script:
  - id: office_dimmer_script
    then:
      - while:
          condition:
            switch.is_on: office_dimming
          then:
            - lambda: |-
                ESP_LOGD("Office Light", "Dim triggered");
                get_cc1101(transceiver).setFreq(433.92);
                get_cc1101(transceiver).beginTransmission();
            - remote_transmitter.transmit_raw:
                code: [449,-386,391,-376,423,-382,391,-414,417,-380,415,-360,417,-392,427,-386,433,-352,405,-388,439,-354,441,-5178,819,-384,421,-752,451,-780,811,-366,853,-354,421,-782,835,-342,841,-362,445,-756,447,-768,439,-748,837,-380,431,-740,825,-410,415,-774,813,-372,857,-348,421,-788,413,-780,443,-734,441,-774,433,-780,391,-776,475,-740,835,-358,851,-344,839,-368,857,-346,423,-768,437,-768,815,-408,401,-770,849,-344,853,-364,441,-736,441,-772,449,-744,411,-804,449,-746,415,-768,439,-778,431,-748,449,-750,459,-744,835,-354,447,-780,411,-786,417,-782,415,-772,831,-382,423,-748,485,-722,427,-790,823,-354,863,-350,851,-348,441,-744,843,-364,841,-386,815,-362,839,-344,443,-770,839,-350,873,-352,817,-350,445,-1]
            - lambda: |-
                get_cc1101(transceiver).endTransmission();
            - delay: 25ms

Now I can simulate holding the dimming function with a toggle switch!