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