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
-
Hook up the CC1101 to ESP32 ( I used normal WROOM) Refer to the wiring info in the YAML configuration below.
-
Place the
casafan_protocol.hfile into your/homeassistant/esphome/directory (I used the Home Assistant File Editor app for this). -
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).
-
Ensure your
api:,wifi_ssid, andwifi_passwordare stored securely in yoursecrets.yaml(accessible via the top-right three-dots menu in ESPHome), or replace the!secrettags directly in the YAML. -
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 -
C opy that 6-bit address string from your logs and update the substitution variable in your YAML configuration:
casafan_address: "001011". -
Flash the updated YAML to your ESP32, and your fan controls will be ready to use!
-
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 thefan:andbutton: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}")