ESPHome + ESP32-C3 OLED + CC1101 as a Novy cooker hood RF bridge with RX/TX state tracking

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.

1 Like

If you don't mind, you could explain briefly what these parameters do and how your settings improved your RX. Esphome documentation doesn't explain a lot, so some time with CC1101 datasheet was likely needed...

Thanks for sharing!

How do you handle the on/off button on the remote? Tried your code and it works great. Very nice to track the state, what the native integration cannot. But when I press power off on the remote the state still gets out of sync in HA.

I lowered the bitrate from originally 5000 to 2600, which helped with the recognition of the sequences, and I added the dampening to limit the background noise that limiting the ability to properly recognize the sequences.

At the moment I don't, true, nobody uses the button here :slight_smile: I haven't tried decoding it to check if it is its own signal, or a sequence of light+plus/minus codes, but I can try with the rflistener to get a grip on it.