Files
zorruno-homeassistant/esphome/esp-dinpowermonitor-pmb.yaml
2025-08-27 00:11:56 +12:00

756 lines
27 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

##########################################################################################
##########################################################################################
# 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 (dont 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);'