@@ -0,0 +1,755 @@
##########################################################################################
##########################################################################################
# SMART DIN POWER MONITOR AND RELAY (Originally CBU)
# V1.0 2025-08-26 Initial Version
##########################################################################################
# Ex Tuya Smart Relay
# Uses this chip for power monitoring BL0942
# Replacement https://templates.blakadder.com/ESP8685-WROOM-06.html
#
# NOTES
# - DIN rail power monitor and relay
# - Relay is a pulse to latch ON (GPIO04) and pulse to latch OFF (GPIO05)
# - Has an "is it running" template sensor with a minimum wattage value of ${min_power_to_state_running_default}
# - The relay opens on over-current at ${max_current_trip_default} (Trip Active sensor set). Trip is latched
# (relay stays OFF) until manually reset via the "Clear Trip" button or you toggle the relay ON again.
# - The status_led flashes with a speed depending on current (faster near limit).
# - The status_led is ON when the relay has tripped
# - Inrush debounce: current must remain above the trip threshold for ${overcurrent_debounce_ms} ms
# before the trip occurs (helps ignore short inrush spikes).
# - Energy totals calculated on-device:
# * Last Hour Energy (kWh) – snapshot of the previous completed hour
# * Today / Yesterday (kWh) – resets at local midnight, survives reboots
# * This Week / Last Week (kWh) – resets at Monday 00:00 local time, survives reboots
# * This Month / Last Month (kWh) – resets at 1st of month 00:00 local time, survives reboots
# - For rolling 24h Min/Max Power, use HA "Statistics" helper instead of on-device buffers.
#
##########################################################################################
##########################################################################################
##########################################################################################
# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS
# If NOT using a secrets file, just replace these with the passwords etc (in quotes)
##########################################################################################
substitutions :
# Device Naming
device_name : "dinpowermonitor-pmb"
friendly_name : "Din Power"
description_comment : "DIN Rail mounted current monitor and relay, with current based (software) trip"
device_area : "Hallway" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant.
# Project Naming
project_name : "Generic ESP32.ESP8685-WROOM-06" # Project details, a dot separates the HA columns
project_version : "v1.0" # Project version allows checking of deployed vs latest version
# Passwords
api_key : !secret esp-api_key # unfortunately you cannot use substitutions inside secrets names
ota_pass : !secret esp-ota_pass
static_ip_address : !secret esp-dinpowermonitor-pmb_ip
# Device General Settings
log_level : "ERROR" # NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE
update_interval : "60s" # update time for general sensors etc
# Device Specific Defaults (used to seed HA-tunable numbers)
relay_1_name : "Relay"
button_1_name : "Toggle Button"
status_led_name : "Power Active"
min_power_to_state_running_default : 4 # Watts, initial value only
max_current_trip_default : 6 # Amps, initial value only
led_flash_slow_ms : 1000 # slow flash period when just running
led_flash_fast_ms : 100 # very fast flash period near trip
overcurrent_debounce_ms : 300 # Over-current inrush debounce (ms)
##########################################################################################
# PACKAGES: Included Common Packages
# https://esphome.io/components/packages.html
##########################################################################################
packages :
common_wifi : !include
file : common/network_common.yaml
vars :
local_device_name : "${device_name}"
local_static_ip_address : "${static_ip_address}"
local_ota_pass : "${ota_pass}"
common_api : !include
file : common/api_common.yaml
vars :
local_api_key : "${api_key}"
common_webportal : !include
file : common/webportal_common.yaml
common_mqtt : !include
file : common/mqtt_common.yaml
vars :
local_device_name : "${device_name}"
common_sntp : !include
file : common/sntp_common.yaml
common_general_sensors : !include
file : common/sensors_common.yaml
vars :
local_friendly_name : "${friendly_name}"
local_update_interval : "${update_interval}"
##########################################################################################
# ESPHome
# https://esphome.io/components/esphome.html
##########################################################################################
esphome :
name : "${device_name}"
friendly_name : "${friendly_name}"
comment : "${description_comment}" # Appears on the ESPHome page in HA
area : "${device_area}"
project :
name : "${project_name}"
version : "${project_version}"
on_boot :
priority : -100
then :
# Restore "Last Trip" text from persisted epoch (if any)
- lambda : |-
if (id(last_trip_epoch) > 0) {
auto t = time::ESPTime::from_epoch_local(id(last_trip_epoch));
char buf[24];
t.strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S");
id(ts_last_trip).publish_state(buf);
} else {
id(ts_last_trip).publish_state("Never");
}
# Apply Power Restore Mode selector (Always ON / Always OFF / Previous State)
- lambda : |-
std::string mode = id(sel_restore_mode).state;
if (mode == "Always ON") {
id(relay_virtual).turn_on();
} else if (mode == "Always OFF") {
id(relay_virtual).turn_off();
} else {
// "Previous State" -> do nothing (template switch will keep last or default OFF)
}
##########################################################################################
# ESP Platform and Framework
# https://esphome.io/components/esp32.html
##########################################################################################
esp32 :
board : esp32-c3-devkitm-1
variant : esp32c3
framework :
type : esp-idf
##########################################################################################
# ESPHome Logging Enable
# https://esphome.io/components/logger.html
##########################################################################################
logger :
level : ${log_level}
##########################################################################################
# STATUS LED (BLUE) - low = ON per mapping
# https://esphome.io/components/status_led.html
##########################################################################################
status_led :
pin :
number : GPIO8
inverted : true
##########################################################################################
# UART BUS
# https://esphome.io/components/uart/
##########################################################################################
uart :
# for BL0942
id : bl_uart
rx_pin : GPIO19 # BL0942 TXD -> MCU RX
tx_pin : GPIO18 # BL0942 RXD -> MCU TX
baud_rate : 4800
parity : NONE
stop_bits : 1
##########################################################################################
# GLOBALS
# https://esphome.io/components/globals.html
##########################################################################################
globals :
# Used for LED blink period calculation (in milliseconds)
- id : _blink_period_ms
type : int
restore_value : no
initial_value : "500"
# Last trip time, saved across reboots.
- id : last_trip_epoch
type : uint32_t
restore_value : yes
initial_value : "0"
# Debounce start timestamp (ms since boot) for over-current detection
- id : _oc_start_ms
type : uint32_t
restore_value : no
initial_value : "0"
# ---- ENERGY ACCUMULATION & PERIOD SNAPSHOTS ----
- id : g_last_ms # millis() timestamp of last integration step
type : uint32_t
restore_value : no
initial_value : "0"
# Current-period totals (persisted)
- id : g_hour_kwh
type : float
restore_value : yes
initial_value : "0.0"
- id : g_today_kwh
type : float
restore_value : yes
initial_value : "0.0"
- id : g_week_kwh
type : float
restore_value : yes
initial_value : "0.0"
- id : g_month_kwh
type : float
restore_value : yes
initial_value : "0.0"
# Last-period snapshots (persisted)
- id : g_last_hour_kwh
type : float
restore_value : yes
initial_value : "0.0"
- id : g_yesterday_kwh
type : float
restore_value : yes
initial_value : "0.0"
- id : g_last_week_kwh
type : float
restore_value : yes
initial_value : "0.0"
- id : g_last_month_kwh
type : float
restore_value : yes
initial_value : "0.0"
# Boundary trackers (don’ t persist)
- id : g_last_hour_seen
type : int
restore_value : no
initial_value : "-1"
- id : g_last_doy_seen
type : int
restore_value : no
initial_value : "-1"
- id : g_last_wday_seen
type : int
restore_value : no
initial_value : "-1"
- id : g_last_month_seen
type : int
restore_value : no
initial_value : "-1"
##########################################################################################
# SENSORS
# https://esphome.io/components/sensor/
##########################################################################################
sensor :
- platform : bl0942
uart_id : bl_uart
update_interval : 250ms
voltage :
name : "${friendly_name} Voltage"
id : pm_voltage
accuracy_decimals : 1
unit_of_measurement : "V"
device_class : voltage
state_class : measurement
current :
name : "${friendly_name} Current"
id : pm_current
accuracy_decimals : 3
unit_of_measurement : "A"
device_class : current
state_class : measurement
on_value :
then :
- lambda : |-
// OVER-CURRENT TRIP HANDLER with inrush debounce
// Trip when current > n_max_trip and relay is ON, but only if it stays above
// for ${overcurrent_debounce_ms} ms (to ignore short inrush spikes).
if (id(bs_tripped).state) {
// Already tripped: reset debounce tracker and do nothing
id(_oc_start_ms) = 0;
} else if (id(relay_virtual).state && x > id(n_max_trip).state) {
// Over threshold while relay is ON
if (id(_oc_start_ms) == 0) {
id(_oc_start_ms) = millis(); // start debounce window
}
uint32_t elapsed = millis() - id(_oc_start_ms);
if (elapsed >= ${overcurrent_debounce_ms}) {
ESP_LOGI("trip",
"Overcurrent trip: %.3f A > %.3f A for %u ms (>= %u ms), opening relay",
x, id(n_max_trip).state, (unsigned) elapsed, (unsigned) ${overcurrent_debounce_ms});
// Mark trip, stop blinker FIRST
id(bs_tripped).publish_state(true);
id(led_blinker).stop();
// Open latching relay (template handles OFF coil pulse)
id(relay_virtual).turn_off();
// Force LED solid ON to indicate trip
auto call = id(led).turn_on();
call.perform();
// Time-stamp "Last Trip" using SNTP time (YYYY-MM-DD HH:MM:SS) and persist
auto now_clk = id(sntp_time).now();
if (now_clk.is_valid()) {
char buf[24];
now_clk.strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S");
id(ts_last_trip).publish_state(buf);
id(last_trip_epoch) = (uint32_t) now_clk.timestamp;
} else {
id(ts_last_trip).publish_state("unknown");
}
// Reset debounce tracker
id(_oc_start_ms) = 0;
}
} else {
// Below threshold or relay OFF: reset debounce tracker
id(_oc_start_ms) = 0;
}
// ------------------------------------------------------------------
// ENERGY ACCUMULATION (kWh) + PERIOD ROLLOVERS (hour/day/week/month)
// ------------------------------------------------------------------
// Integrate W over time using millis() -> kWh
uint32_t now_ms = millis();
if (id(g_last_ms) == 0) {
id(g_last_ms) = now_ms; // first sample since boot
} else {
uint32_t dt_ms = now_ms - id(g_last_ms);
id(g_last_ms) = now_ms;
float p_w = (isnan(x) || x < 0.0f) ? 0.0f : x; // sanitize power
float inc_kwh = p_w * (dt_ms / 3600000.0f); // W * hours -> kWh
id(g_hour_kwh) += inc_kwh;
id(g_today_kwh) += inc_kwh;
id(g_week_kwh) += inc_kwh; // Week rolls at Monday 00:00 local time
id(g_month_kwh) += inc_kwh;
}
// Use SNTP time for clean boundary changes
auto now = id(sntp_time).now();
if (now.is_valid()) {
// Hour rollover: when hour changes
if (id(g_last_hour_seen) != now.hour) {
if (id(g_last_hour_seen) != -1) {
id(g_last_hour_kwh) = id(g_hour_kwh);
id(s_last_hour_kwh).publish_state(id(g_last_hour_kwh));
}
id(g_hour_kwh) = 0.0f;
id(g_last_hour_seen) = now.hour;
}
// Day rollover (midnight local)
if (id(g_last_doy_seen) != now.day_of_year) {
if (id(g_last_doy_seen) != -1) {
id(g_yesterday_kwh) = id(g_today_kwh);
id(s_yesterday_kwh).publish_state(id(g_yesterday_kwh));
}
id(g_today_kwh) = 0.0f;
id(g_last_doy_seen) = now.day_of_year;
}
// Week rollover: Monday 00:00 local time (i.e., after Sunday)
// ESPHome day_of_week: Monday=1 ... Sunday=7
if (now.day_of_week == 1 && now.hour == 0 && id(g_last_wday_seen) != 1) {
id(g_last_week_kwh) = id(g_week_kwh);
id(s_last_week_kwh).publish_state(id(g_last_week_kwh));
id(g_week_kwh) = 0.0f;
}
id(g_last_wday_seen) = now.day_of_week;
// Month rollover: first day of month at 00:00
if (id(g_last_month_seen) != now.month) {
if (id(g_last_month_seen) != -1 && now.day_of_month == 1 && now.hour == 0) {
id(g_last_month_kwh) = id(g_month_kwh);
id(s_last_month_kwh).publish_state(id(g_last_month_kwh));
id(g_month_kwh) = 0.0f;
}
id(g_last_month_seen) = now.month;
}
}
power :
name : "${friendly_name} Power"
id : pm_power
accuracy_decimals : 1
unit_of_measurement : "W"
device_class : power
state_class : measurement
energy :
name : "${friendly_name} Energy"
id : pm_energy
unit_of_measurement : "Wh"
device_class : energy
state_class : total_increasing
frequency :
name : "${friendly_name} Frequency"
id : pm_freq
unit_of_measurement : "Hz"
device_class : frequency
state_class : measurement
# Optional calibration references (uncomment and tune if needed)
# voltage_reference: 15968
# current_reference: 124180
# power_reference: 309.1
# energy_reference: 2653
##########################################################################
# ENERGY TOTALS (kWh) EXPOSED TO HA
# These read the persisted globals; they update periodically.
##########################################################################
- platform : template
name : "${friendly_name} Last Hour Energy"
id : s_last_hour_kwh
unit_of_measurement : "kWh"
device_class : energy
state_class : total
accuracy_decimals : 3
update_interval : 30s
lambda : |-
return id(g_last_hour_kwh);
- platform : template
name : "${friendly_name} Today Energy"
id : s_today_kwh
unit_of_measurement : "kWh"
device_class : energy
state_class : total
accuracy_decimals : 3
update_interval : 30s
lambda : |-
return id(g_today_kwh);
- platform : template
name : "${friendly_name} Yesterday Energy"
id : s_yesterday_kwh
unit_of_measurement : "kWh"
device_class : energy
state_class : total
accuracy_decimals : 3
update_interval : 30s
lambda : |-
return id(g_yesterday_kwh);
- platform : template
name : "${friendly_name} This Week Energy"
id : s_week_kwh
unit_of_measurement : "kWh"
device_class : energy
state_class : total
accuracy_decimals : 3
update_interval : 30s
lambda : |-
return id(g_week_kwh);
- platform : template
name : "${friendly_name} Last Week Energy"
id : s_last_week_kwh
unit_of_measurement : "kWh"
device_class : energy
state_class : total
accuracy_decimals : 3
update_interval : 30s
lambda : |-
return id(g_last_week_kwh);
- platform : template
name : "${friendly_name} This Month Energy"
id : s_month_kwh
unit_of_measurement : "kWh"
device_class : energy
state_class : total
accuracy_decimals : 3
update_interval : 30s
lambda : |-
return id(g_month_kwh);
- platform : template
name : "${friendly_name} Last Month Energy"
id : s_last_month_kwh
unit_of_measurement : "kWh"
device_class : energy
state_class : total
accuracy_decimals : 3
update_interval : 30s
lambda : |-
return id(g_last_month_kwh);
##########################################################################################
# TEXT SENSOR COMPONENT
# https://esphome.io/components/text_sensor/
##########################################################################################
text_sensor :
# Holds the date/time of last trip (string with seconds)
- platform : template
name : "${friendly_name} Last Trip"
id : ts_last_trip
icon : "mdi:clock-alert"
entity_category : diagnostic
##########################################################################################
# BINARY SENSORS
# https://esphome.io/components/binary_sensor/
# https://esphome.io/components/binary_sensor/template.html
##########################################################################################
binary_sensor :
# "Is Running" template sensor
- platform : template
id : bs_running
name : "${friendly_name} Running"
lambda : |-
if (isnan(id(pm_power).state)) {
return false;
} else if (id(pm_power).state > id(n_min_power).state) {
return true;
} else {
return false;
}
filters :
- delayed_off : 15s
on_press :
then :
- if :
condition :
and :
- switch.is_on : relay_virtual
- lambda : 'return !id(bs_tripped).state;'
then :
- script.execute : led_blinker
on_release :
then :
- script.stop : led_blinker
- if :
condition :
lambda : 'return !id(bs_tripped).state;'
then :
- light.turn_off : led
# Trip flag exposed to HA; set true on over-current. Cleared when relay is turned ON or via Reset button.
- platform : template
id : bs_tripped
name : "${friendly_name} Trip Active"
device_class : problem
# On-board button (P17 -> IO9) pulls low when pressed
- platform : gpio
pin :
number : GPIO9
mode : INPUT_PULLUP
inverted : true
name : "${button_1_name}"
on_press :
- switch.toggle : relay_virtual
##########################################################################################
# NUMBER COMPONENT
# https://esphome.io/components/number/
##########################################################################################
# HA-TUNABLE THRESHOLDS (NUMBER ENTITIES)
# https://esphome.io/components/number/template.html
##########################################################################################
number :
- platform : template
name : "${friendly_name} Min Power Running"
id : n_min_power
optimistic : true
restore_value : true
initial_value : ${min_power_to_state_running_default}
min_value : 1
max_value : 100
step : 1
unit_of_measurement : "W"
icon : "mdi:flash-outline"
- platform : template
name : "${friendly_name} Max Current Trip"
id : n_max_trip
optimistic : true
restore_value : true
initial_value : ${max_current_trip_default}
min_value : 0
max_value : 10
step : 0.2
unit_of_measurement : "A"
icon : "mdi:current-ac"
##########################################################################################
# BUTTON COMPONENT
# Reset the trip latch from HA (does not close the relay automatically)
# https://esphome.io/components/button/
##########################################################################################
button :
- platform : template
name : "${friendly_name} Clear Trip"
id : btn_clear_trip
icon : "mdi:alert-remove-outline"
on_press :
- logger.log : "Trip reset requested (clearing Trip Active and LED state)"
- binary_sensor.template.publish :
id : bs_tripped
state : false
- lambda : |-
// Clear any pending debounce
id(_oc_start_ms) = 0;
- if :
condition :
and :
- switch.is_on : relay_virtual
- binary_sensor.is_on : bs_running
then :
- script.execute : led_blinker
else :
- light.turn_off : led
##########################################################################################
# SWITCH COMPONENT
# https://esphome.io/components/switch/
##########################################################################################
# LATCHING RELAY CONTROL (two coils: ON and OFF)
# P24 -> IO5 (close), P26 -> IO4 (open) per mapping.
# Two hidden GPIO pulse switches to drive the coils.
# User-facing switch sends a short pulse to the appropriate coil.
##########################################################################################
switch :
# Hidden coil driver: ON pulse (GPIO5)
- platform : gpio
id : relay_on_coil
pin :
number : GPIO5
inverted : false
restore_mode : ALWAYS_OFF
# Hidden coil driver: OFF pulse (GPIO4)
- platform : gpio
id : relay_off_coil
pin :
number : GPIO4
inverted : false
restore_mode : ALWAYS_OFF
# User-facing virtual switch
- platform : template
id : relay_virtual
name : "${relay_1_name}"
optimistic : true
# Use default restore behavior; "Previous State" is honored by doing nothing in on_boot.
# Set a safe baseline here (default off if no previous state).
restore_mode : RESTORE_DEFAULT_OFF
turn_on_action :
- logger.log : "Relay ON: pulsing ON coil"
- binary_sensor.template.publish :
id : bs_tripped
state : false # clear trip on manual ON
- switch.turn_on : relay_on_coil
- delay : 200ms
- switch.turn_off : relay_on_coil
- if :
condition :
and :
- binary_sensor.is_on : bs_running
- lambda : 'return !id(bs_tripped).state;'
then :
- script.execute : led_blinker
turn_off_action :
- logger.log : "Relay OFF: pulsing OFF coil"
- switch.turn_on : relay_off_coil
- delay : 200ms
- switch.turn_off : relay_off_coil
- script.stop : led_blinker
- if :
condition :
lambda : 'return !id(bs_tripped).state;'
then :
- light.turn_off : led
##########################################################################################
# OUTPUT COMPONENT
# https://esphome.io/components/output/ledc.html
##########################################################################################
# RED LED AS LIGHT (P9 -> IO10, low = ON)
##########################################################################################
output :
- platform : ledc
id : pow_red_led_pwm
pin :
number : GPIO10
inverted : true
##########################################################################################
# LIGHT COMPONENT
# https://esphome.io/components/light/monochromatic.html
##########################################################################################
# Hidden from HA; still used internally by scripts/logic
light :
- platform : monochromatic
id : led
output : pow_red_led_pwm
internal : true
##########################################################################################
# SELECT COMPONENT
# Choose how the relay state should be restored after boot
# https://esphome.io/components/select/template.html
##########################################################################################
select :
- platform : template
name : "${friendly_name} Power Restore Mode"
id : sel_restore_mode
optimistic : true
restore_value : true
options :
- "Always ON"
- "Always OFF"
- "Previous State"
initial_option : "Previous State"
icon : "mdi:power-settings"
##########################################################################################
# SCRIPTS
# https://esphome.io/components/script.html
##########################################################################################
script :
# LED blinker: variable-speed based on current vs n_max_trip; runs only when running, relay ON, not tripped
- id : led_blinker
mode : restart
then :
- while :
condition :
and :
- switch.is_on : relay_virtual
- binary_sensor.is_on : bs_running
- lambda : 'return !id(bs_tripped).state;'
then :
# Compute and store current-period ms in _blink_period_ms
- lambda : |-
float i = isnan(id(pm_current).state) ? 0.0f : id(pm_current).state;
float maxA = id(n_max_trip).state;
if (maxA < 0.1f) maxA = 0.1f;
float f = i / maxA; // 0.0 .. 1.0
if (f < 0.0f) f = 0.0f;
if (f > 1.0f) f = 1.0f;
int slow_ms = ${led_flash_slow_ms};
int fast_ms = ${led_flash_fast_ms};
if (slow_ms < fast_ms) { int t = slow_ms; slow_ms = fast_ms; fast_ms = t; } # ensure slow >= fast
int period = slow_ms - (int)((slow_ms - fast_ms) * f);
id(_blink_period_ms) = period;
# Blink with 50% duty cycle at the computed period
- light.turn_on :
id : led
brightness : 100 %
- delay : !lambda 'return (uint32_t)(id(_blink_period_ms) / 2);'
- light.turn_off : led
- delay : !lambda 'return (uint32_t)(id(_blink_period_ms) / 2);'