ESPHome + ESP32-C3 OLED + CC1101 as a Novy cooker hood RF bridge with RX/TX state tracking
I wanted to share my working ESPHome setup for controlling a Novy cooker hood via 433 MHz RF using an ESP32-C3 SuperMini with onboard OLED and a CC1101 module.
My initial motivation was the new Home Assistant Novy Hood integration. There are two reasons why I decided not to rely on it alone.
First, there is currently a speed-control issue in the released version I am using. For example, when changing from 25% to 50% fan speed, the integration first sends several “down/minus” commands to bring the hood to 0%, and then sends “up/plus” commands to reach the target speed. Since the hood does not report its real state back, this calibration approach is necessary in principle, but it did not work reliably for my hood. The maintainer is already working on a fix, but it is not yet included in the release I am using.
Second, I wanted to use the RX capability of the CC1101 so that the ESPHome node can keep track of both:
- commands sent from Home Assistant / ESPHome
- commands sent by the original Novy remote
That way, the local OLED display and Home Assistant state stay in sync even if someone uses the original remote.
The final result is a local ESPHome RF bridge that can:
send Novy LIGHT / PLUS / MINUS commands
receive the original remote control commands
track local fan speed and light state
show the state on the onboard OLED
blink the onboard blue LED on TX/RX
optionally expose a radio_frequency entity for the official Novy integration
Hardware
I used:
ESP32-C3 SuperMini with onboard OLED display
CC1101 433 MHz module with SMA connector
20 cm Dupont wires
433 MHz SMA antenna / magnetic base antenna
Original Novy RF remote
Novy remote pairing code: 1
The ESP32-C3 board has a tiny OLED display. On my board it is wired as:
OLED SDA -> GPIO5
OLED SCL -> GPIO6
I2C address -> 0x3C
Display model -> SSD1306 72x40
The 20 cm Dupont wires may look long, but for debugging they were actually useful. I have heard that the ESP32 can badly influence the CC1101 RF frontend if both boards are too close together. In my case, having some physical distance between the ESP32-C3 and the CC1101 did not hurt, and may even have helped.
CC1101 wiring
This is the wiring that works for my rfhub.
The CC1101 pin numbers refer to the pin numbering on my CC1101 module.
ESP32-C3 Wire color CC1101 pin CC1101 function
---------------------------------------------------------
GND brown 1 GND
3V3 red 2 VCC
GPIO1 purple 3 GDO0 / TX data
GPIO7 blue 4 CSN / CS
GPIO4 orange 5 SCK
GPIO10 yellow 6 MOSI / SI
GPIO3 green 7 MISO / SO
GPIO2 grey 8 GDO2 / RX data
Important:
CC1101 GDO0 / pin 3 -> ESP32 GPIO1 -> TX
CC1101 GDO2 / pin 8 -> ESP32 GPIO2 -> RX
I do not use gdo0_pin: in the cc1101: block. TX only became reliable for me without it.
Debugging approach
The biggest help was using a second ESP32-C3 + CC1101 as a pure receiver/sniffer called rflisten.
That allowed me to test separately:
rfhub sends -> rflisten verifies RF output
original remote sends -> rflisten captures raw frames
rflisten decoder confirms LIGHT / PLUS / MINUS signatures
This made it much easier to separate wiring problems, TX problems, RX problems, antenna problems and decoder problems.
Important CC1101 RX settings
The biggest breakthrough was not the decoder itself, but the CC1101 RX configuration.
Before these changes, the decoder worked whenever a clean frame arrived, but the receiver only produced usable frames extremely close to the antenna. After tuning the CC1101 for ASK/OOK reception, the same decoder started receiving the original remote reliably.
These settings made the decisive difference in my case:
filter_bandwidth: 325kHz
dc_blocking_filter: true
symbol_rate: 2600
magn_target: 33dB
carrier_sense_above_threshold: true
carrier_sense_rel_thr: +10dB
My interpretation is that the CC1101 was previously too sensitive to local noise / DC offset / ESP32 interference in OOK mode. The combination of a lower symbol rate, narrower filter bandwidth, DC blocking and carrier-sense threshold made the received pulses much cleaner.
This may not be universally optimal for every CC1101 module or every Novy remote, but it was the decisive change for my hardware.
Remote receiver settings
The other important settings are in the remote_receiver section. I had to tune these a bit to reliably capture the bursts from the original remote.
filter: 50us
idle: 6ms
tolerance: 25%
buffer_size: 10kb
For debugging, dump: raw is very useful. For normal operation, I keep it disabled because it produces a lot of log traffic.
Do not debug the decoder before the RF layer is stable
A lesson learned: if remote_receiver does not produce clean, repeatable raw frames, the decoder is not the main problem yet.
I first spent too much time on raw patterns. The real issue was that the CC1101 did not reliably output clean OOK pulses until the RX settings were tuned.
A good debugging order is:
1. Confirm SPI sees the CC1101.
2. Confirm TX works using a second receiver.
3. Confirm RX produces clean dump: raw output.
4. Only then build the decoder.
5. Only then add Home Assistant state tracking.
Decoder idea
Raw timings varied too much to match them directly.
So I convert each received raw pulse into a simple signature:
S = short pulse
L = long pulse
Then I match exact signatures.
For my Novy remote with pairing code 1, I observed these stable signatures:
LIGHT:
SLLSSLLSSLLSSLLSSLLSLSLSSLLSSLSLSLLS
SSLLSSLLSSLLSSLLSSLLSLSLSSLLSSLSLSLLS
SLLSSLLSSLLSSLLSSLLSLSLSSLLSSLSLSLLSL
SSLLSSLLSSLLSSLLSSLLSLSLSSLLSSLSLSLLSL
PLUS:
SLLSSLLSSLLSSLLSSLLSSLLS
SSLLSSLLSSLLSSLLSSLLSSLLS
SLLSSLLSSLLSSLLSSLLSSLLSL
SSLLSSLLSSLLSSLLSSLLSSLLSL
MINUS:
SLLSSLLSSLLSSLLSSLLSLSSL
SSLLSSLLSSLLSSLLSSLLSLSSL
If your Novy remote uses another pairing code, these signatures may differ.
Why I am not relying only on the official Novy integration
The official Novy integration was my starting point, and I still keep the radio_frequency entity available.
However, for local state tracking, I needed ESPHome to know whether a command was LIGHT, PLUS or MINUS.
If Home Assistant sends a raw RF command through the official integration, the ESPHome node does not automatically know which logical Novy command was sent. Therefore I use local ESPHome template buttons/scripts as the main control path.
The original remote control is tracked through the RX decoder.
Final rfhub ESPHome YAML
Replace the secrets with your own values.
esphome:
name: rfhub
friendly_name: rfhub
platformio_options:
board_build.flash_mode: dio
board_build.f_flash: 40000000L
board_build.flash_size: 4MB
on_boot:
- priority: -100
then:
- cc1101.begin_rx:
- script.execute: publish_novy_state
esp32:
board: esp32-c3-devkitm-1
variant: ESP32C3
framework:
type: esp-idf
logger:
level: INFO
hardware_uart: USB_SERIAL_JTAG
logs:
cc1101: INFO
remote_transmitter: INFO
remote_receiver: WARN
novy_decode: INFO
ir_rf_proxy: INFO
radio_frequency: INFO
api:
encryption:
key: !secret rfhub_api_key
ota:
- platform: esphome
password: !secret rfhub_ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "Rfhub Fallback Hotspot"
password: !secret rfhub_fallback_password
captive_portal:
# Onboard blue LED, inverted.
output:
- platform: gpio
pin:
number: GPIO8
inverted: true
id: onboard_led_out
light:
- platform: binary
name: "Onboard LED"
output: onboard_led_out
script:
- id: blink_blue_led
mode: restart
then:
- output.turn_on: onboard_led_out
- delay: 80ms
- output.turn_off: onboard_led_out
- id: publish_novy_state
mode: restart
then:
- lambda: |-
id(novy_fan_speed_local).publish_state(id(novy_fan_speed) * 25);
id(novy_light_local).publish_state(id(novy_light));
i2c:
id: bus_i2c
sda: GPIO5
scl: GPIO6
scan: true
frequency: 100kHz
font:
- file: "gfonts://Roboto Mono"
id: font_small
size: 10
display:
- platform: ssd1306_i2c
id: oled_display
i2c_id: bus_i2c
model: "SSD1306 72x40"
address: 0x3C
update_interval: 1s
pages:
- id: page_status
lambda: |-
it.print(0, 0, id(font_small), "rfhub");
if (millis() - id(last_tx_ms) < 2000) {
it.print(56, 0, id(font_small), "TX");
}
it.strftime(0, 12, id(font_small), "%H:%M:%S", id(sntp_time).now());
it.printf(0, 24, id(font_small), "%.0fdBm", id(wifi_rssi).state);
- id: page_net
lambda: |-
it.print(0, 0, id(font_small), "Net");
std::string ip = id(wifi_ip).state;
size_t dot = ip.find('.', ip.find('.') + 1);
if (dot != std::string::npos) {
it.printf(0, 12, id(font_small), ".%s",
ip.substr(dot + 1).c_str());
} else {
it.print(0, 12, id(font_small), "no ip");
}
it.printf(0, 24, id(font_small), "TX %u",
(unsigned) id(tx_count));
- id: page_hood
lambda: |-
it.print(0, 0, id(font_small), "Novy");
int level = id(novy_fan_speed); // 0..4
int percent = level * 25; // 0..100
int fill = (level * 36) / 4;
it.print(0, 14, id(font_small), "Fan");
it.rectangle(20, 15, 36, 7);
if (fill > 0) {
it.filled_rectangle(20, 15, fill, 7);
}
it.printf(58, 14, id(font_small), "%d", percent);
it.printf(0, 26, id(font_small), "Light %s",
id(novy_light) ? "ON" : "OFF");
globals:
- id: novy_fan_speed
type: int
restore_value: true
initial_value: '0' # 0=off, 1..4=speeds
- id: novy_light
type: bool
restore_value: true
initial_value: 'false'
- id: last_tx_ms
type: uint32_t
initial_value: '0'
- id: last_rx_ms
type: uint32_t
initial_value: '0'
- id: last_rx_cmd
type: int
initial_value: '0'
- id: last_seen_rx_ms
type: uint32_t
initial_value: '0'
- id: last_seen_rx_cmd
type: int
initial_value: '0'
- id: tx_count
type: uint32_t
restore_value: true
initial_value: '0'
interval:
- interval: 5s
then:
- display.page.show_next: oled_display
- interval: 1min
then:
- lambda: |-
auto now = id(sntp_time).now();
if (!now.is_valid()) return;
int h = now.hour;
// OLED on during the day, off at night.
if (h >= 6 && h < 23) {
id(oled_display).turn_on();
} else {
id(oled_display).turn_off();
}
time:
- platform: sntp
id: sntp_time
sensor:
- platform: uptime
id: uptime_s
name: "Uptime"
update_interval: 60s
- platform: wifi_signal
id: wifi_rssi
name: "WiFi RSSI"
update_interval: 30s
- platform: template
id: novy_fan_speed_local
name: "Novy Fan Speed Local"
unit_of_measurement: "%"
accuracy_decimals: 0
update_interval: never
binary_sensor:
- platform: status
name: "Online"
- platform: template
id: novy_light_local
name: "Novy Light Local"
text_sensor:
- platform: wifi_info
ip_address:
id: wifi_ip
name: "IP Address"
spi:
clk_pin: GPIO4
mosi_pin: GPIO10
miso_pin: GPIO3
id: spi_bus
cc1101:
id: cc1101_radio
cs_pin: GPIO7
frequency: 433.92MHz
modulation_type: ASK/OOK
filter_bandwidth: 325kHz
dc_blocking_filter: true
output_power: 10
symbol_rate: 2600
magn_target: 33dB
carrier_sense_above_threshold: true
carrier_sense_rel_thr: +10dB
remote_transmitter:
id: tx_id
pin: GPIO1 # purple / CC1101 pin 3 / GDO0
carrier_duty_percent: 100%
non_blocking: false
on_transmit:
then:
- lambda: |-
id(last_tx_ms) = millis();
id(tx_count) += 1;
- script.execute: blink_blue_led
- cc1101.begin_tx:
on_complete:
then:
- delay: 20ms
- cc1101.begin_rx:
remote_receiver:
id: rx_id
pin:
number: GPIO2 # grey / CC1101 pin 8 / GDO2
mode:
input: true
# Enable temporarily for deep debugging:
# dump: raw
filter: 50us
idle: 6ms
tolerance: 25%
buffer_size: 10kb
on_raw:
then:
- lambda: |-
const uint32_t now = millis();
// Do not treat our own transmissions as remote-control input.
if (now - id(last_tx_ms) < 1000) {
return;
}
std::string sig;
sig.reserve(x.size());
for (auto v : x) {
int a = std::abs((int) v);
// Ignore very short glitches and long sync/gap periods.
if (a < 180) continue;
if (a > 1800) continue;
sig.push_back(a < 550 ? 'S' : 'L');
}
if (sig.size() < 20) {
return;
}
bool looks_novy =
sig.rfind("SLLSSLL", 0) == 0 ||
sig.rfind("SSLLSSLL", 0) == 0;
if (!looks_novy) {
return;
}
auto match_exact_len = [](const std::string &rx,
const char *pattern_c,
int max_errors) -> bool {
const std::string pattern(pattern_c);
if (rx.size() != pattern.size()) {
return false;
}
int errors = 0;
for (size_t i = 0; i < pattern.size(); i++) {
if (rx[i] != pattern[i]) {
errors++;
if (errors > max_errors) {
return false;
}
}
}
return true;
};
// LIGHT
const char *LIGHT_36_A = "SLLSSLLSSLLSSLLSSLLSLSLSSLLSSLSLSLLS";
const char *LIGHT_37_A = "SSLLSSLLSSLLSSLLSSLLSLSLSSLLSSLSLSLLS";
const char *LIGHT_37_B = "SLLSSLLSSLLSSLLSSLLSLSLSSLLSSLSLSLLSL";
const char *LIGHT_38_A = "SSLLSSLLSSLLSSLLSSLLSLSLSSLLSSLSLSLLSL";
// PLUS
const char *PLUS_24_A = "SLLSSLLSSLLSSLLSSLLSSLLS";
const char *PLUS_25_A = "SSLLSSLLSSLLSSLLSSLLSSLLS";
const char *PLUS_25_B = "SLLSSLLSSLLSSLLSSLLSSLLSL";
const char *PLUS_26_A = "SSLLSSLLSSLLSSLLSSLLSSLLSL";
// MINUS
const char *MINUS_24_A = "SLLSSLLSSLLSSLLSSLLSLSSL";
const char *MINUS_25_A = "SSLLSSLLSSLLSSLLSSLLSLSSL";
int cmd_code = 0;
const char *cmd_name = nullptr;
bool is_light =
match_exact_len(sig, LIGHT_36_A, 0) ||
match_exact_len(sig, LIGHT_37_A, 0) ||
match_exact_len(sig, LIGHT_37_B, 0) ||
match_exact_len(sig, LIGHT_38_A, 0);
bool is_plus =
match_exact_len(sig, PLUS_24_A, 0) ||
match_exact_len(sig, PLUS_25_A, 0) ||
match_exact_len(sig, PLUS_25_B, 0) ||
match_exact_len(sig, PLUS_26_A, 0);
bool is_minus =
match_exact_len(sig, MINUS_24_A, 0) ||
match_exact_len(sig, MINUS_25_A, 0);
if (is_light) {
cmd_code = 1;
cmd_name = "LIGHT";
} else if (is_plus) {
cmd_code = 2;
cmd_name = "PLUS";
} else if (is_minus) {
cmd_code = 3;
cmd_name = "MINUS";
}
if (cmd_code == 0) {
return;
}
// Burst dedupe:
// One remote key press sends several identical frames.
// Ignore frames that belong to the same RF burst,
// but allow quick repeated key presses.
const uint32_t burst_gap_ms = 220;
bool same_burst =
(cmd_code == id(last_seen_rx_cmd)) &&
(now - id(last_seen_rx_ms) < burst_gap_ms);
id(last_seen_rx_ms) = now;
id(last_seen_rx_cmd) = cmd_code;
if (same_burst) {
return;
}
id(last_rx_ms) = now;
id(last_rx_cmd) = cmd_code;
bool state_changed = false;
if (cmd_code == 1) {
id(novy_light) = !id(novy_light);
state_changed = true;
ESP_LOGI("novy_decode", "RFHUB RX LIGHT -> %s",
id(novy_light) ? "ON" : "OFF");
} else if (cmd_code == 2) {
int old_level = id(novy_fan_speed);
id(novy_fan_speed) = std::min(4, id(novy_fan_speed) + 1);
state_changed = id(novy_fan_speed) != old_level;
ESP_LOGI("novy_decode", "RFHUB RX PLUS -> level %d",
id(novy_fan_speed));
} else if (cmd_code == 3) {
int old_level = id(novy_fan_speed);
id(novy_fan_speed) = std::max(0, id(novy_fan_speed) - 1);
state_changed = id(novy_fan_speed) != old_level;
ESP_LOGI("novy_decode", "RFHUB RX MINUS -> level %d",
id(novy_fan_speed));
}
id(blink_blue_led).execute();
if (state_changed) {
id(publish_novy_state).execute();
}
button:
- platform: template
name: "Novy Light"
on_press:
- remote_transmitter.transmit_raw:
transmitter_id: tx_id
code: [
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -788,
394, -788,
394, -394,
788, -788,
394, -394,
788, -394,
788, -394,
788, -788,
394, -10000
]
repeat:
times: 6
wait_time: 0ms
- lambda: |-
id(novy_light) = !id(novy_light);
- script.execute: publish_novy_state
- platform: template
name: "Novy Plus"
on_press:
- remote_transmitter.transmit_raw:
transmitter_id: tx_id
code: [
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -10000
]
repeat:
times: 6
wait_time: 0ms
- lambda: |-
id(novy_fan_speed) = std::min(4, id(novy_fan_speed) + 1);
- script.execute: publish_novy_state
- platform: template
name: "Novy Minus"
on_press:
- remote_transmitter.transmit_raw:
transmitter_id: tx_id
code: [
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -394,
788, -788,
394, -788,
394, -394,
788, -10000
]
repeat:
times: 6
wait_time: 0ms
- lambda: |-
id(novy_fan_speed) = std::max(0, id(novy_fan_speed) - 1);
- script.execute: publish_novy_state
# Optional:
# Keeps the radio_frequency entity available for the official Novy integration.
# I do not use this for local state tracking, because rfhub cannot know whether
# the official integration just sent LIGHT, PLUS or MINUS.
radio_frequency:
- platform: ir_rf_proxy
name: "RF TX 433"
id: rf_proxy_tx
frequency: 433.92MHz
remote_transmitter_id: tx_id
on_control:
then:
- delay: 500ms
Notes and gotchas
CC1101 modules vary a lot
I had at least one CC1101 module that behaved strangely. SPI worked, but RF behavior was poor or inconsistent. If the logs show the CC1101 chip correctly but RF does not work, try another module.
Antenna and spacing
The antenna matters, but the CC1101 configuration mattered even more in my case.
Also, do not necessarily assume that short wires are always better here. For debugging, the 20 cm Dupont wires helped because they physically separated the ESP32-C3 from the CC1101. The ESP32 can influence the CC1101 badly when both are too close.
The decoder is pairing-code specific
My remote reports Novy pairing code 1. The signatures above are for that setup. Other pairing codes may require different signatures.
Debug mode
For debugging, enable:
dump: raw
inside remote_receiver: and set:
logger:
level: DEBUG
For normal use, turn raw dumping off. It creates a lot of log traffic.
Current status
ESPHome button LIGHT/PLUS/MINUS -> hood reacts
Original remote LIGHT/PLUS/MINUS -> rfhub receives and updates local state
OLED shows local light and fan state
Home Assistant sees local light/fan percentage entities
Blue LED blinks on TX/RX
This is not a polished ESPHome component, but it is a working reference for ESPHome + CC1101 + Novy RF.