Sonoff iFan03 with ESPHome: The Definitive Guide (Fixes Low Speed Stalling + Silent Mode)

Hi everyone,

UPDATE (18 January): I have significantly updated the configuration to make it more robust and reliable.

Like many of you, I struggled with the Sonoff iFan03 on ESPHome. The main issues were the fan stalling/humming on “Low” speed (due to motor inertia) and the annoying buzzer.

I analyzed the hardware behavior and created a robust configuration that solves these issues completely.

Key Improvements:

  1. RF Reliability (Debounce): [NEW] Implemented a 300ms software debounce. This prevents the common issue where 433MHz remotes send multiple codes per press, fixing “stuttering” buzzer sounds or unintended rapid toggling of the light.
  2. Kickstart Logic (Boost): When turning on to Low or Medium, the fan momentarily powers HIGH (4s) to overcome friction, then drops to the target speed. No more humming motors!
  3. Safety Stop: It turns off all relays for 500ms before switching speeds to prevent arcing.
  4. Silent Mode: Added a simple option to disable the buzzer completely.
  5. Feedback: If the buzzer is on, it gives feedback even when controlled via Home Assistant (not just the remote).

:inbox_tray: Download & Documentation

This configuration is now officially part of the ESPHome Devices Repository!
You can find the documentation and the latest clean YAML code here:

:point_right: Official Page: Sonoff iFan03 on ESPHome-Devices

(If you want to see the development history or contribute, check the GitHub repository)

:gear: YAML Configuration (Quick Copy)

For those who want to try it right now, here is the full configuration.

Click to expand the YAML code
substitutions:
  name: "living-fan"
  friendly_name: "Living Room Fan"
  comment: "Sonoff Ifan03 Fan Controller (ESP8285)"
  api_key: ""
  ota_password: ""
  wifi_ssid: !secret wifi_ssid
  wifi_password: !secret wifi_password
  on_boot_light: ALWAYS_OFF # or ALWAYS_ON
  buzzer_enabled: "true"  

globals:
  - id: last_speed
    type: int
    restore_value: no
    initial_value: '0'   # 0=OFF, 1=LOW, 2=MED, 3=HIGH

  - id: target_speed
    type: int
    restore_value: no
    initial_value: '0'

  - id: last_rf_ms
      type: uint32_t
      restore_value: no
      initial_value: '0'

esphome:
  name: $name
  comment: $comment
  friendly_name: $friendly_name
  on_boot:
    priority: -100
    then:
      - switch.turn_off: fan_relay1
      - switch.turn_off: fan_relay2
      - switch.turn_off: fan_relay3
      - lambda: |-
          id(last_speed) = 0;
          id(target_speed) = 0;

esp8266:
  board: esp8285

api:
  encryption:
    key: $api_key

ota:
  - platform: esphome
    password: $ota_password

wifi:
  ssid: $wifi_ssid
  password: $wifi_password
  min_auth_mode: WPA2  
  ap:
    ssid: "$name AP"
    password: $wifi_password

captive_portal:

logger:
  level: INFO
  baud_rate: 0
  logs:
    remote_receiver: WARN

sensor:
  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: 60s
    entity_category: diagnostic

button:
  - platform: restart
    name: "restart"
    entity_category: diagnostic
  
  - platform: template
    name: "Buzzer"
    id: buzzer
    on_press:
      - switch.turn_on: buzzer_switch
      - delay: 50ms
      - switch.turn_off: buzzer_switch

text_sensor:
  - platform: uptime
    name: "Uptime"
    entity_category: diagnostic

  - platform: version
    name: "ESPHome Version"
    hide_timestamp: true

  - platform: wifi_info
    ip_address:
      id: wifi_ip
      name: "IP Address"
      entity_category: diagnostic

output:
  - platform: gpio
    id: light_relay
    pin: GPIO9
    inverted: true

switch:
  - platform: gpio
    internal: True
    id: buzzer_switch
    name: "Buzzer"
    pin:
      number: GPIO10
      inverted: true

  - platform: gpio
    internal: true
    pin: GPIO14
    id: fan_relay1
    restore_mode: ALWAYS_OFF

  - platform: gpio
    internal: true
    pin: GPIO12
    id: fan_relay2
    restore_mode: ALWAYS_OFF

  - platform: gpio
    internal: true
    pin: GPIO15
    id: fan_relay3
    restore_mode: ALWAYS_OFF

light:
  - platform: binary
    name: "$friendly_name"
    output: light_relay
    id: ifan03_light
    restore_mode: $on_boot_light
    on_state:
      then:
        - script.execute: beep_feedback    

script:
  - id: fan_set_speed
    mode: restart
    then:
      # 1. Turn off if it was on
      - if:
          condition:
            lambda: 'return id(last_speed) != 0;'
          then:
            - switch.turn_off: fan_relay1
            - switch.turn_off: fan_relay2
            - switch.turn_off: fan_relay3
            - delay: 500ms

      # 2. BOOST only from OFF to LOW or MED
      - if:
          condition:
            lambda: |-
              return id(last_speed) == 0 &&
                     (id(target_speed) == 1 || id(target_speed) == 2);
          then:
            - switch.turn_on: fan_relay3
            - delay: 4s
            - switch.turn_off: fan_relay3
            - delay: 500ms

      # 3. Set final velocity
      - if:
          condition:
            lambda: 'return id(target_speed) == 1;'
          then:
            - switch.turn_on: fan_relay1

      - if:
          condition:
            lambda: 'return id(target_speed) == 2;'
          then:
            - switch.turn_on: fan_relay1
            - switch.turn_on: fan_relay2

      - if:
          condition:
            lambda: 'return id(target_speed) == 3;'
          then:
            - switch.turn_on: fan_relay3
      # 4. Store real state
      - lambda: |-
          id(last_speed) = id(target_speed);

  - id: beep_feedback
    mode: restart
    then:
      - if:
          condition:
            lambda: 'return ${buzzer_enabled};'
          then:
            - button.press: buzzer

  - id: rf_gate
    mode: single
    parameters:
      action: int
    then:
      - lambda: |-
          uint32_t now = millis();
          if (now - id(last_rf_ms) < 300) {
            return;
          }
          id(last_rf_ms) = now;

          switch (action) {
            case 0: { 
              auto call = id(ifan03_fan).turn_off();
              call.perform();
              break;
            }

            case 1: {
              auto call = id(ifan03_fan).turn_on();
              call.set_speed(1);
              call.perform();
              break;
            }

            case 2: {
              auto call = id(ifan03_fan).turn_on();
              call.set_speed(2);
              call.perform();
              break;
            }

            case 3: {
              auto call = id(ifan03_fan).turn_on();
              call.set_speed(3);
              call.perform();
              break;
            }

            case 4: {
              auto call = id(ifan03_light).toggle();
              call.perform();
              break;
            }

            case 5: {
              id(buzzer).press();
              break;
            }
            
          }

fan:
  - platform: template
    id: ifan03_fan
    name: "$friendly_name"
    speed_count: 3
    restore_mode: NO_RESTORE # important
    on_turn_on:
      - lambda: |-
          // If for any reason the speed is 0, we force 1 (Low).
          if (id(ifan03_fan).speed == 0) {
            id(target_speed) = 1;
          } else {
            // If you already have speed, we use it.
            id(target_speed) = id(ifan03_fan).speed;
          }
      - script.execute: fan_set_speed
      - script.execute: beep_feedback
    on_turn_off:
      - lambda: |-
          id(target_speed) = 0;
      - script.execute: fan_set_speed
      - script.execute: beep_feedback
    on_speed_set:
      - lambda: |-
          id(target_speed) = id(ifan03_fan).speed;
      - script.execute: fan_set_speed  
      - script.execute: beep_feedback

remote_receiver:
  pin: GPIO3

binary_sensor:
  # remote button row 3 button 1
  - platform: remote_receiver
    name: "Fan Off"
    id: remote_0
    raw:
      code: [-207, 104, -103, 104, -104, 103, -104, 207, -104, 103, -104, 104,
             -103, 104, -104, 103, -104, 105, -102, 104, -725, 104, -311, 103,
             -518, 104, -933, 103, -104, 104, -725, 104, -932, 104, -207, 207, -519]
    on_release:
      - script.execute:
          id: rf_gate
          action: 0
    internal: true
  # remote button row 3 button 2
  - platform: remote_receiver
    name: "Fan Low"
    id: remote_1
    raw:
      code: [-207, 104, -104, 103, -104, 104, -103, 207, -104, 104, -103, 104,
             -104, 103, -104, 104, -103, 104, -104, 103, -726, 103, -312, 103,
             -518, 104, -933, 103, -104, 104, -725, 104, -103, 104, -726, 103,
             -104, 311, -518]
    on_release:
      - script.execute:
          id: rf_gate
          action: 1
    internal: true
  # remote button row 2 button 2
  - platform: remote_receiver
    name: "Fan Medium"
    id: remote_2
    raw:
      code: [-208, 103, -104, 104, -103, 104, -103, 208, -103, 104, -104, 103,
             -104, 104, -103, 104, -104, 103, -104, 103, -726, 104, -310, 104,
             -518, 104, -933, 103, -104, 104, -725, 104, -207, 104, -622, 103,
             -416, 102, -415]
    on_release:
      - script.execute:
          id: rf_gate
          action: 2
    internal: true
  # remote button row 2 button 1
  - platform: remote_receiver
    name: "Fan High"
    id: remote_3
    raw:
      code: [-207, 104, -104, 103, -104, 104, -103, 208, -103, 104, -104, 103,
             -104, 104, -103, 104, -104, 103, -104, 103, -726, 104, -311, 104,
             -518, 103, -934, 103, -103, 104, -726, 103, -104, 207, -622, 104,
             -103, 104, -207, 104, -415]
    on_release:
      - script.execute:
          id: rf_gate
          action: 3
    internal: true
  # remote button row 1 button 1
  - platform: remote_receiver
    name: "Fan Light"
    id: remote_light
    raw:
      code: [-207, 104, -103, 104, -104, 103, -104, 207, -104, 103, -104, 104,
             -103, 104, -103, 104, -104, 103, -104, 104, -725, 104, -311, 103,
             -518, 104, -933, 103, -104, 103, -726, 103, -311, 104, -518, 104,
             -207, 104, -103, 104, -414]
    on_release:
      - script.execute:
          id: rf_gate
          action: 4
    internal: true
  # remote button row 1 button 2
  - platform: remote_receiver
    name: "Spare Button"
    id: remote_spare_button
    filters:
          - delayed_off: 200ms
    raw:
      code: [-207, 104, -103, 104, -104, 103, -104, 207, -104, 103, -104, 103,
             -104, 104, -103, 104, -103, 104, -104, 107, -721, 105, -206, 207,
             -518, 105, -931, 104, -104, 103, -725, 104, -104, 103, -725, 104,
             -104, 103, -207, 104, -414]
    on_release:
      - script.execute:
          id: rf_gate
          action: 5

Credits

I want to thank everyone who worked on this device before. This configuration builds upon the ideas and testing done by the community in previous threads. I took those foundations as inspiration and refined the logic to create this stable version.

1 Like