CC1101 native component — TX only works immediately after RX of real remote (ESPHome 2026.6.4)

Hardware: ESP32-C3 Super Mini + CC1101 (Elecbee SMA module), single-pin wiring on GDO0→GPIO2, SPI on GPIO4/5/6/7. ESPHome 2026.6.4, esp-idf framework.

Fan: VIVOHOME 42" ceiling fan, JK-5340-433 receiver PCB, 433.92MHz OOK. Remote sends a ~64-element PWM frame (two symbols: ~765/-240µs and ~290/-720µs).

What works:

  • CC1101 receives the remote perfectly (Chip ID 0x0014, clean repeating 64-element capture)
  • TX transmits faithfully — confirmed with a second ESP+RXB6 as a spy: the transmitted waveform matches the captured frame element-for-element
  • The chip enters TX/RX correctly (verbose logs show begin_tx/begin_rx firing)

The problem:
The fan only responds to my ESP transmission once, immediately after I press the physical remote. After that, ESP button presses transmit correctly (spy confirms clean output) but the fan ignores them — until I press the real remote again, which "revives" it for exactly one more ESP press.

Tried: begin_tx/begin_rx, begin_tx/set_idle, eot_level true/false, digital_write low before TX, symbol_rate sweep, polarity flip, various repeat counts/gaps, single-pin and dual-pin wiring. TX waveform is always faithful; the "works once after remote" pattern persists regardless.

Question: Is this a known state/calibration issue with the native CC1101 component where the chip needs fresh RX activity before each TX? Is there a version regression (saw mentions of downgrading to 2024.12.2)? What's the correct on_transmit/on_complete sequence for reliable repeated standalone TX?

esphome:
  name: bedroom-fan-controller
  friendly_name: Bedroom Fan Controller

esp32:
  board: esp32-c3-devkitm-1
  framework:
    type: esp-idf

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

logger:
  level: DEBUG

api:
  encryption:
    key: !secret api_encryption_key

ota:
  platform: esphome
  password: !secret ota_password

web_server:
  version: 3

spi:
  clk_pin: GPIO4
  miso_pin: GPIO5
  mosi_pin: GPIO6

cc1101:
  id: cc1101_radio
  cs_pin: GPIO7
  frequency: 433.92MHz
  modulation_type: ASK/OOK
  symbol_rate: 5000
  output_power: 10
  filter_bandwidth: 325kHz

remote_transmitter:
  id: rf_tx
  pin:
    number: GPIO2
    mode:
      output: true
      input: true
      open_drain: true
      pullup: true
    allow_other_uses: true
  carrier_duty_percent: 100%
  on_transmit:
    then:
      - cc1101.begin_tx
  on_complete:
    then:
      - cc1101.begin_rx

remote_receiver:
  id: rf_receiver
  pin:
    number: GPIO2
    allow_other_uses: true
  dump:
    - raw
  filter: 150us
  idle: 8ms
  buffer_size: 8kb

button:
  - platform: template
    name: "Fan Light Test"
    on_press:
      - remote_transmitter.transmit_raw:
          carrier_frequency: 0
          code: [290, -723, 787, -218, 790, -216, 313, -685, 311, -704, 793, -196, 812, -216, 790, -217, 316, -680, 296, -724, 789, -219, 290, -704, 820, -191, 801, -216, 788, -217, 792, -217, 316, -680, 297, -725, 289, -722, 288, -723, 286, -722, 787, -196, 309, -702, 296, -720, 291, -723, 289, -722, 264, -745, 286, -721, 287, -721, 264, -741, 790, -196, 309, -704, 293]
          repeat:
            times: 8
            wait_time: 10ms

I don't remember to ever seen here working single-pin setup. It might (and supposed) to work without hiccups though.

What about your 8 repeats and 10ms gap, are they what your actual remote sends?
Can you post the whole raw log print for single remote button press.

Fair point on single-pin — I can switch to dual-pin (GDO0 for TX, GDO2 for RX) to rule that out.

You’re also right that my 8 repeats / 10ms gap were a guess, not measured from the remote. Here’s a full raw capture of a single physical light-button press on the real remote (CC1101 receiving)


[remote.raw] Received Raw: 290, -723, 787, -218, 790, -216, 313, -685, 311, -704, 793, -196, 812, -216, 790, -217, 316, -680, 296, -724, 789, -219, 290, -704, 820, -191, 801, -216, 788, -217, 792, -217, 316, -680, 297, -725, 289, -722, 288, -723, 286, -722, 787, -196, 309, -702, 296, -720, 291, -723, 289, -722, 264, -745, 286, -721, 287, -721, 264, -741, 790, -196, 309, -704, 293

That ~64-element frame repeats consistently while the button is held. The inter-frame gap between repeats looks to be roughly -9500 to -14600 µs across captures (it varied a bit depending on receiver idle settings). One press of the physical remote sends the frame maybe 4–5 times.

So my transmit repeat/gap almost certainly don’t match the remote’s real cadence. What repeat count and wait_time would you suggest to match — and would you go dual-pin over single-pin here?

I mean full capture with all repeats, log with time stamps..

Of course, but if you make it work with single pin, we get it confirmed as working.. :wink:

Also, signal is not one of the cleanest i have seen, short pulse has range from 191 to 316.
Pulse pair length is rock solid 1ms though, probably 250/750 timing.

[06:48:40.700][I][remote.raw:026]: Received Raw: 275, -245, 243, -752, 268, -749, 739, -244, 783, -245, 760, -245, 260, -748, 254, -747, 749, -245, 268, -746, 777, -243, 748, -244, 782, -244, 759, -246, 261, -748, 255, -742, 249, -769, 247, -763, 246, -765, 745, -245, 267, -745, 250, 
[06:48:40.707][I][remote.raw:035]:   -768, 247, -766, 745, -246, 267, -744, 776, -243, 247, -747, 776, -244, 751, -245, 781, -244, 261
[06:48:40.745][I][remote.raw:026]: Received Raw: 268, -751, 758, -243, 753, -244, 283, -737, 257, -767, 754, -243, 752, -244, 782, -243, 261, -749, 255, -749, 772, -220, 269, -742, 778, -243, 751, -244, 783, -244, 761, -245, 263, -733, 268, -757, 267, -733, 270, -753, 243, -752, 760, 
[06:48:40.750][I][remote.raw:035]:   -244, 278, -724, 277, -746, 247, -766, 745, -245, 267, -741, 776, -243, 249, -766, 756, -243, 753, -244, 782, -244, 260
[06:48:40.830][I][remote.raw:026]: Received Raw: 255, -744, 774, -220, 771, -244, 280, -725, 276, -743, 772, -218, 770, -244, 782, -244, 260, -749, 254, -743, 774, -220, 269, -767, 753, -243, 751, -244, 783, -243, 762, -246, 261, -734, 267, -757, 242, -749, 259, -748, 249, -769, 749, 
[06:48:40.835][I][remote.raw:035]:   -245, 267, -746, 250, -768, 247, -765, 743, -246, 266, -746, 776, -242, 247, -748, 777, -244, 749, -245, 780, -244, 260
[06:48:40.835][I][remote.raw:026]: Received Raw: 255, -744, 775, -244, 749, -244, 254, -764, 255, -747, 750, -269, 749, -245, 755, -269, 258, -747, 253, -740, 776, -244, 247, -765, 754, -244, 751, -268, 759, -245, 760, -245, 261, -759, 241, -758, 241, -758, 267, -752, 241, -750, 761, 
[06:48:40.835][I][remote.raw:035]:   -244, 254, -773, 253, -741, 248, -765, 748, -266, 246, -764, 753, -244, 250, -763, 755, -245, 753, -269, 733, -270, 260
[06:48:40.871][I][remote.raw:026]: Received Raw: 287, -726, 775, -243, 750, -244, 279, -749, 252, -746, 749, -245, 768, -244, 755, -269, 259, -749, 253, -746, 749, -245, 268, -746, 777, -243, 748, -244, 783, -244, 760, -244, 261, -749, 254, -742, 249, -769, 246, -763, 245, -765, 746, 
[06:48:40.875][I][remote.raw:035]:   -246, 266, -745, 248, -766, 246, -767, 744, -246, 266, -741, 775, -243, 249, -748, 778, -243, 749, -244, 783, -244, 260

This is one button press

Ok, you got 5 signals, but first is slightly different from others. You can't just take that and repeat.
2,-5. are all same though, 32 bits, 32ms duration.
I could build the correct signal from these, but esphome fucked up the timestamps somehow. According to time stamps 3rd started at .830 and fourth at .835 which is not possible since signal is 32ms long...

Try what you get if you set idle: 30ms on your receiver code, it should be sufficient to receive the whole crap as single signal (if memory doesn't run out).
At least to see the gaps between 1st, 2nd and 3rd part.

Thanks so much for you help with this, I've changed the idle to 30ms and is the outcome of one button press

[08:59:17.565][I][remote.raw:026]: Received Raw: 481, -266, 211, -776, 238, -775, 735, -283, 738, -254, 738, -276, 239, -746, 258, -772, 728, -269, 247, -763, 754, -244, 753, -244, 757, -269, 760, -245, 260, -747, 255, -746, 249, -768, 246, -765, 245, -763, 747, -246, 266, -744, 249, 
[08:59:17.566][I][remote.raw:026]:   -767, 247, -762, 246, -764, 746, -246, 267, -745, 250, -768, 247, -765, 245, -764, 245, -763, 245, -8573, 246, -768, 745, -245, 767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 776, -243, 
[08:59:17.571][I][remote.raw:026]:   750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 247, -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548, 267, -744, 767, -246, 764, 
[08:59:17.572][I][remote.raw:026]:   -244, 278, -741, 254, -748, 775, -220, 767, -244, 781, -220, 284, -737, 257, -750, 776, -219, 269, -742, 779, -242, 776, -219, 786, -219, 787, -243, 260, -750, 254, -742, 273, -742, 246, -764, 246, -764, 745, -245, 265, -741, 275, -743, 246, -768, 
[08:59:17.572][I][remote.raw:026]:   245, -763, 744, -245, 267, -742, 275, -747, 246, -743, 269, -740, 269, -738, 269, -8548, 266, -741, 768, -246, 763, -243, 276, -725, 276, -744, 774, -219, 767, -243, 781, -219, 285, -725, 278, -748, 772, -220, 267, -749, 774, -219, 772, -243, 783, 
[08:59:17.572][I][remote.raw:026]:   -219, 785, -244, 262, -751, 253, -743, 248, -765, 246, -766, 245, -764, 744, -246, 266, -742, 276, -746, 246, -741, 269, -739, 769, -246, 265, -742, 274, -746, 245, -742, 269, -739, 269, -737, 269, -8550, 266, -741, 768, -246, 765, -243, 276, -742, 
[08:59:18.032][I][remote.raw:026]:   253, -744, 776, -219, 768, -244, 781, -243, 261, -737, 258, -744, 778, -219, 270, -750, 777, -220, 772, -244, 781, -220, 784, -244, 262, -751, 253, -743, 274, -743, 245, -742, 269, -738, 769, -246, 265, -747, 248, -769, 245, -741, 269, -737, 768, 
[08:59:18.032][I][remote.raw:035]:   -245, 266, -748, 274, -745, 244, -742, 269, -737, 269, -740, 269

Nice! So there is 8550us gap between frames. You can send the whole signal just like you received or if memory is concern send it in two parts with 4 repeats for the second frame:

on_press:
  - remote_transmitter.transmit_raw:
      code: [481, -266, 211, -776, 238, -775, 735, -283, 738, -254, 738, -276, 239, -746, 258, -772, 728, -269, 247, -763, 754, -244, 753, -244, 757, -269, 760, -245, 260, -747, 255, -746, 249, -768, 246, -765, 245, -763, 747, -246, 266, -744, 249, -767, 247, -762, 246, -764, 746, -246, 267, -745, 250, -768, 247, -765, 245, -764, 245, -763, 245, -8573]
  - remote_transmitter.transmit_raw:
      code: [246, -768, 745, -245, 767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 776, -243, 750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 247, -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548]
      repeat:
        times: 4
        wait_time: 0s

Edit:
If that doesn't work, it indicates that beginning of the first frame is corrupted and all 5 frames should actually be equal. That would make sense to me.
In that case send just the second frame 5 times:

on_press: 
  - remote_transmitter.transmit_raw:
      code: [246, -768, 745, -245, 767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 776, -243, 750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 247, -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548]
      repeat:
        times: 5

I am still experiencing the same behavior. The provided configuration only functions for a single transmission following a physical button press. Below is the sequence demonstrating a Home Assistant trigger, a physical button press, and a subsequent Home Assistant trigger.

[09:57:40.642][I][remote.raw:026]: Received Raw: 760, -266, 211, -776, 238, -775, 735, -283, 738, -254, 738, -276, 239, -746, 258, -772, 728, -269, 247, -763, 754, -244, 753, -244, 757, -269, 760, -245, 260, -747, 255, -746, 249, -768, 246, -765, 245, -763, 747, -246, 266, -744, 249, 
[09:57:40.648][I][remote.raw:026]:   -767, 247, -762, 246, -764, 746, -246, 267, -745, 250, -768, 247, -765, 245, -764, 245, -763, 245, -8573, 888, -1090, 275, -1040, 509, -768, 745, -245, 767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 
[09:57:40.655][I][remote.raw:026]:   247, -750, 776, -243, 750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 247, -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548, 246, 
[09:57:40.712][I][remote.raw:026]:   -768, 745, -245, 767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 776, -243, 750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 
[09:57:40.724][I][remote.raw:026]:   252, -747, 247, -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548, 246, -768, 745, -245, 767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 776, 
[09:57:40.724][I][remote.raw:026]:   -243, 750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 247, -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548, 246, -768, 745, -245, 
[09:57:40.724][I][remote.raw:026]:   767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 776, -243, 750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 247, 
[09:57:40.724][I][remote.raw:035]:   -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548, 355, -996, 275
[09:57:43.318][I][remote.raw:026]: Received Raw: 271, -258, 259, -747, 256, -745, 750, -270, 748, -244, 755, -268, 259, -747, 253, -766, 725, -269, 248, -764, 754, -244, 752, -269, 734, -269, 760, -246, 260, -747, 254, -744, 250, -768, 247, -763, 246, -764, 745, -246, 268, -744, 249, 
[09:57:43.330][I][remote.raw:026]:   -768, 247, -763, 746, -246, 769, -245, 752, -269, 259, -747, 756, -245, 251, -773, 752, -244, 249, -8580, 254, -744, 775, -243, 750, -244, 281, -733, 267, -732, 768, -248, 765, -247, 765, -246, 264, -750, 254, -742, 776, -243, 247, -765, 755, -243, 
[09:57:43.331][I][remote.raw:026]:   753, -244, 783, -244, 762, -245, 263, -749, 254, -743, 249, -770, 246, -766, 245, -739, 768, -246, 265, -741, 275, -743, 246, -768, 744, -246, 767, -243, 778, -219, 284, -749, 756, -243, 250, -748, 777, -243, 248, -8568, 245, -766, 744, -245, 767, 
[09:57:43.331][I][remote.raw:026]:   -244, 279, -724, 276, -746, 746, -245, 768, -244, 779, -220, 283, -749, 252, -744, 774, -219, 269, -741, 777, -243, 750, -244, 784, -244, 760, -245, 261, -750, 254, -747, 247, -768, 245, -763, 244, -739, 767, -242, 266, -748, 249, -769, 245, -765, 
[09:57:43.338][I][remote.raw:026]:   743, -246, 766, -244, 753, -244, 282, -750, 755, -243, 249, -749, 778, -220, 269, -8543, 268, -742, 769, -245, 764, -243, 251, -766, 254, -744, 773, -220, 770, -244, 781, -243, 260, -749, 253, -743, 773, -219, 269, -742, 777, -243, 750, -245, 781, 
[09:57:43.341][I][remote.raw:026]:   -244, 762, -244, 262, -749, 253, -744, 248, -768, 245, -741, 269, -739, 768, -246, 265, -745, 248, -768, 246, -764, 743, -245, 767, -244, 778, -219, 284, -749, 754, -243, 249, -767, 755, -244, 249, -8556, 274, -745, 745, -246, 767, -244, 278, -724, 
[09:57:43.348][I][remote.raw:026]:   276, -746, 746, -245, 768, -244, 779, -219, 284, -749, 253, -743, 773, -219, 270, -748, 776, -219, 771, -244, 781, -243, 760, -244, 262, -736, 258, -751, 249, -769, 245, -765, 245, -763, 744, -246, 265, -745, 248, -769, 245, -764, 743, -246, 767, 
[09:57:43.351][I][remote.raw:035]:   -244, 753, -245, 281, -750, 755, -242, 249, -765, 754, -242, 250
[09:57:49.743][I][remote.raw:026]: Received Raw: 758, -266, 211, -776, 238, -775, 735, -283, 738, -254, 738, -276, 239, -746, 258, -772, 728, -269, 247, -763, 754, -244, 753, -244, 757, -269, 760, -245, 260, -747, 255, -746, 249, -768, 246, -765, 245, -763, 747, -246, 266, -744, 249, 
[09:57:49.753][I][remote.raw:026]:   -767, 247, -762, 246, -764, 746, -246, 267, -745, 250, -768, 247, -765, 245, -764, 245, -763, 245, -9688, 275, -991, 479, -768, 745, -245, 767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 
[09:57:49.753][I][remote.raw:026]:   776, -243, 750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 247, -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548, 246, -768, 745, 
[09:57:49.756][I][remote.raw:026]:   -245, 767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 776, -243, 750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 
[09:57:49.763][I][remote.raw:026]:   247, -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548, 246, -768, 745, -245, 767, -244, 280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 776, -243, 750, 
[09:57:49.793][I][remote.raw:026]:   -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 247, -768, 245, -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548, 246, -768, 745, -245, 767, -244, 
[09:57:49.797][I][remote.raw:026]:   280, -748, 252, -747, 772, -220, 770, -244, 781, -244, 262, -749, 254, -743, 776, -243, 247, -750, 776, -243, 750, -245, 782, -244, 761, -245, 262, -750, 255, -748, 248, -767, 246, -766, 245, -763, 743, -242, 269, -765, 252, -747, 247, -768, 245, 
[09:57:49.798][I][remote.raw:035]:   -766, 744, -245, 266, -741, 276, -747, 246, -768, 244, -763, 244, -741, 270, -8548, 384, -1009, 275

see the edit i made on my previous post.

If you compare the first and 2-5 frames, you can see that first one is equal to others but missing 4 and half pulses from the beginning. But considering the behavior, it might miss also header or preamble that wakes up your fan receiver.

Are you still on single wire setup?

SOLVED — and it turned out to be nothing to do with the CC1101 at all. Huge thanks @Karosm for the frame analysis, it got me to the answer even though the radio was innocent all along.

Root cause: the fan's receiver rejects duplicate codes. I captured the same button on two separate presses and diffed the decoded bitstreams:

Press 1: 011001110010111100000100001000000
Press 2: 011001110010111100000100001100010
                                    ^   ^
                                bit 27  bit 31

The remote flips a toggle bit (bit 27) on every press, with bit 31 changing alongside it (parity/checksum). The receiver uses this to distinguish a new button press from the same transmission still repeating — so it ignores any code identical to the last one it acted on.

That explains the symptom perfectly: my ESP was replaying one fixed capture. It worked once after a real remote press (the remote had changed the "last seen" code), then every identical retransmission was deliberately ignored — regardless of how much time had passed. Not a chip state issue, not calibration, not the payload structure. The transmission was fine; the fan was just filtering repeats.

The fix: store both captured variants and alternate between them with a global:

globals:
  - id: fan_toggle
    type: bool
    restore_value: true
    initial_value: 'false'

button:
  - platform: template
    name: "Fan Command"
    on_press:
      - if:
          condition:
            lambda: 'return id(fan_toggle);'
          then:
            - remote_transmitter.transmit_raw:
                code: [250, -750, 750, -250, 750, -250, 250, -750, 250, -750, 750, -250, 750, -250, 750, -250, 250, -750, 250, -750, 750, -250, 250, -750, 750, -250, 750, -250, 750, -250, 750, -250, 250, -750, 250, -750, 250, -750, 250, -750, 250, -750, 750, -250, 250, -750, 250, -750, 250, -750, 250, -750, 750, -250, 250, -750, 250, -750, 250, -750, 250, -750, 250, -750, 250, -8550]
                repeat:
                  times: 5
                  wait_time: 0s
          else:
            - remote_transmitter.transmit_raw:
                code: [250, -750, 750, -250, 750, -250, 250, -750, 250, -750, 750, -250, 750, -250, 750, -250, 250, -750, 250, -750, 750, -250, 250, -750, 750, -250, 750, -250, 750, -250, 750, -250, 250, -750, 250, -750, 250, -750, 250, -750, 250, -750, 750, -250, 250, -750, 250, -750, 250, -750, 250, -750, 750, -250, 750, -250, 250, -750, 250, -750, 250, -750, 750, -250, 250, -8550]
                repeat:
                  times: 5
                  wait_time: 0s
      - globals.set:
          id: fan_toggle
          value: !lambda 'return !id(fan_toggle);'

(Timings are idealised from the captures — 250/750µs marks/spaces, 8550µs inter-frame gap, frame sent 5× like the real remote. restore_value: true keeps the toggle across reboots.)

Tested across many consecutive presses from cold, including with intermittent real remote presses mixed in — works reliably every time. Only caveat: if the physical remote's last press happens to leave the fan on the same toggle state the ESP sends next, that one press gets swallowed; the retry always works since the toggle flips.

One more thing worth noting for anyone finding this later: this is all running on a single-wire setup — one GDO pin shared for both RX and TX on an ESP32-C3 Super Mini with the native CC1101 component (ESPHome 2026.6.4). There was some doubt earlier in the thread about whether that was possible — I can confirm it works absolutely fine for capture and transmit.

Thanks again for the help — marking this as solved.

Cherry of the cake.

I'm still curious if the missing part of the first frame is because cc1101 is not catching it on time or because remote control starts sending before transmitter chip is ready.

As the remote repeatedly sends the same code, to prevent the light from just flashing on and off it ignores everything received after the first code until you release the button and it sends a button release command, the esp was only sending the code so the fan remained ignoring all subsequent codes until it received its "button up" command

Yes but the beginning of the signal is missing. I doubt the first frame would have ever worked even if you flip the bits 27/31.
So question remains if cc1101 misses it or transmitter (remote control) doesn't send it.

Four and half "timings" missing from first frame: