Custom ESPHome Firmware for Sonoff PowR2 – Looking for Testers & Feedback

EDIT: I’ve updated this post to share a new revision (Rev 2.0) with improved load detection, burst logic, and overall measurement stability.


Hi everyone,

I’ve been working on a custom ESPHome firmware for the Sonoff PowR2, and I’d like to share it with the community in case someone wants to test it, review the logic, or suggest improvements.

The main goal of this project is to achieve stable and accurate measurements, reliable energy tracking, and clean integration with Home Assistant, while keeping the device responsive and predictable during relay ON/OFF transitions.

This firmware is based on ESPHome 2025.12.7 and uses the internal CSE7766 sensor.


Main Features

Measurement & Data Processing

  • Reads voltage, current, and power directly from the CSE7766 sensor
  • Voltage is always sampled and averaged, even when the relay is OFF
  • Current and power are sampled only when the relay is ON
  • Power is primarily taken from the CSE7766 power register
  • A fallback estimation of current (I ≈ P / V) is used only when measured current is extremely low, but power is non-zero.
  • Sensor samples are accumulated and averaged over a configurable time window
  • Values are published to Home Assistant only when they change beyond a small threshold

Load Detection & Burst Mode

  • Measurements start only after a real electrical load is detected
  • A load is detected when:
    • Relay is ON
    • Power exceeds ~1.8 W
  • When a load is detected, the firmware enters Burst Mode:
    • For ~3 seconds, raw sensor values are published immediately, this provides instant feedback even with long scan intervals
  • After burst mode: measurements switches back to averaged values only
  • Measurement is halted when relay is turned OFF or UART watchdog detects stalled CSE7766 communication.This prevents publishing phantom or frozen measurements to Home Assistant

Energy Management

  • Energy is calculated by integrating instantaneous raw power every second
  • Energy accumulation happens in RAM
  • Two counters are exposed:
    • Daily Energy (kWh)
    • Total Energy (kWh)
  • Optional flash synchronization every 10 minutes to reduce flash wear
  • Energy values can be restored after power loss if saved in flash
  • Daily energy is automatically reset at local midnight, SNTP-based time configurable (default: Europe/Rome)

Overcurrent Protection (OCP)

  • Optional and fully configurable
  • User-defined maximum current threshold
  • When enabled:
    • Relay is turned OFF if the threshold is exceeded
    • A short OCP Alarm binary sensor is triggered for 1 second

Configuration Options (All Stored in Flash)

  • Boot Mode
    • Always OFF
    • Always ON
    • Restore Previous State
  • Scan Time (1–60 s)
    • Averaging and publishing interval
  • Voltage Offset / Current Offset
    • Calibration offsets applied to raw measurements
  • Status LED
    • Always ON
    • OFF after WiFi connection
  • Overcurrent Protection
    • Enable / Disable
    • Max current threshold
  • Save Energy to Flash
    • Enable / Disable periodic energy persistence

All settings are restored automatically after reboot or power loss.


Entities Exposed to Home Assistant

Sensors

  • Voltage (V)
  • Current (A)
  • Power (W)
  • Energy Today (kWh)
  • Total Energy (kWh)
  • WiFi Signal (dB)
  • Uptime

Binary Sensors

  • OCP Alarm

Switches

  • Relay
  • Overcurrent Protection Enable
  • Save Energy to Flash
  • Restart Device

Buttons

  • Reset Energy Counters

here is the code Rev 2.0:

# ESPHome Firmware for Sonoff PowR2
# Rev 2.0 2026/01/20
# Ivo Colleoni
# ESPHOME 2025.12.7
# ---------------------------------
# This firmware reads voltage, current, and power from the internal CSE7766 sensor
# and performs advanced filtering, averaging, and energy calculation.
#
# The following values are sent to Home Assistant:
#   - Voltage (averaged)
#   - Current (averaged)
#   - Instantaneous Power (averaged)
#   - Daily Energy (kWh)
#   - Total Energy (kWh)
#   - OCP Alarm (ON for 1 second when overcurrent protection trips)
#   - WiFi Signal
#   - Uptime
#
# The relay can be controlled from Home Assistant and the physical button.
#
# CONFIGURATION ENTITIES
#   - Boot Mode: selects the relay state at startup (ON / OFF / PREVIOUS STATE)
#   - Scan Time: defines the averaging and publishing interval (1–60 seconds)
#   - Status LED: sets the LED state after connection (ON / OFF)
#   - Voltage and Current Offsets: adjustable
#   - Max Current Threshold: maximum allowed current for OCP (0–16 A)
#   - Overcurrent Protection (OCP): when enabled, the relay is turned off if current exceeds the threshold
#   - Save Energy to Flash: enables persistent storage of energy counters
#   - Reset Energy Counters button
#   - Restart Device button
#   - All configurations are stored in flash and restored after power loss
#
# ENERGY MANAGEMENT
#   - Energy is calculated every second from instantaneous power
#   - Energy values are stored in RAM and optionally synchronized to flash
#   - Flash synchronization occurs every 10 minutes (when enabled)
#   - Daily energy resets automatically at local midnight using SNTP time (timezone dependent)
#
# SENSOR LOGIC & FILTERING
#   - Voltage is always sampled and averaged
#   - Current and power are sampled only when the relay is ON
#   - Load detection: measurements start only after a real electrical load is detected
#   - Burst mode is activated upon load detection: 
#       For the first 3 seconds after load detection, raw values are published immediately for fast UI feedback
#   - After burst mode, values are averaged over the Scan Time interval and published only if they change beyond a defined threshold
#
# RELIABILITY FEATURES
#   - UART watchdog: stops measurements if the CSE7766 stops reporting data
#   - Restore from flash: Relay state (when selected). Configuration options, Energy counters (when enabled)
#
# -----------------------------------------------------------------------------------------------------------------------------------------------------------------------
# Changelog
# Rev 2.0
#   - Major refactor of sensor processing and load detection logic
#   - Burst mode is now triggered only when a real electrical load is detected
#     (relay ON and power > ~1.8 W), not simply on relay activation
#   - Improved load detection to avoid phantom readings with relay ON and no load
#   - Voltage is now always sampled and averaged, even when relay is OFF
#   - Added separate voltage sample counter for more accurate averaging
#   - Improved burst publishing logic with load confirmation (burst_has_load)
#   - Initial voltage value is published at boot to avoid "Unknown" state in Home Assistant
#   - Added UART watchdog to reset measurements if CSE7766 data stalls while relay is ON
#   - Improved energy calculation using raw power readings from CSE7766
#   - Improved current estimation fallback (P/V) for very low current readings
#   - Better separation between burst phase and averaging phase (discard window)
#   - Improved flash synchronization logic and reduced unnecessary writes
#   - Improved stability and reliability during relay ON/OFF transitions
# Rev 1.3.2
#   - Offset changes are reflected immediately
#   - Improved initial burst mode
# Rev 1.3.1
#   - Fix voltage reporting with relay off
# Rev 1.3
#   - Improved initial burst mode
#   - Added Local Timezone: Midnight reset occurs at local time (Europe/Rome).
# -----------------------------------------------------------------------------------------------------------------------------------------------------------------------


esphome:
  name: test-powr2
  friendly_name: "Test Powr2"
  
  on_boot:
    priority: 600.0
    then:
      - lambda: |-
          id(ocp_alarm).publish_state(false);
          auto index = id(relay_boot_mode).active_index();
          if (index == 0) {
            id(relay_switch).turn_off();
          } else if (index == 1) {
            id(relay_switch).turn_on();
          }

esp8266:
  board: esp8285
  restore_from_flash: true

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  min_auth_mode: WPA2
  ap:
    ssid: hotspot_powr2
    password: !secret hotspot_wifi_password
  
captive_portal:

preferences: 
  flash_write_interval: 3600s

globals:
  - id: v_sum
    type: float
    initial_value: '0.0'
  - id: v_sample_count
    type: int
    initial_value: '0'
  - id: i_sum
    type: float
    initial_value: '0.0'
  - id: p_sum
    type: float
    initial_value: '0.0'
  - id: sample_count
    type: int
    initial_value: '0'
  - id: seconds_elapsed
    type: int
    initial_value: '0'
  - id: discard_timer
    type: int
    initial_value: '0'
  - id: is_burst
    type: bool
    initial_value: 'false'
  - id: burst_has_load
    type: bool
    initial_value: 'false'
  - id: load_detected
    type: bool
    initial_value: 'false'
  - id: last_update_millis
    type: uint32_t
    initial_value: '0'
  
  - id: last_v_pub
    type: float
    initial_value: '-1.0'
  - id: last_i_pub
    type: float
    initial_value: '-1.0'
  - id: last_p_pub
    type: float
    initial_value: '-1.0'
  
  - id: global_energy_total_flash
    type: double
    initial_value: '0.0'
    restore_value: true
  - id: global_energy_daily_flash
    type: double
    initial_value: '0.0'
    restore_value: true
  - id: energy_total_ram
    type: double
    initial_value: '0.0'
  - id: energy_daily_ram
    type: double
    initial_value: '0.0'

api:
ota:
  - platform: esphome
    password: "dlkjghsjlkjb4634AA"

logger:
  level: WARN
  baud_rate: 0

uart:
  rx_pin: RX
  tx_pin: TX
  baud_rate: 4800
  parity: EVEN

time:
  - platform: sntp
    id: sntp_time
    timezone: "Europe/Rome" 
    on_time:
      - seconds: 0
        minutes: 0
        hours: 0
        then:
          - lambda: |-
              id(energy_daily_ram) = 0.0;
              id(global_energy_daily_flash) = 0.0;
              id(energy_today_display).publish_state(0.0);
              id(debounced_sync).execute();

script:
  - id: burst_logic
    mode: restart
    then:
      - lambda: |-
          id(is_burst) = true;
          id(burst_has_load) = false;
          id(discard_timer) = 3; 
      - delay: 3s
      - lambda: |-
          id(is_burst) = false;
          id(burst_has_load) = false;

  - id: debounced_sync
    mode: restart
    then:
      - delay: 1s
      - lambda: |-
          if (id(energy_flash_save_enabled).state) {
            id(global_energy_daily_flash) = id(energy_daily_ram);
            id(global_energy_total_flash) = id(energy_total_ram);
          }
          global_preferences->sync();

  - id: reset_ocp_alarm
    mode: restart
    then:
      - delay: 1s
      - lambda: 'id(ocp_alarm).publish_state(false);'

binary_sensor:
  - platform: gpio
    pin: { number: GPIO0, mode: INPUT_PULLUP, inverted: true }
    name: "Physical Button"
    internal: true
    on_press:
      - switch.toggle: relay_switch

  - platform: template
    name: "OCP Alarm"
    id: ocp_alarm

sensor:
  - platform: cse7766
    voltage:
      id: voltage_raw
      internal: true
    current:
      id: current_raw
      internal: true
    power:
      id: power_raw
      internal: true
      on_value:
        then:
          - lambda: |-
              id(last_update_millis) = millis();
              float v_now = std::max(0.0f, id(voltage_raw).state + id(voltage_offset).state);
              float p_raw = std::max(0.0f, id(power_raw).state);
              float i_now = 0.0f;
              float p_now = p_raw; 

              id(v_sum) += v_now;
              id(v_sample_count)++;
              
              if (id(relay_switch).state) {
                  i_now = std::max(0.0f, id(current_raw).state + id(current_offset).state);
                  if (p_now > 0.5f && i_now < 0.005f && v_now > 100.0f) i_now = p_now / v_now;

                  if (p_now >= 0.8f) {
                      if (!id(load_detected) && p_now > 1.8f) {
                          id(load_detected) = true;
                          id(i_sum) = 0.0; id(p_sum) = 0.0;
                          id(sample_count) = 0; id(seconds_elapsed) = 0;
                          id(burst_logic).execute();
                      }
                  }
              }

              if (id(is_burst)) {
                  if (i_now > 0.01f || id(burst_has_load)) {
                      id(burst_has_load) = true;
                      id(voltage_avg).publish_state(v_now);
                      id(current_avg).publish_state(i_now);
                      id(power_avg).publish_state(p_now);
                      id(last_v_pub) = v_now; id(last_i_pub) = i_now; id(last_p_pub) = p_now;
                  }
              }

              if (id(discard_timer) == 0 && id(load_detected)) {
                id(i_sum) += i_now;
                id(p_sum) += p_now;
                id(sample_count)++;
              }

  - platform: template
    name: "Voltage"
    id: voltage_avg
    unit_of_measurement: V
    device_class: voltage
    state_class: measurement
    accuracy_decimals: 1

  - platform: template
    name: "Current"
    id: current_avg
    unit_of_measurement: A
    device_class: current
    state_class: measurement
    accuracy_decimals: 3

  - platform: template
    name: "Power"
    id: power_avg
    unit_of_measurement: W
    device_class: power
    state_class: measurement
    accuracy_decimals: 1

  - platform: template
    name: "Energy Today"
    id: energy_today_display
    unit_of_measurement: kWh
    device_class: energy
    state_class: total_increasing
    accuracy_decimals: 3

  - platform: template
    name: "Total Energy"
    id: energy_total_display
    unit_of_measurement: kWh
    device_class: energy
    state_class: total_increasing
    accuracy_decimals: 3

  - platform: uptime
    name: "Uptime"

  - platform: wifi_signal
    name: "WiFi Signal dB"

number:
  - platform: template
    name: "Scan Time"
    id: interval_input
    entity_category: config
    min_value: 1
    max_value: 60
    step: 1
    unit_of_measurement: "s"
    mode: box
    optimistic: true
    restore_value: true
    initial_value: 5
    on_value:
      then:
        - script.execute: debounced_sync

  - platform: template
    name: "Voltage Offset"
    id: voltage_offset
    entity_category: config
    min_value: -10.0
    max_value: 10.0
    step: 0.1
    mode: box
    optimistic: true
    restore_value: true
    initial_value: 0.0
    on_value:
      then:
        - lambda: |-
            float v_now = id(voltage_raw).state + x;
            id(voltage_avg).publish_state(v_now);
            id(last_v_pub) = v_now;
        - script.execute: debounced_sync

  - platform: template
    name: "Current Offset"
    id: current_offset
    entity_category: config
    min_value: -1.000
    max_value: 1.000
    step: 0.001
    mode: box
    optimistic: true
    restore_value: true
    initial_value: 0.000
    on_value:
      then:
        - lambda: |-
            float i_now = std::max(0.0f, id(current_raw).state + x);
            id(current_avg).publish_state(i_now);
            id(last_i_pub) = i_now;
        - script.execute: debounced_sync

  - platform: template
    name: "Max Current Threshold"
    id: max_current_threshold
    entity_category: config
    min_value: 0.0
    max_value: 15.0
    step: 0.1
    mode: box
    optimistic: true
    restore_value: true
    initial_value: 15.0
    on_value:
      then:
        - script.execute: debounced_sync

select:
  - platform: template
    name: "Status LED"
    id: led_behavior
    entity_category: config
    options: ["Off", "On"]
    restore_value: true 
    optimistic: true
    on_value:
      then:
        - script.execute: debounced_sync

  - platform: template
    name: "Boot Mode"
    id: relay_boot_mode
    entity_category: config
    options: ["Always Off", "Always On", "Restore Previous"]
    restore_value: true
    initial_option: "Restore Previous"
    optimistic: true
    on_value:
      then:
        - script.execute: debounced_sync

button:
  - platform: template
    name: "Reset Energy Counters"
    id: reset_energy
    entity_category: config
    on_press:
      then:
        - lambda: |-
            id(energy_daily_ram) = 0.0; id(energy_total_ram) = 0.0;
            id(global_energy_daily_flash) = 0.0; id(global_energy_total_flash) = 0.0;
            id(energy_today_display).publish_state(0.0); id(energy_total_display).publish_state(0.0);
            id(debounced_sync).execute();

switch:
  - platform: gpio
    name: "Relay"
    pin: GPIO12
    id: relay_switch
    restore_mode: RESTORE_DEFAULT_OFF 
    on_turn_on:
      - lambda: |-
          id(i_sum) = 0.0; id(p_sum) = 0.0;
          id(sample_count) = 0; id(seconds_elapsed) = 0;
          id(last_i_pub) = -1.0; id(last_p_pub) = -1.0;
          id(load_detected) = false;
          id(last_update_millis) = millis();
      - script.execute: burst_logic
      - script.execute: debounced_sync
    on_turn_off:
      - script.stop: burst_logic
      - lambda: |-
          id(is_burst) = false; id(burst_has_load) = false;
          id(load_detected) = false; id(discard_timer) = 0;
          id(current_avg).publish_state(0.0); id(power_avg).publish_state(0.0);
          id(last_i_pub) = 0.0; id(last_p_pub) = 0.0;
          id(i_sum) = 0.0; id(p_sum) = 0.0; id(sample_count) = 0;
          id(debounced_sync).execute();
  
  - platform: template
    name: "Overcurrent Protection"
    id: overcurrent_enabled
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    on_turn_on:
      - script.execute: debounced_sync
    on_turn_off:
      - script.execute: debounced_sync

  - platform: template
    name: "Save Energy to Flash"
    id: energy_flash_save_enabled
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    on_turn_on:
      - script.execute: debounced_sync
    on_turn_off:
      - script.execute: debounced_sync

  - platform: restart
    name: "Restart Device"
    entity_category: config

output:
  - platform: esp8266_pwm
    id: led_output
    pin: { number: GPIO13, inverted: true }

interval:
  - interval: 1s
    then:
      - lambda: |-
          static bool init_done = false;
          if (!init_done) {
            id(energy_total_ram) = id(global_energy_total_flash);
            id(energy_daily_ram) = id(global_energy_daily_flash);
            id(last_update_millis) = millis();
            init_done = true;
          }

          if (id(relay_switch).state && (millis() - id(last_update_millis) > 1000)) {
              id(load_detected) = false;
              id(power_avg).publish_state(0.0);
              id(current_avg).publish_state(0.0);
              id(last_p_pub) = 0.0; id(last_i_pub) = 0.0;
              id(i_sum) = 0.0; id(p_sum) = 0.0; id(sample_count) = 0;
              id(seconds_elapsed) = 0;
              id(is_burst) = false;
          }

          static bool first_boot_pub = false;
          if (!first_boot_pub && id(voltage_raw).has_state()) {
              float v_start = id(voltage_raw).state + id(voltage_offset).state;
              id(voltage_avg).publish_state(v_start);
              id(last_v_pub) = v_start;
              
              if (id(relay_switch).state && !id(load_detected)) {
                  id(power_avg).publish_state(0.0);
                  id(current_avg).publish_state(0.0);
                  id(last_p_pub) = 0.0; id(last_i_pub) = 0.0;
              }
              first_boot_pub = true;
          }

          float p_now_energy = 0.0;
          if (id(relay_switch).state) {
            float v_e = std::max(0.0f, id(voltage_raw).state + id(voltage_offset).state);
            float p_e = std::max(0.0f, id(power_raw).state);
            float i_e = std::max(0.0f, id(current_raw).state + id(current_offset).state);
            if (p_e > 0.5f && i_e < 0.005f && v_e > 100.0f) i_e = p_e / v_e;
            p_now_energy = p_e;

            if (id(overcurrent_enabled).state && i_e > id(max_current_threshold).state) {
               id(relay_switch).turn_off();
               id(ocp_alarm).publish_state(true);
               id(reset_ocp_alarm).execute();
            }
          }

          if (p_now_energy > 0.5f) {
            double delta_e = ((double)p_now_energy * 1.0) / 3600000.0;
            id(energy_total_ram) += delta_e;
            id(energy_daily_ram) += delta_e;
          }

          static uint32_t last_energy_sync = 0;
          if (millis() - last_energy_sync > 600000) {
              last_energy_sync = millis();
              if (id(energy_flash_save_enabled).state) id(debounced_sync).execute();
          }

          if (id(discard_timer) > 0) id(discard_timer)--;
          if (!id(is_burst)) id(seconds_elapsed)++;

          if (!id(is_burst) && id(seconds_elapsed) >= (int)id(interval_input).state) {
            if (id(v_sample_count) > 0) {
                float avg_v = id(v_sum) / (float)id(v_sample_count);
                if (abs(avg_v - id(last_v_pub)) >= 0.1f) {
                    id(voltage_avg).publish_state(avg_v);
                    id(last_v_pub) = avg_v;
                }
                id(v_sum) = 0.0f; id(v_sample_count) = 0;
            }

            if (id(sample_count) > 0) {
                float avg_i = id(i_sum) / (float)id(sample_count);
                float avg_p = id(p_sum) / (float)id(sample_count);
                if (abs(avg_i - id(last_i_pub)) > 0.001f) { id(current_avg).publish_state(avg_i); id(last_i_pub) = avg_i; }
                if (abs(avg_p - id(last_p_pub)) > 0.1f) { id(power_avg).publish_state(avg_p); id(last_p_pub) = avg_p; }
                id(i_sum) = 0.0; id(p_sum) = 0.0; id(sample_count) = 0;
            }
            
            static float last_e_today_pub = -1.0;
            static float last_e_total_pub = -1.0;
            float e_today = (float)id(energy_daily_ram);
            float e_total = (float)id(energy_total_ram);
            if (abs(e_today - last_e_today_pub) > 0.001f) { id(energy_today_display).publish_state(e_today); last_e_today_pub = e_today; }
            if (abs(e_total - last_e_total_pub) > 0.001f) { id(energy_total_display).publish_state(e_total); last_e_total_pub = e_total; }
            id(seconds_elapsed) = 0;
          }

  - interval: 500ms
    then:
      - lambda: |-
          static int tick = 0; tick++;
          bool connected = wifi::global_wifi_component->is_connected();
          if (!connected) id(led_output).set_level((tick % 2 == 0) ? 1.0 : 0.0);
          else id(led_output).set_level((id(led_behavior).active_index() == 1) ? 1.0 : 0.0);