Today, 12:34 AM
The fan (CARRO USA Osborn) uses a rolling counter, so a captured frame can never be replayed. It is rc_switch protocol 1, 297us pulse, 32 bits:
The counter increments by one on every button press on the physical remote, whichever button it is, and the fan ignores a frame whose counter it has already seen. That is why capture and replay looks correct on the air but does nothing at the fan, and why bumping the repeat count does not help.
Commands for my remote (the 12 bits), remote ID 1110111110001111:
Two things were needed to make transmission work:
I gave up on KCS for this and used ESPHome, because the counter arithmetic has to happen on the device. Below is what I run on the AGv3. The fan logic is a package so each fan and remote pair is a few lines of configuration.
common/rf_fan.yaml:
kc868-agv3.yaml, the relevant parts:
Six fans run this way now. To add one, capture the first 16 bits of its remote's frames from the rc_switch dump and add another package entry.
Two notes on the receiver settings. Do not raise `idle` to 10ms: at that setting the constant 433MHz background noise merges with real packets and nothing decodes at all. And the `raw` dump at these thresholds is pure noise, so it was no help in identifying an unknown protocol.
Each new press on the physical remote also fires an `esphome.rf_fan_button` event in Home Assistant with the fan ID and the command, so the remotes can drive other things. That needs "Allow the device to perform Home Assistant actions" enabled on the ESPHome integration for the device.
Code:
[16-bit remote ID][12-bit command][4-bit counter]The counter increments by one on every button press on the physical remote, whichever button it is, and the fan ignores a frame whose counter it has already seen. That is why capture and replay looks correct on the air but does nothing at the fan, and why bumping the repeat count does not help.
Commands for my remote (the 12 bits), remote ID 1110111110001111:
Code:
off 000000010001
speed up 000000110001
speed down 000000110000
forward/reverse 000000100001
breeze/nature 000001000001
light white 000001010000
light yellow 000001100000
light down 000001110000
light up 000010000000Two things were needed to make transmission work:
- Track the counter. The receiver watches the physical remote and stores the last counter it saw; every transmission sends the stored value plus one. That keeps the ESP32 and the remote in sync no matter which one is used.
- Send the repeats back to back. With `wait_time: 0ms` the fan responds. With a 40ms gap between repeats it ignores the burst entirely, no matter how many repeats are sent.
I gave up on KCS for this and used ESPHome, because the counter arithmetic has to happen on the device. Below is what I run on the AGv3. The fan logic is a package so each fan and remote pair is a few lines of configuration.
common/rf_fan.yaml:
Code:
# One 433MHz RF ceiling fan remote (rc_switch protocol 1).
#
# Frame: 16-bit remote ID, 12-bit command, 4-bit rolling counter. The counter
# increments on every press of any button and the fan rejects stale values, so
# we track it from the physical remote and send counter+1.
#
# Each new press on the physical remote also fires an esphome.rf_fan_button
# event in Home Assistant with fan_id and command (12-bit command, decimal).
# The remote repeats each frame several times; only the first frame with a new
# counter fires an event. Our own transmissions update the counter before
# sending, so they never fire events.
#
# Vars:
# fan_id: C++ identifier prefix, e.g. living_room
# fan_name: entity name prefix, e.g. "Living Room"
# remote_id: first 16 bits of the remote's frames, e.g. "0xEF8F"
# pulse_length: base pulse in microseconds (optional)
defaults:
pulse_length: "297"
remote_receiver:
on_rc_switch:
then:
- if:
condition:
lambda: |-
return x.protocol == 1 && (x.code >> 16) == ${remote_id} &&
(x.code & 0xF) != id(${fan_id}_counter);
then:
- lambda: 'id(${fan_id}_counter) = x.code & 0xF;'
- homeassistant.event:
event: esphome.rf_fan_button
data:
fan_id: ${fan_id}
command: !lambda 'return to_string((x.code >> 4) & 0xFFF);'
globals:
- id: ${fan_id}_counter
type: uint8_t
restore_value: true
initial_value: "0"
script:
- id: ${fan_id}_send
mode: queued
parameters:
command: int
then:
- lambda: 'id(${fan_id}_counter) = (id(${fan_id}_counter) + 1) & 0xF;'
- remote_transmitter.transmit_rc_switch_raw:
transmitter_id: rf_tx
code: !lambda |-
uint32_t frame = ((uint32_t) ${remote_id} << 16) |
(((uint32_t) command & 0xFFF) << 4) |
id(${fan_id}_counter);
std::string code;
for (int i = 31; i >= 0; i--)
code += ((frame >> i) & 1) ? '1' : '0';
return code;
protocol:
pulse_length: ${pulse_length}
repeat:
times: 8
wait_time: 0ms
button:
- platform: template
name: "${fan_name} Fan Off"
on_press:
- script.execute:
id: ${fan_id}_send
command: 0x011
# Turns the fan on and increases speed; also exits Breeze/Nature mode.
- platform: template
name: "${fan_name} Fan Speed Up"
on_press:
- script.execute:
id: ${fan_id}_send
command: 0x031
# Turns the fan on and decreases speed; also exits Breeze/Nature mode.
- platform: template
name: "${fan_name} Fan Speed Down"
on_press:
- script.execute:
id: ${fan_id}_send
command: 0x030
# Toggles summer (counter-clockwise) and winter (clockwise); fan must be running.
- platform: template
name: "${fan_name} Fan Forward Reverse"
on_press:
- script.execute:
id: ${fan_id}_send
command: 0x021
# Toggles between Breeze and Nature modes.
- platform: template
name: "${fan_name} Fan Breeze Nature"
on_press:
- script.execute:
id: ${fan_id}_send
command: 0x041
- platform: template
name: "${fan_name} Light White"
on_press:
- script.execute:
id: ${fan_id}_send
command: 0x050
- platform: template
name: "${fan_name} Light Yellow"
on_press:
- script.execute:
id: ${fan_id}_send
command: 0x060
- platform: template
name: "${fan_name} Light Down"
on_press:
- script.execute:
id: ${fan_id}_send
command: 0x070
- platform: template
name: "${fan_name} Light Up"
on_press:
- script.execute:
id: ${fan_id}_send
command: 0x080kc868-agv3.yaml, the relevant parts:
Code:
remote_receiver:
pin: GPIO9
dump:
- rc_switch
tolerance: 50%
filter: 4us
idle: 4ms
remote_transmitter:
- id: rf_tx
pin: GPIO2
carrier_duty_percent: 100%
non_blocking: true
packages:
living_room_fan: !include
file: common/rf_fan.yaml
vars:
fan_id: living_room
fan_name: "Living Room"
remote_id: "0xEF8F"
library_fan: !include
file: common/rf_fan.yaml
vars:
fan_id: library
fan_name: "Library"
remote_id: "0x2FC8"Six fans run this way now. To add one, capture the first 16 bits of its remote's frames from the rc_switch dump and add another package entry.
Two notes on the receiver settings. Do not raise `idle` to 10ms: at that setting the constant 433MHz background noise merges with real packets and nothing decodes at all. And the `raw` dump at these thresholds is pure noise, so it was no help in identifying an unknown protocol.
Each new press on the physical remote also fires an `esphome.rf_fan_button` event in Home Assistant with the fan ID and the command, so the remotes can drive other things. That needs "Allow the device to perform Home Assistant actions" enabled on the ESPHome integration for the device.

