Ceiling fan bridge
Teaching Home Assistant to speak a 433 MHz fan remote's dialect
The ceiling fans came with remotes that get lost. This is a small radio that speaks their language, so the fans can be run from a phone, a wall panel or an automation, and the remotes still work.
| Board | ESP32-C6 SuperMini |
|---|---|
| Radio | CC1101 on an Ebyte E07-M1101D-SMA module, 433.92 MHz ASK/OOK |
| Wiring | SPI on GPIO 6/7/2/3; GDO0 (transmit) on GPIO 18, GDO2 (receive) on GPIO 19; radio on 3.3 V |
| Firmware | ESPHome, esp-idf framework; the YAML is generated by a small Python script |
| Fans | Two so far (living room and my office); the bedroom remote still to capture |
| Protocol | 29-bit fixed codes: a 20-bit address per fan, 9 command bits; no rolling code |
The problem
The house came with ceiling fans driven by cheap 433 MHz handheld remotes. No wall switch knows their state, no app exists, and the remotes live wherever the dog last knocked them. I wanted the fans in Home Assistant like everything else: speed, direction and light as proper entities, with automations that can turn them off when the room is empty and physical remotes that still work.
The dead end first
My first plan was clever and wrong. In May I tried to make the bridge a Zigbee end device using a community Zigbee component for ESPHome, so it would join the house’s Zigbee network with no WiFi at all. The component was interesting, the toolchain was fragile, and I spent more time on Zigbee plumbing than on the actual radio problem. I abandoned it for plain WiFi ESPHome, which took an afternoon.
Capturing the remotes
With the CC1101 set to receive and ESPHome dumping raw timings, I pressed every button on every remote and wrote down what came back. The fans turned out to use a simple fixed-code protocol: 29 bits per frame, the first 20 bits an address that identifies the fan, the last 9 the command. Same brand, same command bits on every remote, only the address differs. There is no rolling code, which I confirmed by replaying a captured frame and watching the fan obey.
Each remote gave up its codes for off, six speeds, direction, “air down” and, where the fan has a light, separate light on and light off. One unmarked button is still a mystery.
Receiving reliably
The hard part was hearing the remotes, not transmitting. The handheld remotes are weak, and ESPHome’s stock rc_switch decoder threw away about four frames in five. Two changes fixed it. The CC1101’s receive filter is set to its narrowest bandwidth, 58 kHz, which trades a little tolerance for remote frequency drift for much better sensitivity. And instead of the protocol decoder, the firmware reads the raw mark/space timings itself: a long mark is a one, a short mark a zero, a long gap ends the frame. If 29 bits arrive before the gap, the code is looked up and the matching Home Assistant entity is updated. That keeps HA in sync when someone uses the physical remote.
A small guard stops the bridge from hearing its own transmissions and re-sending them in a loop: after a received frame, transmit is suppressed for 600 ms, and the radio is put back into receive mode every 15 seconds in case it wedges during a long capture session.
The gotcha that cost a day
ESPHome’s CC1101 component has a gdo0_pin option. Setting it looks correct and produces no error, and the radio emits nothing. The transmitter has to drive the GDO0 pin directly through remote_transmitter with the CC1101 block left unaware of it. That one is an open upstream bug; I only found it by watching a cheap RTL-SDR waterfall stay flat.
How it’s maintained
The YAML is long and repetitive (every code, every fan, every entity), so it’s generated by a Python script. I edit the generator, run it, and let the Home Assistant ESPHome add-on build and flash over the air. The device also serves its own small web page for poking at it without Home Assistant.
What I’d do differently
Skip Zigbee. Buy two CC1101 modules, because one spent a week in a drawer being blamed for the GDO0 bug. And capture the bedroom remote before my wife falls asleep with it.