The light in my room came with the apartment, and on the wall there's a wireless switch with no wires at all. Who installed it, where it came from, what brand it is: I have no idea.

After the AC and the air purifier had both found their way into Home Assistant, the obvious next thought was: surely the light can go in too? I happened to have a Broadlink RM4 Pro I'd bought for the AC, and the spec sheet says it supports 433MHz RF learning. I figured I'd just record the signal and be done.

Two days later, I had pressed that wall switch hundreds of times, to the point where I was worried it would break before I did. The RM4 Pro never captured it once. What finally worked was an NT$220 (about US$7) ESP32-C3 plus a CC1101. Along the way I also fell into a really sneaky trap: I recorded a code that looked statistically perfect, flashed it, and the light completely ignored me.

This post walks the whole path: what this kind of switch actually is, why the RM4 Pro can't record it, how to wire up and configure the CC1101, and the places where recording a signal makes it easiest to fool yourself. If your home has the same kind of switch, you can copy the config in the second half and just swap in the codes you record.

Getting to know the switch#

When I took it off the wall, there was nothing on the back. No live wire, no battery. It's a : the press itself generates electricity, just enough to send one wireless signal. It goes "click, click" when you press it, once on the way down and once on the way back up.

The thing that actually cuts the power is a small box someone added next to the ceiling light, called a . All it has is input/output terminals labeled in Simplified Chinese, a key-shaped button, and a blue LED. Again, no brand or model number anywhere.

Swapping in a smart bulb was dead from the start. The fixture is a 24W DanceLight ceiling lamp, and the light source is 24 SMD LEDs soldered onto the board. There's no bulb to swap. So to get HA to control it, there was only one option left: pretend to be that switch and send the relay the exact same signal.

The problem was that I didn't even know what frequency or format it used.

RM4 Pro: great for the AC, useless for the light#

The AC really did go smoothly. I recorded one IR code, compared it against the SmartIR code database, and got a hit on a Hisense code with 93% of its 171 bits matching. The AC was in HA in no time. So my hopes for the light were high.

Then I spent an entire night on it, and the conclusion was that the RM4 Pro just can't hear this switch. A few of the most ridiculous wrong turns are worth writing down, because every single one looked like progress at the time.

"Learned" at 868 MHz#

The first step of RF learning on the RM4 Pro is to hold down a remote button so it can sweep frequencies. But a self-powered switch only transmits for a few milliseconds per press, so there's no way to "hold" it. Digging through the python-broadlink source, I found that find_rf_packet() lets you specify a frequency directly and skip the sweep, so I wrote a little tool that cycled through listening at 433.92, 315, and 868 MHz.

On the very first run it reported learning 114 bytes at 868.35 MHz. I was happy for about ten seconds, then noticed the packet type was 0x00 (the only valid ones are 0x26 for IR, 0xb2 for 433, and 0xd7 for 315), and there were 15 different pulse widths ranging from 131µs to 49260µs. The RM4 Pro doesn't support 868MHz at all. If you give it a frequency it doesn't support, it won't throw an error. It'll just hand you noise.

Don't trust the frequency the device reports either#

So I switched to letting the RM4 Pro sweep on its own, and it confidently reported 433.460 MHz. I believed it, rewrote all my tools to record and transmit at 433.46, and ran several more rounds.

Later I wanted a control group, so I had it sweep the remote for my T100 speaker. It confidently reported 305.000 MHz. That remote is infrared.

Only after dumping the raw sweep response did it make sense:

text
check_frequency raw response: 00 68 a7 04 00
                              ↑  └─────────┘
                              │   0x0004a768 = 305000 kHz
                              └─ is_found = 0 (didn't actually find anything)

The first byte is 0, which means nothing was found. 305 MHz is just where the sweeper parks when it's idle. That earlier 433.46 was very likely the same thing, and I'd spent several rounds reasoning from a fake number.

Holding the relay button for ten seconds wipes all pairings#

Since recording wasn't working, I tried the other direction: put the relay into learning mode and have it learn a new code sent by the RM4 Pro. But no model number means no manual, so all I could do was guess.

A single press of the key-shaped button turned the light off. Turns out that's just a manual toggle. So I tried a long press, waiting for the LED to start blinking. It blinked once, twice, three times, four times, and then the wall switch stopped working. Later I found a manual for a similar relay and learned what had happened:

WARNING

For these unbranded relays, the usual controls are: hold 3 seconds and it blinks once to enter learning mode; hold 10 seconds and it blinks 4 times to delete all pairings. I was even being careful to avoid the "press 8 times in a row to reset" thing people mention online, and I wiped it with a long press instead.

If you wipe yours, don't panic. Hold for 3 seconds until it blinks once, let go, then press the wall switch once, and the pairing is back.

Opening up the switch#

When guessing fails, take it apart.

The board has four rectifier diodes, a boost inductor, and a tantalum capacitor for storing energy. The ring of copper coil underneath is the generator, and the snaking copper trace on the left is the PCB antenna. The most useful part was the crystal:

text
Y1 = 26.2982 MHz
26.2982 × 16.5 = 433.9203 MHz

The frequency is 433.92, confirmed from the hardware itself, so 433.46 could go in the bin. Unfortunately the main chip next to it has no markings. Working back from the crystal value, it's probably something from HopeRF's CMT21xx family. On chips like this, the is burned into EEPROM by the module maker, could be anything from 0.5 to 40 kbps, and the datasheet won't tell you what this particular one is set to.

My own tools were broken too#

By the small hours, I had swept everything I could: frequency, pulse width, packet type, polarity. All failed. When I went back to check, I found that for several of those rounds, my own tools had been sabotaging me:

  • pulses_to_data used a default time unit of 32.84µs, but the official docs say 2⁻¹⁵ seconds, which is 30.52µs. Off by 7.7%.
  • The conversion used floor rounding, so 25µs got rounded down to 0, and 0x00 is an escape character in Broadlink's format. The whole packet was misaligned from that point on.
  • RF packets actually need 4 extra bytes. In python-broadlink's #778 I found a working packet someone had posted. It starts with b1c0, meaning RF 433MHz, and after the length there's an extra 00 9f 06 00, which in little-endian is exactly 433920, i.e. 433.92MHz. Every packet I sent that night was missing that chunk.
  • I only caught this last one when adding self-tests: while rewriting a different command, I had deleted the entire transmit command that was closest to the right answer. It had never once been tested with a correct packet.

After fixing all of it and getting all 17 tests to pass, I tried again. The light still didn't move.

Why it can't hear the switch#

What finally explained everything was two issues. In python-broadlink's #739, someone put it bluntly: the "RF" that Broadlink understands is only certain specific protocols on 433 and 315 MHz, and for other protocols you might need something like a Flipper Zero. In other words, its IR learning really does record pulses, but its RF learning is a protocol demodulator in the firmware, and formats it doesn't recognize simply don't get received.

rtl_433's #1790, "Help decoding Kinetic light switch", opens with my exact symptoms: the poster had tried a Sonoff RF bridge and a Broadlink RM, neither could receive anything, and only switching to an RTL-SDR showed the signal.

I tried the official app too. At first it occasionally captured something, but pressing the captured code did nothing to the light, and after a while it couldn't capture anything at all.

My own tools, the official app, other people's experience: all zero. Only then did I finally accept that the RM4 Pro route was a dead end. Honestly, the biggest time sink wasn't the technical stuff. It was those rounds where I kept reasoning forward from fake numbers without stopping to check whether the numbers were real.

433MHz is just a band#

The thing I couldn't wrap my head around at first: isn't 433MHz super common? Garage doors, car keys, wireless doorbells all use it. How can a device that claims to support 433 fail to record it?

Eventually I understood that "433MHz" only describes the outermost layer:

This switchRM4 Pro
Carrier frequency433.92 MHz✅
Modulation✅
Encoding and timingShortest pulse around 37µs❌

Just like WiFi and Bluetooth are "both 2.4GHz", getting the frequency right only means both sides are on the same channel. What format the other side speaks, and how fast, is a different story. The RM4 Pro's receiving side only recognizes the protocols built into its firmware, and this switch isn't one of them. The transmitting side might theoretically just about reach it, but none of my format-validated packets ever worked, and I had no instrument to confirm whether it was even transmitting anything.

To find out the truth, I needed something that could show me the raw waveform directly.

Picking hardware#

The options I considered (prices checked in October 2026):

OptionPriceWhy I didn't pick it
Shelly 1PM Mini wired upstream of each relay ×2Rare in Taiwan, about €15 each on the official siteMeans climbing into the ceiling to do mains wiring twice, and I've never touched mains wiring
SwitchBot button pusher ×2About NT$2,370 for twoHas to be stuck on the wall, needs battery changes
LILYGO T-Embed CC1101NT$3,400 to 4,200Expensive, and I have no use for the screen, NFC, or IR
Flipper ZeroNo official retail in Taiwan, US$169 on the official siteCan capture and transmit, but can't live permanently in HA
RTL-SDR Blog V4NT$3,000 to 3,700Receive only, can't transmit
USB CC1101 stick on ShopeeNT$291The chip is actually the cut-down CC110L

TIP

If you're shopping for an RTL-SDR: listings on Shopee labeled "V4" that sell for just over a thousand NT dollars and mention R860 are very likely knockoffs. A genuine V4 uses the R828D tuner.

In the end I went with the cheapest: a bare module wired to an ESP32-C3 SuperMini, with firmware written in . Its only downside is that it's ugly: two bare boards and eight jumper wires.

text
CC1101 433MHz module (with SMA antenna)   $110
ESP32-C3 SuperMini                         $90
Female-to-female jumper wires              $20
──────────────────────────────────────────────
                                          $220, free shipping

I went with the C3 because it has WiFi, and this job only needs 6 pins, so an S3 would be overkill. The ugliness was solved by a coworker. He has a 3D printer, soldered everything together for me once the parts arrived, and printed the case too.

The case went through five versions. You can find cases online for an ESP32-S3 SuperMini plus CC1101, and for regular ESP32 dev boards, but of course there was nothing for the C3 SuperMini plus CC1101 combo. So it was measure, tweak, and reprint, one version at a time, until everything fit.

Wiring and config#

Following the two-pin wiring from the official ESPHome docs, SPI goes on the pins already labeled on the board's silkscreen, and the two data pins go on GPIOs with no boot duties:

CC1101ESP32-C3 SuperMiniPurpose
VCC3V3⚠️ Do not connect to 5V
GNDGND
SCKGPIO4SPI
MISOGPIO5SPI
MOSIGPIO6SPI
CSNGPIO7SPI
GDO0GPIO10Transmit
GDO2GPIO3Receive

On the C3 SuperMini, avoid GPIO2/8/9 (strapping pins used at boot) and GPIO20/21 (UART). If it's wired correctly, the boot log will show CC1101 found! Chip ID: 0x0004.

The core of the config is these sections (simplified; full version below):

yaml
logger:
  hardware_uart: USB_SERIAL_JTAG   # C3 defaults to UART0; without this you get no log over USB

wifi:
  power_save_mode: NONE            # without disabling power save, LAN ping spikes to 147ms

spi:
  clk_pin: GPIO4
  miso_pin: GPIO5
  mosi_pin: GPIO6

cc1101:
  id: transceiver
  cs_pin: GPIO7
  frequency: 433.92MHz
  modulation_type: ASK/OOK
  filter_bandwidth: 200kHz
  symbol_rate: 40000               # the single most important line in this post, more on it later

remote_receiver:
  pin: GPIO3
  dump: raw                        # turn on while recording, can turn off afterwards
  filter: 20us
  idle: 25ms
  tolerance: 45%

remote_transmitter:
  pin: GPIO10
  carrier_duty_percent: 100%
  on_transmit:
    - cc1101.begin_tx: transceiver
  on_complete:
    - cc1101.begin_rx: transceiver

switch:
  - platform: template
    name: 房間燈
    optimistic: true
    restore_mode: DISABLED         # this one matters a lot too, more on it later
    turn_on_action: &toggle
      - remote_transmitter.transmit_raw:
          code: [<your recorded code>]
          repeat:
            times: 8
    turn_off_action: *toggle

This kind of switch only has one action, "toggle", so on and off send the same code.

The version I actually run adds queued transmission, reverse detection, and a few diagnostic sensors. The full config is here, with both codes replaced by placeholders. Once you've recorded your own codes, fill them into transmit_raw and the two comparison values in on_raw, and it's ready to go:

The full rf-bridge.yaml
yaml
# ─────────────────────────────────────────────────────────────────────────
# rf-bridge: 433MHz transceiver gateway on ESP32-C3 SuperMini + CC1101
#
# Purpose: get the living room / bedroom "self-powered wireless switches" into HA.
#   • The RM4 Pro can't receive these switches (its RF only recognizes specific protocols, and its timing resolution isn't fine enough)
#   • The CC1101 can sniff any raw OOK waveform → record the switch's code → transmit it to the relay from the same board
#
# Two-stage usage:
#   Stage 1 (recording): dump: raw, press the wall switch, read the raw timings printed in the log.
#                        Bring this device right next to the switch (on a power bank) so it's sure to receive.
#   Stage 2 (light control): put the recorded code into transmit_raw in the scripts below, and park it somewhere that reaches both rooms.
#
# Pin mapping against the ESP32-C3 SuperMini silkscreen:
#   SPI uses the pins the board already labels: SCK(IO4) / MISO(IO5) / MOSI(IO6) / SS(IO7)
#   The two GDOs go on free pins with "no boot or system duties at all": IO3 and IO10
#   (the other two free pins, IO0 / IO1, are kept spare)
# Avoid the three strapping pins IO2 / IO8 / IO9, and the two UART pins IO20 / IO21.
# Note: IO4-IO7 are listed as JTAG (MTMS/MTDI/MTCK/MTDO) in the datasheet, but the C3 has built-in USB-JTAG,
#       so using them for SPI without external JTAG is normal, and it's what the board maker labels them for.
# ─────────────────────────────────────────────────────────────────────────

substitutions:
  name: rf-bridge
  friendly_name: "RF Bridge (CC1101)"

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2024.6.0
  on_boot:
    # Safety net: explicitly put the CC1101 into receive mode at boot.
    # The official example only calls begin_rx "after a transmission completes" and never covers the initial state at boot,
    # so if you receive nothing while recording, it's hard to tell "no signal" from "not listening".
    priority: -100
    then:
      - cc1101.begin_rx: transceiver
      # "Display" the remembered light states back to HA; publish only, no action, no RF sent
      - lambda: |-
          id(room_light).publish_state(id(room_light_saved));
          id(mom_light).publish_state(id(mom_light_saved));

esp32:
  board: esp32-c3-devkitm-1     # this board setting works for the C3 SuperMini
  framework:
    type: esp-idf

logger:
  # INFO for normal use. When recording a new remote, switch back to DEBUG and enable
  # dump: raw in remote_receiver below to see the raw µs timings.
  level: INFO
  # ⚠️ The ESP32-C3 logger outputs to UART0 by default (physical pins GPIO20/21),
  #    and nothing is connected there → reading the log over USB shows a blank screen (hit this on my first flash).
  #    You have to explicitly point it at the chip's native USB Serial/JTAG to see boot messages.
  hardware_uart: USB_SERIAL_JTAG

api:
  # Commands for HA that "only change the display, never transmit": HA calls these when its light sensor finds the state is off,
  # and publish_state triggers on_state, so the state saved in flash gets updated too.
  actions:
    - action: set_room_light_state
      variables:
        state: bool
      then:
        - lambda: id(room_light).publish_state(state);
    - action: set_mom_light_state
      variables:
        state: bool
      then:
        - lambda: id(mom_light).publish_state(state);
  encryption:
    key: !secret rf_bridge_api_key

ota:
  - platform: esphome
    password: !secret rf_bridge_ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  # ⚠️ Power save must be off. ESP32-C3 WiFi light sleep causes two problems (hit both):
  #   1. LAN ping latency spikes to 67-147ms with heavy jitter, unacceptable for an RF bridge that needs to react instantly
  #   2. USB Serial/JTAG dies along with it and the serial port goes blank, which made me think the chip had crashed
  power_save_mode: NONE
  # ⚠️ Don't leave it plugged into a server / PC USB 3.0 port long-term: the 2.4GHz noise there made it drop 17 times a day,
  #    and after moving it to a phone charger it dropped 0 times in 2 days.
  #    Tried output_power: 8.5dB → couldn't connect at all even after a power cycle, worse than default, removed.
  # Count each disconnect so HA can see how many drops this boot has had once it reconnects
  on_disconnect:
    - lambda: id(wifi_drops) += 1;
  # Fallback hotspot for when reception is bad (used for first-time setup)
  ap:
    ssid: "rf-bridge-setup"
    password: !secret rf_bridge_ap_password

globals:
  - id: wifi_drops
    type: int
    restore_value: no
    initial_value: '0'
  # Last state of both lights, stored in flash, used to restore HA's display after a reboot
  - id: room_light_saved
    type: bool
    restore_value: yes
    initial_value: 'false'
  - id: mom_light_saved
    type: bool
    restore_value: yes
    initial_value: 'false'
  # Time of the last transmission, used by on_raw for the cooldown check
  - id: last_tx_ms
    type: uint32_t
    initial_value: '0'

captive_portal:

# Built-in web UI, for testing the buttons directly without hooking into HA first.
# Delete this section to remove it later (saves a bit of flash).
web_server:
  port: 80
  version: 3

# After the first flash over USB serial, you can use WiFi OTA from then on
improv_serial:

# ─── SPI bus: software SPI on ordinary GPIOs that avoid the problem pins ───
spi:
  clk_pin: GPIO4
  miso_pin: GPIO5
  mosi_pin: GPIO6

# ─── The CC1101 itself ───
# Frequency locked to 433.92 (worked out from my switch's crystal: 26.2982×16.5).
cc1101:
  id: transceiver
  cs_pin: GPIO7
  frequency: 433.92MHz
  modulation_type: ASK/OOK
  # 350kHz is too wide; the AGC amplifies the noise floor along with it. Narrowed to 200kHz to suppress noise.
  # (Measured at 325kHz: each switch press produced 8 fragments of varying length)
  filter_bandwidth: 200kHz
  # ⚠️ symbol_rate affects the CC1101's data filter bandwidth; it is NOT "only relevant when transmitting".
  #    It was originally 5000 (5 kbaud = 200µs/symbol), but the signal's measured base time unit is 25µs
  #    (every pulse width is an integer multiple of 25: 25/50/75/100/.../475/875),
  #    i.e. 8 times faster, so the filter smears out the fast transitions → every demodulation comes out different.
  #    40000 baud = 25µs/symbol, which matches the signal's smallest feature exactly.
  symbol_rate: 40000
  output_power: 10             # dBm, max is about +11
  # Note: cc1101 has no rssi / lqi settings (I wrote them in at first and compile rejected it outright).
  #       RSSI and LQI are only available as variables inside the on_packet trigger in packet_mode;
  #       async mode (the one we use) can't get them, and can't expose them as sensor entities either.

# ─── Receive (two-pin wiring, GDO2) ───
remote_receiver:
  id: rx
  pin:
    number: GPIO3               # to CC1101 GDO2
  # Enable `dump: raw` while recording to see raw µs timings.
  # ⚠️ Don't use dump: all. That only enables the "protocol decoders", not the raw waveform,
  #    and it forces noise into IR protocols like Pronto / Beo4, flooding the log for nothing.
  # Keep it off normally: reverse detection relies on on_raw below, which works without dump,
  #                       and leaving it on prints the neighbors' remotes and all the noise into the log.
  # dump: raw
  # ⚠️ filter doesn't just "drop short pulses", it "merges" the signal on both sides of a short pulse:
  #      real 185 high / 60 low / 185 high  →  after filter 90µs becomes a single 430µs high
  #    so the 477 / 327 recorded at 90µs were very likely artifacts made this way.
  #    When recording up close the signal is far above the noise, so filter can go lower and capture the real structure.
  filter: 20us
  # Longer idle so all the repeat frames from one press land in the same capture instead of getting cut off
  idle: 25ms
  tolerance: 45%
  # Note: the receiver doesn't need (or accept) on_transmit / on_complete.
  #       All mode switching is handled by remote_transmitter, which switches back to rx by itself after transmitting.
  #
  # ── Reverse detection: when someone presses the physical switch, flip the state in HA ──
  # Not using ESPHome's built-in raw matching, because it requires alignment from the start of a frame,
  # and our capture start phase isn't fixed (one press gives 2-4 repeats, starting at different points).
  # Instead, decode into 26 bits ourselves and compare the value, which works no matter where it starts.
  on_raw:
    then:
      - lambda: |-
          // Ignore for 1 second after transmitting, so our own signal doesn't flip the state twice
          static uint32_t last_seen = 0;
          if (millis() - id(last_tx_ms) < 1000) return;

          uint32_t code = 0; int nbits = 0;
          for (size_t i = 0; i + 1 < x.size(); i += 2) {
            int32_t m = x[i], sp = -x[i+1];
            if (m < 20 || sp < 20) { code = 0; nbits = 0; continue; }
            if (sp > 400) {            // sync gap → end of a frame
              code = 0; nbits = 0; continue;
            }
            code = (code << 1) | (m > sp ? 1 : 0);
            if (++nbits > 26) { code &= 0x3FFFFFF; nbits = 26; }

            if (nbits == 26) {
              if (millis() - last_seen < 800) break;   // count repeat frames from one press only once
              if (code == 0x0000000UL) {     // replace with the room light's 26-bit value
                last_seen = millis();
                ESP_LOGI("rf", "偵測到實體開關:房間燈");
                id(room_light).publish_state(!id(room_light).state);
                break;
              } else if (code == 0x0000001UL) {   // replace with mom's room light's 26-bit value
                last_seen = millis();
                ESP_LOGI("rf", "偵測到實體開關:媽媽房間燈");
                id(mom_light).publish_state(!id(mom_light).state);
                break;
              }
            }
          }

# ─── Transmit (two-pin wiring, GDO0) ───
remote_transmitter:
  id: tx
  pin:
    number: GPIO10              # to CC1101 GDO0
  carrier_duty_percent: 100%
  on_transmit:
    then:
      - lambda: 'id(last_tx_ms) = millis();'
      - cc1101.begin_tx: transceiver
  on_complete:
    then:
      - cc1101.begin_rx: transceiver

# ─── Light switch entities ───
# Using switch instead of button so HA has an on/off state to display and automate on.
# This kind of RF switch only has a single "toggle" action (no separate on/off commands),
# so turn_on and turn_off send the same code, and optimistic keeps track of the state.
# When someone presses the physical switch, on_raw in remote_receiver above flips the state.
#
# Codes for the two switches (recorded 2026-09-09, each pressed ten times right against the module, consensus taken):
#   Room light       26 bit = <your code>
#   Mom's room light 26 bit = <your code>
#   First 10 bits are identical (same model's vendor code), then they diverge → no cross-talk
switch:
  - platform: template
    name: "房間燈"
    id: room_light
    icon: "mdi:ceiling-light"
    optimistic: true
    # ⚠️ The default restore_mode is ALWAYS_OFF, which "actually runs turn_off once" at boot,
    #    and turn_off sends the toggle code → the light toggles on every reboot. So it must be DISABLED,
    #    with state remembered by the globals above and on_boot only displaying, never transmitting.
    restore_mode: DISABLED
    on_state:
      - lambda: id(room_light_saved) = x;
    turn_on_action: &toggle_room
      - script.execute: tx_room_light
    turn_off_action: *toggle_room

  - platform: template
    name: "媽媽房間燈"
    id: mom_light
    icon: "mdi:ceiling-light"
    optimistic: true
    # ⚠️ The default restore_mode is ALWAYS_OFF, which "actually runs turn_off once" at boot,
    #    and turn_off sends the toggle code → the light toggles on every reboot. So it must be DISABLED,
    #    with state remembered by the globals above and on_boot only displaying, never transmitting.
    restore_mode: DISABLED
    on_state:
      - lambda: id(mom_light_saved) = x;
    turn_on_action: &toggle_mom
      - script.execute: tx_mom_light
    turn_off_action: *toggle_mom

# ─── Transmit queue ───
# When toggled rapidly in HA, two bursts can run together and the light treats them as one press, while HA has flipped twice → out of sync.
# So each light gets a queued script: only one burst at a time, with at least 700ms before the next.
script:
  - id: tx_room_light
    mode: queued
    max_runs: 6
    then:
      - remote_transmitter.transmit_raw:
          code: [<replace with your recorded code>]
          repeat:
            times: 8
            wait_time: 0s
      - delay: 700ms
  - id: tx_mom_light
    mode: queued
    max_runs: 6
    then:
      - remote_transmitter.transmit_raw:
          code: [<replace with your recorded code>]
          repeat:
            times: 8
            wait_time: 0s
      - delay: 700ms

# ─── Diagnostics ───
sensor:
  - platform: wifi_signal
    name: "${friendly_name} WiFi"
    update_interval: 60s
    entity_category: diagnostic
  - platform: uptime
    name: "${friendly_name} 開機時間"
    update_interval: 60s
    entity_category: diagnostic
  - platform: template
    name: "${friendly_name} WiFi 斷線次數"
    lambda: return id(wifi_drops);
    accuracy_decimals: 0
    state_class: measurement
    update_interval: 60s
    entity_category: diagnostic

text_sensor:
  - platform: wifi_info
    ip_address:
      name: "${friendly_name} IP"
    bssid:
      name: "${friendly_name} BSSID"
  - platform: debug
    reset_reason:
      name: "${friendly_name} 重開原因"

debug:
  update_interval: 5min

Passwords and keys go in ESPHome's secrets.yaml. The fields you need are wifi_ssid, wifi_password, rf_bridge_api_key, rf_bridge_ota_password, and rf_bridge_ap_password.

One more small gotcha with the C3 board on its first flash: esptool's final "Hard resetting via RTS" doesn't work on it. You have to press RESET yourself after flashing, or it'll sit in download mode forever and look like it's crashed.

Recording: where it's easiest to fool yourself#

With the hardware hooked up, the real time sink was recording. I did three rounds, and the second one nearly convinced me I was done.

Round one: "looks the same" by eye#

At first I used dump: all, and the log was full of Received Pronto, ten a second. That's ESPHome forcing noise through IR protocol decoders. dump: all only turns on the protocol decoders and doesn't include the raw waveform. You need dump: raw to see the actual timings.

While pressing I got 8 captures per second, and without pressing just 1 every 30 seconds, so at least I knew the switch was transmitting and the CC1101 was receiving. I picked two captures, put them side by side, saw that the values at each position were nearly identical, and thought, that's it.

Then I ran the stats:

text
Length-41 group (25 captures), per-position coefficient of variation: median 45%  max 374%
Capture length: 15 to 67, standard deviation 9.4

For the same remote code, the same position across captures should be nearly identical, and the needs to be under 10% to count. 45% meant those two captures I picked just happened to look alike.

Round two: the beautiful fake code#

I decided the really short pulses, 25, 50, 75µs, were noise, and raised filter from 10µs to 90µs to drop them. The numbers looked much better this time:

text
160 complete frames, 134 with identical length
Per-position variation: median 7.3%  max 12.2%
Frame: 186, -110, 477, -105, 184, -847, 182, -111, 327, -552, 186, -1305

7.3%, under the threshold. I happily turned it into an HA button, pressed it six times in a row, and the light didn't move once.

I suspected distance at first, but that didn't add up: I'd taken that switch off the wall and pressed it from really far away and it still worked. If its tiny bit of power was enough, there's no reason the CC1101's wouldn't be. So the problem had to be the code itself.

Looking back, two parameters were wrong. The first was filter. It doesn't just throw away pulses that are too short; it also glues together the signal on both sides of a short pulse:

text
Real:             185µs high → 60µs low → 185µs high
After filter 90µs: 430µs high

That 477 and 327 in the frame were very likely glued together exactly like this.

The second was more fundamental. When I re-recorded with the switch held right against the module, I noticed every pulse width was an integer multiple of about 25µs, while the config said symbol_rate: 5000, i.e. 200µs per symbol. The CC1101's data filter bandwidth follows the symbol rate, so if it's set too slow, the fast transitions just get filtered out. I had even written a comment in the config saying "doesn't affect raw capture during recording". Completely backwards.

The two lines that actually made the code recordable+2−2
cc1101:
frequency: 433.92MHz
  symbol_rate: 5000
  symbol_rate: 40000
remote_receiver:
  filter: 90us
  filter: 20us
idle: 25ms

I could afford to drop filter to 20µs because I was now pressing the switch right up against the module, so the signal was far stronger than the noise. At that point I was recording with the antenna in one hand and pressing the switch with the other.

Round three: 54 values#

After the change, capture lengths jumped from 19 to 63 up to 93 to 169, and split into two clusters that matched the same frame repeated twice and three times. The period worked out to 54 values, with a per-position variation median of 9.4%.

The structure finally made sense too: a sync header followed by 26 bits, each bit being a short and a long pulse, the short one around 37µs and the long one three times that. Classic family. I flashed it, hit the button in HA, and the light went off. It was around 7:40 that evening.

WARNING

Look at rounds two and three: the fake code had a lower coefficient of variation than the real one.

When the filter is misconfigured, it smears the signal the same way every time, so the result is wrong in a perfectly consistent way, and the statistics can't see it at all. A coefficient of variation under 10% is only a necessary condition. What exposed the fake code was two other things: the structure was too short (only 6 high/low pulse pairs, when a typical remote code has at least twenty-something bits), and the plain common sense that "the switch works even from far away".

Mom's room, and listening the other way#

Once it worked, I did the light in my mom's room too. Same method: 9 frames recorded, variation median 6.7%. The two codes share their first 10 bits exactly (probably the same manufacturer), and 11 of the remaining bits differ, so they won't interfere with each other. At one point I got a variation of 0.0%, and was happy for less than a second before realizing there was only one sample. With nothing to compare against, of course it's 0.

Since the CC1101 can hear the switches, it can also work in reverse: when someone presses a physical switch, HA's state flips to match. ESPHome's built-in matching requires alignment from the start of a frame, but the capture start point isn't fixed each time, so instead I decode the waveform into 26 bits myself in on_raw and compare the value. Add a 1-second cooldown after transmitting (otherwise it hears itself) and count a single press only once within 800ms. On the first day of testing, it told the two rooms apart perfectly.

Problems that took three weeks to surface#

The light toggles itself on every reboot#

On September 26, the light state in HA started drifting from reality. The culprit was the default restore_mode of ESPHome's template switch, ALWAYS_OFF, which means it actually runs turn_off once at boot. And since this light only has a toggle code, sending turn_off means toggling. So every time the board rebooted (unplugged, firmware update, rebooting itself after WiFi was down too long), the light got toggled while HA showed it as off.

I changed it to restore_mode: DISABLED, stored the state in flash, and at boot it only reports the state to HA without transmitting. While I was at it, I made transmissions queue up with 700ms between them, so two quick taps don't run together and get treated as one by the relay. The config above is already the fixed version.

It can barely hear the physical switches#

Reverse detection tested fine on day one, but from September 9 to 26 it only detected a physical switch press once. I slowly pressed it 10 times and still got 0. At the time, the board was plugged into the server in the living room, 4 to 5 meters from the switches in both rooms with a wall in between. I'd recorded the codes with the switch right against the module, and a self-powered switch's tiny output isn't enough to get through a wall.

I didn't force a fix for this one. Instead I patched it with the light sensor on a SwitchBot Hub 3: it only acts at night; if the room brightness is level 4 or above while HA shows off, it switches the state to on; if it's level 2 or below while HA shows on, it switches to off. It only updates HA's display and never transmits. The sensor reports about every 7.5 minutes, so the state catches up within 8 minutes at worst.

WiFi dropping 17 times a day#

Around the same time, it started disconnecting. From September 25 it was dropping a dozen-plus times a day, and by the evening of the 26th it couldn't even see the IoT SSID anymore. Only unplugging and replugging the USB fixed it. Over the next three days I tried everything I could think of:

HypothesisResult
The router's IoT network settings got changed✗ My phone could see it; 2.4GHz and channel 6 were both normal
The router blocked its MAC✗ Filters, block lists, and AiProtection were all clean
The router's band steering refused to take 2.4GHz devices back✗ I set the IoT network to 2.4GHz only, and the other 8 devices (including a C210 camera that's also 2.4GHz only) all reconnected. Only this one couldn't
Lower transmit power output_power: 8.5dB✗ Worse, couldn't connect even after a power cycle
C3 SuperMini design flaw (crystal too close to the antenna)△ The board is genuinely weak, but that wasn't the main cause

The design flaw in one batch of C3 SuperMini boards is fairly well known, and done.land has a write-up: a 2024 revision moved the crystal to just 0.3mm from the ceramic antenna (it should be at least 1mm). The symptom is that it can see WiFi networks but can't connect, and some people even have to touch the antenna with their finger to get it online. For a while I was sure this was it and nearly bought a board with an external antenna. The funny part is that the fix that article recommends is exactly setting transmit power to 8.5dB, and when I did, it got even worse at connecting. Looking back, that was already a hint the problem wasn't just the board.

What actually made me change direction was another detail: after one replug it ended up in the neighboring USB port and simply would not connect. I moved it back to the original port and it connected right away.

A few centimeters making that much difference smells more like an environment problem. USB 3.0 ports and cables themselves emit 2.4GHz interference, and behind the server there's a metal case plus motherboard noise. For a board whose WiFi is already on the weak side, that's probably the worst spot in the whole house. Funnily enough, when I was planning this, I knew that if it went next to the server it should be kept away from the case. It still lived there for three weeks.

I thought about plugging it into the PC in my room instead, but that PC has USB 3.0 too, and USB might lose power when it sleeps, which means it'd be most likely to be dead in the middle of the night, exactly when I most need to turn off the light. In the end I plugged it into a plain old phone charger:

82drops
Plugged in next to the server
4.8 days, 17 a day, offline 17% of the time
0drops
Moved to a phone charger
2.0 days

No need to replace the board after all.

Where it's at now#

This is the fifth version of the case my coworker printed, plugged into a charger on my bedroom wall with the antenna standing up. From this spot it can control both my light and the one in my mom's room. The RM4 Pro has gone back to handling just the AC, and it's always been perfectly competent at infrared.

What I haven't verified yet is state sync: whether it can hear the two physical switches from this spot. If it can, HA's light state will follow the physical switches in real time. If not, the light sensor will keep correcting it. My guess is it can't. A self-powered switch puts out so little power, and it's still a bit too far away.

But sync or no sync, turning off the light while lying in bed is officially a thing now.

參考連結
  • python-broadlink: discussion on RF learning only recognizing specific protocolsissue #739
  • python-broadlink: a working packet for transmitting RF with the b1c0 prefixissue #778
  • rtl_433: Help decoding Kinetic light switchissue #1790
  • ESP-Home CC1101 Kinetic Switch Transceiverbillmaterial
  • ESP32-C3 SuperMini: design flaw with the crystal too close to the antennadone.land
  • ESPHome: CC1101 componentesphome.io