ESPHome CasaFan Eco Plano II Ceiling fan RF control

I wanted to control my CasaFan Eco Plano II ceiling fan in Home Assistant.
I used ESP32 and a CC1101 radio module running ESPHome.

Install

  1. Hook up the CC1101 to ESP32 ( I used normal WROOM) Refer to the wiring info in the YAML configuration below.

  2. Place the casafan_protocol.h file into your /homeassistant/esphome/ directory (I used the Home Assistant File Editor app for this).

  3. Initial Flash: Load the configuration YAML into ESPHome and flash it to your ESP32. (Don’t worry about the casafan_address yet; we will grab it from the logs next).

  4. Ensure your api:, wifi_ssid, and wifi_password are stored securely in your secrets.yaml (accessible via the top-right three-dots menu in ESPHome), or replace the !secret tags directly in the YAML.

  5. Wait for the device to boot up and connect, then press any button on your physical CasaFan remote control. Check the ESPHome logs; you should see a line similar to:

    [I][casafan]: Recognized CasaFan frame - address: 001011

  6. C opy that 6-bit address string from your logs and update the substitution variable in your YAML configuration: casafan_address: "001011".

  7. Flash the updated YAML to your ESP32, and your fan controls will be ready to use!

  8. If you want to control more than one fan, add a second substitution variable (e.g., casafan_address_2: "YOUR_CODE", and second Last_fan_speed_2 in globals), then duplicate the fan: and button: blocks with new unique names and point them to the new address variable. No other modifications are necessary.

Here is what I ended up with as yaml:

# ---------------------------------------------------------------------------
# ESP32 + CC1101 bridge for CasaFan Eco Plano II fans (433.92MHz)
# Protocol reverse-engineered from real captures - see casafan_protocol_small.h
# ---------------------------------------------------------------------------
substitutions:
  # This fan's 6-bit address, in the order bit2,bit4,bit6,bit10,bit12,bit14 -
  # copy it straight out of the "Recognized CasaFan frame" log line below.
  # Baked in at compile time - not runtime-changeable.
  casafan_address: "001011"

esphome:
  name: casafan-bridge
  includes:
    - casafan_protocol.h

esp32:
  board: esp32dev
  framework:
    type: esp-idf

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

api:
  encryption:
    key: !secret api_encryption_key

logger:

ota:
  platform: esphome

globals:
  - id: last_fan_speed
    type: int
    restore_value: yes
    initial_value: '1'

# --- Wiring ------------------------------------------------------------
# ESP32           CC1101
# 3V3      ------  VCC
# GND      ------  GND
# GPIO23   ------  MOSI (SI)
# GPIO19   ------  MISO (SO)
# GPIO18   ------  SCK (SCLK)
# GPIO5    ------  CSN (CS)
# GPIO4    ------  GDO0   (this is remote_transmitter's pin, below)
# GPIO2    ------  GDO2   (this is remote_receiver's pin, below)
# There is no separate gdo0_pin/gdo2_pin option on the cc1101: block itself -
# ESPHome infers the wiring from which pin remote_transmitter/remote_receiver
# are configured to use.
# -------------------------------------------------------------------------

spi:
  clk_pin: GPIO18
  mosi_pin: GPIO23
  miso_pin: GPIO19

cc1101:
  cs_pin: GPIO5
  frequency: 433.92MHz

remote_transmitter:
  id: rf_tx
  pin: GPIO4
  carrier_duty_percent: 100%
  on_transmit:
    then:
      - cc1101.begin_tx
  on_complete:
    then:
      - cc1101.begin_rx

# Point a remote at the CC1101 (yours, or a brand new one) and this logs its 6-bit address as soon as it recognizes a valid CasaFan frame - grab that string straight out of the log for the `casafan_address` substitution above, or to add a second fan entity. `dump: raw` is kept alongside it as a fallback - if a button doesn't decode (e.g. SET, or a different chip revision), you'll still get the full raw pulse dump to work from, same as before.
remote_receiver:
  pin:
    number: GPIO2
    inverted: false
  dump: raw
  tolerance: 40%
  filter: 150us
  idle: 10ms
  buffer_size: 2kb
  on_raw:
    then:
      - lambda: |-
          std::string full_bits;
          std::string addr = casafan_try_decode(x, &full_bits);
          if (!addr.empty()) {
            ESP_LOGI("casafan", "Recognized CasaFan frame - address: %s  (full bits: %s)",
                     addr.c_str(), full_bits.c_str());
          }

fan:
  - platform: template
    name: "CasaFan Eco Plano II"
    id: my_ceiling_fan
    speed_count: 6

    on_turn_on:
      - remote_transmitter.transmit_raw:
          code: !lambda |-
            CasaFanCommand cmd;
            switch (id(last_fan_speed)) {
              case 1: cmd = CASAFAN_SPEED1; break;
              case 2: cmd = CASAFAN_SPEED2; break;
              case 3: cmd = CASAFAN_SPEED3; break;
              case 4: cmd = CASAFAN_SPEED4; break;
              case 5: cmd = CASAFAN_SPEED5; break;
              default: cmd = CASAFAN_SPEED6; break;
            }
            return casafan_send("${casafan_address}", cmd);

    on_turn_off:
      - remote_transmitter.transmit_raw:
          code: !lambda 'return casafan_send("${casafan_address}", CASAFAN_OFF);'

    on_speed_set:
      - remote_transmitter.transmit_raw:
          code: !lambda |-
            id(last_fan_speed) = x;
            CasaFanCommand cmd;
            switch (x) {
              case 1: cmd = CASAFAN_SPEED1; break;
              case 2: cmd = CASAFAN_SPEED2; break;
              case 3: cmd = CASAFAN_SPEED3; break;
              case 4: cmd = CASAFAN_SPEED4; break;
              case 5: cmd = CASAFAN_SPEED5; break;
              default: cmd = CASAFAN_SPEED6; break;
            }
            return casafan_send("${casafan_address}", cmd);

# Optional - light and direction aren't wired into the fan entity above
# (ESPHome's fan platform has no slot for them), so expose them as buttons
# if you want them. Delete this block if you don't need light/direction.
button:
  - platform: template
    name: "CasaFan Light"
    on_press:
      remote_transmitter.transmit_raw:
        code: !lambda 'return casafan_send("${casafan_address}", CASAFAN_LIGHT);'
  - platform: template
    name: "CasaFan Direction"
    on_press:
      # Reversing while the blades are still spinning can damage the motor,
      # so turn off and give it time to fully coast to a stop first.
      - fan.turn_off: my_ceiling_fan
      - delay: 10s   # rough starting point - watch the fan at max speed and
                      # adjust this to comfortably outlast its longest coast-down
      - remote_transmitter.transmit_raw:
          code: !lambda 'return casafan_send("${casafan_address}", CASAFAN_DIRECTION);'

And here is the Protocol Handler casafan_protocol.h

#pragma once
#include <vector>
#include <string>
#include <cstdint>
#include "esphome.h"

// ---------------------------------------------------------------------------
// CasaFan Eco Plano II protocol - reverse-engineered from real captures.
// 31-bit frame. Bit encoding: '0' = short space + long mark,
//                              '1' = long space + short mark.
// Layout (0-indexed):
//   address  : bits 2, 4, 6, 10, 12, 14   (6 bits, unique per remote/fan)
//   light    : bit 16
//   direction: bit 22
//   speed    : bits 24-26 (3-bit binary, 000=off, 001..110=speed 1-6)
//   everything else: fixed preamble/trailer, identical on every remote seen
// ---------------------------------------------------------------------------

static const int32_t CASAFAN_TE_SHORT = 420;
static const int32_t CASAFAN_TE_LONG  = 780;
static const int32_t CASAFAN_GAP      = -9600; // inter-frame gap (~9.6ms)

// Skeleton = a real captured "off" frame without adress or data, only the fixed protocol bits are preserved.
static const char *CASAFAN_SKELETON = "0101010110000001000000000001001";
static const int CASAFAN_ADDR_POS[6] = {2, 4, 6, 10, 12, 14};

// Marks which of the 31 positions are allowed to vary (address or function bits) vs. fixed protocol bits - used by the decoder's sanity check.
static const bool CASAFAN_IS_FREE[31] = {
    0,0,1,0,1,0,1,0,0,0,1,0,1,0,1,0,   // 0-15  (2,4,6,10,12,14 = address)
    1,0,0,0,0,0,1,0,1,1,1,0,0,0,0      // 16-30 (16,22,24,25,26 = function)
};

enum CasaFanCommand {
  CASAFAN_OFF = 0,
  CASAFAN_SPEED1, CASAFAN_SPEED2, CASAFAN_SPEED3, CASAFAN_SPEED4, CASAFAN_SPEED5, CASAFAN_SPEED6,
  CASAFAN_LIGHT,
  CASAFAN_DIRECTION,
};

// address6: 6 chars '0'/'1', in the order bit2,bit4,bit6,bit10,bit12,bit14 (exactly the order printed by the recognizer below, so you can copy-paste straight from the log into a new globals: entry).
static std::string casafan_build_bits(const std::string &address6, CasaFanCommand cmd) {
  std::string b = CASAFAN_SKELETON;
  
  for (int i = 0; i < 6 && i < (int) address6.size(); i++){
   b[CASAFAN_ADDR_POS[i]] = address6[i];
  }

  int speed = 0;
  bool light = false, direction = false;
  switch (cmd) {
    case CASAFAN_SPEED1: speed = 1; break;
    case CASAFAN_SPEED2: speed = 2; break;
    case CASAFAN_SPEED3: speed = 3; break;
    case CASAFAN_SPEED4: speed = 4; break;
    case CASAFAN_SPEED5: speed = 5; break;
    case CASAFAN_SPEED6: speed = 6; break;
    case CASAFAN_LIGHT: light = true; break;
    case CASAFAN_DIRECTION: direction = true; break;
    case CASAFAN_OFF: default: break;
  }
  b[16] = light ? '1' : '0';
  b[22] = direction ? '1' : '0';
  b[24] = ((speed >> 2) & 1) ? '1' : '0';
  b[25] = ((speed >> 1) & 1) ? '1' : '0';
  b[26] = (speed & 1) ? '1' : '0';
  return b;
}

static std::vector<int32_t> casafan_single_frame(const std::string &bits31) {
  std::vector<int32_t> pulses;
  pulses.push_back(CASAFAN_TE_SHORT); // leading orphan mark
  for (char c : bits31) {
    if (c == '0') {
      pulses.push_back(-CASAFAN_TE_SHORT);
      pulses.push_back(CASAFAN_TE_LONG);
    } else {
      pulses.push_back(-CASAFAN_TE_LONG);
      pulses.push_back(CASAFAN_TE_SHORT);
    }
  }
  pulses.push_back(CASAFAN_GAP); // trailing gap
  return pulses;
}
// ---------------------------------------------------------------------------
// Main entry point for transmit_raw lambdas: casafan_send(id(fan_address), CASAFAN_SPEED3)
// ---------------------------------------------------------------------------

static std::vector<int32_t> casafan_send(const std::string &address6, CasaFanCommand cmd, int repeat_times = 6) {
    std::vector<int32_t> full_burst;
	std::string bits31 = casafan_build_bits(address6, cmd);
    std::vector<int32_t> single = casafan_single_frame(bits31);

    for (int r = 0; r < repeat_times; r++) {
        full_burst.insert(full_burst.end(), single.begin(), single.end());
    }

    // Log a safe summary to prevent ESPHome log buffer truncation
    ESP_LOGI("casafan", "Transmitting Raw: Generated %d frames (%d total timing values)", repeat_times, (int)full_burst.size());

    return full_burst;
}

// ---------------------------------------------------------------------------
// Recognizer: feed it the raw pulse vector from remote_receiver's on_raw trigger. Returns the 6-bit address string if it looks like a valid CasaFan frame (fixed bits all match), "" otherwise - e.g. wrap it in your on_raw automation to log new remote IDs as you capture them.
// ---------------------------------------------------------------------------

static std::string casafan_try_decode(const std::vector<int32_t> &x, std::string *out_full_bits = nullptr) {
  // 1 leading mark + 31*2 bit pulses = 63. on_raw can append one extra
  // trailing idle-gap value on some platforms - harmless, we just ignore
  // anything past index 62.
  if (x.size() < 63) return "";

  std::string bits;
  bits.reserve(31);
  for (int i = 0; i < 31; i++) {
    int32_t space = x[1 + 2 * i];
    int32_t mark  = x[1 + 2 * i + 1];
    bool space_long = std::abs(space) >= 600;
    bool mark_long  = std::abs(mark)  >= 600;
    if (!space_long && mark_long) bits += '0';
    else if (space_long && !mark_long) bits += '1';
    else return ""; // ambiguous pulse pair - not a clean CasaFan frame
  }

  for (int i = 0; i < 31; i++)
    if (!CASAFAN_IS_FREE[i] && bits[i] != CASAFAN_SKELETON[i]) return "";

  if (out_full_bits) *out_full_bits = bits;
  std::string addr;
  for (int i = 0; i < 6; i++) addr += bits[CASAFAN_ADDR_POS[i]];
  return addr;
}

AI helped a lot and I kept its comments so the code is easyer to underastand.
I hope this helps someone.

Optional Decoder Python Script
(Note: You shouldn’t need this script to get everything working, but it can be a helpful tool if you need to analyze signals or work with different fan variants).

import re
from collections import Counter

# 1. Read your capture file
lines = open('user_capture.txt').read().splitlines()
all_nums = []

# List of exact substrings to ignore or strip from log lines
ignore_patterns = [
    r'\[\d{2}:\d{2}:\d{2}\.\d+\]\[I\]\[remote\.raw:\d+\]:\s*Received Raw:',
    r'\[\d{2}:\d{2}:\d{2}\.\d+\]\[I\]\[remote\.raw:\d+\]:'
]

for line in lines:
    cleaned_line = line
    # Remove the timestamp and log tags if they appear at the start
    for pat in ignore_patterns:
        cleaned_line = re.sub(pat, '', cleaned_line)
    
    # Extract all signed integers from the remaining text
    found = re.findall(r'-?\d+', cleaned_line)
    all_nums.extend([int(x) for x in found])

print(f"Total raw timing values extracted: {len(all_nums)}")

# 2. Split the stream into individual frames using the large gap (~9600us) as a boundary
frames = []
current_frame = []

for n in all_nums:
    if n < -5000:
        if len(current_frame) > 10:
            frames.append(current_frame)
        current_frame = []
    else:
        current_frame.append(n)

if current_frame:
    frames.append(current_frame)

print(f"Successfully isolated {len(frames)} individual frames.")

# 3. Decode each isolated frame into a 31-bit string
def cls(val):
    return 'S' if abs(val) < 600 else 'L'

decoded_bitstrings = []

for idx, frame in enumerate(frames):
    if len(frame) < 63:
        continue
    
    rest = frame[1:63]  # Grab the 62 pulse pairs after the leading mark
    bits = []
    for i in range(31):
        space = rest[2*i]
        mark = rest[2*i+1]
        combo = cls(space) + cls(mark)
        if combo == 'SL':
            bits.append('0')
        elif combo == 'LS':
            bits.append('1')
        else:
            bits.append('?')
            
    bitstr = ''.join(bits)
    decoded_bitstrings.append(bitstr)
    print(f"Frame {idx+1}: {bitstr}")

# 4. Show summary of unique decoded command patterns
print("\nUnique command patterns found:")
for pat, count in Counter(decoded_bitstrings).items():
    print(f"Count: {count} -> Pattern: {pat}")