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);