#:########################################################################################:# # TITLE: GARAGE DB CONTROLS # zorruno.com layout v1.1 2026 #:########################################################################################:# # REPO: # https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-garagedbcontrols.yaml #:########################################################################################:# # VERSIONS: # v1.2 2026-03-03 EV monitoring updated to match Device A: # - Rolling average EV power (default 5 min @ 10s) # - EV charging binary sensor (threshold configurable) # - EV totals and energy summaries (today/yesterday/month/year) # v1.1 2026-02-25 Updated yaml to zorruno layout V1.1 # v1.0 2025-10-23 Initial Version #:########################################################################################:# # HARDWARE: # - Sonoff 4CH R2 (ESP8266 / ESP8285) # - Reference YAML: https://github.com/jvyoralek/homeassistant-config/blob/master/esphome/sonoff-4ch.yaml # - Relays (4CH R2): GPIO12, GPIO5, GPIO4, GPIO15 # - Buttons (4CH R2): GPIO0, GPIO9, GPIO10, GPIO14 # - Status LED: GPIO13 (active-low) # - Extras (as used here): # - EV pulse input: GPIO2 (must be HIGH at boot; INPUT_PULLUP used) # - DHT sensor: GPIO3 (note: UART RX pin on ESP8266; ensure logger baud_rate=0) #:########################################################################################:# # OPERATION NOTES: # - Provides 4 relay outputs with matching local buttons. # - Relay restore modes are set per-output (some default ON, some default OFF). # - EV charger monitoring (2 Wh per pulse on GPIO2): # - Lifetime total energy (kWh) from persisted absolute pulse total. # - Rolling average power (default 5 min) to reduce spiky readings at low load. # - Charging binary sensor based on averaged power threshold. # - Energy summaries: today, yesterday, this month, last month, this year, last year. #:########################################################################################:# # OFFLINE NOTES: # a) Home Assistant OFFLINE, WiFi OK # - Device continues relay/button operation and continues pulse counting locally. # - API/MQTT publishing will fail until HA (and/or broker) is back. # b) MQTT OFFLINE (or broker unreachable) # - Relays/buttons still operate locally and via HA API (if HA is available). # - MQTT state publishing depends on broker. # c) Entire WiFi/Network OFFLINE # - Accurate time IS needed for daily/monthly/year rollovers (SNTP required for correct period boundaries). # - Loss of network will drift the timeclocks; time of day can be reset by powering off then on again at 12pm (midday) #:########################################################################################:# #:########################################################################################:# # SUBSTITUTIONS: Specific device variable substitutions #:########################################################################################:# substitutions: # Device Naming device_name: "esp-garagedbcontrols" friendly_name: "Garage DB Control" description_comment: "Garage DB Power, EV Power & Measure, Lighting :: Sonoff 4ch R2 (Layout V1.1)" device_area: "Garage" # Project Naming project_name: "Sonoff Technologies.Sonoff 4Ch R2" project_version: "v1.2" # Passwords & Secrets api_key: !secret esp-api_key ota_pass: !secret esp-ota_pass static_ip_address: !secret esp-garagedbcontrols_ip # If we are changing IP addresses, you must update the current IP address here, otherwise it remains # Don't forget to switch it back when changed. current_ip_address: ${static_ip_address} # General Settings log_level: "ERROR" update_interval: "60s" # Globals persistence tuning (gentler on flash) globals_save_interval_slow: "10min" globals_save_interval_pulses: "2min" # I/O Naming switch_1_name: "16A Wallcharger Operation" button_1_name: "Button 1" relay_1_icon: "mdi:power-socket-au" switch_2_name: "Garage West Power" button_2_name: "Button 2" relay_2_icon: "mdi:power-socket-au" switch_3_name: "Spare 3" button_3_name: "Button 3" relay_3_icon: "mdi:toggle-switch" switch_4_name: "Spare 4" button_4_name: "Button 4" relay_4_icon: "mdi:toggle-switch" ######################################################################################## # EV Pulse Settings (Device A logic) ######################################################################################## ev_pulse_wh: "2.0" # Each pulse is 2Wh ev_pulse_filter_ms: "10ms" # Debounce / filter ev_pulse_pin_mode: "INPUT_PULLUP" # EV Charging Detection (Averaged power + binary sensor) # Minimum EV charging power threshold (W) for binary sensor ev_charging_min_power_w: "200" # Rolling average window - configuration notes: # - Substitutions are string replacements, so window samples must be updated manually if you change times. # - window_samples = (avg_minutes * 60) / sample_interval_seconds ev_power_avg_window_minutes: "5" # informational ev_power_avg_sample_interval: "10s" ev_power_avg_sample_interval_seconds: "10" # must match the value above ev_power_avg_window_samples: "15" # 5 min @ 10s = 30 samples ev_power_avg_buffer_size: "60" # ring buffer >= window_samples (180 @ 10s = 30 minutes) # Rollover check interval (time-valid day/month/year updates) rollover_check_interval: "30s" # Names - EV Wallcharger (Device B) ev_charger_name: "16A Wallcharger" ev_power_name: "${ev_charger_name} Power" ev_charging_name: "${ev_charger_name} Charging" ev_total_energy_name: "${ev_charger_name} Total Energy" ev_today_name: "${ev_charger_name} Energy Today" ev_yesterday_name: "${ev_charger_name} Energy Yesterday" ev_month_name: "${ev_charger_name} Energy This Month" ev_last_month_name: "${ev_charger_name} Energy Last Month" ev_year_name: "${ev_charger_name} Energy This Year" ev_last_year_name: "${ev_charger_name} Energy Last Year" #:########################################################################################:# # PACKAGES: Included Common Packages #:########################################################################################:# packages: #### WIFI, Network (Static/DHCP/IPV6 etc), Fallback AP, Safemode #### 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}" local_current_ip_address: "${current_ip_address}" #### HOME ASSISTANT API #### common_api: !include file: common/api_common.yaml vars: local_api_key: "${api_key}" #### MQTT #### common_mqtt: !include file: common/mqtt_common.yaml vars: local_device_name: "${device_name}" #### SNTP #### common_sntp: !include common/sntp_common.yaml #### DIAGNOSTICS Sensors #### diag_basic: !include common/include_basic_diag_sensors.yaml diag_more: !include common/include_more_diag_sensors.yaml #:########################################################################################:# # ESPHOME #:########################################################################################:# esphome: name: "${device_name}" friendly_name: "${friendly_name}" comment: "${description_comment}" area: "${device_area}" project: name: "${project_name}" version: "${project_version}" on_boot: priority: -100 then: - script.execute: init_ev_history_arrays - delay: 3s - script.execute: ev_rollover_check #:########################################################################################:# # ESP PLATFORM AND FRAMEWORK #:########################################################################################:# esp8266: board: esp8285 restore_from_flash: true preferences: flash_write_interval: 5min mdns: disabled: false #:########################################################################################:# # LOGGING #:########################################################################################:# logger: level: "${log_level}" baud_rate: 0 #:########################################################################################:# # STATUS LED #:########################################################################################:# status_led: pin: number: GPIO13 inverted: yes #:########################################################################################:# # GLOBALS (EV totals + rollovers + rolling average history) #:########################################################################################:# globals: # Date tracking for rollovers (restored) - id: g_last_day type: int restore_value: yes update_interval: ${globals_save_interval_slow} initial_value: "0" - id: g_last_month type: int restore_value: yes update_interval: ${globals_save_interval_slow} initial_value: "0" - id: g_last_year type: int restore_value: yes update_interval: ${globals_save_interval_slow} initial_value: "0" # EV baseline totals for period calculations (restored) - id: g_ev_day_start_kwh type: float restore_value: yes update_interval: ${globals_save_interval_slow} initial_value: "-1.0" - id: g_ev_month_start_kwh type: float restore_value: yes update_interval: ${globals_save_interval_slow} initial_value: "-1.0" - id: g_ev_year_start_kwh type: float restore_value: yes update_interval: ${globals_save_interval_slow} initial_value: "-1.0" # EV stored prior periods (restored) - id: g_ev_yesterday_kwh type: float restore_value: yes update_interval: ${globals_save_interval_slow} initial_value: "0.0" - id: g_ev_last_month_kwh type: float restore_value: yes update_interval: ${globals_save_interval_slow} initial_value: "0.0" - id: g_ev_last_year_kwh type: float restore_value: yes update_interval: ${globals_save_interval_slow} initial_value: "0.0" # EV pulse accumulation (persisted across reboots) (restored) - id: g_ev_pulses_total_abs type: uint32_t restore_value: yes update_interval: ${globals_save_interval_pulses} initial_value: "0" - id: g_ev_pulses_raw_last type: uint32_t restore_value: yes update_interval: ${globals_save_interval_pulses} initial_value: "0" # EV rolling average power (RAM only) - id: g_ev_power_avg_w type: float restore_value: no initial_value: "0.0" # EV pulse history ring buffer for rolling average (RAM only) - id: g_ev_pulses_hist type: uint32_t[${ev_power_avg_buffer_size}] restore_value: no - id: g_ev_pulses_hist_index type: int restore_value: no initial_value: "0" - id: g_ev_pulses_hist_count type: int restore_value: no initial_value: "0" #:########################################################################################:# # SWITCHES #:########################################################################################:# switch: # Sonoff 4Ch R2 Relays are GPIO12, GPIO5, GPIO4, GPIO15 - platform: gpio id: relay1 name: "${switch_1_name}" restore_mode: RESTORE_DEFAULT_OFF pin: GPIO12 icon: "${relay_1_icon}" - platform: gpio id: relay2 name: "${switch_2_name}" restore_mode: RESTORE_DEFAULT_ON pin: GPIO5 icon: "${relay_2_icon}" - platform: gpio id: relay3 name: "${switch_3_name}" restore_mode: RESTORE_DEFAULT_ON pin: GPIO4 icon: "${relay_3_icon}" - platform: gpio id: relay4 name: "${switch_4_name}" restore_mode: RESTORE_DEFAULT_OFF pin: GPIO15 icon: "${relay_4_icon}" #:########################################################################################:# # BINARY SENSORS #:########################################################################################:# binary_sensor: # Sonoff 4Ch R2 Buttons are GPIO0, GPIO9, GPIO10, GPIO14 - platform: gpio id: button1 name: "${button_1_name}" pin: number: GPIO0 mode: INPUT_PULLUP inverted: true on_press: - switch.toggle: relay1 - platform: gpio id: button2 name: "${button_2_name}" pin: number: GPIO9 mode: INPUT_PULLUP inverted: true on_press: - switch.toggle: relay2 - platform: gpio id: button3 name: "${button_3_name}" pin: number: GPIO10 mode: INPUT_PULLUP inverted: true on_press: - switch.toggle: relay3 - platform: gpio id: button4 name: "${button_4_name}" pin: number: GPIO14 mode: INPUT_PULLUP inverted: true on_press: - switch.toggle: relay4 # EV charging detection (Device A logic: averaged power threshold) - platform: template name: "${ev_charging_name}" id: ev_wallcharger_charging device_class: power icon: "mdi:ev-station" lambda: |- const float p = id(ev_power_avg_w).state; if (isnan(p)) return false; return (p >= ${ev_charging_min_power_w}); #:########################################################################################:# # SENSORS #:########################################################################################:# sensor: ######################################################################################## # EV WALLCHARGER PULSE ENERGY (GPIO2) ######################################################################################## - platform: pulse_counter id: ev_pulse_rate_ppm internal: true pin: number: GPIO2 mode: ${ev_pulse_pin_mode} # GPIO2 must be HIGH at boot inverted: false update_interval: ${ev_power_avg_sample_interval} internal_filter: ${ev_pulse_filter_ms} total: id: ev_pulses_raw_total internal: true on_value: then: - lambda: |- uint32_t raw = (uint32_t) x; uint32_t last = id(g_ev_pulses_raw_last); if (raw < last) { last = 0; } id(g_ev_pulses_total_abs) += (raw - last); id(g_ev_pulses_raw_last) = raw; - platform: template name: "${ev_power_name}" id: ev_power_avg_w unit_of_measurement: "W" device_class: power state_class: measurement accuracy_decimals: 0 update_interval: "${ev_power_avg_sample_interval}" lambda: |- return id(g_ev_power_avg_w); - platform: template name: "${ev_total_energy_name}" id: ev_total_energy_kwh unit_of_measurement: "kWh" device_class: energy state_class: total_increasing accuracy_decimals: 3 update_interval: ${ev_power_avg_sample_interval} lambda: |- return ((float) id(g_ev_pulses_total_abs)) * (${ev_pulse_wh} / 1000.0f); - platform: template name: "${ev_today_name}" id: ev_energy_today_kwh unit_of_measurement: "kWh" device_class: energy state_class: total accuracy_decimals: 3 update_interval: 60s lambda: |- const float total = id(ev_total_energy_kwh).state; if (isnan(total) || id(g_ev_day_start_kwh) < 0.0f) return NAN; float v = total - id(g_ev_day_start_kwh); if (v < 0.0f) v = 0.0f; return v; - platform: template name: "${ev_yesterday_name}" id: ev_energy_yesterday_kwh unit_of_measurement: "kWh" device_class: energy state_class: total accuracy_decimals: 3 update_interval: 10min lambda: |- return id(g_ev_yesterday_kwh); - platform: template name: "${ev_month_name}" id: ev_energy_this_month_kwh unit_of_measurement: "kWh" device_class: energy state_class: total accuracy_decimals: 3 update_interval: 10min lambda: |- const float total = id(ev_total_energy_kwh).state; if (isnan(total) || id(g_ev_month_start_kwh) < 0.0f) return NAN; float v = total - id(g_ev_month_start_kwh); if (v < 0.0f) v = 0.0f; return v; - platform: template name: "${ev_last_month_name}" id: ev_energy_last_month_kwh unit_of_measurement: "kWh" device_class: energy state_class: total accuracy_decimals: 3 update_interval: 30min lambda: |- return id(g_ev_last_month_kwh); - platform: template name: "${ev_year_name}" id: ev_energy_this_year_kwh unit_of_measurement: "kWh" device_class: energy state_class: total accuracy_decimals: 3 update_interval: 30min lambda: |- const float total = id(ev_total_energy_kwh).state; if (isnan(total) || id(g_ev_year_start_kwh) < 0.0f) return NAN; float v = total - id(g_ev_year_start_kwh); if (v < 0.0f) v = 0.0f; return v; - platform: template name: "${ev_last_year_name}" id: ev_energy_last_year_kwh unit_of_measurement: "kWh" device_class: energy state_class: total accuracy_decimals: 3 update_interval: 60min lambda: |- return id(g_ev_last_year_kwh); ######################################################################################## # DHT SENSOR (GPIO3) ######################################################################################## - platform: dht pin: GPIO3 update_interval: ${update_interval} temperature: name: "${friendly_name} Temperature" unit_of_measurement: "°C" accuracy_decimals: 1 humidity: name: "${friendly_name} Humidity" unit_of_measurement: "%" accuracy_decimals: 0 #:########################################################################################:# # INTERVALS (EV rolling average + rollover checks) #:########################################################################################:# interval: - interval: "${rollover_check_interval}" then: - script.execute: ev_rollover_check - interval: "${ev_power_avg_sample_interval}" then: - script.execute: ev_power_avg_update #:########################################################################################:# # SCRIPTS (EV) #:########################################################################################:# script: # Initialize EV pulse history buffer so rolling average starts stable after boot - id: init_ev_history_arrays mode: queued then: - lambda: |- const int ev_buf = ${ev_power_avg_buffer_size}; const uint32_t p0 = id(g_ev_pulses_total_abs); for (int i = 0; i < ev_buf; i++) { id(g_ev_pulses_hist)[i] = p0; } id(g_ev_pulses_hist_index) = 0; id(g_ev_pulses_hist_count) = ev_buf; id(g_ev_power_avg_w) = 0.0f; # EV rolling average update (based on absolute pulse total) - id: ev_power_avg_update mode: queued then: - lambda: |- const int buf = ${ev_power_avg_buffer_size}; int idx = id(g_ev_pulses_hist_index); if (idx < 0 || idx >= buf) idx = 0; const uint32_t p_now = id(g_ev_pulses_total_abs); id(g_ev_pulses_hist)[idx] = p_now; idx = (idx + 1) % buf; id(g_ev_pulses_hist_index) = idx; if (id(g_ev_pulses_hist_count) < buf) id(g_ev_pulses_hist_count)++; int win = ${ev_power_avg_window_samples}; if (win < 1) win = 1; if (win > buf) win = buf; if (id(g_ev_pulses_hist_count) < win) { return; } int old_idx = idx - win; if (old_idx < 0) old_idx += buf; const uint32_t p_old = id(g_ev_pulses_hist)[old_idx]; const uint32_t dp = (p_now >= p_old) ? (p_now - p_old) : 0; const float wh = ((float) dp) * (${ev_pulse_wh}); const float seconds = ((float) win) * (${ev_power_avg_sample_interval_seconds}); if (seconds <= 0.0f) { id(g_ev_power_avg_w) = 0.0f; return; } // W = Wh * 3600 / seconds id(g_ev_power_avg_w) = wh * 3600.0f / seconds; # Period rollover check (day/month/year) for EV totals - id: ev_rollover_check mode: restart then: - lambda: |- auto now = id(sntp_time).now(); if (!now.is_valid()) { return; } const int d = now.day_of_month; const int m = now.month; const int y = now.year; const float ev_total = id(ev_total_energy_kwh).state; // Initialize date markers on first valid time if (id(g_last_year) == 0) { id(g_last_day) = d; id(g_last_month) = m; id(g_last_year) = y; if (!isnan(ev_total)) { id(g_ev_day_start_kwh) = ev_total; id(g_ev_month_start_kwh) = ev_total; id(g_ev_year_start_kwh) = ev_total; } return; } // Protect against meter reset / wrap (total < baseline) if (!isnan(ev_total)) { if (id(g_ev_day_start_kwh) < 0.0f) id(g_ev_day_start_kwh) = ev_total; if (id(g_ev_month_start_kwh) < 0.0f) id(g_ev_month_start_kwh) = ev_total; if (id(g_ev_year_start_kwh) < 0.0f) id(g_ev_year_start_kwh) = ev_total; if (ev_total < id(g_ev_day_start_kwh)) id(g_ev_day_start_kwh) = ev_total; if (ev_total < id(g_ev_month_start_kwh)) id(g_ev_month_start_kwh) = ev_total; if (ev_total < id(g_ev_year_start_kwh)) id(g_ev_year_start_kwh) = ev_total; } // YEAR rollover if (y != id(g_last_year)) { if (!isnan(ev_total) && id(g_ev_year_start_kwh) >= 0.0f) { float ykwh = ev_total - id(g_ev_year_start_kwh); if (ykwh < 0.0f) ykwh = 0.0f; id(g_ev_last_year_kwh) = ykwh; id(g_ev_year_start_kwh) = ev_total; } id(g_last_year) = y; } // MONTH rollover if (m != id(g_last_month)) { if (!isnan(ev_total) && id(g_ev_month_start_kwh) >= 0.0f) { float mkwh = ev_total - id(g_ev_month_start_kwh); if (mkwh < 0.0f) mkwh = 0.0f; id(g_ev_last_month_kwh) = mkwh; id(g_ev_month_start_kwh) = ev_total; } id(g_last_month) = m; } // DAY rollover if (d != id(g_last_day)) { if (!isnan(ev_total) && id(g_ev_day_start_kwh) >= 0.0f) { float dkwh = ev_total - id(g_ev_day_start_kwh); if (dkwh < 0.0f) dkwh = 0.0f; id(g_ev_yesterday_kwh) = dkwh; id(g_ev_day_start_kwh) = ev_total; } id(g_last_day) = d; } #:########################################################################################:# # ROLLBACK COPY (v1.1) - PREVIOUS EV SECTION (pulse_meter + daily snapshot + mqtt window) # NOTE: Commented out. Kept for quick revert if needed. #:########################################################################################:# #globals: # - id: ev_last_total_kwh # type: float # restore_value: true # initial_value: "0.0" # # - id: ev_energy_yesterday_kwh # type: float # restore_value: true # initial_value: "0.0" # # - id: ev_last_snapshot_doy # type: int # restore_value: no # initial_value: "-1" # #sensor: # - platform: pulse_meter # id: ev_pulse_pm # pin: # number: GPIO2 # mode: INPUT_PULLUP # inverted: false # name: "${switch_1_name} Power Now" # unit_of_measurement: "W" # accuracy_decimals: 0 # internal_filter: ${ev_pulse_filter} # timeout: ${ev_pulse_timeout} # filters: # - multiply: 120.0 # total: # id: ev_energy_total_kwh # name: "${switch_1_name} Energy Cumulative Total" # unit_of_measurement: "kWh" # accuracy_decimals: 1 # filters: # - multiply: 0.002 # #interval: # - interval: 60s # then: # - mqtt.publish: # topic: "${ev_tasmota_tele_topic}" # qos: 0 # retain: false # payload: !lambda |- # float current = id(ev_energy_total_kwh).state; # if (!isfinite(current)) current = id(ev_last_total_kwh); # float delta_kwh = current - id(ev_last_total_kwh); # if (delta_kwh < 0.0f || !isfinite(delta_kwh)) delta_kwh = 0.0f; # float delta_wh = delta_kwh * 1000.0f; # id(ev_last_total_kwh) = current; # char buf[64]; # snprintf(buf, sizeof(buf), "{\"EVChargerWhCount\":%.3f}", delta_wh); # return std::string(buf); # # - interval: 60s # then: # - lambda: |- # auto now = id(sntp_time).now(); # if (!now.is_valid()) return; # if (now.hour != 23 || now.minute != 59) return; # if (id(ev_last_snapshot_doy) == now.day_of_year) return; # if (isfinite(id(ev_energy_today_kwh).state)) { # id(ev_energy_yesterday_kwh) = id(ev_energy_today_kwh).state; # } # id(ev_last_snapshot_doy) = now.day_of_year;