##########################################################################################
##########################################################################################
# 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) {
            time_t tt = (time_t) id(last_trip_epoch);
            struct tm tm;
            localtime_r(&tt, &tm);
            char buf[24];
            strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
            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).current_option();
          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
    ignore_strapping_warning: true # GPIO08: This just supresses a warning, we know what we are doing

##########################################################################################
# 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
      ignore_strapping_warning: true # GPIO09: This just supresses a warning, we know what we are doing
    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);'
