diff --git a/.ha_run.lock b/.ha_run.lock index 4646251..470d9d4 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 67, "version": 1, "ha_version": "2026.2.2", "start_ts": 1771127640.2174048} \ No newline at end of file +{"pid": 68, "version": 1, "ha_version": "2026.2.3", "start_ts": 1771822737.408799} \ No newline at end of file diff --git a/automations.yaml b/automations.yaml index e69de29..fe51488 100644 --- a/automations.yaml +++ b/automations.yaml @@ -0,0 +1 @@ +[] diff --git a/configuration.yaml b/configuration.yaml index 75a7c89..b475938 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -1,6 +1,9 @@ # Configure a default setup of Home Assistant (frontend, api, etc) default_config: +# Helpers (default_config no longer loads input_number) +input_number: + frontend: themes: !include_dir_merge_named themes @@ -9,35 +12,25 @@ homeassistant: packages: !include_dir_named packages template: !include_dir_merge_list templates -#group: !include groups.yaml group: !include_dir_merge_named group/ automation: !include_dir_merge_list automations/ -automation UI: !include automations.yaml script: !include_dir_merge_named scripts/ -#script UI: !include scripts.yaml scene: !include scenes.yaml -#mqtt merged: !include_dir_merge_named mqtt/ -#sensor merged: !include_dir_merge_list sensor/ sensor: !include_dir_merge_list sensor/ -# Example base configuration.yaml entry conversation: logger: default: info -# logs: -#custom_components.hacs: debug -#custom_components.evnex: debug -#evnex: debug zha: enable_quirks: true custom_quirks_path: /config/custom_zha_quirks/ - device_config: # https://community.home-assistant.io/t/turning-a-light-entity-to-switch-entity/589599/7 + device_config: a4:c1:38:02:58:65:86:ed-1: # format: {ieee}-{endpoint_id} - type: “switch” # corrected device type + type: "switch" # corrected device type a4:c1:38:a1:db:1d:38:b7-1: # format: {ieee}-{endpoint_id} - type: “switch” # corrected device type + type: "switch" # corrected device type zigpy_config: ota: otau_directory: /config/zigpy_ota diff --git a/esphome/common/network_common.yaml b/esphome/common/network_common.yaml index f56e28b..69803ae 100644 --- a/esphome/common/network_common.yaml +++ b/esphome/common/network_common.yaml @@ -51,7 +51,7 @@ wifi: subnet: ${static_ip_subnet} dns1: ${static_ip_dns1} dns2: ${static_ip_dns2} - use_address: ${local_static_ip_address} + use_address: ${local_current_ip_address} ap: # Details for fallback hotspot in case wifi connection fails https://esphome.io/components/wifi.html#access-point-mode ssid: ${local_device_name} password: ${fallback_ap_password} diff --git a/esphome/esp-3dprinterpow.yaml b/esphome/esp-3dprinterpow.yaml index ae9c203..7d93d52 100644 --- a/esphome/esp-3dprinterpow.yaml +++ b/esphome/esp-3dprinterpow.yaml @@ -34,6 +34,10 @@ substitutions: mqtt_command_main_topic: !secret mqtt_command_main_topic mqtt_status_main_topic: !secret mqtt_status_main_topic + # 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} + # MQTT LOCAL Controls mqtt_local_device_name: "3dprinter-power" mqtt_local_command_topic: "${mqtt_command_main_topic}/${mqtt_local_device_name}" # Topic we will use to command this locally without HA @@ -56,6 +60,7 @@ packages: 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 (choose encryption or no encryption options) #### common_api: !include diff --git a/esphome/esp-arcademachine1.yaml b/esphome/esp-arcademachine1.yaml index 18f8e43..5a2ceeb 100644 --- a/esphome/esp-arcademachine1.yaml +++ b/esphome/esp-arcademachine1.yaml @@ -25,6 +25,10 @@ substitutions: ota_pass: !secret esp-ota_pass # unfortunately you can't use substitutions inside secrets names static_ip_address: !secret esp-arcademachine1_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} + # Device Settings log_level: "NONE" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE update_interval: "10s" # update time for for general sensors etc @@ -49,6 +53,7 @@ packages: local_device_name: "${device_name}" local_static_ip_address: "${static_ip_address}" local_ota_pass: "${ota_pass}" + local_current_ip_address: "${current_ip_address}" ################################################## # OPTIONAL packages (comment if you don't want them) @@ -62,8 +67,8 @@ packages: local_api_key: "${api_key}" #### WEB PORTAL #### - #common_webportal: !include - # file: common/webportal_common.yaml + common_webportal: !include + file: common/webportal_common.yaml #### MQTT #### common_mqtt: !include @@ -124,8 +129,8 @@ esp8266: preferences: flash_write_interval: 10min -#mdns: -# disabled: True # binary size saving +mdns: + disabled: False # binary size saving ########################################################################################## # ESPHome Logging Enable @@ -137,33 +142,6 @@ logger: #esp8266_store_log_strings_in_flash: false #tx_buffer_size: 64 -########################################################################################## -# Global Variables for use in automations etc -# https://esphome.io/guides/automations.html?highlight=globals#global-variables -########################################################################################## -globals: - - # Tracks the time (in seconds from midnight) at the previous boot - - id: last_boot_time_s - type: int - restore_value: true - initial_value: "0" - - # Counts how many consecutive boots have occurred (within X seconds) - - id: boot_count - type: int - restore_value: true - initial_value: "0" - - #################################################### - # boost_timer: counts minutes in BOOST mode - # After 'boost_duration' minutes, revert to TIMER. - #################################################### - - id: boost_timer - type: int - restore_value: true - initial_value: "0" - ########################################################################################## # BINARY SENSORS # https://esphome.io/components/binary_sensor/ diff --git a/esphome/esp-athombulbflasher.yaml b/esphome/esp-athombulbflasher.yaml.final20260221 similarity index 100% rename from esphome/esp-athombulbflasher.yaml rename to esphome/esp-athombulbflasher.yaml.final20260221 diff --git a/esphome/esp-athomv1temp1 copy.yaml.old b/esphome/esp-athomv1temp1 copy.yaml.old deleted file mode 100644 index 7daba38..0000000 --- a/esphome/esp-athomv1temp1 copy.yaml.old +++ /dev/null @@ -1,992 +0,0 @@ -########################################################################################## -########################################################################################## -# -########################################################################################## -########################################################################################## - - -########################################################################################## -# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS -# If NOT using a secrets file, just replace these with the passwords etc (in quotes) -########################################################################################## -substitutions: - # Device Naming - device_name: "esp-athomv1temp1" - friendly_name: "esp-athomv1temp1" - description_comment: "esp-athomv1temp1 :: Athom Smart Plug Power V1" - device_area: "Outside" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. - - # Project Naming - project_name: "Athom Technology.Smart Plug V1" # Project Details - project_version: "v1.0" # Project V denotes release of yaml file, allowing checking of deployed vs latest version - - # Passwords - api_key: !secret esp-api_key # unfortunately you can't use substitutions inside secrets names - ota_pass: !secret esp-ota_pass # unfortunately you can't use substitutions inside secrets names - static_ip_address: !secret esp-athomv1temp1_ip - - # Device Settings - log_level: "NONE" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE - update_interval: "10s" # update time for for general sensors etc - - # Device Settings - relay_icon: "mdi:power-socket-au" - current_limit : "10" # Current Limit in Amps. AU Plug = 10. IL, BR, EU, UK, US Plug = 16. - mqtt_timer_topic: "viewroad-commands/esp-athomv1temp1" # Topics you will use to change stuff - boost_duration_default: "180" # Minutes to stay ON in BOOST mode before reverting to TIMER - morning_on_default: "450" # Default in minutes from midnight. Default 07:30 => 450 - morning_off_default: "450" # Default in minutes from midnight. Default 07:30 => 450 (same as ON as no need for morning schedule) - evening_on_default: "1140" # Default in minutes from midnight. Default 19:00 => 1140 - evening_off_default: "1350" # Default in minutes from midnight. Default 22:30 => 1350 => 1440 is midnight - - ############################################################################## - # POOL LIGHT MODE CONTROL (new) - ############################################################################## - max_modes: "16" # total number of modes in the cycle - mode_pulse_off: "400ms" # short OFF between quick pulses - mode_pulse_on: "400ms" # short ON after each quick pulse - mode_reset_off: "3s" # long OFF to force hardware reset to mode 1 - mode_reset_window_ms: "3000" # treat OFF>=this ms as a reset-to-1 when turning back ON - mode_prime_on: "800ms" # if relay is OFF when a color is selected, turn ON for this long before pulsing - # Ignored modes bitmask: bit (mode_index-1). For 4 and 5 ignored -> (1<<3)|(1<<4) = 24. - ignored_modes_mask: "8288" # Ignore 6(32), 7(64), 14(8192) :: 32 + 64 + 8192 = 8288 - -########################################################################################## -# PACKAGES: Included Common Packages -# https://esphome.io/components/packages.html -########################################################################################## -packages: - ################################################## - # MANDATORY packages (won't compile without them!) - ################################################## - - #### 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}" - - ################################################## - # OPTIONAL packages (comment if you don't want them) - ################################################## - - #### HOME ASSISTANT API (choose encryption or no encryption options) #### - common_api: !include - file: common/api_common.yaml - #file: common/api_common_noencryption.yaml - vars: - local_api_key: "${api_key}" - - #### WEB PORTAL #### - #common_webportal: !include - # file: common/webportal_common.yaml - - #### MQTT #### - common_mqtt: !include - file: common/mqtt_common.yaml - vars: - local_device_name: "${device_name}" - - #### SNTP (Only use if you want/need accurate timeclocks) #### - common_sntp: !include - file: common/sntp_common.yaml - - #### DIAGNOSTICS Lite Sensors #### - common_lite_sensors: !include - file: common/sensors_common_lite.yaml - - #### DIAGNOSTICS General Sensors #### - common_general_sensors: !include - file: common/sensors_common.yaml - - #### DEBUGGING Sensors #### - # A count of various resets - #common_resetcount_debugging: !include - # file: common/resetcount_common.yaml - - # Device Specific included packages - common_athompowermonV1: !include - file: common/athompowermonv1_common.yaml - vars: - local_friendly_name: "${friendly_name}" - local_current_limit: "${current_limit}" - -########################################################################################## -# 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}" - name_add_mac_suffix: False - min_version: 2024.6.0 - project: - name: "${project_name}" - version: "${project_version}" - #platformio_options: - # build_unflags: - # - -std=gnu++20 - # - -std=gnu++2a - # build_flags: - # - -std=gnu++11 - # - -Os - # - -Wl,--gc-sections - # - -fno-exceptions - # - -fno-rtti - -########################################################################################## -# ESP Platform and Framework -# https://esphome.io/components/esp32.html -########################################################################################## -esp8266: - board: esp8285 - restore_from_flash: true # mainly for calculating cumulative energy, but not that important here - #framework: - # version: 2.7.4 - -preferences: - flash_write_interval: 10min - -mdns: - disabled: True # binary size saving - -########################################################################################## -# ESPHome Logging Enable -# https://esphome.io/components/logger.html -########################################################################################## -logger: - level: "${log_level}" #INFO Level suggested, or DEBUG for testing - baud_rate: 0 #set to 0 for no logging via UART, needed if you are using it for other serial things (eg PZEM) - #esp8266_store_log_strings_in_flash: false - #tx_buffer_size: 64 - -########################################################################################## -# Global Variables for use in automations etc -# https://esphome.io/guides/automations.html?highlight=globals#global-variables -########################################################################################## -globals: - - # Tracks the time (in seconds from midnight) at the previous boot - - id: last_boot_time_s - type: int - restore_value: true - initial_value: "0" - - # Counts how many consecutive boots have occurred (within X seconds) - - id: boot_count - type: int - restore_value: true - initial_value: "0" - - # Morning On time (minutes from midnight), - - id: morning_on - type: int - restore_value: true - initial_value: "${morning_on_default}" - - # Morning Off time (minutes from midnight), - - id: morning_off - type: int - restore_value: true - initial_value: "${morning_off_default}" - - # Evening On time (minutes from midnight), - - id: evening_on - type: int - restore_value: true - initial_value: "${evening_on_default}" - - # Evening Off time (minutes from midnight), - - id: evening_off - type: int - restore_value: true - initial_value: "${evening_off_default}" - - # Boost Duration (minutes), - - id: boost_duration - type: int - restore_value: true - initial_value: "${boost_duration_default}" - - #################################################### - # operation_mode: - # 0 = OFF - # 1 = ON - # 2 = TIMER - # 3 = BOOST - #################################################### - - id: operation_mode - type: int - restore_value: true - initial_value: "2" - - #################################################### - # current_mins is set if SNTP is invalid. - # We assume user powers on the device at 12:00 noon - # => 12 * 60 = 720 minutes from midnight. - #################################################### - - id: current_mins - type: int - restore_value: true - initial_value: "720" # 720 is 12:00 Noon - - #################################################### - # boost_timer: counts minutes in BOOST mode - # After 'boost_duration' minutes, revert to TIMER. - #################################################### - - id: boost_timer - type: int - restore_value: true - initial_value: "0" - - ############################################################################## - # POOL LIGHT MODE CONTROL GLOBALS (new) - ############################################################################## - # Current assumed pool-light mode (0..max_modes). 0 means relay OFF. - - id: current_mode - type: int - restore_value: false - initial_value: "1" - - # Total modes in the cycle (edit in substitutions). - - id: max_modes - type: int - restore_value: false - initial_value: "${max_modes}" - - # Flag while we are actively pulsing to change modes (prevents scheduler interference). - - id: mode_changing - type: bool - restore_value: false - initial_value: "false" - - # Timestamp of last relay off (for delta measurement). - - id: last_off_ms - type: uint32_t - restore_value: false - initial_value: "0" - - # Millisecond threshold that separates "quick pulse" vs "reset to mode 1". - - id: reset_window_ms - type: int - restore_value: false - initial_value: "${mode_reset_window_ms}" - - # Desired target mode for the cycle script. - - id: desired_mode - type: int - restore_value: false - initial_value: "1" - - # Bitmask of ignored modes (1..max_modes). Example: ignore 4 & 5 -> 24. - - id: ignored_mask - type: int - restore_value: false - initial_value: "${ignored_modes_mask}" - - # Internal: whether a recheck is already scheduled for min OFF enforcement. - - id: recheck_pending - type: bool - restore_value: false - initial_value: "false" - -########################################################################################## -# Text Sensors -# https://esphome.io/components/text_sensor/index.html -########################################################################################## -text_sensor: - -############################ -# MQTT Subscriptions -############################ - #################################################### - # Subscribe to the Morning On time, format "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Morning On Time Setting" - id: morning_on_topic - topic: "${mqtt_timer_topic}/morning-on" # Stored in the format HH:MM - internal: True - on_value: - then: - - lambda: |- - // Expect "HH:MM" => total length = 5, with ':' - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); // "HH" - int minute = atoi(x.substr(3, 2).c_str()); // "MM" - id(morning_on) = hour * 60 + minute; - ESP_LOGI("timer","Received new Morning On: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Morning On format: %s", x.c_str()); - } - #################################################### - # Morning Off time => "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Morning Off Time Setting" - id: morning_off_topic - topic: "${mqtt_timer_topic}/morning-off" # Stored in the format HH:MM - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); - int minute = atoi(x.substr(3, 2).c_str()); - id(morning_off) = hour * 60 + minute; - ESP_LOGI("timer","Received new Morning Off: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Morning Off format: %s", x.c_str()); - } - #################################################### - # Evening On time => "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Evening On Time Setting" - id: evening_on_topic - topic: "${mqtt_timer_topic}/evening-on" # Stored in the format HH:MM - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); - int minute = atoi(x.substr(3, 2).c_str()); - id(evening_on) = hour * 60 + minute; - ESP_LOGI("timer","Received new Evening On: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Evening On format: %s", x.c_str()); - } - #################################################### - # Evening Off time => "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Evening Off Time Setting" - id: evening_off_topic - topic: "${mqtt_timer_topic}/evening-off" # Stored in the format HH:MM - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); - int minute = atoi(x.substr(3, 2).c_str()); - id(evening_off) = hour * 60 + minute; - ESP_LOGI("timer","Received new Evening Off: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Evening Off format: %s", x.c_str()); - } - #################################################### - # Boost duration => 1 - 1439 - #################################################### - - platform: mqtt_subscribe - name: "Boost Duration" - id: boost_time_topic - topic: "${mqtt_timer_topic}/boost-time" # Stored as an integer from 1-1439 - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - // parse as integer - char *endptr; - long v = strtol(x.c_str(), &endptr, 10); - - // invalid if nothing parsed, trailing chars, or out of 0–1439 - if (endptr == x.c_str() || *endptr != '\0' || v < 0 || v > 1439) { - ESP_LOGE("boost_time", "Invalid boost_time '%s'", x.c_str()); - } else { - id(boost_duration) = static_cast(v); - } - - #################################################### - # Subscribe to operation mode: - # OFF, ON, TIMER, BOOST - #################################################### - # MQTT subscription: set mode, then immediately re-evaluate relay - - platform: mqtt_subscribe - id: timer_operation_mode_topic - topic: "${mqtt_timer_topic}/operation" - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (strcasecmp(x.c_str(), "TIMER") == 0) { - id(operation_mode) = 2; - ESP_LOGI("timer","Operation mode set to TIMER"); - } else if (strcasecmp(x.c_str(), "ON") == 0) { - id(operation_mode) = 1; - ESP_LOGI("timer","Operation mode set to ON"); - } else if (strcasecmp(x.c_str(), "OFF") == 0) { - id(operation_mode) = 0; - ESP_LOGI("timer","Operation mode set to OFF"); - } else if (strcasecmp(x.c_str(), "BOOST") == 0) { - id(operation_mode) = 3; - id(boost_timer) = 0; - ESP_LOGI("timer","Operation mode set to BOOST"); - } else { - ESP_LOGW("timer","Invalid operation mode: %s", x.c_str()); - } - - script.execute: evaluate_relay_state - - ###################################################### - # Expose the current operation mode (OFF, ON, TIMER, BOOST) - ###################################################### - - platform: template - name: "Operation Mode State" - lambda: |- - // 0=OFF, 1=ON, 2=TIMER, 3=BOOST - switch (id(operation_mode)) { - case 0: return {"OFF"}; - case 1: return {"ON"}; - case 2: return {"TIMER"}; - case 3: return {"BOOST"}; - default: return {"UNKNOWN"}; - } - update_interval: 5s - - ###################################################### - # Expose the "Morning On" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Morning On Time" - lambda: |- - int hour = id(morning_on) / 60; - int minute = id(morning_on) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - - ###################################################### - # Expose the "Morning Off" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Morning Off Time" - lambda: |- - int hour = id(morning_off) / 60; - int minute = id(morning_off) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - - ###################################################### - # Expose the "Evening On" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Evening On Time" - lambda: |- - int hour = id(evening_on) / 60; - int minute = id(evening_on) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - - ###################################################### - # Expose the "Evening Off" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Evening Off Time" - lambda: |- - int hour = id(evening_off) / 60; - int minute = id(evening_off) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - -########################################################################################## -# BINARY SENSORS -# https://esphome.io/components/binary_sensor/ -########################################################################################## -binary_sensor: - - platform: gpio - pin: - number: GPIO03 - mode: INPUT_PULLUP - inverted: true - name: "Power Button" - id: power_button - filters: - - delayed_on: 20ms - on_click: - - min_length: 20ms - max_length: 500ms - then: - - lambda: |- - if (id(relay).state) { - // Relay is ON: turn it OFF and set mode to 2 (TIMER) - id(relay).turn_off(); - id(operation_mode) = 2; - } else { - // Relay is OFF: turn it ON and set mode to 3 (BOOST) - id(relay).turn_on(); - id(operation_mode) = 3; - } - - - platform: template - name: "Relay Status" - lambda: |- - return id(relay).state; - -########################################################################################## -# Sensors -# https://esphome.io/components/text_sensor/index.html -########################################################################################## -sensor: - - platform: template - name: "Timeclock: Boost Duration" - id: boost_duration_time - unit_of_measurement: "mins" - accuracy_decimals: "0" - update_interval: "${update_interval}" - lambda: |- - return id(boost_duration); - - - platform: template - name: "Mins from Midnight" - id: mins_from_midnight - unit_of_measurement: "mins" - accuracy_decimals: "0" - update_interval: "${update_interval}" - internal: True # No need to show this in Home Assistant - lambda: |- - return id(current_mins); - - # A value in mins if a timer is running showing how many mins left - - platform: template - name: "Timer Minutes Remaining" - id: timer_minutes_remaining - unit_of_measurement: "Mins" - update_interval: 5s - accuracy_decimals: "0" - lambda: |- - // always zero if relay is off - if (!id(relay).state) { - return 0; - } - int rem = 0; - // only calculate for mode 2 (scheduled) or mode 3 (BOOST) - if (id(operation_mode) == 2) { - int a = id(morning_off) - id(current_mins); - int b = id(evening_off) - id(current_mins); - // if a is negative, use b; otherwise pick the smaller of a or b - rem = (a < 0) ? b : (a < b ? a : b); - } - else if (id(operation_mode) == 3) { - rem = id(boost_duration) - id(boost_timer); - } - // never return negative - return rem > 0 ? rem : 0; - - -################################################################################################# -# SWITCH COMPONENT -# https://esphome.io/components/switch/ -################################################################################################# -switch: - - platform: gpio - name: "Power Output" - pin: GPIO14 - id: relay - restore_mode: RESTORE_DEFAULT_OFF # Ensures the relay is restored (or off) at boot - #internal: true # Hides the switch from Home Assistant - icon: "${relay_icon}" - # POOL LIGHT MODE CONTROL: detect quick vs long off periods and keep current_mode in sync - on_turn_off: - - lambda: |- - id(last_off_ms) = millis(); - if (!id(mode_changing)) id(current_mode) = 0; // reflect OFF in select when off normally - on_turn_on: - - lambda: |- - if (id(mode_changing)) { - return; - } - uint32_t delta = millis() - id(last_off_ms); - if (delta >= (uint32_t)id(reset_window_ms)) { - id(current_mode) = 1; - } else { - id(current_mode)++; - if (id(current_mode) > id(max_modes)) id(current_mode) = 1; // keep internal index sane - } - -################################################################################################# -# BUTTON COMPONENT -# https://esphome.io/components/button/index.html -################################################################################################# -button: - - platform: template - name: "Boost now" - id: boost_button - icon: "mdi:play-circle-outline" - on_press: - # 1) reset BOOST timer and set mode - - lambda: |- - id(boost_timer) = 0; - id(operation_mode) = 3; - # 2) immediately re-evaluate relay state - - script.execute: evaluate_relay_state - - platform: template - name: "Default timer settings" - id: default_timer_settings_button - icon: "mdi:restore" - on_press: - - lambda: |- - // Restore all timing globals to their YAML defaults - id(morning_on) = ${morning_on_default}; - id(morning_off) = ${morning_off_default}; - id(evening_on) = ${evening_on_default}; - id(evening_off) = ${evening_off_default}; - id(boost_duration)= ${boost_duration_default}; - // Reset mode to TIMER and clear any running boost - id(operation_mode)= 2; - id(boost_timer) = 0; - ESP_LOGI("timer","Default timer settings applied"); - - script.execute: evaluate_relay_state - -################################################################################################# -# SELECT COMPONENT -# https://esphome.io/components/select/index.html -################################################################################################# -select: - - platform: template - name: "Operation Mode" - id: operation_mode_select - update_interval: 5s - options: - - "OFF" - - "ON" - - "TIMER" - - "BOOST" - - # show the current mode - lambda: |- - switch (id(operation_mode)) { - case 1: return std::string("ON"); - case 2: return std::string("TIMER"); - case 3: return std::string("BOOST"); - default: return std::string("OFF"); - } - - # when changed in HA, set mode & re-evaluate - set_action: - - lambda: |- - if (x == "OFF") { id(operation_mode) = 0; } - else if (x == "ON") { id(operation_mode) = 1; } - else if (x == "TIMER") { id(operation_mode) = 2; } - else { // BOOST - id(boost_timer) = 0; - id(operation_mode) = 3; - } - - script.execute: evaluate_relay_state - - ############################################################################## - # POOL LIGHT MODE SELECT (new) - # 0=OFF -> immediately turns relay OFF and sets Operation Mode = TIMER. - # Selecting a color (>=1) forces Operation Mode = ON. - # If target < current, do a long reset to mode 1, then pulse forward. - # Ignored modes are hidden here but still counted by pulses. - ############################################################################## - ############################################################################## - # POOL LIGHT MODE SELECT (updated display logic) - # 0=OFF -> immediately turns relay OFF and sets Operation Mode = TIMER. - # Selecting a color (>=1) forces Operation Mode = ON. - # If target < current, do a long reset to mode 1, then pulse forward. - # Ignored modes are hidden here but still counted by pulses. - ############################################################################## - - platform: template - name: "Pool Light Mode" - id: pool_light_mode_select - update_interval: 250ms - # Options include all 1..16 (no ignored modes currently) plus 0=OFF - options: - - "0= OFF" - - "1= Dark Blue" - - "2= Red" - - "3= Green" - - "4= Purple" - - "5= Aqua" - #- "6= Lime" - #- "7= Cool White" - - "8= Speed Up" - - "9= Transformer" - - "10= Blurple" - - "11= Redlime" - - "12= Greenqua" - - "13= Bluequa" - #- "14= Grelime" - - "15= Redurple" - - "16= RGB Mad" - - # Show OFF only when not in a color-change sequence. - # While changing, reflect the in-flight/current mode (never 0). - lambda: |- - auto label_for = [](int m) -> std::string { - switch (m) { - case 0: return "0= OFF"; - case 1: return "1= Dark Blue"; - case 2: return "2= Red"; - case 3: return "3= Green"; - case 4: return "4= Purple"; - case 5: return "5= Aqua"; - case 6: return "6= Lime"; - case 7: return "7= Cool White"; - case 8: return "8= Speed Up"; - case 9: return "9= Transformer"; - case 10: return "10= Blurple"; - case 11: return "11= Redlime"; - case 12: return "12= Greenqua"; - case 13: return "13= Bluequa"; - case 14: return "14= Grelime"; - case 15: return "15= Redurple"; - case 16: return "16= RGB Mad"; - default: return "1= Dark Blue"; - } - }; - auto is_ignored = [&](int m) -> bool { - if (m < 1 || m > id(max_modes)) return true; - int bit = 1 << (m - 1); - return (id(ignored_mask) & bit) != 0; - }; - - // While stepping (mode_changing), don't show OFF even if relay is briefly OFF. - if (id(mode_changing)) { - int cm = id(current_mode); - if (cm <= 0) cm = 1; // during long reset, assume Mode 1 is next - // Skip ignored modes for display if ever configured later - if (!is_ignored(cm)) return label_for(cm); - // find next non-ignored just for display robustness - for (int m = cm + 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - for (int m = 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - return label_for(1); - } - - // Normal (not changing): show OFF if relay is actually OFF, else show current mode - if (!id(relay).state) return std::string("0= OFF"); - - int cm = id(current_mode); - if (cm == 0) return std::string("0= OFF"); - if (!is_ignored(cm)) return label_for(cm); - for (int m = cm + 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - for (int m = 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - return label_for(1); - - set_action: - # First, set flags based on selection - - lambda: |- - int target = atoi(x.c_str()); - if (target < 0) target = 0; - if (target > id(max_modes)) target = id(max_modes); - - if (target == 0) { - // MODE 0: force everything OFF; UI goes back to TIMER control - id(mode_changing) = false; - id(recheck_pending) = false; - id(operation_mode) = 2; // TIMER - id(current_mode) = 0; // reflect OFF in dropdown - } else { - // defensive: ignore choosing an ignored mode (options already hide them) - int bit = 1 << (target - 1); - if ((id(ignored_mask) & bit) != 0) { - ESP_LOGW("mode","Selected target %d is ignored; ignoring request", target); - return; - } - // Color selection: force ON mode and start changer - id(operation_mode) = 1; - id(desired_mode) = target; - id(mode_changing) = true; - } - - # If OFF was selected, hard-stop any running scripts and cut power - - if: - condition: - lambda: 'return atoi(x.c_str()) == 0;' - then: - - script.stop: change_to_desired_mode - - script.stop: reset_to_mode1 - - script.stop: enforce_off_guard - - switch.turn_off: relay - else: - - script.execute: change_to_desired_mode - -################################################################################################# -# SCRIPT COMPONENT -# https://esphome.io/components/script.html -################################################################################################# -script: - # Core: evaluate and drive the relay (with enforced min OFF window) - - id: evaluate_relay_state - then: - - lambda: |- - if (id(mode_changing)) { - // do not fight the pulser - return; - } - // Determine target state - bool target_on = false; - int mode = id(operation_mode); - - if (mode == 3) { // BOOST - target_on = true; - } else if (mode == 0) { // OFF - target_on = false; - } else if (mode == 1) { // ON - target_on = true; - } else if (mode == 2) { // TIMER - bool should_on = false; - if (id(current_mins) >= id(morning_on) && id(current_mins) < id(morning_off)) - should_on = true; - if (id(current_mins) >= id(evening_on) && id(current_mins) < id(evening_off)) - should_on = true; - target_on = should_on; - } - - // Enforce minimum OFF time when turning ON normally (not during color pulses) - if (target_on) { - uint32_t delta = millis() - id(last_off_ms); - if (delta < (uint32_t) id(reset_window_ms)) { - // Still inside OFF window: wait; schedule a recheck if not already - if (!id(recheck_pending)) { - id(enforce_off_guard).execute(); - } - return; - } else { - id(relay).turn_on(); - return; - } - } else { - id(relay).turn_off(); - return; - } - - # Poll until OFF window elapsed, then re-evaluate (does not toggle the relay itself) - - id: enforce_off_guard - mode: restart - then: - - lambda: |- - id(recheck_pending) = true; - - while: - condition: - lambda: |- - if (id(mode_changing)) return false; // abort if color change starts - uint32_t delta = millis() - id(last_off_ms); - return delta < (uint32_t) id(reset_window_ms); - then: - - delay: 200ms - - lambda: |- - id(recheck_pending) = false; - - script.execute: evaluate_relay_state - - # Long reset to Mode 1 - - id: reset_to_mode1 - mode: restart - then: - - switch.turn_off: relay - - delay: ${mode_reset_off} - - switch.turn_on: relay - - delay: ${mode_pulse_on} - - lambda: |- - id(current_mode) = 1; - - # Change to desired_mode, robust from OFF and without wrap on lower targets - # Change to desired_mode, robust from OFF and without wrap on lower targets - - id: change_to_desired_mode - mode: restart - then: - # If relay is currently OFF, establish a known starting point - - if: - condition: - lambda: 'return !id(relay).state;' - then: - - lambda: |- - uint32_t delta = millis() - id(last_off_ms); - // If OFF < reset window, enforce a true reset first for predictable start - if (delta < (uint32_t) id(reset_window_ms)) { - id(reset_to_mode1).execute(); - } - - if: - condition: - lambda: 'return !id(relay).state;' - then: - - switch.turn_on: relay - - delay: ${mode_prime_on} - - lambda: |- - // If we turned ON after ≥ reset window, hardware is at Mode 1 - if ((millis() - id(last_off_ms)) >= (uint32_t) id(reset_window_ms)) { - id(current_mode) = 1; - } - - # If target is below current, do a long reset to Mode 1, then proceed - - if: - condition: - lambda: |- - return id(desired_mode) < id(current_mode); - then: - - script.execute: reset_to_mode1 - - script.wait: reset_to_mode1 - - # If already exactly at target, ensure ON and finish - - if: - condition: - lambda: 'return id(desired_mode) == id(current_mode);' - then: - - switch.turn_on: relay - - lambda: |- - id(mode_changing) = false; - - # Step forward until we reach desired (ignored modes still counted) - - while: - condition: - lambda: |- - // Allow immediate cancellation if OFF is selected mid-flight - return id(mode_changing) && (id(current_mode) < id(desired_mode)); - then: - - switch.turn_off: relay - - delay: ${mode_pulse_off} - - switch.turn_on: relay - - delay: ${mode_pulse_on} - - lambda: |- - id(current_mode)++; - - # End: make sure we leave the light ON and clear the guard - - switch.turn_on: relay - - lambda: |- - id(mode_changing) = false; - - -################################################################################################# -# INTERVAL COMPONENT -# https://esphome.io/components/interval.html -################################################################################################# -# Interval: bumps time (even if no SNTP), then calls the script to evaluate relay state -interval: - - interval: "1min" - then: - - lambda: |- - // — update current_mins via SNTP or fallback - if (!id(sntp_time).now().is_valid()) { - id(current_mins)++; - if (id(current_mins) >= 1440) id(current_mins) = 0; - } else { - auto now = id(sntp_time).now(); - id(current_mins) = now.hour * 60 + now.minute; - } - - // — if in BOOST, advance boost_timer and expire when done - if (id(operation_mode) == 3) { - id(boost_timer)++; - if (id(boost_timer) >= id(boost_duration)) { - id(operation_mode) = 2; - //id(mqtt_client).publish("${mqtt_timer_topic}/operation", "TIMER"); - } - } - - script.execute: evaluate_relay_state diff --git a/esphome/esp-bed1bedsideathombulb.yaml b/esphome/esp-bed1bedsideathombulb.yaml new file mode 100644 index 0000000..baca8a9 --- /dev/null +++ b/esphome/esp-bed1bedsideathombulb.yaml @@ -0,0 +1,890 @@ +########################################################################################## +########################################################################################## +# MASTER BEDROOM BEDSIDE LAMP BULB +# File: esp-bed1bedsideathombulb.yaml +# +# v1.1 - 2026-02-21 Correct GPIO mapping + use RGBCT platform (Athom LB01) +# v1.0 - 2026-02-21 Initial version (merged from preferred dimmer + Athom GPIO map) +# +# Hardware: Athom 15W E27 Bulb LB01 (RGB + CT Brightness + CT control) +# +# NOTES +# - This keeps the "preferred control" ramping / min-max / watchdog / restore logic. +# - All ramping and "Output Set (0-100)" controls FORCE the bulb into Warm White endpoint +# (so the dimming behavior mainly drives the CT Brightness channel at 3000K). +# - You can still control full RGB and CT from HA using the normal light entity. +# - Max On watchdog turns the whole bulb OFF at the time limit (all channels). +########################################################################################## +########################################################################################## + +########################################################################################## +# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS +########################################################################################## +substitutions: + # Device Naming + device_name: "esp-bed1bedsideathombulb" # yaml file should be device_name.yaml + friendly_name: "Master Bedroom Bedside Lamp Bulb" + description_comment: "Master Bedroom Bedside lamp using RGB-CW 15W Bulb" + device_area: "Master Bedroom" + + # Project Naming + project_name: "Athom 15W E27 Bulb LB01 RGBCT.Athom" + project_version: "v1.1" + + # Passwords & Secrets (Unfortunately, you can't use substitutions inside secret names) + #api_key: !secret esp-api_key + api_key: !secret esp-athombulbflasher_api_key + #ota_pass: !secret esp-ota_pass + ota_pass: !secret esp-athombulbflasher_ota_pass + static_ip_address: !secret esp-bed1bedsideathombulb_ip + + # If we are changing IP addresses, you must update the current IP address here, otherwise it remains + #current_ip_address: 192.168.2.121 + current_ip_address: ${static_ip_address} + + # MQTT LOCAL Controls (optional local control method) + mqtt_local_device_name: "bed1-bedsidelamp" + mqtt_local_command_main_topic: !secret mqtt_command_main_topic + mqtt_local_status_main_topic: !secret mqtt_status_main_topic + mqtt_local_command_topic: "${mqtt_local_command_main_topic}/${mqtt_local_device_name}" + mqtt_local_status_topic: "${mqtt_local_status_main_topic}/${mqtt_local_device_name}" + mqtt_local_device_command_ON: "ON" + mqtt_local_device_command_OFF: "OFF" + + # Device Specific Settings + log_level: "NONE" # NONE, ERROR, WARN, INFO, DEBUG, VERBOSE, VERY_VERBOSE + update_interval: "20s" + led_gamma: "1.2" + minimum_led_output: "1" # % If at this value or below, we'll switch it completely off + maximum_led_output: "90" # % Hard cap + max_on_default_hours: "24" # 0 = no automatic turn-off + pwm_frequency: "1000" # Hz + + # Warm endpoint for this bulb (spec range is 3000K-6000K) + bulb_warm_kelvin: "3000K" + bulb_cold_kelvin: "6000K" + + # RGBCT endpoints must be given as mireds in the rgbct component + # 6000K = 166.7 mireds, 3000K = 333.3 mireds + bulb_cold_mireds: "167 mireds" + bulb_warm_mireds: "333 mireds" + + # Athom LB01 GPIO mapping (correct) + bulb_red_pin: GPIO4 + bulb_green_pin: GPIO12 + bulb_blue_pin: GPIO14 + bulb_ct_brightness_pin: GPIO5 + bulb_ct_pin: GPIO13 + +########################################################################################## +# 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 + file: common/api_common_noencryption.yaml + vars: + local_api_key: "${api_key}" + + #### MQTT #### + common_mqtt: !include + file: common/mqtt_common.yaml + vars: + local_device_name: "${device_name}" + + #### DIAGNOSTICS Sensors #### + diag_basic: !include common/include_basic_diag_sensors.yaml + diag_more: !include common/include_more_diag_sensors.yaml + diag_debug: !include common/include_debug_diag_sensors.yaml + +########################################################################################## +# ESPHome CORE CONFIGURATION +########################################################################################## +esphome: + name: "${device_name}" + friendly_name: "${friendly_name}" + comment: "${description_comment}" + area: "${device_area}" + project: + name: "${project_name}" + version: "${project_version}" + + on_boot: + # Run later so everything is initialised before we touch states. + priority: 200 + then: + # Keep the HA dropdown in sync with the stored mode + - lambda: |- + switch (id(restart_mode)) { + case 0: id(restart_action).publish_state("Fade up to full"); break; + case 1: id(restart_action).publish_state("Restore Brightness"); break; + case 2: default: id(restart_action).publish_state("Remain Off"); break; + } + + # Mode 0: Fade up to full (obeys fade settings; forces Warm endpoint) + - if: + condition: + lambda: 'return id(restart_mode) == 0;' + then: + - lambda: 'id(ramp_switch_target_on) = true;' + - script.execute: ramp_on_script + + # Mode 1: Restore Brightness (deferred; forces Warm endpoint) + - if: + condition: + lambda: 'return id(restart_mode) == 1;' + then: + - script.execute: deferred_restore_brightness + + # Mode 2: Remain Off + - if: + condition: + lambda: 'return id(restart_mode) == 2;' + then: + - script.stop: ramp_on_script + - script.stop: ramp_off_script + - lambda: 'id(ramp_switch_target_on) = false;' + - light.turn_off: + id: bulb_light + transition_length: 0s + + # Boot complete: allow OFF handlers again + - lambda: 'id(booting) = false;' + +########################################################################################## +# ESP PLATFORM AND FRAMEWORK +########################################################################################## +esp8266: + board: esp8285 + restore_from_flash: true + +mdns: + disabled: true + +preferences: + flash_write_interval: 2min + +########################################################################################## +# GLOBAL VARIABLES +########################################################################################## +globals: + - id: min_brightness_pct + type: int + restore_value: true + initial_value: "${minimum_led_output}" + + - id: max_brightness_pct + type: int + restore_value: false + initial_value: "${maximum_led_output}" + + - id: max_on_hours + type: int + restore_value: true + initial_value: "${max_on_default_hours}" + + - id: led_gamma_f + type: float + restore_value: false + initial_value: ${led_gamma} + + - id: ramp_up_ms + type: int + restore_value: true + initial_value: "5000" + + - id: ramp_down_ms + type: int + restore_value: true + initial_value: "10000" + + - id: restart_mode + type: int + restore_value: true + initial_value: "0" # 0=Fade full, 1=Restore brightness, 2=Remain off + + - id: ramp_switch_target_on + type: bool + restore_value: true + initial_value: "false" + + - id: suppress_slider_sync + type: bool + restore_value: false + initial_value: "false" + + - id: last_brightness_pct + type: float + restore_value: true + initial_value: "0.0" + + - id: last_set_pos + type: int + restore_value: false + initial_value: "-1" + + - id: last_ramp_ms + type: int + restore_value: false + initial_value: "0" + + - id: restore_target_pct + type: float + restore_value: false + initial_value: "0.0" + + - id: is_ramping + type: bool + restore_value: false + initial_value: "false" + + - id: booting + type: bool + restore_value: false + initial_value: "true" + + - id: ramping_for_off + type: bool + restore_value: false + initial_value: "false" + +########################################################################################## +# LOGGER +########################################################################################## +logger: + level: "${log_level}" + baud_rate: 0 + +########################################################################################## +# MQTT COMMANDS (adds device-specific triggers to common MQTT) +########################################################################################## +mqtt: + on_message: + - topic: "${mqtt_local_command_topic}/light/set" + payload: "${mqtt_local_device_command_ON}" + then: + - switch.turn_on: bulb_ramp_switch + + - topic: "${mqtt_local_command_topic}/light/set" + payload: "${mqtt_local_device_command_OFF}" + then: + - switch.turn_off: bulb_ramp_switch + +########################################################################################## +# SWITCH +########################################################################################## +switch: + - platform: template + id: bulb_ramp_switch + name: "${friendly_name} Fade Up-Down" + icon: mdi:lightbulb-on-outline + lambda: |- + return id(ramp_switch_target_on); + turn_on_action: + - lambda: |- + id(ramp_switch_target_on) = true; + - script.stop: ramp_off_script + - script.execute: ramp_on_script + turn_off_action: + - lambda: |- + id(ramp_switch_target_on) = false; + - script.stop: ramp_on_script + - script.execute: ramp_off_script + +########################################################################################## +# BUTTON +########################################################################################## +button: + - platform: template + id: fade_up_button + name: "${friendly_name} Fade Up" + icon: mdi:arrow-up-bold + on_press: + - lambda: |- + id(ramp_switch_target_on) = true; + id(bulb_ramp_switch).publish_state(true); + - script.stop: ramp_off_script + - script.execute: ramp_on_script + + - platform: template + id: fade_down_button + name: "${friendly_name} Fade Down" + icon: mdi:arrow-down-bold + on_press: + - lambda: |- + id(ramp_switch_target_on) = false; + id(bulb_ramp_switch).publish_state(false); + - script.stop: ramp_on_script + - script.execute: ramp_off_script + + - platform: template + id: fade_stop_button + name: "${friendly_name} Fade Stop" + icon: mdi:pause + on_press: + - script.stop: ramp_on_script + - script.stop: ramp_off_script + - lambda: |- + id(ramping_for_off) = false; + id(is_ramping) = false; + + const auto &cv = id(bulb_light).current_values; + if (cv.is_on()) { + auto call = id(bulb_light).make_call(); + call.set_state(true); + call.set_brightness(cv.get_brightness()); + call.set_transition_length(0); + call.perform(); + } + + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + id(last_brightness_pct) = pct; + } + +########################################################################################## +# SELECT +########################################################################################## +select: + - platform: template + id: restart_action + name: "${friendly_name} Restart Action" + icon: mdi:restart + optimistic: true + options: + - "Fade up to full" + - "Restore Brightness" + - "Remain Off" + initial_option: "Restore Brightness" + set_action: + - lambda: |- + if (x == "Fade up to full") { + id(restart_mode) = 0; + } else if (x == "Restore Brightness") { + id(restart_mode) = 1; + } else { + id(restart_mode) = 2; + } + +########################################################################################## +# SENSORS +########################################################################################## +sensor: + - platform: template + id: bulb_output_pct + name: "${friendly_name} Output (%)" + unit_of_measurement: "%" + icon: mdi:percent + accuracy_decimals: 0 + update_interval: 2s + lambda: |- + const auto &cv = id(bulb_light).current_values; + return cv.is_on() ? (float) (int)(cv.get_brightness() * 100.0f + 0.5f) : 0.0f; + + on_value: + then: + - lambda: |- + if (!id(suppress_slider_sync)) { + float actual = x; + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + + float pos = (actual <= 0.0f) ? 0.0f : ((actual - minp) * 100.0f / (maxp - minp)); + if (pos < 0.0f) pos = 0.0f; + if (pos > 100.0f) pos = 100.0f; + int pos_i = (int) floorf(pos + 0.5f); + + if (pos_i != id(last_set_pos)) { + id(last_set_pos) = pos_i; + id(led_output_set_pct).publish_state(pos_i); + } + } + + - platform: template + id: bulb_output_pwm_pct + name: "${friendly_name} Output PWM (%)" + unit_of_measurement: "%" + icon: mdi:square-wave + accuracy_decimals: 1 + update_interval: 2s + lambda: |- + const auto &cv = id(bulb_light).current_values; + if (!cv.is_on()) return 0.0f; + const float lin = cv.get_brightness(); + float pwm = powf(lin, id(led_gamma_f)); + if (pwm < 0.0f) pwm = 0.0f; + if (pwm > 1.0f) pwm = 1.0f; + return pwm * 100.0f; + +########################################################################################## +# OUTPUTS: 5 x PWM channels for the Athom RGBCT bulb +########################################################################################## +output: + - platform: esp8266_pwm + id: output_red + pin: ${bulb_red_pin} + frequency: ${pwm_frequency} + + - platform: esp8266_pwm + id: output_green + pin: ${bulb_green_pin} + frequency: ${pwm_frequency} + + - platform: esp8266_pwm + id: output_blue + pin: ${bulb_blue_pin} + frequency: ${pwm_frequency} + + # White brightness (CT Brightness) + - platform: esp8266_pwm + id: output_white_brightness + pin: ${bulb_ct_brightness_pin} + frequency: ${pwm_frequency} + + # Color temperature control (CT), inverted + - platform: esp8266_pwm + id: output_ct + pin: ${bulb_ct_pin} + frequency: ${pwm_frequency} + inverted: true + +########################################################################################## +# LIGHT: RGBCT (correct for this bulb) +########################################################################################## +light: + - platform: rgbct + id: bulb_light + name: "${friendly_name}" + icon: mdi:lightbulb + + red: output_red + green: output_green + blue: output_blue + white_brightness: output_white_brightness + color_temperature: output_ct + + cold_white_color_temperature: ${bulb_cold_mireds} + warm_white_color_temperature: ${bulb_warm_mireds} + + # Safety: do not allow RGB and CT whites simultaneously + color_interlock: true + + # Important: keep OFF at boot to avoid instant restore flashes; boot script handles behavior. + restore_mode: ALWAYS_OFF + + default_transition_length: 2s + gamma_correct: "${led_gamma}" + + on_turn_on: + - mqtt.publish: + topic: "${mqtt_local_status_topic}/light/state" + payload: "${mqtt_local_device_command_ON}" + retain: true + - lambda: |- + if (!id(ramping_for_off)) { + id(ramp_switch_target_on) = true; + } + - script.stop: max_on_watchdog + - if: + condition: + lambda: 'return id(max_on_hours) > 0;' + then: + - script.execute: max_on_watchdog + + on_turn_off: + - if: + condition: + lambda: 'return !id(booting);' + then: + - mqtt.publish: + topic: "${mqtt_local_status_topic}/light/state" + payload: "${mqtt_local_device_command_OFF}" + retain: true + - lambda: 'id(ramp_switch_target_on) = false;' + - script.stop: max_on_watchdog + - lambda: |- + id(last_brightness_pct) = 0.0f; + id(last_set_pos) = 0; + id(led_output_set_pct).publish_state(0); + + # Cap brightness to max_brightness_pct without a visible step + on_state: + - lambda: |- + const float cap = id(max_brightness_pct) / 100.0f; + const auto &cv = id(bulb_light).current_values; + if (!cv.is_on()) return; + const float b = cv.get_brightness(); + if (b <= cap + 0.001f) return; + + auto call = id(bulb_light).make_call(); + call.set_state(true); + call.set_brightness(cap); + call.set_transition_length(0); + call.perform(); + +########################################################################################## +# NUMBER +########################################################################################## +number: + - platform: template + id: cfg_ramp_up_s + name: "${friendly_name} Fade Up Time (s)" + entity_category: config + unit_of_measurement: s + icon: mdi:timer-sand + mode: slider + min_value: 0 + max_value: 60 + step: 1 + lambda: |- + return (float) id(ramp_up_ms) / 1000.0f; + set_action: + - lambda: |- + int secs = (int) floorf(x + 0.5f); + if (secs < 0) secs = 0; + if (secs > 60) secs = 60; + id(ramp_up_ms) = secs * 1000; + id(cfg_ramp_up_s).publish_state((float) secs); + + - platform: template + id: cfg_ramp_down_s + name: "${friendly_name} Fade Down Time (s)" + entity_category: config + unit_of_measurement: s + icon: mdi:timer-sand-complete + mode: slider + min_value: 0 + max_value: 60 + step: 1 + lambda: |- + return (float) id(ramp_down_ms) / 1000.0f; + set_action: + - lambda: |- + int secs = (int) floorf(x + 0.5f); + if (secs < 0) secs = 0; + if (secs > 60) secs = 60; + id(ramp_down_ms) = secs * 1000; + id(cfg_ramp_down_s).publish_state((float) secs); + + # This is the preferred everyday dimmer slider (FORCES Warm endpoint) + - platform: template + id: led_output_set_pct + name: "${friendly_name} Output Set (0-100)" + icon: mdi:tune + mode: slider + min_value: 0 + max_value: 100 + step: 1 + lambda: |- + const auto &cv = id(bulb_light).current_values; + float actual = cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f; + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + if (actual <= 0.0f) return 0.0f; + float pos = (actual - minp) * 100.0f / (maxp - minp); + if (pos < 0.0f) pos = 0.0f; + if (pos > 100.0f) pos = 100.0f; + return floorf(pos + 0.5f); + set_action: + - if: + condition: + lambda: 'return x <= 0.0f;' + then: + - lambda: 'id(suppress_slider_sync) = true;' + - script.stop: ramp_on_script + - script.stop: ramp_off_script + - light.turn_off: + id: bulb_light + transition_length: 200ms + - lambda: |- + id(ramp_switch_target_on) = false; + id(led_output_set_pct).publish_state(0); + id(last_brightness_pct) = 0.0f; + id(last_set_pos) = 0; + - delay: 400ms + - lambda: 'id(suppress_slider_sync) = false;' + else: + - lambda: |- + id(suppress_slider_sync) = true; + float pos = x; + if (pos < 1.0f) pos = 1.0f; + if (pos > 100.0f) pos = 100.0f; + id(led_output_set_pct).publish_state((int) floorf(pos + 0.5f)); + - script.stop: ramp_off_script + - script.stop: ramp_on_script + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda |- + float pos = id(led_output_set_pct).state; + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + float out_pct = minp + (pos * (maxp - minp) / 100.0f); + if (out_pct > maxp) out_pct = maxp; + return out_pct / 100.0f; + transition_length: 250ms + - lambda: |- + id(ramp_switch_target_on) = true; + float pos = id(led_output_set_pct).state; + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + float out_pct = minp + (pos * (maxp - minp) / 100.0f); + if (out_pct > maxp) out_pct = maxp; + id(last_brightness_pct) = out_pct; + - delay: 400ms + - lambda: 'id(suppress_slider_sync) = false;' + + - platform: template + id: cfg_max_on_hours + name: "${friendly_name} Max On (h)" + entity_category: config + unit_of_measurement: h + icon: mdi:timer-cog + mode: slider + min_value: 0 + max_value: 48 + step: 1 + lambda: |- + return (float) id(max_on_hours); + set_action: + - lambda: |- + int hrs = (int) x; + if (hrs < 0) hrs = 0; + if (hrs > 48) hrs = 48; + id(max_on_hours) = hrs; + id(cfg_max_on_hours).publish_state((float) hrs); + - if: + condition: + lambda: 'return id(bulb_light).current_values.is_on();' + then: + - script.stop: max_on_watchdog + - if: + condition: + lambda: 'return id(max_on_hours) > 0;' + then: + - script.execute: max_on_watchdog + +########################################################################################## +# SCRIPTS +########################################################################################## +script: + - id: ramp_on_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' + - script.stop: ramp_off_script + + - if: + condition: + lambda: |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); + then: + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: 80ms + + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda 'return id(max_brightness_pct) / 100.0f;' + transition_length: !lambda |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + const float cap = id(max_brightness_pct) / 100.0f; + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } + + float curr = cv.is_on() ? cv.get_brightness() : 0.0f; + + if (curr + 0.0005f < floor) { + id(last_ramp_ms) = id(ramp_up_ms); + return (uint32_t) id(last_ramp_ms); + } + if (curr > cap) curr = cap; + if (curr < floor) curr = floor; + + float frac = (cap - curr) / (cap - floor); + if (frac < 0.0f) frac = 0.0f; + if (frac > 1.0f) frac = 1.0f; + + id(last_ramp_ms) = (int) (id(ramp_up_ms) * frac); + return (uint32_t) id(last_ramp_ms); + + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - lambda: |- + id(is_ramping) = false; + const auto &cv = id(bulb_light).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + id(last_brightness_pct) = pct; + } + + - id: ramp_to_target_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' + - script.stop: ramp_off_script + + - if: + condition: + lambda: |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); + then: + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: 80ms + + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda |- + float cap_pct = id(restore_target_pct); + float maxp = (float) id(max_brightness_pct); + if (cap_pct > maxp) cap_pct = maxp; + return cap_pct / 100.0f; + transition_length: !lambda |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + + float cap = id(restore_target_pct) / 100.0f; + const float maxp = id(max_brightness_pct) / 100.0f; + if (cap > maxp) cap = maxp; + + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } + + float curr = cv.is_on() ? cv.get_brightness() : 0.0f; + + if (curr + 0.0005f < floor) { + id(last_ramp_ms) = id(ramp_up_ms); + return (uint32_t) id(last_ramp_ms); + } + + if (curr > cap) curr = cap; + if (curr < floor) curr = floor; + + float frac = (cap - curr) / (cap - floor); + if (frac < 0.0f) frac = 0.0f; + if (frac > 1.0f) frac = 1.0f; + + id(last_ramp_ms) = (int) (id(ramp_up_ms) * frac); + return (uint32_t) id(last_ramp_ms); + + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - lambda: |- + id(is_ramping) = false; + const auto &cv = id(bulb_light).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + id(last_brightness_pct) = pct; + } + + - id: ramp_off_script + mode: restart + then: + - lambda: |- + id(is_ramping) = true; + id(ramping_for_off) = true; + - script.stop: ramp_on_script + + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: !lambda |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + float curr = cv.get_brightness(); + if (curr < floor) curr = floor; + + const float denom = (1.0f - floor); + if (denom <= 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } + + float frac = (curr - floor) / denom; + if (frac < 0.0f) frac = 0.0f; + if (frac > 1.0f) frac = 1.0f; + + id(last_ramp_ms) = (int) (id(ramp_down_ms) * frac); + return (uint32_t) id(last_ramp_ms); + + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - light.turn_off: + id: bulb_light + transition_length: 150ms + - delay: 150ms + + - lambda: |- + id(is_ramping) = false; + id(ramping_for_off) = false; + id(last_brightness_pct) = 0.0f; + id(last_set_pos) = 0; + id(led_output_set_pct).publish_state(0); + + - id: max_on_watchdog + mode: restart + then: + - if: + condition: + lambda: 'return id(max_on_hours) > 0;' + then: + - delay: !lambda 'return (uint32_t) (id(max_on_hours) * 3600000UL);' + - if: + condition: + lambda: 'return id(bulb_light).current_values.is_on();' + then: + - lambda: |- + id(ramp_switch_target_on) = false; + id(bulb_ramp_switch).publish_state(false); + - script.stop: ramp_on_script + - script.execute: ramp_off_script + + - id: deferred_restore_brightness + mode: restart + then: + - delay: 5s + - lambda: |- + float target = id(last_brightness_pct); + + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (target > 0.0f && target < minp) target = minp; + if (target > maxp) target = maxp; + + id(restore_target_pct) = target; + + - if: + condition: + lambda: 'return id(restore_target_pct) > 0.5f;' + then: + - lambda: |- + id(ramp_switch_target_on) = true; + id(suppress_slider_sync) = true; + - script.stop: ramp_off_script + - script.execute: ramp_to_target_script + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - lambda: 'id(suppress_slider_sync) = false;' + else: + - lambda: 'id(ramp_switch_target_on) = false;' + - light.turn_off: + id: bulb_light + transition_length: 0s \ No newline at end of file diff --git a/esphome/esp-bedrm1arlecbulb.yaml b/esphome/esp-bedrm1arlecbulb.yaml new file mode 100644 index 0000000..a063664 --- /dev/null +++ b/esphome/esp-bedrm1arlecbulb.yaml @@ -0,0 +1,1018 @@ +########################################################################################## +########################################################################################## +# MASTER BEDROOM BEDSIDE LAMP BULB +# File: esp-bedrm1arlecbulb.yaml +# +# v1.1 - 2026-02-21 Add "Jump Fade Up/Down" switch + configurable Jump Fade (%) +# v1.0 - 2026-02-21 Initial version (ported from Athom bulb YAML to Arlec GLD120HA) +# +# Hardware: Arlec Grid Connect Smart LED Globe RGB (GLD120HA) - B22 +# RGB + Warm White + Cold White (RGBWW, 5x PWM channels) +# +# NOTES +# - This keeps the "preferred control" ramping / min-max / watchdog / restore logic. +# - All ramping and "Output Set (0-100)" controls FORCE the bulb into Warm White endpoint +# (so everyday dimming uses the Warm White channel only, not mixed CT). +# - You can still control full RGB and CT from HA using the normal light entity. +# - Max On watchdog turns the whole bulb OFF at the time limit (all channels). +# - Jump Fade switch behavior: +# - ON: jump to (jump% of max cap), then fade up to full (normal fade-up). +# - OFF: jump down to (current - current*jump%), then fade down to OFF (normal fade-down). +########################################################################################## +########################################################################################## + +########################################################################################## +# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS +########################################################################################## +substitutions: + # Device Naming + device_name: "esp-bedrm1arlecbulb" # yaml file should be device_name.yaml + friendly_name: "Master Bedroom Bedside Lamp Arlec Bulb" + description_comment: "Bedside lightbulb using gridconnect Arlec RGB bulb" + device_area: "Master Bedroom" + + # Project Naming + project_name: "Grid Connect GLD120HA RGBWW.Arlec" + project_version: "v1.1" + + # Passwords & Secrets (Unfortunately, you can't use substitutions inside secret names) + # Re-using your existing bulb flasher secrets (adjust if you prefer per-device secrets) + api_key: !secret esp-api_key + ota_pass: !secret esp-ota_pass + static_ip_address: !secret esp-bedrm1arlecbulb_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} + + # MQTT LOCAL Controls (optional local control method) + mqtt_local_device_name: "bed1-bedsidelamp" + mqtt_local_command_main_topic: !secret mqtt_command_main_topic + mqtt_local_status_main_topic: !secret mqtt_status_main_topic + mqtt_local_command_topic: "${mqtt_local_command_main_topic}/${mqtt_local_device_name}" + mqtt_local_status_topic: "${mqtt_local_status_main_topic}/${mqtt_local_device_name}" + mqtt_local_device_command_ON: "ON" + mqtt_local_device_command_OFF: "OFF" + + # Device Specific Settings + log_level: "NONE" # NONE, ERROR, WARN, INFO, DEBUG, VERBOSE, VERY_VERBOSE + update_interval: "20s" + led_gamma: "2.8" + minimum_led_output: "1" # % If at this value or below, we'll switch it completely off + maximum_led_output: "80" # % Hard cap + max_on_default_hours: "24" # 0 = no automatic turn-off + pwm_frequency: "1000" # Hz + + # Warm/cold endpoints (reported range around 3000K-5700K) + bulb_warm_kelvin: "3000 K" + bulb_cold_kelvin: "5700 K" + + # Arlec GLD120HA GPIO mapping (PWM) + bulb_red_pin: GPIO4 + bulb_green_pin: GPIO12 + bulb_blue_pin: GPIO14 + bulb_warm_white_pin: GPIO13 + bulb_cold_white_pin: GPIO5 + +########################################################################################## +# 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}" + + #### DIAGNOSTICS Sensors #### + diag_basic: !include common/include_basic_diag_sensors.yaml + diag_more: !include common/include_more_diag_sensors.yaml + #diag_debug: !include common/include_debug_diag_sensors.yaml + +########################################################################################## +# ESPHome CORE CONFIGURATION +########################################################################################## +esphome: + name: "${device_name}" + friendly_name: "${friendly_name}" + comment: "${description_comment}" + area: "${device_area}" + project: + name: "${project_name}" + version: "${project_version}" + + on_boot: + # Run later so everything is initialised before we touch states. + priority: 200 + then: + # Keep the HA dropdown in sync with the stored mode + - lambda: |- + switch (id(restart_mode)) { + case 0: id(restart_action).publish_state("Fade up to full"); break; + case 1: id(restart_action).publish_state("Restore Brightness"); break; + case 2: default: id(restart_action).publish_state("Remain Off"); break; + } + + # Mode 0: Fade up to full (obeys fade settings; forces Warm endpoint) + - if: + condition: + lambda: 'return id(restart_mode) == 0;' + then: + - lambda: 'id(ramp_switch_target_on) = true;' + - script.execute: ramp_on_script + + # Mode 1: Restore Brightness (deferred; forces Warm endpoint) + - if: + condition: + lambda: 'return id(restart_mode) == 1;' + then: + - script.execute: deferred_restore_brightness + + # Mode 2: Remain Off + - if: + condition: + lambda: 'return id(restart_mode) == 2;' + then: + - script.stop: ramp_on_script + - script.stop: ramp_off_script + - lambda: 'id(ramp_switch_target_on) = false;' + - light.turn_off: + id: bulb_light + transition_length: 0s + + # Boot complete: allow OFF handlers again + - lambda: 'id(booting) = false;' + +########################################################################################## +# ESP PLATFORM AND FRAMEWORK +########################################################################################## +esp8266: + board: esp01_1m + restore_from_flash: true + +mdns: + disabled: true + +preferences: + flash_write_interval: 1min + +########################################################################################## +# GLOBAL VARIABLES +########################################################################################## +globals: + - id: min_brightness_pct + type: int + restore_value: true + initial_value: "${minimum_led_output}" + + - id: max_brightness_pct + type: int + restore_value: false + initial_value: "${maximum_led_output}" + + - id: max_on_hours + type: int + restore_value: true + initial_value: "${max_on_default_hours}" + + - id: led_gamma_f + type: float + restore_value: false + initial_value: ${led_gamma} + + - id: ramp_up_ms + type: int + restore_value: true + initial_value: "5000" + + - id: ramp_down_ms + type: int + restore_value: true + initial_value: "20000" + + - id: restart_mode + type: int + restore_value: true + initial_value: "0" # 0=Fade full, 1=Restore brightness, 2=Remain off + + - id: ramp_switch_target_on + type: bool + restore_value: true + initial_value: "false" + + - id: suppress_slider_sync + type: bool + restore_value: false + initial_value: "false" + + - id: last_brightness_pct + type: float + restore_value: true + initial_value: "0.0" + + - id: last_set_pos + type: int + restore_value: false + initial_value: "-1" + + - id: last_ramp_ms + type: int + restore_value: false + initial_value: "0" + + - id: restore_target_pct + type: float + restore_value: false + initial_value: "0.0" + + - id: is_ramping + type: bool + restore_value: false + initial_value: "false" + + - id: booting + type: bool + restore_value: false + initial_value: "true" + + - id: ramping_for_off + type: bool + restore_value: false + initial_value: "false" + + # Jump Fade percentage (default 25%) + - id: jump_fade_pct + type: int + restore_value: true + initial_value: "15" + +########################################################################################## +# LOGGER +########################################################################################## +logger: + level: "${log_level}" + baud_rate: 0 + +########################################################################################## +# MQTT COMMANDS (adds device-specific triggers to common MQTT) +########################################################################################## +mqtt: + on_message: + - topic: "${mqtt_local_command_topic}/light/set" + payload: "${mqtt_local_device_command_ON}" + then: + - switch.turn_on: bulb_ramp_switch + + - topic: "${mqtt_local_command_topic}/light/set" + payload: "${mqtt_local_device_command_OFF}" + then: + - switch.turn_off: bulb_ramp_switch + +########################################################################################## +# SWITCH +########################################################################################## +switch: + - platform: template + id: bulb_ramp_switch + name: "${friendly_name} Fade Up-Down" + icon: mdi:lightbulb-on-outline + lambda: |- + return id(ramp_switch_target_on); + turn_on_action: + - lambda: |- + id(ramp_switch_target_on) = true; + - script.stop: ramp_off_script + - script.execute: ramp_on_script + turn_off_action: + - lambda: |- + id(ramp_switch_target_on) = false; + - script.stop: ramp_on_script + - script.execute: ramp_off_script + + - platform: template + id: bulb_jump_ramp_switch + name: "${friendly_name} Jump Fade Up-Down" + icon: mdi:lightbulb-auto + lambda: |- + return id(ramp_switch_target_on); + turn_on_action: + - lambda: |- + id(ramp_switch_target_on) = true; + - script.execute: jump_ramp_on_script + turn_off_action: + - lambda: |- + id(ramp_switch_target_on) = false; + - script.execute: jump_ramp_off_script + +########################################################################################## +# BUTTON +########################################################################################## +button: + - platform: template + id: fade_up_button + name: "${friendly_name} Fade Up" + icon: mdi:arrow-up-bold + on_press: + - lambda: |- + id(ramp_switch_target_on) = true; + id(bulb_ramp_switch).publish_state(true); + - script.stop: ramp_off_script + - script.execute: ramp_on_script + + - platform: template + id: fade_down_button + name: "${friendly_name} Fade Down" + icon: mdi:arrow-down-bold + on_press: + - lambda: |- + id(ramp_switch_target_on) = false; + id(bulb_ramp_switch).publish_state(false); + - script.stop: ramp_on_script + - script.execute: ramp_off_script + + - platform: template + id: fade_stop_button + name: "${friendly_name} Fade Stop" + icon: mdi:pause + on_press: + - script.stop: ramp_on_script + - script.stop: ramp_off_script + - lambda: |- + id(ramping_for_off) = false; + id(is_ramping) = false; + + const auto &cv = id(bulb_light).current_values; + if (cv.is_on()) { + auto call = id(bulb_light).make_call(); + call.set_state(true); + call.set_brightness(cv.get_brightness()); + call.set_transition_length(0); + call.perform(); + } + + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + id(last_brightness_pct) = pct; + } + +########################################################################################## +# SELECT +########################################################################################## +select: + - platform: template + id: restart_action + name: "${friendly_name} Restart Action" + icon: mdi:restart + optimistic: true + options: + - "Fade up to full" + - "Restore Brightness" + - "Remain Off" + initial_option: "Restore Brightness" + set_action: + - lambda: |- + if (x == "Fade up to full") { + id(restart_mode) = 0; + } else if (x == "Restore Brightness") { + id(restart_mode) = 1; + } else { + id(restart_mode) = 2; + } + +########################################################################################## +# SENSORS +########################################################################################## +sensor: + - platform: template + id: bulb_output_pct + name: "${friendly_name} Output (%)" + unit_of_measurement: "%" + icon: mdi:percent + accuracy_decimals: 0 + update_interval: 2s + lambda: |- + const auto &cv = id(bulb_light).current_values; + return cv.is_on() ? (float) (int)(cv.get_brightness() * 100.0f + 0.5f) : 0.0f; + + on_value: + then: + - lambda: |- + if (!id(suppress_slider_sync)) { + float actual = x; + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + + float pos = (actual <= 0.0f) ? 0.0f : ((actual - minp) * 100.0f / (maxp - minp)); + if (pos < 0.0f) pos = 0.0f; + if (pos > 100.0f) pos = 100.0f; + int pos_i = (int) floorf(pos + 0.5f); + + if (pos_i != id(last_set_pos)) { + id(last_set_pos) = pos_i; + id(led_output_set_pct).publish_state(pos_i); + } + } + + - platform: template + id: bulb_output_pwm_pct + name: "${friendly_name} Output PWM (%)" + unit_of_measurement: "%" + icon: mdi:square-wave + accuracy_decimals: 1 + update_interval: 2s + lambda: |- + const auto &cv = id(bulb_light).current_values; + if (!cv.is_on()) return 0.0f; + const float lin = cv.get_brightness(); + float pwm = powf(lin, id(led_gamma_f)); + if (pwm < 0.0f) pwm = 0.0f; + if (pwm > 1.0f) pwm = 1.0f; + return pwm * 100.0f; + +########################################################################################## +# OUTPUTS: 5 x PWM channels for the Arlec GLD120HA RGBWW bulb +########################################################################################## +output: + - platform: esp8266_pwm + id: output_red + pin: ${bulb_red_pin} + frequency: ${pwm_frequency} + + - platform: esp8266_pwm + id: output_green + pin: ${bulb_green_pin} + frequency: ${pwm_frequency} + + - platform: esp8266_pwm + id: output_blue + pin: ${bulb_blue_pin} + frequency: ${pwm_frequency} + + - platform: esp8266_pwm + id: output_warm_white + pin: ${bulb_warm_white_pin} + frequency: ${pwm_frequency} + + - platform: esp8266_pwm + id: output_cold_white + pin: ${bulb_cold_white_pin} + frequency: ${pwm_frequency} + +########################################################################################## +# LIGHT: RGBWW (correct for this bulb) +########################################################################################## +light: + - platform: rgbww + id: bulb_light + name: "${friendly_name}" + icon: mdi:lightbulb + + red: output_red + green: output_green + blue: output_blue + warm_white: output_warm_white + cold_white: output_cold_white + + warm_white_color_temperature: ${bulb_warm_kelvin} + cold_white_color_temperature: ${bulb_cold_kelvin} + + # Safety: do not allow RGB and whites simultaneously + color_interlock: true + + # Important: keep OFF at boot to avoid instant restore flashes; boot script handles behavior. + restore_mode: ALWAYS_OFF + + default_transition_length: 2s + gamma_correct: "${led_gamma}" + + on_turn_on: + - mqtt.publish: + topic: "${mqtt_local_status_topic}/light/state" + payload: "${mqtt_local_device_command_ON}" + retain: true + - lambda: |- + if (!id(ramping_for_off)) { + id(ramp_switch_target_on) = true; + } + - script.stop: max_on_watchdog + - if: + condition: + lambda: 'return id(max_on_hours) > 0;' + then: + - script.execute: max_on_watchdog + + on_turn_off: + - if: + condition: + lambda: 'return !id(booting);' + then: + - mqtt.publish: + topic: "${mqtt_local_status_topic}/light/state" + payload: "${mqtt_local_device_command_OFF}" + retain: true + - lambda: 'id(ramp_switch_target_on) = false;' + - script.stop: max_on_watchdog + - lambda: |- + id(last_brightness_pct) = 0.0f; + id(last_set_pos) = 0; + id(led_output_set_pct).publish_state(0); + + # Cap brightness to max_brightness_pct without a visible step + on_state: + - lambda: |- + const float cap = id(max_brightness_pct) / 100.0f; + const auto &cv = id(bulb_light).current_values; + if (!cv.is_on()) return; + const float b = cv.get_brightness(); + if (b <= cap + 0.001f) return; + + auto call = id(bulb_light).make_call(); + call.set_state(true); + call.set_brightness(cap); + call.set_transition_length(0); + call.perform(); + +########################################################################################## +# NUMBER +########################################################################################## +number: + - platform: template + id: cfg_ramp_up_s + name: "${friendly_name} Fade Up Time (s)" + entity_category: config + unit_of_measurement: s + icon: mdi:timer-sand + mode: slider + min_value: 0 + max_value: 60 + step: 1 + lambda: |- + return (float) id(ramp_up_ms) / 1000.0f; + set_action: + - lambda: |- + int secs = (int) floorf(x + 0.5f); + if (secs < 0) secs = 0; + if (secs > 60) secs = 60; + id(ramp_up_ms) = secs * 1000; + id(cfg_ramp_up_s).publish_state((float) secs); + + - platform: template + id: cfg_ramp_down_s + name: "${friendly_name} Fade Down Time (s)" + entity_category: config + unit_of_measurement: s + icon: mdi:timer-sand-complete + mode: slider + min_value: 0 + max_value: 60 + step: 1 + lambda: |- + return (float) id(ramp_down_ms) / 1000.0f; + set_action: + - lambda: |- + int secs = (int) floorf(x + 0.5f); + if (secs < 0) secs = 0; + if (secs > 60) secs = 60; + id(ramp_down_ms) = secs * 1000; + id(cfg_ramp_down_s).publish_state((float) secs); + + - platform: template + id: cfg_jump_fade_pct + name: "${friendly_name} Jump Fade (%)" + entity_category: config + unit_of_measurement: "%" + icon: mdi:percent + mode: slider + min_value: 0 + max_value: 100 + step: 1 + lambda: |- + return (float) id(jump_fade_pct); + set_action: + - lambda: |- + int v = (int) floorf(x + 0.5f); + if (v < 0) v = 0; + if (v > 100) v = 100; + id(jump_fade_pct) = v; + id(cfg_jump_fade_pct).publish_state((float) v); + + # This is the preferred everyday dimmer slider (FORCES Warm endpoint) + - platform: template + id: led_output_set_pct + name: "${friendly_name} Output Set (0-100)" + icon: mdi:tune + mode: slider + min_value: 0 + max_value: 100 + step: 1 + lambda: |- + const auto &cv = id(bulb_light).current_values; + float actual = cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f; + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + if (actual <= 0.0f) return 0.0f; + float pos = (actual - minp) * 100.0f / (maxp - minp); + if (pos < 0.0f) pos = 0.0f; + if (pos > 100.0f) pos = 100.0f; + return floorf(pos + 0.5f); + set_action: + - if: + condition: + lambda: 'return x <= 0.0f;' + then: + - lambda: 'id(suppress_slider_sync) = true;' + - script.stop: ramp_on_script + - script.stop: ramp_off_script + - light.turn_off: + id: bulb_light + transition_length: 200ms + - lambda: |- + id(ramp_switch_target_on) = false; + id(led_output_set_pct).publish_state(0); + id(last_brightness_pct) = 0.0f; + id(last_set_pos) = 0; + - delay: 400ms + - lambda: 'id(suppress_slider_sync) = false;' + else: + - lambda: |- + id(suppress_slider_sync) = true; + float pos = x; + if (pos < 1.0f) pos = 1.0f; + if (pos > 100.0f) pos = 100.0f; + id(led_output_set_pct).publish_state((int) floorf(pos + 0.5f)); + - script.stop: ramp_off_script + - script.stop: ramp_on_script + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda |- + float pos = id(led_output_set_pct).state; + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + float out_pct = minp + (pos * (maxp - minp) / 100.0f); + if (out_pct > maxp) out_pct = maxp; + return out_pct / 100.0f; + transition_length: 250ms + - lambda: |- + id(ramp_switch_target_on) = true; + float pos = id(led_output_set_pct).state; + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + float out_pct = minp + (pos * (maxp - minp) / 100.0f); + if (out_pct > maxp) out_pct = maxp; + id(last_brightness_pct) = out_pct; + - delay: 400ms + - lambda: 'id(suppress_slider_sync) = false;' + + - platform: template + id: cfg_max_on_hours + name: "${friendly_name} Max On (h)" + entity_category: config + unit_of_measurement: h + icon: mdi:timer-cog + mode: slider + min_value: 0 + max_value: 48 + step: 1 + lambda: |- + return (float) id(max_on_hours); + set_action: + - lambda: |- + int hrs = (int) x; + if (hrs < 0) hrs = 0; + if (hrs > 48) hrs = 48; + id(max_on_hours) = hrs; + id(cfg_max_on_hours).publish_state((float) hrs); + - if: + condition: + lambda: 'return id(bulb_light).current_values.is_on();' + then: + - script.stop: max_on_watchdog + - if: + condition: + lambda: 'return id(max_on_hours) > 0;' + then: + - script.execute: max_on_watchdog + +########################################################################################## +# SCRIPTS +########################################################################################## +script: + - id: ramp_on_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' + - script.stop: ramp_off_script + + - if: + condition: + lambda: |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); + then: + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: 80ms + + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda 'return id(max_brightness_pct) / 100.0f;' + transition_length: !lambda |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + const float cap = id(max_brightness_pct) / 100.0f; + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } + + float curr = cv.is_on() ? cv.get_brightness() : 0.0f; + + if (curr + 0.0005f < floor) { + id(last_ramp_ms) = id(ramp_up_ms); + return (uint32_t) id(last_ramp_ms); + } + if (curr > cap) curr = cap; + if (curr < floor) curr = floor; + + float frac = (cap - curr) / (cap - floor); + if (frac < 0.0f) frac = 0.0f; + if (frac > 1.0f) frac = 1.0f; + + id(last_ramp_ms) = (int) (id(ramp_up_ms) * frac); + return (uint32_t) id(last_ramp_ms); + + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - lambda: |- + id(is_ramping) = false; + const auto &cv = id(bulb_light).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + id(last_brightness_pct) = pct; + } + + - id: ramp_to_target_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' + - script.stop: ramp_off_script + + - if: + condition: + lambda: |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); + then: + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: 80ms + + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda |- + float cap_pct = id(restore_target_pct); + float maxp = (float) id(max_brightness_pct); + if (cap_pct > maxp) cap_pct = maxp; + return cap_pct / 100.0f; + transition_length: !lambda |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + + float cap = id(restore_target_pct) / 100.0f; + const float maxp = id(max_brightness_pct) / 100.0f; + if (cap > maxp) cap = maxp; + + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } + + float curr = cv.is_on() ? cv.get_brightness() : 0.0f; + + if (curr + 0.0005f < floor) { + id(last_ramp_ms) = id(ramp_up_ms); + return (uint32_t) id(last_ramp_ms); + } + + if (curr > cap) curr = cap; + if (curr < floor) curr = floor; + + float frac = (cap - curr) / (cap - floor); + if (frac < 0.0f) frac = 0.0f; + if (frac > 1.0f) frac = 1.0f; + + id(last_ramp_ms) = (int) (id(ramp_up_ms) * frac); + return (uint32_t) id(last_ramp_ms); + + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - lambda: |- + id(is_ramping) = false; + const auto &cv = id(bulb_light).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + id(last_brightness_pct) = pct; + } + + - id: ramp_off_script + mode: restart + then: + - lambda: |- + id(is_ramping) = true; + id(ramping_for_off) = true; + - script.stop: ramp_on_script + + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: !lambda |- + const auto &cv = id(bulb_light).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + float curr = cv.get_brightness(); + if (curr < floor) curr = floor; + + const float denom = (1.0f - floor); + if (denom <= 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } + + float frac = (curr - floor) / denom; + if (frac < 0.0f) frac = 0.0f; + if (frac > 1.0f) frac = 1.0f; + + id(last_ramp_ms) = (int) (id(ramp_down_ms) * frac); + return (uint32_t) id(last_ramp_ms); + + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - light.turn_off: + id: bulb_light + transition_length: 150ms + - delay: 150ms + + - lambda: |- + id(is_ramping) = false; + id(ramping_for_off) = false; + id(last_brightness_pct) = 0.0f; + id(last_set_pos) = 0; + id(led_output_set_pct).publish_state(0); + + - id: max_on_watchdog + mode: restart + then: + - if: + condition: + lambda: 'return id(max_on_hours) > 0;' + then: + - delay: !lambda 'return (uint32_t) (id(max_on_hours) * 3600000UL);' + - if: + condition: + lambda: 'return id(bulb_light).current_values.is_on();' + then: + - lambda: |- + id(ramp_switch_target_on) = false; + id(bulb_ramp_switch).publish_state(false); + - script.stop: ramp_on_script + - script.execute: ramp_off_script + + - id: deferred_restore_brightness + mode: restart + then: + - delay: 5s + - lambda: |- + float target = id(last_brightness_pct); + + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (target > 0.0f && target < minp) target = minp; + if (target > maxp) target = maxp; + + id(restore_target_pct) = target; + + - if: + condition: + lambda: 'return id(restore_target_pct) > 0.5f;' + then: + - lambda: |- + id(ramp_switch_target_on) = true; + id(suppress_slider_sync) = true; + - script.stop: ramp_off_script + - script.execute: ramp_to_target_script + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - lambda: 'id(suppress_slider_sync) = false;' + else: + - lambda: 'id(ramp_switch_target_on) = false;' + - light.turn_off: + id: bulb_light + transition_length: 0s + + # Jump Fade: ON = jump to (jump% of max cap) then normal fade up to full + - id: jump_ramp_on_script + mode: restart + then: + - script.stop: ramp_off_script + + # Jump UP to (jump_pct% of max cap), only if we are below it. + - if: + condition: + lambda: |- + const auto &cv = id(bulb_light).current_values; + + float maxp = (float) id(max_brightness_pct); + float minp = (float) id(min_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + + float jp = (float) id(jump_fade_pct); + if (jp < 0.0f) jp = 0.0f; + if (jp > 100.0f) jp = 100.0f; + + float target_pct = maxp * (jp / 100.0f); + if (jp > 0.0f && target_pct < minp) target_pct = minp; + if (target_pct > maxp) target_pct = maxp; + + float curr_pct = cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f; + + return (!cv.is_on()) || (curr_pct + 0.1f < target_pct); + then: + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda |- + float maxp = (float) id(max_brightness_pct); + float minp = (float) id(min_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + + float jp = (float) id(jump_fade_pct); + if (jp < 0.0f) jp = 0.0f; + if (jp > 100.0f) jp = 100.0f; + + float target_pct = maxp * (jp / 100.0f); + if (jp > 0.0f && target_pct < minp) target_pct = minp; + if (target_pct > maxp) target_pct = maxp; + + return target_pct / 100.0f; + transition_length: 0s + + # Then fade up to full using your normal fade logic + - script.execute: ramp_on_script + + # Jump Fade: OFF = jump down to (current - current*jump%) then normal fade down to OFF + - id: jump_ramp_off_script + mode: restart + then: + - script.stop: ramp_on_script + + # If the light is off already, do nothing fancy (avoids a flash from ramp_off_script). + - if: + condition: + lambda: 'return id(bulb_light).current_values.is_on();' + then: + # Jump DOWN to (100 - jump)% of current brightness (25 -> 75%), clamp to min. + - light.turn_on: + id: bulb_light + color_temperature: "${bulb_warm_kelvin}" + brightness: !lambda |- + const auto &cv = id(bulb_light).current_values; + float curr_pct = cv.get_brightness() * 100.0f; + + float jp = (float) id(jump_fade_pct); + if (jp < 0.0f) jp = 0.0f; + if (jp > 100.0f) jp = 100.0f; + + float factor = (100.0f - jp) / 100.0f; // 25 -> 0.75 + float target_pct = curr_pct * factor; + + float minp = (float) id(min_brightness_pct); + float maxp = (float) id(max_brightness_pct); + if (maxp <= minp) maxp = minp + 1.0f; + + if (target_pct < minp) target_pct = minp; + if (target_pct > maxp) target_pct = maxp; + + return target_pct / 100.0f; + transition_length: 0s + + # Then fade down to off using your normal fade logic + - script.execute: ramp_off_script + else: + - light.turn_off: + id: bulb_light + transition_length: 0s \ No newline at end of file diff --git a/esphome/esp-datacupboardfans.yaml b/esphome/esp-datacupboardfans.yaml new file mode 100644 index 0000000..52a5322 --- /dev/null +++ b/esphome/esp-datacupboardfans.yaml @@ -0,0 +1,595 @@ +########################################################################################## +########################################################################################## +# DATA CUPBOARD FAN CONTROL +# Fan control for Data Cupboard +# +# Hardware: Yunshan 7-30V 10A Relay Board (HW-622) with ESP8266MOD +# +# V1.3 2026-02-20 Remove invalid allow_other_uses on pulse_counter pins (ESPHome 2026.2.x) +# V1.2 2026-02-20 Use common SNTP package, and publish fan fault/status via interval script +# V1.1 2026-02-20 Fix template binary_sensor update method for ESPHome 2026.2.x +# V1.0 2026-02-20 Initial Version +# +# NOTES +# - Fan tach inputs are on GPIO1/GPIO3 (UART pins). Logger UART is disabled (baud_rate: 0). +# - DHT22 data is on GPIO0 (boot-strap pin). Usually OK, but if you ever get boot issues, +# move the DHT22 to a different GPIO if your board allows. +# - Using common/sntp_common.yaml for time and diagnostic time text sensors. +########################################################################################## +########################################################################################## + +########################################################################################## +# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS +########################################################################################## +substitutions: + # Device Naming + device_name: "esp-datacupboardfans" + friendly_name: "Data Cupboard Fans" + description_comment: "Fan control for Data Cupboard" + device_area: "Data Cupboard" + + # Project Naming + project_name: "Yunshan.HW-622 ESP8266MOD Relay Board" + project_version: "v1.3" + + # Passwords + api_key: !secret esp-api_key + ota_pass: !secret esp-ota_pass + static_ip_address: !secret esp-datacupboardfans_ip + mqtt_command_main_topic: !secret mqtt_command_main_topic + + # MQTT Controls + mqtt_device_name: "data-cupboard-fans" + mqtt_main_topic: "${mqtt_command_main_topic}/${mqtt_device_name}" + + # Device Settings + log_level: "ERROR" + update_interval: "60s" + + # Names + relay_name: "Data Cupboard Fans Relay" + override_name: "Override Fans On" + door_name: "Data Cupboard Door Status" + temp_name: "Data Cupboard Temperature" + hum_name: "Data Cupboard Humidity" + fan1_rpm_name: "Data Cupboard Fan1 RPM" + fan2_rpm_name: "Data Cupboard Fan2 RPM" + fan_status_name: "Data Cupboard Fan Status" + + # Fan tach assumptions (typical PC fan = 2 pulses per revolution) + fan_pulses_per_rev: "2" + + # Fault thresholds + rpm_min_when_relay_off: "950" + rpm_min_when_relay_on: "2300" + + # Default thermostat thresholds + default_temp_on: "35" + default_temp_off: "28" + +########################################################################################## +# 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}" + + #### 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 + file: 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: + - lambda: |- + id(boot_ms) = millis(); + id(relay_last_change_ms) = millis(); + - delay: 2s + - script.execute: apply_fan_control + - script.execute: evaluate_fan_fault + +########################################################################################## +# ESP Platform and Framework +########################################################################################## +esp8266: + board: esp12e + restore_from_flash: true + +preferences: + flash_write_interval: 5min + +mdns: + disabled: false + +########################################################################################## +# LOGGING +########################################################################################## +logger: + level: "${log_level}" + baud_rate: 0 + +########################################################################################## +# GLOBALS +########################################################################################## +globals: + # Max temperature tracking + - id: g_max_temp_today + type: float + restore_value: yes + initial_value: "-1000.0" + - id: g_max_temp_yesterday + type: float + restore_value: yes + initial_value: "-1000.0" + - id: g_max_temp_month + type: float + restore_value: yes + initial_value: "-1000.0" + - id: g_max_temp_last_month + type: float + restore_value: yes + initial_value: "-1000.0" + - id: g_max_temp_year + type: float + restore_value: yes + initial_value: "-1000.0" + + # Last seen date parts (for rollover) + - id: g_last_day + type: int + restore_value: yes + initial_value: "0" + - id: g_last_month + type: int + restore_value: yes + initial_value: "0" + - id: g_last_year + type: int + restore_value: yes + initial_value: "0" + + # Fault evaluation grace timing + - id: boot_ms + type: uint32_t + restore_value: no + initial_value: "0" + - id: relay_last_change_ms + type: uint32_t + restore_value: no + initial_value: "0" + +########################################################################################## +# INTERVAL (periodic evaluations) +# NOTE: common_sntp also defines its own interval (60s) for its fallback clock. +########################################################################################## +interval: + - interval: 5s + then: + - script.execute: evaluate_fan_fault + +########################################################################################## +# SWITCHES (Relay + Override) +########################################################################################## +switch: + # Relay output (GPIO4) - powers both fans + - platform: gpio + name: "${relay_name}" + id: fan_relay + pin: GPIO4 + restore_mode: RESTORE_DEFAULT_OFF + icon: "mdi:fan" + on_turn_on: + then: + - lambda: |- + id(relay_last_change_ms) = millis(); + - script.execute: apply_fan_control + - script.execute: evaluate_fan_fault + on_turn_off: + then: + - lambda: |- + id(relay_last_change_ms) = millis(); + - script.execute: apply_fan_control + - script.execute: evaluate_fan_fault + + # Override switch (forces fans ON, bypasses thermostat logic) + - platform: template + name: "${override_name}" + id: override_fans_on + restore_mode: RESTORE_DEFAULT_OFF + optimistic: true + icon: "mdi:fan-plus" + entity_category: config + turn_on_action: + - switch.turn_on: fan_relay + - script.execute: apply_fan_control + - script.execute: evaluate_fan_fault + turn_off_action: + - script.execute: apply_fan_control + - script.execute: evaluate_fan_fault + +########################################################################################## +# NUMBERS (On/Off thresholds with hysteresis) +########################################################################################## +number: + - platform: template + name: "Fans On Temperature" + id: temp_on_threshold + unit_of_measurement: "degC" + min_value: 25 + max_value: 40 + step: 0.5 + mode: slider + restore_value: true + initial_value: ${default_temp_on} + optimistic: true + entity_category: config + icon: "mdi:thermometer-chevron-up" + set_action: + - script.execute: apply_fan_control + + - platform: template + name: "Fans Off Temperature" + id: temp_off_threshold + unit_of_measurement: "degC" + min_value: 25 + max_value: 40 + step: 0.5 + mode: slider + restore_value: true + initial_value: ${default_temp_off} + optimistic: true + entity_category: config + icon: "mdi:thermometer-chevron-down" + set_action: + - script.execute: apply_fan_control + +########################################################################################## +# BINARY SENSORS (Door + Fault) +########################################################################################## +binary_sensor: + # Door status on GPIO5 + - platform: gpio + name: "${door_name}" + id: door_status + device_class: door + pin: + number: GPIO5 + mode: INPUT_PULLUP + inverted: false + filters: + - delayed_on: 50ms + - delayed_off: 50ms + + # Fan fault boolean (published by script) + - platform: template + name: "Data Cupboard Fan Fault" + id: fan_fault + device_class: problem + +########################################################################################## +# STATUS LED (GPIO2) +########################################################################################## +status_led: + pin: + number: GPIO2 + inverted: true + +########################################################################################## +# SENSORS (Temp/Humidity + Fan RPM + Max temp templates) +########################################################################################## +sensor: + # DHT22 on GPIO0 + - platform: dht + pin: GPIO0 + model: DHT22 + update_interval: 30s + temperature: + name: "${temp_name}" + id: cupboard_temp + unit_of_measurement: "degC" + accuracy_decimals: 1 + on_value: + then: + - script.execute: update_temp_maxima + - script.execute: apply_fan_control + humidity: + name: "${hum_name}" + id: cupboard_humidity + unit_of_measurement: "%" + accuracy_decimals: 1 + + # Fan1 tach pulses on GPIO1 (UART TX pin) + - platform: pulse_counter + pin: + number: GPIO1 + mode: INPUT_PULLUP + inverted: false + id: fan1_rpm + name: "${fan1_rpm_name}" + unit_of_measurement: "rpm" + accuracy_decimals: 0 + update_interval: 10s + count_mode: + rising_edge: DISABLE + falling_edge: INCREMENT + filters: + # pulse_counter is pulses/minute; typical PC fan tach = 2 pulses per revolution + - lambda: |- + return x / (float) ${fan_pulses_per_rev}; + - clamp: + min_value: 0 + max_value: 20000 + + # Fan2 tach pulses on GPIO3 (UART RX pin) + - platform: pulse_counter + pin: + number: GPIO3 + mode: INPUT_PULLUP + inverted: false + id: fan2_rpm + name: "${fan2_rpm_name}" + unit_of_measurement: "rpm" + accuracy_decimals: 0 + update_interval: 10s + count_mode: + rising_edge: DISABLE + falling_edge: INCREMENT + filters: + - lambda: |- + return x / (float) ${fan_pulses_per_rev}; + - clamp: + min_value: 0 + max_value: 20000 + + # Max temperature sensors (derived from cupboard_temp) + - platform: template + name: "Data Cupboard Max Temp Today" + id: max_temp_today + unit_of_measurement: "degC" + accuracy_decimals: 1 + update_interval: 60s + lambda: |- + return id(g_max_temp_today); + + - platform: template + name: "Data Cupboard Max Temp Yesterday" + id: max_temp_yesterday + unit_of_measurement: "degC" + accuracy_decimals: 1 + update_interval: 60s + lambda: |- + return id(g_max_temp_yesterday); + + - platform: template + name: "Data Cupboard Max Temp This Month" + id: max_temp_month + unit_of_measurement: "degC" + accuracy_decimals: 1 + update_interval: 60s + lambda: |- + return id(g_max_temp_month); + + - platform: template + name: "Data Cupboard Max Temp Last Month" + id: max_temp_last_month + unit_of_measurement: "degC" + accuracy_decimals: 1 + update_interval: 60s + lambda: |- + return id(g_max_temp_last_month); + + - platform: template + name: "Data Cupboard Max Temp This Year" + id: max_temp_year + unit_of_measurement: "degC" + accuracy_decimals: 1 + update_interval: 60s + lambda: |- + return id(g_max_temp_year); + +########################################################################################## +# TEXT SENSOR (Normal/Fault status) - published by script +# NOTE: common_sntp also defines its own text_sensors (time status etc). +########################################################################################## +text_sensor: + - platform: template + name: "${fan_status_name}" + id: fan_status + icon: "mdi:fan-alert" + +########################################################################################## +# SCRIPTS +########################################################################################## +script: + # Apply fan control logic (override + hysteresis thresholds) + - id: apply_fan_control + mode: queued + then: + - lambda: |- + const float t = id(cupboard_temp).state; + if (isnan(t)) { + return; + } + + float on_t = id(temp_on_threshold).state; + float off_t = id(temp_off_threshold).state; + + // Guard against bad settings (keep at least 0.5C hysteresis) + if (off_t >= on_t) { + off_t = on_t - 0.5f; + } + + if (id(override_fans_on).state) { + if (!id(fan_relay).state) { + id(fan_relay).turn_on(); + } + return; + } + + // Hysteresis-based control + if (!id(fan_relay).state) { + if (t >= on_t) { + id(fan_relay).turn_on(); + } + } else { + if (t <= off_t) { + id(fan_relay).turn_off(); + } + } + + # Evaluate and publish fan fault and status + - id: evaluate_fan_fault + mode: restart + then: + - lambda: |- + const uint32_t now_ms = millis(); + + // Grace window after boot / relay change to allow RPM to stabilize + const bool in_grace = + (now_ms - id(boot_ms) < 20000) || + (now_ms - id(relay_last_change_ms) < 15000); + + if (in_grace) { + id(fan_fault).publish_state(false); + id(fan_status).publish_state("Normal"); + return; + } + + const float r1 = id(fan1_rpm).state; + const float r2 = id(fan2_rpm).state; + + if (isnan(r1) || isnan(r2)) { + id(fan_fault).publish_state(true); + id(fan_status).publish_state("Fault"); + return; + } + + const float req = id(fan_relay).state ? ${rpm_min_when_relay_on} : ${rpm_min_when_relay_off}; + + // Requirement as requested: + // - Relay OFF => both fans must be > 1800 RPM + // - Relay ON => both fans must be > 3000 RPM + const bool fault = (r1 < req) || (r2 < req); + + id(fan_fault).publish_state(fault); + id(fan_status).publish_state(fault ? "Fault" : "Normal"); + + # Update daily/monthly/yearly maxima based on current temperature and current date + - id: update_temp_maxima + mode: queued + then: + - lambda: |- + const float t = id(cupboard_temp).state; + if (isnan(t)) { + return; + } + + auto now = id(sntp_time).now(); + if (!now.is_valid()) { + // Time not valid yet: still track as rolling maxima + if (t > id(g_max_temp_today)) id(g_max_temp_today) = t; + if (t > id(g_max_temp_month)) id(g_max_temp_month) = t; + if (t > id(g_max_temp_year)) id(g_max_temp_year) = t; + } else { + // Initialize date tracking on first valid time + if (id(g_last_year) == 0) { + id(g_last_year) = now.year; + id(g_last_month) = now.month; + id(g_last_day) = now.day_of_month; + + id(g_max_temp_today) = t; + id(g_max_temp_yesterday) = t; + id(g_max_temp_month) = t; + id(g_max_temp_last_month) = t; + id(g_max_temp_year) = t; + } else { + // Year rollover + if (now.year != id(g_last_year)) { + id(g_last_year) = now.year; + id(g_max_temp_year) = t; + } + + // Month rollover + if (now.month != id(g_last_month)) { + id(g_max_temp_last_month) = id(g_max_temp_month); + id(g_max_temp_month) = t; + id(g_last_month) = now.month; + } + + // Day rollover + if (now.day_of_month != id(g_last_day)) { + id(g_max_temp_yesterday) = id(g_max_temp_today); + id(g_max_temp_today) = t; + id(g_last_day) = now.day_of_month; + } + + // Update running maxima + if (t > id(g_max_temp_today)) id(g_max_temp_today) = t; + if (t > id(g_max_temp_month)) id(g_max_temp_month) = t; + if (t > id(g_max_temp_year)) id(g_max_temp_year) = t; + } + } + + // Publish template sensors promptly + id(max_temp_today).publish_state(id(g_max_temp_today)); + id(max_temp_yesterday).publish_state(id(g_max_temp_yesterday)); + id(max_temp_month).publish_state(id(g_max_temp_month)); + id(max_temp_last_month).publish_state(id(g_max_temp_last_month)); + id(max_temp_year).publish_state(id(g_max_temp_year)); + +########################################################################################## +# MQTT COMMANDS (device-specific) +########################################################################################## +mqtt: + on_message: + # Relay control + - topic: "${mqtt_main_topic}/relay/set" + payload: "ON" + then: + - switch.turn_on: fan_relay + - topic: "${mqtt_main_topic}/relay/set" + payload: "OFF" + then: + - switch.turn_off: fan_relay + + # Override control + - topic: "${mqtt_main_topic}/override/set" + payload: "ON" + then: + - switch.turn_on: override_fans_on + - topic: "${mqtt_main_topic}/override/set" + payload: "OFF" + then: + - switch.turn_off: override_fans_on \ No newline at end of file diff --git a/esphome/esp-lounge6chdimmer.yaml b/esphome/esp-lounge6chdimmer.yaml index f21a664..8aeca4e 100644 --- a/esphome/esp-lounge6chdimmer.yaml +++ b/esphome/esp-lounge6chdimmer.yaml @@ -71,6 +71,10 @@ substitutions: ota_pass: !secret esp-ota_pass # unfortunately you can't use substitutions inside secrets names static_ip_address: !secret esp-lounge6chdimmer_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} + # Device Settings #relay_icon: "mdi:lightbulb-group" log_level: "INFO" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE @@ -114,6 +118,7 @@ packages: 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 (choose encryption or no encryption options) #### common_api: !include diff --git a/esphome/esp-mainbathfancombo.yaml b/esphome/esp-mainbathfancombo.yaml index d98ce4a..85cb2b8 100644 --- a/esphome/esp-mainbathfancombo.yaml +++ b/esphome/esp-mainbathfancombo.yaml @@ -1,14 +1,22 @@ ########################################################################################## ########################################################################################## -# MAIN BATHROOM FAN/HEAT COMBO SWITCH +# MAIN BATHROOM FAN/HEAT COMBO SWITCH +# V1.5 2026-02-23 MQTT numeric timer only applies if fan already ON (does not turn fan ON) +# V1.4 2026-02-23 Add mm:ss countdown text sensor (eg 22:12) + per-second countdown engine +# V1.3 2026-02-23 Add 60-min cap timer + Remaining Minutes sensor (counts down) +# V1.2 2026-02-18 Stop delayed-off countdown when Relay_1 turns OFF # V1.1 2025-08-26 Minor Changes (MQTT) # V1.0 2025-06-01 Initial Version ########################################################################################## -# Zemismart KS-811 Triple push button +# Zemismart KS-811 Double Push Button # pinout/schematic https://community.home-assistant.io/t/zemismart-ks-811-working-with-esphome/ # # NOTES -# - +# - MQTT Local Usage +# Send ON to ${mqtt_command_main_topic}/${mqtt_local_device_name} and it will turn the fan ON (60 min cap) +# Send OFF to ${mqtt_command_main_topic}/${mqtt_local_device_name} and it will turn the fan OFF +# Send a value from 1 to 60 to ${mqtt_command_main_topic}/${mqtt_local_device_name} +# and it will ONLY start/restart the countdown IF the fan is already ON (in minutes) # ########################################################################################## ########################################################################################## @@ -22,26 +30,35 @@ substitutions: device_name: "esp-mainbathfancombo" friendly_name: "Main Bathroom Fan-Heat Combo Switch (2)" description_comment: "Main Bathroom Fan/Heat Switch using a Zemismart KS-811 Double Push Button. Extract Fan (1), IR heater (2)" - device_area: "Laundry" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. - + device_area: "Main Bathroom" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. + # Project Naming - project_name: "Zemismart Technologies.KS-811-2 (Double)" # Project Details - project_version: "v1.1" # Project V denotes release of yaml file, allowing checking of deployed vs latest version - - entity_prefix: "Main Bathroom" # Simple device name where we want to prefix a sensor or switch, eg "Load" Current. + project_name: "Zemismart Technologies.KS-811-2 (Double)" # Project Details + project_version: "v1.5" # Project V denotes release of yaml file, allowing checking of deployed vs latest version + + entity_prefix: "Main Bathroom" # Simple device name where we want to prefix a sensor or switch, eg "Load" Current. # Passwords api_key: !secret esp-api_key # unfortunately you can't use substitutions inside secrets names ota_pass: !secret esp-ota_pass # unfortunately you can't use substitutions inside secrets names static_ip_address: !secret esp-mainbathfancombo_ip mqtt_command_main_topic: !secret mqtt_command_main_topic - #mqtt_status_main_topic: !secret mqtt_status_main_topic - + mqtt_status_main_topic: !secret mqtt_status_main_topic + + # 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} + # Device Settings #relay_icon: "mdi:heating-coil" log_level: "INFO" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE update_interval: "60s" # update time for for general sensors etc + # MQTT LOCAL Controls + mqtt_local_device1_name: "masterbath-fan" + mqtt_local_command1_topic: "${mqtt_command_main_topic}/${mqtt_local_device1_name}" # Topic we will use to command this locally without HA + mqtt_local_status1_topic: "${mqtt_status_main_topic}/${mqtt_local_device1_name}" # Topic we will use to view status locally without HA + # MQTT REMOTE Controls mqtt_remote_device1_name: "masterbath-towelrail" mqtt_remote_device1_command_topic: "${mqtt_command_main_topic}/${mqtt_remote_device1_name}/operation" @@ -59,6 +76,7 @@ packages: 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 (choose encryption or no encryption options) #### common_api: !include @@ -72,7 +90,7 @@ packages: file: common/mqtt_common.yaml vars: local_device_name: "${device_name}" - + #### WEB PORTAL #### #common_webportal: !include common/webportal_common.yaml #### SNTP (Only use if you want/need accurate timeclocks) #### @@ -96,26 +114,30 @@ esphome: project: name: "${project_name}" version: "${project_version}" - platformio_options: - build_flags: - - "-Os" # optimize for size - - "-Wl,--gc-sections" # drop unused code/data - #- "-fno-exceptions" # strip C++ exceptions - #- "-fno-rtti" # strip C++ RTTI + #platformio_options: + # build_flags: + # - "-Os" # optimize for size + # - "-Wl,--gc-sections" # drop unused code/data + # - "-fno-exceptions" # strip C++ exceptions + # - "-fno-rtti" # strip C++ RTTI on_boot: priority: -100 then: + # Avoid stale relay2_forced_relay1 across boots - if: condition: switch.is_on: Relay_2 then: + - lambda: |- + id(relay2_forced_relay1) = !id(Relay_1).state; - if: condition: switch.is_off: Relay_1 then: - - lambda: |- - id(relay2_forced_relay1) = true; - switch.turn_on: Relay_1 + else: + - lambda: |- + id(relay2_forced_relay1) = false; ########################################################################################## # ESP Platform and Framework @@ -123,16 +145,16 @@ esphome: ########################################################################################## esp8266: board: esp01_1m - early_pin_init: False # Initialise pins early to known values. Recommended false where switches are involved. Defaults to True. + early_pin_init: false # Recommended false where switches are involved. board_flash_mode: dout # Default is dout -########################################################################################## +########################################################################################## # ESPHome Logging Enable # https://esphome.io/components/logger.html -########################################################################################## +########################################################################################## logger: - level: "${log_level}" #INFO Level suggested, or DEBUG for testing - #baud_rate: 0 #set to 0 for no logging via UART, needed if you are using it for other serial things (eg PZEM) + level: "${log_level}" # INFO Level suggested, or DEBUG for testing + #baud_rate: 0 # set to 0 for no logging via UART, needed if you are using it for other serial things (eg PZEM) #esp8266_store_log_strings_in_flash: false #tx_buffer_size: 64 @@ -141,11 +163,39 @@ logger: # https://esphome.io/guides/automations.html?highlight=globals#global-variables ########################################################################################## globals: + # Tracks if Relay_2 forced Relay_1 ON (so we can decide if Relay_1 should turn OFF when Relay_2 turns OFF) - id: relay2_forced_relay1 type: bool restore_value: yes initial_value: "false" + # 60-min cap / countdown state for Relay_1 (Fan) + - id: relay1_timer_active + type: bool + restore_value: no + initial_value: "false" + + - id: relay1_autooff_pending + type: bool + restore_value: no + initial_value: "false" + + - id: relay1_remaining_min + type: int + restore_value: no + initial_value: "0" + + # mm:ss countdown tracking + - id: relay1_end_ms + type: uint32_t + restore_value: no + initial_value: "0" + + - id: relay1_remaining_sec + type: int + restore_value: no + initial_value: "0" + ########################################################################################## # STATUS LED # https://esphome.io/components/status_led.html @@ -157,6 +207,126 @@ status_led: number: GPIO2 inverted: yes +########################################################################################## +# SENSORS +########################################################################################## +sensor: + - platform: template + name: "${entity_prefix} Fan Remaining Minutes" + id: relay1_remaining_minutes + unit_of_measurement: "min" + accuracy_decimals: 0 + icon: mdi:timer-outline + update_interval: 1s + lambda: |- + return (float) id(relay1_remaining_min); + +text_sensor: + - platform: template + name: "${entity_prefix} Fan Countdown" + id: relay1_countdown_text + icon: mdi:timer-outline + update_interval: 1s + lambda: |- + int sec = id(relay1_remaining_sec); + if (sec < 0) sec = 0; + int m = sec / 60; + int s = sec % 60; + char buf[12]; + snprintf(buf, sizeof(buf), "%d:%02d", m, s); + return std::string(buf); + +########################################################################################## +# MQTT COMMANDS +# This adds device-specific MQTT command triggers to the common MQTT configuration. +########################################################################################## +mqtt: + on_message: + # Fan control: ON / OFF / 1..60 minutes (timer only if fan already ON) + - topic: "${mqtt_local_command1_topic}" + then: + - lambda: |- + std::string p = x; + + // Trim leading/trailing whitespace + while (!p.empty() && (p.back() == '\n' || p.back() == '\r' || p.back() == ' ' || p.back() == '\t')) p.pop_back(); + size_t s = p.find_first_not_of(" \t\r\n"); + if (s == std::string::npos) return; + p = p.substr(s); + + // Uppercase + for (size_t i = 0; i < p.size(); i++) { + if (p[i] >= 'a' && p[i] <= 'z') p[i] = (char)(p[i] - 'a' + 'A'); + } + + if (p == "ON") { + // ON always turns fan ON (if needed) and starts/restarts 60 min cap timer + id(relay_1_delayed_off).execute(60, true); + return; + } + + if (p == "OFF") { + if (id(Relay_1).state) id(Relay_1).turn_off(); + return; + } + + // Parse 1..60 minutes + char *endptr = nullptr; + long v = strtol(p.c_str(), &endptr, 10); + + if (endptr != nullptr) { + while (*endptr == ' ' || *endptr == '\t' || *endptr == '\r' || *endptr == '\n') endptr++; + } + + if (endptr != nullptr && *endptr == '\0' && v >= 1 && v <= 60) { + // Minor change: ONLY start/restart countdown if fan already ON (do not turn it ON) + if (id(Relay_1).state) { + id(relay_1_delayed_off).execute((int) v, false); + ESP_LOGI("mqtt", "Relay_1 timer restarted: %ld minute(s).", v); + } else { + ESP_LOGI("mqtt", "Ignoring timer value %ld because Relay_1 is OFF.", v); + } + } else { + ESP_LOGW("mqtt", "Ignoring payload '%s' on %s", p.c_str(), "${mqtt_local_command1_topic}"); + } + +########################################################################################## +# SCRIPTS +########################################################################################## +script: + # Starts/restarts mm:ss countdown, and optionally turns fan ON (used for MQTT ON / auto-start) + - id: relay_1_delayed_off + mode: restart + parameters: + off_delay_min: int + ensure_on: bool + then: + - lambda: |- + int m = off_delay_min; + if (m < 1) m = 1; + if (m > 60) m = 60; + + uint32_t dur_ms = (uint32_t) m * 60UL * 1000UL; + id(relay1_end_ms) = millis() + dur_ms; + + id(relay1_remaining_sec) = m * 60; + id(relay1_remaining_min) = m; + + id(relay1_timer_active) = true; + id(relay1_autooff_pending) = false; + + # Optionally ensure fan is ON + - if: + condition: + lambda: |- + return ensure_on; + then: + - if: + condition: + switch.is_off: Relay_1 + then: + - switch.turn_on: Relay_1 + ########################################################################################## # BINARY SENSORS # https://esphome.io/components/binary_sensor/ @@ -168,8 +338,8 @@ binary_sensor: pin: number: GPIO16 mode: INPUT - inverted: True - use_interrupt: false # GPO16 pin doesn't support interrupts so use polling. Only supresses a warning. + inverted: true + use_interrupt: false # GPIO16 pin doesn't support interrupts so use polling. Only suppresses a warning. name: "Button 1: Extract Fan" on_press: - switch.toggle: "Relay_1" @@ -185,14 +355,14 @@ binary_sensor: - delayed_off: 20ms on_multi_click: - # ── Single click: one quick press & release ───────────────── + # -- Single click: one quick press & release -- - timing: - ON for at most 400ms - OFF for at least 200ms then: - switch.toggle: "Relay_2" - # ── Double click: two quick presses/releases ─────────────── + # -- Double click: two quick presses/releases -- - timing: - ON for at most 400ms - OFF for at most 400ms @@ -214,8 +384,37 @@ switch: name: "Relay 1: Extract Fan" pin: GPIO13 id: Relay_1 + + on_turn_on: + then: + # Any direct ON of Relay_1 starts a 60 minute cap timer, EXCEPT: + # - When a timer/script has already started (prevents MQTT "5" being overwritten to 60) + # - When Relay_2 forced Relay_1 ON + - if: + condition: + lambda: |- + if (id(relay1_timer_active)) return false; + if (id(Relay_2).state && id(relay2_forced_relay1)) return false; + return true; + then: + - script.execute: + id: relay_1_delayed_off + off_delay_min: 60 + ensure_on: false + on_turn_off: then: + # Clear timer state + - lambda: |- + id(relay1_timer_active) = false; + id(relay1_remaining_min) = 0; + id(relay1_remaining_sec) = 0; + id(relay1_end_ms) = 0; + id(relay1_autooff_pending) = false; + + # Stop any pending delayed-off countdown if Relay_1 is turned OFF (by any means) + - script.stop: relay_1_delayed_off + # Rule 3: If Relay_1 is turned OFF while Relay_2 is ON, turn OFF Relay_2 too - if: condition: @@ -227,6 +426,7 @@ switch: name: "Relay 2: IR Heater" pin: GPIO12 id: Relay_2 + on_turn_on: then: # Rule 2: Relay_2 implies Relay_1 must be ON @@ -247,19 +447,60 @@ switch: then: - switch.turn_off: Relay_1 + # If a fan timer finished while heater was ON, auto-off fan now (when heater turns OFF) + - if: + condition: + lambda: |- + return id(relay1_autooff_pending); + then: + - lambda: |- + id(relay1_autooff_pending) = false; + - if: + condition: + switch.is_on: Relay_1 + then: + - switch.turn_off: Relay_1 + ########################################################################################## # INTERVAL COMPONENT # https://esphome.io/components/interval/ ########################################################################################## -# An assurance that if Relay_2 (Heater) is ever on, check that Relay_1 (Fan) is on also +# Assurance: if Relay_2 (Heater) is ever on, check that Relay_1 (Fan) is on also ########################################################################################## interval: - interval: 1s then: + # Countdown engine (mm:ss) + - lambda: |- + if (id(relay1_timer_active) && id(Relay_1).state) { + int32_t diff = (int32_t) (id(relay1_end_ms) - millis()); + + if (diff <= 0) { + id(relay1_remaining_sec) = 0; + id(relay1_remaining_min) = 0; + id(relay1_timer_active) = false; + + // If heater is ON, keep fan running but remember to turn it off when heater turns off + if (id(Relay_2).state) { + id(relay1_autooff_pending) = true; + } else { + id(Relay_1).turn_off(); + } + } else { + // Ceil to avoid showing 0:00 early + int sec = (diff + 999) / 1000; + id(relay1_remaining_sec) = sec; + + // Minutes sensor uses ceil minutes (stays 1 until it hits 0:00) + id(relay1_remaining_min) = (sec + 59) / 60; + } + } + + # Safety: if Relay_2 (Heater) is ever on, check that Relay_1 (Fan) is on also - if: condition: and: - switch.is_on: Relay_2 - switch.is_off: Relay_1 then: - - switch.turn_off: Relay_2 + - switch.turn_off: Relay_2 \ No newline at end of file diff --git a/esphome/esp-mainbathroomlights.yaml b/esphome/esp-mainbathroomlights.yaml index 98eee43..af77b66 100644 --- a/esphome/esp-mainbathroomlights.yaml +++ b/esphome/esp-mainbathroomlights.yaml @@ -1,6 +1,7 @@ ########################################################################################## ########################################################################################## # MAIN BATHROOM LIGHTSWITCH 2 (OBK Converted, BK7231N) +# V2.1 2026-02-23 Button 3 multi-click publishes MQTT commands to bathroom fan (1x/2x/3x) # V2.0 2026-02-04 Reflash into new switch (old switch electrical failure, now a BK) # V1.0 2025-11-23 Initial ########################################################################################## @@ -8,7 +9,7 @@ # pinout/schematic https://community.home-assistant.io/t/zemismart-ks-811-working-with-esphome/ # # NOTES -# - +# - # ########################################################################################## ########################################################################################## @@ -22,16 +23,22 @@ substitutions: device_name: "esp-mainbathroomlights" friendly_name: "Main Bathroom Lightswitch (3)" description_comment: "BK7231N - Main Bathroom Lightswitch using a Zemismart KS-811 Triple Push Button. Main Lights (1), Shower Lights (2), Cabinet Lights (3)" - device_area: "Main Bathroom" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. - + device_area: "Main Bathroom" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. + # Project Naming - project_name: "Zemismart Technologies.KS-811 Triple BK7231N" # Project Details - project_version: "v1.0" # Project V denotes release of yaml file, allowing checking of deployed vs latest version + project_name: "Zemismart Technologies.KS-811 Triple BK7231N" # Project Details + project_version: "v2.1" # Project V denotes release of yaml file, allowing checking of deployed vs latest version # Passwords api_key: !secret esp-api_key # unfortunately you can't use substitutions inside secrets names ota_pass: !secret esp-ota_pass # unfortunately you can't use substitutions inside secrets names static_ip_address: !secret esp-garageentrylights2_ip + mqtt_command_main_topic: !secret mqtt_command_main_topic + #mqtt_status_main_topic: !secret mqtt_status_main_topic + + # 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} # Device Settings log_level: "INFO" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE @@ -40,11 +47,23 @@ substitutions: # Default relay restore mode # Options: RESTORE_DEFAULT_OFF, RESTORE_DEFAULT_ON, ALWAYS_ON, ALWAYS_OFF relay_restore_mode: "RESTORE_DEFAULT_OFF" - + # Switch Naming - switch_1_name: "Main Lights" # Only one light (Garage Side) actually connected to this relay - switch_2_name: "Shower Lights" # Output for pario string hanging lights - switch_3_name: "Cabinet Lights" # This is virtual only, no power connected to 3rd relay + switch_1_name: "Main Lights" # Only one light (Garage Side) actually connected to this relay + switch_2_name: "Shower Lights" # Output for pario string hanging lights + switch_3_name: "Cabinet Lights" # This is virtual only, no power connected to 3rd relay + + # MQTT LOCAL Controls + #mqtt_local_device_name: "masterbath-lights" + #mqtt_local_command_topic: "${mqtt_command_main_topic}/${mqtt_local_device_name}" # Topic we will use to command this locally without HA + #mqtt_local_status_topic: "${mqtt_status_main_topic}/${mqtt_local_device_name}" # Topic we will use to view status locally without HA + + # MQTT REMOTE Controls + mqtt_remote_device1_name: "masterbath-fan" + mqtt_remote_device1_command_topic: "${mqtt_command_main_topic}/${mqtt_remote_device1_name}" + mqtt_remote_device1_command1: "10" + mqtt_remote_device1_command2: "20" + mqtt_remote_device1_command3: "30" ########################################################################################## # PACKAGES: Included Common Packages @@ -58,6 +77,7 @@ packages: 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 (choose encryption or no encryption options) #### common_api: !include @@ -71,7 +91,7 @@ packages: file: common/mqtt_common.yaml vars: local_device_name: "${device_name}" - + #### WEB PORTAL #### #common_webportal: !include common/webportal_common.yaml #### SNTP (Only use if you want/need accurate timeclocks) #### @@ -83,7 +103,7 @@ packages: #diag_debug: !include common/include_debug_diag_sensors.yaml #diag_resetcount: !include common/include_resetcount_diag_sensors.yaml - # Package item removals +# Package item removals wifi: min_auth_mode: !remove # The WPA2 only works on esp32/8266 reboot_timeout: 15min # override the selection from from the common package @@ -104,7 +124,7 @@ esphome: project: name: "${project_name}" version: "${project_version}" -# on_boot: +# on_boot: # priority: 200 # then: # - switch.turn_on: Relay_3 @@ -118,18 +138,9 @@ bk72xx: board: generic-bk7231n-qfn32-tuya ########################################################################################## -# ESP Platform and Framework -# https://esphome.io/components/esp32.html -########################################################################################## -#esp8266: -# board: esp01_1m -# early_pin_init: False # Initialise pins early to known values. Recommended false where switches are involved. Defaults to True. -# board_flash_mode: dout # Default is dout - -########################################################################################## # ESPHome Logging Enable # https://esphome.io/components/logger.html -########################################################################################## +########################################################################################## logger: level: "${log_level}" #INFO Level suggested, or DEBUG for testing baud_rate: 0 #set to 0 for no logging via UART, needed if you are using it for other serial things (eg PZEM) @@ -170,8 +181,8 @@ binary_sensor: inverted: true name: "Button 2: ${switch_2_name}" on_press: - - delay: 50ms - - switch.toggle: Relay_2 + - delay: 50ms + - switch.toggle: Relay_2 - platform: gpio pin: @@ -181,11 +192,48 @@ binary_sensor: pullup: true inverted: true name: "Button 3: ${switch_3_name}" + + # Keep existing actions for ANY press (immediate OFF behavior) on_press: - - delay: 50ms - #- switch.turn_off: Relay_1 - - switch.turn_off: Relay_2 - - switch.turn_off: Relay_3 + - delay: 50ms + #- switch.turn_off: Relay_1 + - switch.turn_off: Relay_2 + - switch.turn_off: Relay_3 + + # Also publish MQTT commands depending on 1x/2x/3x press count + on_multi_click: + # -- Single click: one quick press & release -- + - timing: + - ON for at most 400ms + - OFF for at least 200ms + then: + - mqtt.publish: + topic: "${mqtt_remote_device1_command_topic}" + payload: "${mqtt_remote_device1_command1}" + + # -- Double click: two quick presses/releases -- + - timing: + - ON for at most 400ms + - OFF for at most 400ms + - ON for at most 400ms + - OFF for at least 200ms + then: + - mqtt.publish: + topic: "${mqtt_remote_device1_command_topic}" + payload: "${mqtt_remote_device1_command2}" + + # -- Triple click: three quick presses/releases -- + - timing: + - ON for at most 400ms + - OFF for at most 400ms + - ON for at most 400ms + - OFF for at most 400ms + - ON for at most 400ms + - OFF for at least 200ms + then: + - mqtt.publish: + topic: "${mqtt_remote_device1_command_topic}" + payload: "${mqtt_remote_device1_command3}" ########################################################################################## # SWITCH COMPONENT @@ -215,7 +263,6 @@ switch: ########################################################################################## # TEMPLATE COMPONENT -# ########################################################################################## # Virtual group switch for Home Assistant - platform: template diff --git a/esphome/esp-maindishwasherpower.yaml b/esphome/esp-maindishwasherpower.yaml index 826fb1d..0b28e79 100644 --- a/esphome/esp-maindishwasherpower.yaml +++ b/esphome/esp-maindishwasherpower.yaml @@ -31,6 +31,10 @@ substitutions: ota_pass: !secret esp-maindishwasherpower_ota_pass # unfortunately you can't use substitutions inside secrets names static_ip_address: !secret esp-maindishwasherpower_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} + # Device Settings log_level: "INFO" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE update_interval: "60s" # update time for for general sensors etc @@ -51,6 +55,7 @@ packages: 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 (choose encryption or no encryption options) #### common_api: !include diff --git a/esphome/esp-mainkitchenlights.yaml b/esphome/esp-mainkitchenlights.yaml index eda0b7a..5742f50 100644 --- a/esphome/esp-mainkitchenlights.yaml +++ b/esphome/esp-mainkitchenlights.yaml @@ -77,7 +77,7 @@ packages: local_device_name: "${device_name}" #### WEB PORTAL #### - #common_webportal: !include common/webportal_common.yaml + common_webportal: !include common/webportal_common.yaml #### SNTP (Only use if you want/need accurate timeclocks) #### #common_sntp: !include common/sntp_common.yaml diff --git a/esphome/esp-mainpowermonitor.yaml b/esphome/esp-mainpowermonitor.yaml new file mode 100644 index 0000000..0ac8a61 --- /dev/null +++ b/esphome/esp-mainpowermonitor.yaml @@ -0,0 +1,1125 @@ +########################################################################################## +########################################################################################## +# MAIN HOUSE 3 PHASE POWER MONITOR +# Power Monitor using 3x PZEM004 for 3 phase monitoring plus energy monitoring for 32A EV Charger +# +# Hardware: Wemos D1 mini (ESP8266) + 3x PZEM-004T V3 (Modbus) + DHT11 + EV Pulse Input +# +# V1.1 2026-02-21 Fix globals array init compile error, reduce flash writes (globals update_interval) +# V1.0 2026-02-21 Initial Version +# +# NOTES +# - PZEM-004T V3 uses Modbus over UART at 9600 baud. +# - UART is on GPIO1 (TX) and GPIO3 (RX). Logger UART is disabled (baud_rate: 0). +# - You will usually need to disconnect PZEM UART wires while flashing via USB. +# - DHT11 is on GPIO2 (boot-strap pin and D1 mini onboard LED pin). Usually OK, but if you ever +# get boot issues or flaky DHT readings, move DHT to a different GPIO. +# - PZEM "energy" from ESPHome pzemac is in Wh, converted to kWh in this YAML. +# - Phase mapping requested: Red=0, Yellow=1, Blue=2 (Tasmota-style). In ESPHome pzemac Modbus +# addresses must be 0x01..0xF7, so this YAML maps to Red=0x01, Yellow=0x02, Blue=0x03. +# - 365-day history array is RAM-only (not restored) to avoid ESP8266 restore limits and flash wear. +########################################################################################## +########################################################################################## + +########################################################################################## +# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS +########################################################################################## +substitutions: + # Device Naming + device_name: "esp-mainpowermonitor" + friendly_name: "Main House 3 Phase Power Monitor" + description_comment: "Power Monitor using 3x PZEM004 for 3 phase monitoring plus energy monitoring for 32A EV Charger" + device_area: "Data Cupboard" + + # Project Naming + project_name: "Wemos.D1Mini PZEM-004T V3 3-Phase Power Monitor" + project_version: "v1.1" + + # Passwords + api_key: !secret esp-api_key + ota_pass: !secret esp-ota_pass + static_ip_address: !secret esp-mainpowermonitor_ip + mqtt_command_main_topic: !secret mqtt_command_main_topic + + # 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} + + # MQTT Controls + #mqtt_device_name: "main-house-3phase-power" + #mqtt_main_topic: "${mqtt_command_main_topic}/${mqtt_device_name}" + + # Device Settings + log_level: "ERROR" + pzem_update_interval: "10s" + env_update_interval: "60s" + rollover_check_interval: "30s" + + # Globals persistence tuning (gentler on flash) + globals_save_interval_slow: "10min" + globals_save_interval_pulses: "2min" + + # PZEM Modbus Addresses (valid 0x01..0xF7) + # Requested phase IDs: Red=0, Yellow=1, Blue=2 -> map to 0x01, 0x02, 0x03 + pzem_addr_red: "0x01" + pzem_addr_yellow: "0x02" + pzem_addr_blue: "0x03" + + # EV Pulse Settings + ev_pulse_wh: "2.0" # Each pulse is 2Wh + ev_pulse_filter_ms: "50ms" # Debounce / filter + ev_pulse_pin_mode: "INPUT_PULLUP" + + # Names - PZEM (per phase) + pzem_red_v_name: "Main Power Red Voltage" + pzem_red_i_name: "Main Power Red Current" + pzem_red_p_name: "Main Power Red Active Power" + pzem_red_e_name: "Main Power Red Active Energy" + pzem_red_f_name: "Main Power Red Frequency" + pzem_red_pf_name: "Main Power Red Power Factor" + + pzem_yellow_v_name: "Main Power Yellow Voltage" + pzem_yellow_i_name: "Main Power Yellow Current" + pzem_yellow_p_name: "Main Power Yellow Active Power" + pzem_yellow_e_name: "Main Power Yellow Active Energy" + pzem_yellow_f_name: "Main Power Yellow Frequency" + pzem_yellow_pf_name: "Main Power Yellow Power Factor" + + pzem_blue_v_name: "Main Power Blue Voltage" + pzem_blue_i_name: "Main Power Blue Current" + pzem_blue_p_name: "Main Power Blue Active Power" + pzem_blue_e_name: "Main Power Blue Active Energy" + pzem_blue_f_name: "Main Power Blue Frequency" + pzem_blue_pf_name: "Main Power Blue Power Factor" + + # Names - Totals + mains_total_p_name: "Main Power Total Active Power" + mains_total_s_name: "Main Power Total Apparent Power" + mains_total_pf_name: "Main Power Total Power Factor" + mains_total_e_name: "Main Power Total Active Energy" + + mains_today_name: "Main Power Energy Today" + mains_yesterday_name: "Main Power Energy Yesterday" + mains_month_name: "Main Power Energy This Month" + mains_last_month_name: "Main Power Energy Last Month" + mains_year_name: "Main Power Energy This Year" + mains_last_year_name: "Main Power Energy Last Year" + mains_daily_max_365_name: "Main Power Daily Energy Max (365d)" + mains_daily_min_365_name: "Main Power Daily Energy Min (365d)" + + # Names - EV Wallcharger + ev_power_name: "EV Wallcharger Power" + ev_total_energy_name: "EV Wallcharger Total Energy" + ev_today_name: "EV Wallcharger Energy Today" + ev_yesterday_name: "EV Wallcharger Energy Yesterday" + ev_month_name: "EV Wallcharger Energy This Month" + ev_last_month_name: "EV Wallcharger Energy Last Month" + ev_year_name: "EV Wallcharger Energy This Year" + ev_last_year_name: "EV Wallcharger Energy Last Year" + + # Names - Environment + temp_name: "Main Power Monitor Temperature" + hum_name: "Main Power Monitor Humidity" + dew_name: "Main Power Monitor Dew Point" + +########################################################################################## +# 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_history_arrays + - delay: 3s + - script.execute: rollover_check + +########################################################################################## +# ESP Platform and Framework +########################################################################################## +esp8266: + board: d1_mini + restore_from_flash: true + +preferences: + flash_write_interval: 5min + +mdns: + disabled: false + +########################################################################################## +# LOGGING +########################################################################################## +logger: + level: "${log_level}" + baud_rate: 0 + +########################################################################################## +# UART + MODBUS (PZEM-004T V3) +########################################################################################## +uart: + id: uart_pzem_bus + tx_pin: GPIO1 + rx_pin: GPIO3 + baud_rate: 9600 + +modbus: + id: modbus_pzem_bus + send_wait_time: 200ms + +########################################################################################## +# GLOBALS (Energy rollovers + 365-day history + temp min/max) +########################################################################################## +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" + + # Main (3-phase) baseline totals for period calculations (restored) + - id: g_mains_day_start_kwh + type: float + restore_value: yes + update_interval: ${globals_save_interval_slow} + initial_value: "-1.0" + - id: g_mains_month_start_kwh + type: float + restore_value: yes + update_interval: ${globals_save_interval_slow} + initial_value: "-1.0" + - id: g_mains_year_start_kwh + type: float + restore_value: yes + update_interval: ${globals_save_interval_slow} + initial_value: "-1.0" + + # Stored prior periods (restored) + - id: g_mains_yesterday_kwh + type: float + restore_value: yes + update_interval: ${globals_save_interval_slow} + initial_value: "0.0" + - id: g_mains_last_month_kwh + type: float + restore_value: yes + update_interval: ${globals_save_interval_slow} + initial_value: "0.0" + - id: g_mains_last_year_kwh + type: float + restore_value: yes + update_interval: ${globals_save_interval_slow} + initial_value: "0.0" + + # Daily history ring buffer (last 365 days) - RAM ONLY (NOT restored) + # NOTE: Array globals MUST NOT use initial_value: "{}" (causes compile error). + - id: g_mains_daily_hist_kwh + type: float[365] + restore_value: no + - id: g_mains_daily_hist_index + type: int + restore_value: no + initial_value: "0" + - id: g_mains_daily_hist_count + type: int + restore_value: no + 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" + + # Temperature min/max tracking (RAM ONLY - not restored, gentler on flash) + - id: g_temp_today_min + type: float + restore_value: no + initial_value: "9999.0" + - id: g_temp_today_max + type: float + restore_value: no + initial_value: "-9999.0" + + - id: g_temp_yesterday_min + type: float + restore_value: no + initial_value: "9999.0" + - id: g_temp_yesterday_max + type: float + restore_value: no + initial_value: "-9999.0" + + - id: g_temp_month_min + type: float + restore_value: no + initial_value: "9999.0" + - id: g_temp_month_max + type: float + restore_value: no + initial_value: "-9999.0" + + - id: g_temp_last_month_min + type: float + restore_value: no + initial_value: "9999.0" + - id: g_temp_last_month_max + type: float + restore_value: no + initial_value: "-9999.0" + +########################################################################################## +# INTERVAL (periodic rollover checks) +# NOTE: common_sntp may define its own interval. That is OK. +########################################################################################## +interval: + - interval: "${rollover_check_interval}" + then: + - script.execute: rollover_check + +########################################################################################## +# SENSORS +########################################################################################## +sensor: + ######################################################################################## + # PZEM-004T V3 - RED PHASE + ######################################################################################## + - platform: pzemac + id: pzem_red + modbus_id: modbus_pzem_bus + address: ${pzem_addr_red} + update_interval: ${pzem_update_interval} + voltage: + name: "${pzem_red_v_name}" + id: pzem_red_voltage + accuracy_decimals: 1 + current: + name: "${pzem_red_i_name}" + id: pzem_red_current + accuracy_decimals: 3 + power: + name: "${pzem_red_p_name}" + id: pzem_red_power_w + accuracy_decimals: 0 + energy: + name: "${pzem_red_e_name}" + id: pzem_red_energy_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + filters: + - multiply: 0.001 + frequency: + name: "${pzem_red_f_name}" + id: pzem_red_freq_hz + accuracy_decimals: 1 + power_factor: + name: "${pzem_red_pf_name}" + id: pzem_red_pf + accuracy_decimals: 2 + + - platform: template + name: "Main Power Red Apparent Power" + id: pzem_red_apparent_power_va + unit_of_measurement: "VA" + accuracy_decimals: 0 + update_interval: ${pzem_update_interval} + lambda: |- + if (isnan(id(pzem_red_voltage).state) || isnan(id(pzem_red_current).state)) return NAN; + return id(pzem_red_voltage).state * id(pzem_red_current).state; + + - platform: template + name: "Main Power Red Reactive Power" + id: pzem_red_reactive_power_var + unit_of_measurement: "var" + accuracy_decimals: 0 + update_interval: ${pzem_update_interval} + lambda: |- + const float p = id(pzem_red_power_w).state; + const float s = id(pzem_red_apparent_power_va).state; + if (isnan(p) || isnan(s)) return NAN; + const float d = (s * s) - (p * p); + if (d <= 0.0f) return 0.0f; + return sqrtf(d); + + - platform: integration + name: "Main Power Red Apparent Energy" + id: pzem_red_apparent_energy_kvah + sensor: pzem_red_apparent_power_va + time_unit: h + unit_of_measurement: "kVAh" + accuracy_decimals: 3 + filters: + - multiply: 0.001 + + ######################################################################################## + # PZEM-004T V3 - YELLOW PHASE + ######################################################################################## + - platform: pzemac + id: pzem_yellow + modbus_id: modbus_pzem_bus + address: ${pzem_addr_yellow} + update_interval: ${pzem_update_interval} + voltage: + name: "${pzem_yellow_v_name}" + id: pzem_yellow_voltage + accuracy_decimals: 1 + current: + name: "${pzem_yellow_i_name}" + id: pzem_yellow_current + accuracy_decimals: 3 + power: + name: "${pzem_yellow_p_name}" + id: pzem_yellow_power_w + accuracy_decimals: 0 + energy: + name: "${pzem_yellow_e_name}" + id: pzem_yellow_energy_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + filters: + - multiply: 0.001 + frequency: + name: "${pzem_yellow_f_name}" + id: pzem_yellow_freq_hz + accuracy_decimals: 1 + power_factor: + name: "${pzem_yellow_pf_name}" + id: pzem_yellow_pf + accuracy_decimals: 2 + + - platform: template + name: "Main Power Yellow Apparent Power" + id: pzem_yellow_apparent_power_va + unit_of_measurement: "VA" + accuracy_decimals: 0 + update_interval: ${pzem_update_interval} + lambda: |- + if (isnan(id(pzem_yellow_voltage).state) || isnan(id(pzem_yellow_current).state)) return NAN; + return id(pzem_yellow_voltage).state * id(pzem_yellow_current).state; + + - platform: template + name: "Main Power Yellow Reactive Power" + id: pzem_yellow_reactive_power_var + unit_of_measurement: "var" + accuracy_decimals: 0 + update_interval: ${pzem_update_interval} + lambda: |- + const float p = id(pzem_yellow_power_w).state; + const float s = id(pzem_yellow_apparent_power_va).state; + if (isnan(p) || isnan(s)) return NAN; + const float d = (s * s) - (p * p); + if (d <= 0.0f) return 0.0f; + return sqrtf(d); + + - platform: integration + name: "Main Power Yellow Apparent Energy" + id: pzem_yellow_apparent_energy_kvah + sensor: pzem_yellow_apparent_power_va + time_unit: h + unit_of_measurement: "kVAh" + accuracy_decimals: 3 + filters: + - multiply: 0.001 + + ######################################################################################## + # PZEM-004T V3 - BLUE PHASE + ######################################################################################## + - platform: pzemac + id: pzem_blue + modbus_id: modbus_pzem_bus + address: ${pzem_addr_blue} + update_interval: ${pzem_update_interval} + voltage: + name: "${pzem_blue_v_name}" + id: pzem_blue_voltage + accuracy_decimals: 1 + current: + name: "${pzem_blue_i_name}" + id: pzem_blue_current + accuracy_decimals: 3 + power: + name: "${pzem_blue_p_name}" + id: pzem_blue_power_w + accuracy_decimals: 0 + energy: + name: "${pzem_blue_e_name}" + id: pzem_blue_energy_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + filters: + - multiply: 0.001 + frequency: + name: "${pzem_blue_f_name}" + id: pzem_blue_freq_hz + accuracy_decimals: 1 + power_factor: + name: "${pzem_blue_pf_name}" + id: pzem_blue_pf + accuracy_decimals: 2 + + - platform: template + name: "Main Power Blue Apparent Power" + id: pzem_blue_apparent_power_va + unit_of_measurement: "VA" + accuracy_decimals: 0 + update_interval: ${pzem_update_interval} + lambda: |- + if (isnan(id(pzem_blue_voltage).state) || isnan(id(pzem_blue_current).state)) return NAN; + return id(pzem_blue_voltage).state * id(pzem_blue_current).state; + + - platform: template + name: "Main Power Blue Reactive Power" + id: pzem_blue_reactive_power_var + unit_of_measurement: "var" + accuracy_decimals: 0 + update_interval: ${pzem_update_interval} + lambda: |- + const float p = id(pzem_blue_power_w).state; + const float s = id(pzem_blue_apparent_power_va).state; + if (isnan(p) || isnan(s)) return NAN; + const float d = (s * s) - (p * p); + if (d <= 0.0f) return 0.0f; + return sqrtf(d); + + - platform: integration + name: "Main Power Blue Apparent Energy" + id: pzem_blue_apparent_energy_kvah + sensor: pzem_blue_apparent_power_va + time_unit: h + unit_of_measurement: "kVAh" + accuracy_decimals: 3 + filters: + - multiply: 0.001 + + ######################################################################################## + # 3-PHASE TOTALS (Active/Real + Apparent + Reactive + PF) + ######################################################################################## + - platform: template + name: "${mains_total_p_name}" + id: mains_total_active_power_w + unit_of_measurement: "W" + accuracy_decimals: 0 + update_interval: ${pzem_update_interval} + lambda: |- + if (isnan(id(pzem_red_power_w).state) || isnan(id(pzem_yellow_power_w).state) || isnan(id(pzem_blue_power_w).state)) return NAN; + return id(pzem_red_power_w).state + id(pzem_yellow_power_w).state + id(pzem_blue_power_w).state; + + - platform: template + name: "${mains_total_s_name}" + id: mains_total_apparent_power_va + unit_of_measurement: "VA" + accuracy_decimals: 0 + update_interval: ${pzem_update_interval} + lambda: |- + if (isnan(id(pzem_red_apparent_power_va).state) || isnan(id(pzem_yellow_apparent_power_va).state) || isnan(id(pzem_blue_apparent_power_va).state)) return NAN; + return id(pzem_red_apparent_power_va).state + id(pzem_yellow_apparent_power_va).state + id(pzem_blue_apparent_power_va).state; + + - platform: template + name: "Main Power Total Reactive Power" + id: mains_total_reactive_power_var + unit_of_measurement: "var" + accuracy_decimals: 0 + update_interval: ${pzem_update_interval} + lambda: |- + if (isnan(id(pzem_red_reactive_power_var).state) || isnan(id(pzem_yellow_reactive_power_var).state) || isnan(id(pzem_blue_reactive_power_var).state)) return NAN; + return id(pzem_red_reactive_power_var).state + id(pzem_yellow_reactive_power_var).state + id(pzem_blue_reactive_power_var).state; + + - platform: template + name: "${mains_total_pf_name}" + id: mains_total_pf + accuracy_decimals: 2 + update_interval: ${pzem_update_interval} + lambda: |- + const float p = id(mains_total_active_power_w).state; + const float s = id(mains_total_apparent_power_va).state; + if (isnan(p) || isnan(s) || s <= 0.0f) return NAN; + float pf = p / s; + if (pf < 0.0f) pf = 0.0f; + if (pf > 1.0f) pf = 1.0f; + return pf; + filters: + - round: 2 + + - platform: template + name: "${mains_total_e_name}" + id: mains_total_active_energy_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: ${pzem_update_interval} + lambda: |- + if (isnan(id(pzem_red_energy_kwh).state) || isnan(id(pzem_yellow_energy_kwh).state) || isnan(id(pzem_blue_energy_kwh).state)) return NAN; + return id(pzem_red_energy_kwh).state + id(pzem_yellow_energy_kwh).state + id(pzem_blue_energy_kwh).state; + + ######################################################################################## + # MAIN ENERGY PERIOD TOTALS (Today / Yesterday / Month / Year) + ######################################################################################## + - platform: template + name: "${mains_today_name}" + id: mains_energy_today_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: 60s + lambda: |- + const float total = id(mains_total_active_energy_kwh).state; + if (isnan(total) || id(g_mains_day_start_kwh) < 0.0f) return NAN; + float v = total - id(g_mains_day_start_kwh); + if (v < 0.0f) v = 0.0f; + return v; + + - platform: template + name: "${mains_yesterday_name}" + id: mains_energy_yesterday_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: 5min + lambda: |- + return id(g_mains_yesterday_kwh); + + - platform: template + name: "${mains_month_name}" + id: mains_energy_this_month_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: 5min + lambda: |- + const float total = id(mains_total_active_energy_kwh).state; + if (isnan(total) || id(g_mains_month_start_kwh) < 0.0f) return NAN; + float v = total - id(g_mains_month_start_kwh); + if (v < 0.0f) v = 0.0f; + return v; + + - platform: template + name: "${mains_last_month_name}" + id: mains_energy_last_month_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: 10min + lambda: |- + return id(g_mains_last_month_kwh); + + - platform: template + name: "${mains_year_name}" + id: mains_energy_this_year_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: 10min + lambda: |- + const float total = id(mains_total_active_energy_kwh).state; + if (isnan(total) || id(g_mains_year_start_kwh) < 0.0f) return NAN; + float v = total - id(g_mains_year_start_kwh); + if (v < 0.0f) v = 0.0f; + return v; + + - platform: template + name: "${mains_last_year_name}" + id: mains_energy_last_year_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: 10min + lambda: |- + return id(g_mains_last_year_kwh); + + - platform: template + name: "${mains_daily_max_365_name}" + id: mains_daily_energy_max_365d_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: 10min + lambda: |- + if (id(g_mains_daily_hist_count) <= 0) return NAN; + float maxv = NAN; + for (int i = 0; i < 365; i++) { + const float v = id(g_mains_daily_hist_kwh)[i]; + if (isnan(v)) continue; + if (isnan(maxv) || v > maxv) maxv = v; + } + return maxv; + + - platform: template + name: "${mains_daily_min_365_name}" + id: mains_daily_energy_min_365d_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: 10min + lambda: |- + if (id(g_mains_daily_hist_count) <= 0) return NAN; + float minv = NAN; + for (int i = 0; i < 365; i++) { + const float v = id(g_mains_daily_hist_kwh)[i]; + if (isnan(v)) continue; + if (isnan(minv) || v < minv) minv = v; + } + return minv; + + ######################################################################################## + # DHT11 (Temp / Humidity / Dewpoint) + TEMP MIN/MAX (Today / Yesterday / Month / Last Month) + ######################################################################################## + - platform: dht + pin: GPIO2 + model: DHT11 + update_interval: ${env_update_interval} + temperature: + name: "${temp_name}" + id: env_temp + unit_of_measurement: "°C" + accuracy_decimals: 1 + on_value: + then: + - script.execute: update_temp_minmax + humidity: + name: "${hum_name}" + id: env_humidity + unit_of_measurement: "%" + accuracy_decimals: 0 + + - platform: template + name: "${dew_name}" + id: env_dewpoint + unit_of_measurement: "°C" + accuracy_decimals: 1 + update_interval: ${env_update_interval} + lambda: |- + const float t = id(env_temp).state; + const float rh = id(env_humidity).state; + if (isnan(t) || isnan(rh) || rh <= 0.0f) return NAN; + const float h = rh / 100.0f; + const float a = 17.67f; + const float b = 243.5f; + const float gamma = (a * t) / (b + t) + logf(h); + return (b * gamma) / (a - gamma); + + - platform: template + name: "Main Power Temp Min Today" + id: temp_min_today + unit_of_measurement: "°C" + accuracy_decimals: 1 + update_interval: 2min + lambda: |- + return (id(g_temp_today_min) > 9000.0f) ? NAN : id(g_temp_today_min); + + - platform: template + name: "Main Power Temp Max Today" + id: temp_max_today + unit_of_measurement: "°C" + accuracy_decimals: 1 + update_interval: 2min + lambda: |- + return (id(g_temp_today_max) < -9000.0f) ? NAN : id(g_temp_today_max); + + - platform: template + name: "Main Power Temp Min Yesterday" + id: temp_min_yesterday + unit_of_measurement: "°C" + accuracy_decimals: 1 + update_interval: 10min + lambda: |- + return (id(g_temp_yesterday_min) > 9000.0f) ? NAN : id(g_temp_yesterday_min); + + - platform: template + name: "Main Power Temp Max Yesterday" + id: temp_max_yesterday + unit_of_measurement: "°C" + accuracy_decimals: 1 + update_interval: 10min + lambda: |- + return (id(g_temp_yesterday_max) < -9000.0f) ? NAN : id(g_temp_yesterday_max); + + - platform: template + name: "Main Power Temp Min This Month" + id: temp_min_month + unit_of_measurement: "°C" + accuracy_decimals: 1 + update_interval: 30min + lambda: |- + return (id(g_temp_month_min) > 9000.0f) ? NAN : id(g_temp_month_min); + + - platform: template + name: "Main Power Temp Max This Month" + id: temp_max_month + unit_of_measurement: "°C" + accuracy_decimals: 1 + update_interval: 30min + lambda: |- + return (id(g_temp_month_max) < -9000.0f) ? NAN : id(g_temp_month_max); + + - platform: template + name: "Main Power Temp Min Last Month" + id: temp_min_last_month + unit_of_measurement: "°C" + accuracy_decimals: 1 + update_interval: 60min + lambda: |- + return (id(g_temp_last_month_min) > 9000.0f) ? NAN : id(g_temp_last_month_min); + + - platform: template + name: "Main Power Temp Max Last Month" + id: temp_max_last_month + unit_of_measurement: "°C" + accuracy_decimals: 1 + update_interval: 60min + lambda: |- + return (id(g_temp_last_month_max) < -9000.0f) ? NAN : id(g_temp_last_month_max); + + ######################################################################################## + # EV WALLCHARGER PULSE ENERGY (GPIO14) + # - pulse_counter reports pulses/minute + # - Power (W) = pulses_per_min * (Wh_per_pulse) * 60 + ######################################################################################## + - platform: pulse_counter + id: ev_pulse_rate_ppm + pin: + number: GPIO14 + mode: ${ev_pulse_pin_mode} + inverted: false + update_interval: 10s + internal_filter: ${ev_pulse_filter_ms} + name: "${ev_power_name}" + unit_of_measurement: "W" + accuracy_decimals: 0 + filters: + - lambda: |- + return x * (${ev_pulse_wh} * 60.0f); + 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_total_energy_name}" + id: ev_total_energy_kwh + unit_of_measurement: "kWh" + accuracy_decimals: 3 + update_interval: 10s + 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" + 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" + 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" + 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" + 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" + 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" + accuracy_decimals: 3 + update_interval: 60min + lambda: |- + return id(g_ev_last_year_kwh); + +########################################################################################## +# SCRIPTS +########################################################################################## +script: + # Initialize RAM-only history arrays and counters + - id: init_history_arrays + mode: queued + then: + - lambda: |- + for (int i = 0; i < 365; i++) { + id(g_mains_daily_hist_kwh)[i] = NAN; + } + id(g_mains_daily_hist_index) = 0; + id(g_mains_daily_hist_count) = 0; + + # Period rollover check (day/month/year) for: + # - Main total energy (sum of 3 PZEM channels) + # - EV total energy (pulse derived) + # - Temperature min/max rollovers (today/yesterday, month/last_month) + - id: 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 mains_total = id(mains_total_active_energy_kwh).state; + 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(mains_total)) { + id(g_mains_day_start_kwh) = mains_total; + id(g_mains_month_start_kwh) = mains_total; + id(g_mains_year_start_kwh) = mains_total; + } + 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; + } + + // Helper: protect against meter reset / wrap (total < baseline) + auto clamp_baseline = [&](float total, float &baseline) { + if (baseline < 0.0f) baseline = total; + if (total < baseline) baseline = total; + }; + + if (!isnan(mains_total)) { + clamp_baseline(mains_total, id(g_mains_day_start_kwh)); + clamp_baseline(mains_total, id(g_mains_month_start_kwh)); + clamp_baseline(mains_total, id(g_mains_year_start_kwh)); + } + if (!isnan(ev_total)) { + clamp_baseline(ev_total, id(g_ev_day_start_kwh)); + clamp_baseline(ev_total, id(g_ev_month_start_kwh)); + clamp_baseline(ev_total, id(g_ev_year_start_kwh)); + } + + // --- YEAR ROLLOVER --- + if (y != id(g_last_year)) { + if (!isnan(mains_total) && id(g_mains_year_start_kwh) >= 0.0f) { + float ykwh = mains_total - id(g_mains_year_start_kwh); + if (ykwh < 0.0f) ykwh = 0.0f; + id(g_mains_last_year_kwh) = ykwh; + id(g_mains_year_start_kwh) = mains_total; + } + 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(mains_total) && id(g_mains_month_start_kwh) >= 0.0f) { + float mkwh = mains_total - id(g_mains_month_start_kwh); + if (mkwh < 0.0f) mkwh = 0.0f; + id(g_mains_last_month_kwh) = mkwh; + id(g_mains_month_start_kwh) = mains_total; + } + 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; + } + + // Temperature month rollover + id(g_temp_last_month_min) = id(g_temp_month_min); + id(g_temp_last_month_max) = id(g_temp_month_max); + id(g_temp_month_min) = 9999.0f; + id(g_temp_month_max) = -9999.0f; + + id(g_last_month) = m; + } + + // --- DAY ROLLOVER --- + if (d != id(g_last_day)) { + if (!isnan(mains_total) && id(g_mains_day_start_kwh) >= 0.0f) { + float dkwh = mains_total - id(g_mains_day_start_kwh); + if (dkwh < 0.0f) dkwh = 0.0f; + id(g_mains_yesterday_kwh) = dkwh; + + // Push into RAM ring buffer + int idx = id(g_mains_daily_hist_index); + if (idx < 0) idx = 0; + if (idx > 364) idx = 0; + id(g_mains_daily_hist_kwh)[idx] = dkwh; + idx = (idx + 1) % 365; + id(g_mains_daily_hist_index) = idx; + if (id(g_mains_daily_hist_count) < 365) id(g_mains_daily_hist_count)++; + + id(g_mains_day_start_kwh) = mains_total; + } + 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; + } + + // Temperature day rollover + id(g_temp_yesterday_min) = id(g_temp_today_min); + id(g_temp_yesterday_max) = id(g_temp_today_max); + id(g_temp_today_min) = 9999.0f; + id(g_temp_today_max) = -9999.0f; + + id(g_last_day) = d; + } + + # Update temperature min/max (today + month) + - id: update_temp_minmax + mode: queued + then: + - lambda: |- + const float t = id(env_temp).state; + if (isnan(t)) return; + + if (t < id(g_temp_today_min)) id(g_temp_today_min) = t; + if (t > id(g_temp_today_max)) id(g_temp_today_max) = t; + + if (t < id(g_temp_month_min)) id(g_temp_month_min) = t; + if (t > id(g_temp_month_max)) id(g_temp_month_max) = t; + +########################################################################################## +# SWITCHES +########################################################################################## +switch: + - platform: restart + name: "Main Power Monitor Restart" \ No newline at end of file diff --git a/esphome/esp-poollightpower copy.yaml.old b/esphome/esp-poollightpower copy.yaml.old deleted file mode 100644 index c17588f..0000000 --- a/esphome/esp-poollightpower copy.yaml.old +++ /dev/null @@ -1,1038 +0,0 @@ -########################################################################################## -########################################################################################## -# POOL LIGHT POWER AND TIMER -# Controlled by a Athom Smart Plug V1 -# -# dashboard_import: -# package_import_url: github://athom-tech/esp32-configs/athom-smart-plug.yaml -# -# V3.0 2025-09-02 Pool Light mode controller fixes; Mode select forces Operation=ON; -# added Mode 0=OFF; select shows OFF when relay off; enforce 7s min OFF window -# V2.4 2025-06-15 Changed back to an Athom V1 (esp8266) -# V2.3 2025-06-15 Changed to an Athom V3 (esp32) -# V2.2 2025-06-14 Fixes to offline time when sntp/network unavailable -# V2.1 2025-06-12 Added select and button to chose modes, added countdown & startup to boost -# V2.0 2025-06-05 YAML Tidyups -# -# INSTRUCTIONS -# - It allows the device to work in a standalone timer style operation -# - The timer has a morning and evening time (but no weekday/weekend settings) -# - Default values are set, but changed values are remembered in flash -# - It uses SNTP for time setting (but obviously only if wifi & networking are working) -# - It will default to an internal timer if no wifi. To reset internal timer, reboot the device at 12pm (noon) -# - If on a network and there is a MQTT server, you can set the on/off times via MQTT (See below commands) -# - You can set 4 modes ON/OFF/TIMER/BOOST via MQTT. Setting BOOST gives you a oneshot operation -# - Any new timer times set via MQTT will be remembered though a reboot -# - On startup, or a reboot, the device will always turn on for the BOOST Duration (BOOST mode, default 2 hours) -# - TIMER mode will always be switched on after BOOST mode is complete -# - Home Assistant entities are set so that BOOST mode can be pressed with a button and other modes selectable with a dropdown -# - If you need it ON continuously with no MQTT, toggle power ON/OFF 4 times within 30 seconds (with ~2 secs in between to allow it to boot) -# -# MQTT Commands -# Values will be set in place on the update_interval time, not immediately -# Use 00:00 in 24hr format for time setting. (Note there is no weekday/weekend setting) -# mqtt_timer_topic/morning-on/06:00 : Time device will go on -# mqtt_timer_topic/morning-off/08:00 : Time device will go off -# mqtt_timer_topic/evening-on/09:00 : Time device will go on -# mqtt_timer_topic/evening-off/00:00 : Time device will go off -# mqtt_timer_topic/boost-time/0000 : Time in minutes device will temporarily go on for (1-1439) -# mqtt_timer_topic/operation/ON : Device permanently on -# mqtt_timer_topic/operation/OFF : Device permanently off -# mqtt_timer_topic/operation/TIMER : Device will obey timer settings -# mqtt_timer_topic/operation/BOOST : Turn on for (boost_duration) minutes then BOOST (also on startup) -# -# operation_mode: -# 0 = OFF -# 1 = ON -# 2 = TIMER -# 3 = BOOST -# -########################################################################################## -########################################################################################## - - -########################################################################################## -# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS -# If NOT using a secrets file, just replace these with the passwords etc (in quotes) -########################################################################################## -substitutions: - # Device Naming - device_name: "esp-poollightpower" - friendly_name: "Pool Light Power" - description_comment: "Pool Light Power :: Athom Smart Plug Power V1" - device_area: "Outside" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. - - # Project Naming - project_name: "Athom Technology.Smart Plug V1" # Project Details - project_version: "v3.0" # Project V denotes release of yaml file, allowing checking of deployed vs latest version - - # Passwords - api_key: !secret esp-api_key # unfortunately you can't use substitutions inside secrets names - ota_pass: !secret esp-ota_pass # unfortunately you can't use substitutions inside secrets names - static_ip_address: !secret esp-poollightpower_ip - - # Device Settings - log_level: "NONE" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE - update_interval: "10s" # update time for for general sensors etc - - # Device Settings - relay_icon: "mdi:power-socket-au" - current_limit : "10" # Current Limit in Amps. AU Plug = 10. IL, BR, EU, UK, US Plug = 16. - mqtt_timer_topic: "viewroad-commands/poollight-timer" # Topics you will use to change stuff - boost_duration_default: "180" # Minutes to stay ON in BOOST mode before reverting to TIMER - morning_on_default: "450" # Default in minutes from midnight. Default 07:30 => 450 - morning_off_default: "450" # Default in minutes from midnight. Default 07:30 => 450 (same as ON as no need for morning schedule) - evening_on_default: "1140" # Default in minutes from midnight. Default 19:00 => 1140 - evening_off_default: "1350" # Default in minutes from midnight. Default 22:30 => 1350 => 1440 is midnight - - ############################################################################## - # POOL LIGHT MODE CONTROL (new) - ############################################################################## - max_modes: "16" # total number of modes in the cycle - mode_pulse_off: "400ms" # short OFF between quick pulses - mode_pulse_on: "400ms" # short ON after each quick pulse - mode_reset_off: "3s" # long OFF to force hardware reset to mode 1 - mode_reset_window_ms: "3000" # treat OFF>=this ms as a reset-to-1 when turning back ON - mode_prime_on: "800ms" # if relay is OFF when a color is selected, turn ON for this long before pulsing - # Ignored modes bitmask: bit (mode_index-1). For 4 and 5 ignored -> (1<<3)|(1<<4) = 24. - ignored_modes_mask: "8288" # Ignore 6(32), 7(64), 14(8192) :: 32 + 64 + 8192 = 8288 - -########################################################################################## -# PACKAGES: Included Common Packages -# https://esphome.io/components/packages.html -########################################################################################## -packages: - ################################################## - # MANDATORY packages (won't compile without them!) - ################################################## - - #### 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}" - - ################################################## - # OPTIONAL packages (comment if you don't want them) - ################################################## - - #### HOME ASSISTANT API (choose encryption or no encryption options) #### - common_api: !include - file: common/api_common.yaml - #file: common/api_common_noencryption.yaml - vars: - local_api_key: "${api_key}" - - #### WEB PORTAL #### - #common_webportal: !include - # file: common/webportal_common.yaml - - #### MQTT #### - common_mqtt: !include - file: common/mqtt_common.yaml - vars: - local_device_name: "${device_name}" - - #### SNTP (Only use if you want/need accurate timeclocks) #### - common_sntp: !include - file: common/sntp_common.yaml - - #### DIAGNOSTICS Lite Sensors #### - common_lite_sensors: !include - file: common/sensors_common_lite.yaml - - #### DIAGNOSTICS General Sensors #### - common_general_sensors: !include - file: common/sensors_common.yaml - - #### DEBUGGING Sensors #### - # A count of various resets - #common_resetcount_debugging: !include - # file: common/resetcount_common.yaml - - # Device Specific included packages - common_athompowermonV1: !include - file: common/athompowermonv1_common.yaml - vars: - local_friendly_name: "${friendly_name}" - local_current_limit: "${current_limit}" - -########################################################################################## -# 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}" - name_add_mac_suffix: False - min_version: 2024.6.0 - project: - name: "${project_name}" - version: "${project_version}" - #platformio_options: - # build_unflags: - # - -std=gnu++20 - # - -std=gnu++2a - # build_flags: - # - -std=gnu++11 - # - -Os - # - -Wl,--gc-sections - # - -fno-exceptions - # - -fno-rtti - -########################################################################################## -# ESP Platform and Framework -# https://esphome.io/components/esp32.html -########################################################################################## -esp8266: - board: esp8285 - restore_from_flash: true # mainly for calculating cumulative energy, but not that important here - #framework: - # version: 2.7.4 - -preferences: - flash_write_interval: 10min - -mdns: - disabled: True # binary size saving - -########################################################################################## -# ESPHome Logging Enable -# https://esphome.io/components/logger.html -########################################################################################## -logger: - level: "${log_level}" #INFO Level suggested, or DEBUG for testing - baud_rate: 0 #set to 0 for no logging via UART, needed if you are using it for other serial things (eg PZEM) - #esp8266_store_log_strings_in_flash: false - #tx_buffer_size: 64 - -########################################################################################## -# Global Variables for use in automations etc -# https://esphome.io/guides/automations.html?highlight=globals#global-variables -########################################################################################## -globals: - - # Tracks the time (in seconds from midnight) at the previous boot - - id: last_boot_time_s - type: int - restore_value: true - initial_value: "0" - - # Counts how many consecutive boots have occurred (within X seconds) - - id: boot_count - type: int - restore_value: true - initial_value: "0" - - # Morning On time (minutes from midnight), - - id: morning_on - type: int - restore_value: true - initial_value: "${morning_on_default}" - - # Morning Off time (minutes from midnight), - - id: morning_off - type: int - restore_value: true - initial_value: "${morning_off_default}" - - # Evening On time (minutes from midnight), - - id: evening_on - type: int - restore_value: true - initial_value: "${evening_on_default}" - - # Evening Off time (minutes from midnight), - - id: evening_off - type: int - restore_value: true - initial_value: "${evening_off_default}" - - # Boost Duration (minutes), - - id: boost_duration - type: int - restore_value: true - initial_value: "${boost_duration_default}" - - #################################################### - # operation_mode: - # 0 = OFF - # 1 = ON - # 2 = TIMER - # 3 = BOOST - #################################################### - - id: operation_mode - type: int - restore_value: true - initial_value: "2" - - #################################################### - # current_mins is set if SNTP is invalid. - # We assume user powers on the device at 12:00 noon - # => 12 * 60 = 720 minutes from midnight. - #################################################### - - id: current_mins - type: int - restore_value: true - initial_value: "720" # 720 is 12:00 Noon - - #################################################### - # boost_timer: counts minutes in BOOST mode - # After 'boost_duration' minutes, revert to TIMER. - #################################################### - - id: boost_timer - type: int - restore_value: true - initial_value: "0" - - ############################################################################## - # POOL LIGHT MODE CONTROL GLOBALS (new) - ############################################################################## - # Current assumed pool-light mode (0..max_modes). 0 means relay OFF. - - id: current_mode - type: int - restore_value: false - initial_value: "1" - - # Total modes in the cycle (edit in substitutions). - - id: max_modes - type: int - restore_value: false - initial_value: "${max_modes}" - - # Flag while we are actively pulsing to change modes (prevents scheduler interference). - - id: mode_changing - type: bool - restore_value: false - initial_value: "false" - - # Timestamp of last relay off (for delta measurement). - - id: last_off_ms - type: uint32_t - restore_value: false - initial_value: "0" - - # Millisecond threshold that separates "quick pulse" vs "reset to mode 1". - - id: reset_window_ms - type: int - restore_value: false - initial_value: "${mode_reset_window_ms}" - - # Desired target mode for the cycle script. - - id: desired_mode - type: int - restore_value: false - initial_value: "1" - - # Bitmask of ignored modes (1..max_modes). Example: ignore 4 & 5 -> 24. - - id: ignored_mask - type: int - restore_value: false - initial_value: "${ignored_modes_mask}" - - # Internal: whether a recheck is already scheduled for min OFF enforcement. - - id: recheck_pending - type: bool - restore_value: false - initial_value: "false" - -########################################################################################## -# Text Sensors -# https://esphome.io/components/text_sensor/index.html -########################################################################################## -text_sensor: - -############################ -# MQTT Subscriptions -############################ - #################################################### - # Subscribe to the Morning On time, format "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Morning On Time Setting" - id: morning_on_topic - topic: "${mqtt_timer_topic}/morning-on" # Stored in the format HH:MM - internal: True - on_value: - then: - - lambda: |- - // Expect "HH:MM" => total length = 5, with ':' - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); // "HH" - int minute = atoi(x.substr(3, 2).c_str()); // "MM" - id(morning_on) = hour * 60 + minute; - ESP_LOGI("timer","Received new Morning On: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Morning On format: %s", x.c_str()); - } - #################################################### - # Morning Off time => "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Morning Off Time Setting" - id: morning_off_topic - topic: "${mqtt_timer_topic}/morning-off" # Stored in the format HH:MM - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); - int minute = atoi(x.substr(3, 2).c_str()); - id(morning_off) = hour * 60 + minute; - ESP_LOGI("timer","Received new Morning Off: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Morning Off format: %s", x.c_str()); - } - #################################################### - # Evening On time => "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Evening On Time Setting" - id: evening_on_topic - topic: "${mqtt_timer_topic}/evening-on" # Stored in the format HH:MM - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); - int minute = atoi(x.substr(3, 2).c_str()); - id(evening_on) = hour * 60 + minute; - ESP_LOGI("timer","Received new Evening On: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Evening On format: %s", x.c_str()); - } - #################################################### - # Evening Off time => "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Evening Off Time Setting" - id: evening_off_topic - topic: "${mqtt_timer_topic}/evening-off" # Stored in the format HH:MM - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); - int minute = atoi(x.substr(3, 2).c_str()); - id(evening_off) = hour * 60 + minute; - ESP_LOGI("timer","Received new Evening Off: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Evening Off format: %s", x.c_str()); - } - #################################################### - # Boost duration => 1 - 1439 - #################################################### - - platform: mqtt_subscribe - name: "Boost Duration" - id: boost_time_topic - topic: "${mqtt_timer_topic}/boost-time" # Stored as an integer from 1-1439 - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - // parse as integer - char *endptr; - long v = strtol(x.c_str(), &endptr, 10); - - // invalid if nothing parsed, trailing chars, or out of 0–1439 - if (endptr == x.c_str() || *endptr != '\0' || v < 0 || v > 1439) { - ESP_LOGE("boost_time", "Invalid boost_time '%s'", x.c_str()); - } else { - id(boost_duration) = static_cast(v); - } - - #################################################### - # Subscribe to operation mode: - # OFF, ON, TIMER, BOOST - #################################################### - # MQTT subscription: set mode, then immediately re-evaluate relay - - platform: mqtt_subscribe - id: timer_operation_mode_topic - topic: "${mqtt_timer_topic}/operation" - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (strcasecmp(x.c_str(), "TIMER") == 0) { - id(operation_mode) = 2; - ESP_LOGI("timer","Operation mode set to TIMER"); - } else if (strcasecmp(x.c_str(), "ON") == 0) { - id(operation_mode) = 1; - ESP_LOGI("timer","Operation mode set to ON"); - } else if (strcasecmp(x.c_str(), "OFF") == 0) { - id(operation_mode) = 0; - ESP_LOGI("timer","Operation mode set to OFF"); - } else if (strcasecmp(x.c_str(), "BOOST") == 0) { - id(operation_mode) = 3; - id(boost_timer) = 0; - ESP_LOGI("timer","Operation mode set to BOOST"); - } else { - ESP_LOGW("timer","Invalid operation mode: %s", x.c_str()); - } - - script.execute: evaluate_relay_state - - ###################################################### - # Expose the current operation mode (OFF, ON, TIMER, BOOST) - ###################################################### - - platform: template - name: "Operation Mode State" - lambda: |- - // 0=OFF, 1=ON, 2=TIMER, 3=BOOST - switch (id(operation_mode)) { - case 0: return {"OFF"}; - case 1: return {"ON"}; - case 2: return {"TIMER"}; - case 3: return {"BOOST"}; - default: return {"UNKNOWN"}; - } - update_interval: 5s - - ###################################################### - # Expose the "Morning On" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Morning On Time" - lambda: |- - int hour = id(morning_on) / 60; - int minute = id(morning_on) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - - ###################################################### - # Expose the "Morning Off" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Morning Off Time" - lambda: |- - int hour = id(morning_off) / 60; - int minute = id(morning_off) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - - ###################################################### - # Expose the "Evening On" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Evening On Time" - lambda: |- - int hour = id(evening_on) / 60; - int minute = id(evening_on) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - - ###################################################### - # Expose the "Evening Off" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Evening Off Time" - lambda: |- - int hour = id(evening_off) / 60; - int minute = id(evening_off) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - -########################################################################################## -# BINARY SENSORS -# https://esphome.io/components/binary_sensor/ -########################################################################################## -binary_sensor: - - platform: gpio - pin: - number: GPIO03 - mode: INPUT_PULLUP - inverted: true - name: "Power Button" - id: power_button - filters: - - delayed_on: 20ms - on_click: - - min_length: 20ms - max_length: 500ms - then: - - lambda: |- - if (id(relay).state) { - // Relay is ON: turn it OFF and set mode to 2 (TIMER) - id(relay).turn_off(); - id(operation_mode) = 2; - } else { - // Relay is OFF: turn it ON and set mode to 3 (BOOST) - id(relay).turn_on(); - id(operation_mode) = 3; - } - - - platform: template - name: "Relay Status" - lambda: |- - return id(relay).state; - -########################################################################################## -# Sensors -# https://esphome.io/components/text_sensor/index.html -########################################################################################## -sensor: - - platform: template - name: "Timeclock: Boost Duration" - id: boost_duration_time - unit_of_measurement: "mins" - accuracy_decimals: "0" - update_interval: "${update_interval}" - lambda: |- - return id(boost_duration); - - - platform: template - name: "Mins from Midnight" - id: mins_from_midnight - unit_of_measurement: "mins" - accuracy_decimals: "0" - update_interval: "${update_interval}" - internal: True # No need to show this in Home Assistant - lambda: |- - return id(current_mins); - - # A value in mins if a timer is running showing how many mins left - - platform: template - name: "Timer Minutes Remaining" - id: timer_minutes_remaining - unit_of_measurement: "Mins" - update_interval: 5s - accuracy_decimals: "0" - lambda: |- - // always zero if relay is off - if (!id(relay).state) { - return 0; - } - int rem = 0; - // only calculate for mode 2 (scheduled) or mode 3 (BOOST) - if (id(operation_mode) == 2) { - int a = id(morning_off) - id(current_mins); - int b = id(evening_off) - id(current_mins); - // if a is negative, use b; otherwise pick the smaller of a or b - rem = (a < 0) ? b : (a < b ? a : b); - } - else if (id(operation_mode) == 3) { - rem = id(boost_duration) - id(boost_timer); - } - // never return negative - return rem > 0 ? rem : 0; - - -################################################################################################# -# SWITCH COMPONENT -# https://esphome.io/components/switch/ -################################################################################################# -switch: - - platform: gpio - name: "Power Output" - pin: GPIO14 - id: relay - restore_mode: RESTORE_DEFAULT_OFF # Ensures the relay is restored (or off) at boot - #internal: true # Hides the switch from Home Assistant - icon: "${relay_icon}" - # POOL LIGHT MODE CONTROL: detect quick vs long off periods and keep current_mode in sync - on_turn_off: - - lambda: |- - id(last_off_ms) = millis(); - if (!id(mode_changing)) id(current_mode) = 0; // reflect OFF in select when off normally - on_turn_on: - - lambda: |- - if (id(mode_changing)) { - return; - } - uint32_t delta = millis() - id(last_off_ms); - if (delta >= (uint32_t)id(reset_window_ms)) { - id(current_mode) = 1; - } else { - id(current_mode)++; - if (id(current_mode) > id(max_modes)) id(current_mode) = 1; // keep internal index sane - } - -################################################################################################# -# BUTTON COMPONENT -# https://esphome.io/components/button/index.html -################################################################################################# -button: - - platform: template - name: "Boost now" - id: boost_button - icon: "mdi:play-circle-outline" - on_press: - # 1) reset BOOST timer and set mode - - lambda: |- - id(boost_timer) = 0; - id(operation_mode) = 3; - # 2) immediately re-evaluate relay state - - script.execute: evaluate_relay_state - - platform: template - name: "Default timer settings" - id: default_timer_settings_button - icon: "mdi:restore" - on_press: - - lambda: |- - // Restore all timing globals to their YAML defaults - id(morning_on) = ${morning_on_default}; - id(morning_off) = ${morning_off_default}; - id(evening_on) = ${evening_on_default}; - id(evening_off) = ${evening_off_default}; - id(boost_duration)= ${boost_duration_default}; - // Reset mode to TIMER and clear any running boost - id(operation_mode)= 2; - id(boost_timer) = 0; - ESP_LOGI("timer","Default timer settings applied"); - - script.execute: evaluate_relay_state - -################################################################################################# -# SELECT COMPONENT -# https://esphome.io/components/select/index.html -################################################################################################# -select: - - platform: template - name: "Operation Mode" - id: operation_mode_select - update_interval: 5s - options: - - "OFF" - - "ON" - - "TIMER" - - "BOOST" - - # show the current mode - lambda: |- - switch (id(operation_mode)) { - case 1: return std::string("ON"); - case 2: return std::string("TIMER"); - case 3: return std::string("BOOST"); - default: return std::string("OFF"); - } - - # when changed in HA, set mode & re-evaluate - set_action: - - lambda: |- - if (x == "OFF") { id(operation_mode) = 0; } - else if (x == "ON") { id(operation_mode) = 1; } - else if (x == "TIMER") { id(operation_mode) = 2; } - else { // BOOST - id(boost_timer) = 0; - id(operation_mode) = 3; - } - - script.execute: evaluate_relay_state - - ############################################################################## - # POOL LIGHT MODE SELECT (new) - # 0=OFF -> immediately turns relay OFF and sets Operation Mode = TIMER. - # Selecting a color (>=1) forces Operation Mode = ON. - # If target < current, do a long reset to mode 1, then pulse forward. - # Ignored modes are hidden here but still counted by pulses. - ############################################################################## - ############################################################################## - # POOL LIGHT MODE SELECT (updated display logic) - # 0=OFF -> immediately turns relay OFF and sets Operation Mode = TIMER. - # Selecting a color (>=1) forces Operation Mode = ON. - # If target < current, do a long reset to mode 1, then pulse forward. - # Ignored modes are hidden here but still counted by pulses. - ############################################################################## - - platform: template - name: "Pool Light Mode" - id: pool_light_mode_select - update_interval: 250ms - # Options include all 1..16 (no ignored modes currently) plus 0=OFF - options: - - "0= OFF" - - "1= Dark Blue" - - "2= Red" - - "3= Green" - - "4= Purple" - - "5= Aqua" - #- "6= Lime" - #- "7= Cool White" - - "8= Speed Up" - - "9= Transformer" - - "10= Blurple" - - "11= Redlime" - - "12= Greenqua" - - "13= Bluequa" - #- "14= Grelime" - - "15= Redurple" - - "16= RGB Mad" - - # Show OFF only when not in a color-change sequence. - # While changing, reflect the in-flight/current mode (never 0). - lambda: |- - auto label_for = [](int m) -> std::string { - switch (m) { - case 0: return "0= OFF"; - case 1: return "1= Dark Blue"; - case 2: return "2= Red"; - case 3: return "3= Green"; - case 4: return "4= Purple"; - case 5: return "5= Aqua"; - case 6: return "6= Lime"; - case 7: return "7= Cool White"; - case 8: return "8= Speed Up"; - case 9: return "9= Transformer"; - case 10: return "10= Blurple"; - case 11: return "11= Redlime"; - case 12: return "12= Greenqua"; - case 13: return "13= Bluequa"; - case 14: return "14= Grelime"; - case 15: return "15= Redurple"; - case 16: return "16= RGB Mad"; - default: return "1= Dark Blue"; - } - }; - auto is_ignored = [&](int m) -> bool { - if (m < 1 || m > id(max_modes)) return true; - int bit = 1 << (m - 1); - return (id(ignored_mask) & bit) != 0; - }; - - // While stepping (mode_changing), don't show OFF even if relay is briefly OFF. - if (id(mode_changing)) { - int cm = id(current_mode); - if (cm <= 0) cm = 1; // during long reset, assume Mode 1 is next - // Skip ignored modes for display if ever configured later - if (!is_ignored(cm)) return label_for(cm); - // find next non-ignored just for display robustness - for (int m = cm + 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - for (int m = 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - return label_for(1); - } - - // Normal (not changing): show OFF if relay is actually OFF, else show current mode - if (!id(relay).state) return std::string("0= OFF"); - - int cm = id(current_mode); - if (cm == 0) return std::string("0= OFF"); - if (!is_ignored(cm)) return label_for(cm); - for (int m = cm + 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - for (int m = 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - return label_for(1); - - set_action: - # First, set flags based on selection - - lambda: |- - int target = atoi(x.c_str()); - if (target < 0) target = 0; - if (target > id(max_modes)) target = id(max_modes); - - if (target == 0) { - // MODE 0: force everything OFF; UI goes back to TIMER control - id(mode_changing) = false; - id(recheck_pending) = false; - id(operation_mode) = 2; // TIMER - id(current_mode) = 0; // reflect OFF in dropdown - } else { - // defensive: ignore choosing an ignored mode (options already hide them) - int bit = 1 << (target - 1); - if ((id(ignored_mask) & bit) != 0) { - ESP_LOGW("mode","Selected target %d is ignored; ignoring request", target); - return; - } - // Color selection: force ON mode and start changer - id(operation_mode) = 1; - id(desired_mode) = target; - id(mode_changing) = true; - } - - # If OFF was selected, hard-stop any running scripts and cut power - - if: - condition: - lambda: 'return atoi(x.c_str()) == 0;' - then: - - script.stop: change_to_desired_mode - - script.stop: reset_to_mode1 - - script.stop: enforce_off_guard - - switch.turn_off: relay - else: - - script.execute: change_to_desired_mode - -################################################################################################# -# SCRIPT COMPONENT -# https://esphome.io/components/script.html -################################################################################################# -script: - # Core: evaluate and drive the relay (with enforced min OFF window) - - id: evaluate_relay_state - then: - - lambda: |- - if (id(mode_changing)) { - // do not fight the pulser - return; - } - // Determine target state - bool target_on = false; - int mode = id(operation_mode); - - if (mode == 3) { // BOOST - target_on = true; - } else if (mode == 0) { // OFF - target_on = false; - } else if (mode == 1) { // ON - target_on = true; - } else if (mode == 2) { // TIMER - bool should_on = false; - if (id(current_mins) >= id(morning_on) && id(current_mins) < id(morning_off)) - should_on = true; - if (id(current_mins) >= id(evening_on) && id(current_mins) < id(evening_off)) - should_on = true; - target_on = should_on; - } - - // Enforce minimum OFF time when turning ON normally (not during color pulses) - if (target_on) { - uint32_t delta = millis() - id(last_off_ms); - if (delta < (uint32_t) id(reset_window_ms)) { - // Still inside OFF window: wait; schedule a recheck if not already - if (!id(recheck_pending)) { - id(enforce_off_guard).execute(); - } - return; - } else { - id(relay).turn_on(); - return; - } - } else { - id(relay).turn_off(); - return; - } - - # Poll until OFF window elapsed, then re-evaluate (does not toggle the relay itself) - - id: enforce_off_guard - mode: restart - then: - - lambda: |- - id(recheck_pending) = true; - - while: - condition: - lambda: |- - if (id(mode_changing)) return false; // abort if color change starts - uint32_t delta = millis() - id(last_off_ms); - return delta < (uint32_t) id(reset_window_ms); - then: - - delay: 200ms - - lambda: |- - id(recheck_pending) = false; - - script.execute: evaluate_relay_state - - # Long reset to Mode 1 - - id: reset_to_mode1 - mode: restart - then: - - switch.turn_off: relay - - delay: ${mode_reset_off} - - switch.turn_on: relay - - delay: ${mode_pulse_on} - - lambda: |- - id(current_mode) = 1; - - # Change to desired_mode, robust from OFF and without wrap on lower targets - # Change to desired_mode, robust from OFF and without wrap on lower targets - - id: change_to_desired_mode - mode: restart - then: - # If relay is currently OFF, establish a known starting point - - if: - condition: - lambda: 'return !id(relay).state;' - then: - - lambda: |- - uint32_t delta = millis() - id(last_off_ms); - // If OFF < reset window, enforce a true reset first for predictable start - if (delta < (uint32_t) id(reset_window_ms)) { - id(reset_to_mode1).execute(); - } - - if: - condition: - lambda: 'return !id(relay).state;' - then: - - switch.turn_on: relay - - delay: ${mode_prime_on} - - lambda: |- - // If we turned ON after ≥ reset window, hardware is at Mode 1 - if ((millis() - id(last_off_ms)) >= (uint32_t) id(reset_window_ms)) { - id(current_mode) = 1; - } - - # If target is below current, do a long reset to Mode 1, then proceed - - if: - condition: - lambda: |- - return id(desired_mode) < id(current_mode); - then: - - script.execute: reset_to_mode1 - - script.wait: reset_to_mode1 - - # If already exactly at target, ensure ON and finish - - if: - condition: - lambda: 'return id(desired_mode) == id(current_mode);' - then: - - switch.turn_on: relay - - lambda: |- - id(mode_changing) = false; - - # Step forward until we reach desired (ignored modes still counted) - - while: - condition: - lambda: |- - // Allow immediate cancellation if OFF is selected mid-flight - return id(mode_changing) && (id(current_mode) < id(desired_mode)); - then: - - switch.turn_off: relay - - delay: ${mode_pulse_off} - - switch.turn_on: relay - - delay: ${mode_pulse_on} - - lambda: |- - id(current_mode)++; - - # End: make sure we leave the light ON and clear the guard - - switch.turn_on: relay - - lambda: |- - id(mode_changing) = false; - - -################################################################################################# -# INTERVAL COMPONENT -# https://esphome.io/components/interval.html -################################################################################################# -# Interval: bumps time (even if no SNTP), then calls the script to evaluate relay state -interval: - - interval: "1min" - then: - - lambda: |- - // — update current_mins via SNTP or fallback - if (!id(sntp_time).now().is_valid()) { - id(current_mins)++; - if (id(current_mins) >= 1440) id(current_mins) = 0; - } else { - auto now = id(sntp_time).now(); - id(current_mins) = now.hour * 60 + now.minute; - } - - // — if in BOOST, advance boost_timer and expire when done - if (id(operation_mode) == 3) { - id(boost_timer)++; - if (id(boost_timer) >= id(boost_duration)) { - id(operation_mode) = 2; - //id(mqtt_client).publish("${mqtt_timer_topic}/operation", "TIMER"); - } - } - - script.execute: evaluate_relay_state diff --git a/esphome/esp-poollightpower.yaml b/esphome/esp-poollightpower.yaml deleted file mode 100644 index 429c860..0000000 --- a/esphome/esp-poollightpower.yaml +++ /dev/null @@ -1,1038 +0,0 @@ -########################################################################################## -########################################################################################## -# POOL LIGHT POWER AND TIMER -# Controlled by a Athom Smart Plug V1 -# -# dashboard_import: -# package_import_url: github://athom-tech/esp32-configs/athom-smart-plug.yaml -# -# V3.0 2025-09-02 Pool Light mode controller fixes; Mode select forces Operation=ON; -# added Mode 0=OFF; select shows OFF when relay off; enforce 7s min OFF window -# V2.4 2025-06-15 Changed back to an Athom V1 (esp8266) -# V2.3 2025-06-15 Changed to an Athom V3 (esp32) -# V2.2 2025-06-14 Fixes to offline time when sntp/network unavailable -# V2.1 2025-06-12 Added select and button to chose modes, added countdown & startup to boost -# V2.0 2025-06-05 YAML Tidyups -# -# INSTRUCTIONS -# - It allows the device to work in a standalone timer style operation -# - The timer has a morning and evening time (but no weekday/weekend settings) -# - Default values are set, but changed values are remembered in flash -# - It uses SNTP for time setting (but obviously only if wifi & networking are working) -# - It will default to an internal timer if no wifi. To reset internal timer, reboot the device at 12pm (noon) -# - If on a network and there is a MQTT server, you can set the on/off times via MQTT (See below commands) -# - You can set 4 modes ON/OFF/TIMER/BOOST via MQTT. Setting BOOST gives you a oneshot operation -# - Any new timer times set via MQTT will be remembered though a reboot -# - On startup, or a reboot, the device will always turn on for the BOOST Duration (BOOST mode, default 2 hours) -# - TIMER mode will always be switched on after BOOST mode is complete -# - Home Assistant entities are set so that BOOST mode can be pressed with a button and other modes selectable with a dropdown -# - If you need it ON continuously with no MQTT, toggle power ON/OFF 4 times within 30 seconds (with ~2 secs in between to allow it to boot) -# -# MQTT Commands -# Values will be set in place on the update_interval time, not immediately -# Use 00:00 in 24hr format for time setting. (Note there is no weekday/weekend setting) -# mqtt_timer_topic/morning-on/06:00 : Time device will go on -# mqtt_timer_topic/morning-off/08:00 : Time device will go off -# mqtt_timer_topic/evening-on/09:00 : Time device will go on -# mqtt_timer_topic/evening-off/00:00 : Time device will go off -# mqtt_timer_topic/boost-time/0000 : Time in minutes device will temporarily go on for (1-1439) -# mqtt_timer_topic/operation/ON : Device permanently on -# mqtt_timer_topic/operation/OFF : Device permanently off -# mqtt_timer_topic/operation/TIMER : Device will obey timer settings -# mqtt_timer_topic/operation/BOOST : Turn on for (boost_duration) minutes then BOOST (also on startup) -# -# operation_mode: -# 0 = OFF -# 1 = ON -# 2 = TIMER -# 3 = BOOST -# -########################################################################################## -########################################################################################## - - -########################################################################################## -# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS -# If NOT using a secrets file, just replace these with the passwords etc (in quotes) -########################################################################################## -substitutions: - # Device Naming - device_name: "esp-poollightpower" - friendly_name: "Pool Light Power (temp)" - description_comment: "Pool Light Power (temp):: Athom Smart Plug Power V1" - device_area: "Outside" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. - - # Project Naming - project_name: "Athom Technology.Smart Plug V1" # Project Details - project_version: "v3.0" # Project V denotes release of yaml file, allowing checking of deployed vs latest version - - # Passwords - api_key: !secret esp-api_key # unfortunately you can't use substitutions inside secrets names - ota_pass: !secret esp-ota_pass # unfortunately you can't use substitutions inside secrets names - static_ip_address: !secret esp-poollightpower_ip - - # Device Settings - log_level: "NONE" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE - update_interval: "10s" # update time for for general sensors etc - - # Device Settings - relay_icon: "mdi:power-socket-au" - current_limit : "10" # Current Limit in Amps. AU Plug = 10. IL, BR, EU, UK, US Plug = 16. - mqtt_timer_topic: "viewroad-commands/poollightold-timer" # Topics you will use to change stuff - boost_duration_default: "180" # Minutes to stay ON in BOOST mode before reverting to TIMER - morning_on_default: "450" # Default in minutes from midnight. Default 07:30 => 450 - morning_off_default: "450" # Default in minutes from midnight. Default 07:30 => 450 (same as ON as no need for morning schedule) - evening_on_default: "1140" # Default in minutes from midnight. Default 19:00 => 1140 - evening_off_default: "1350" # Default in minutes from midnight. Default 22:30 => 1350 => 1440 is midnight - - ############################################################################## - # POOL LIGHT MODE CONTROL (new) - ############################################################################## - max_modes: "16" # total number of modes in the cycle - mode_pulse_off: "400ms" # short OFF between quick pulses - mode_pulse_on: "400ms" # short ON after each quick pulse - mode_reset_off: "3s" # long OFF to force hardware reset to mode 1 - mode_reset_window_ms: "3000" # treat OFF>=this ms as a reset-to-1 when turning back ON - mode_prime_on: "800ms" # if relay is OFF when a color is selected, turn ON for this long before pulsing - # Ignored modes bitmask: bit (mode_index-1). For 4 and 5 ignored -> (1<<3)|(1<<4) = 24. - ignored_modes_mask: "8288" # Ignore 6(32), 7(64), 14(8192) :: 32 + 64 + 8192 = 8288 - -########################################################################################## -# PACKAGES: Included Common Packages -# https://esphome.io/components/packages.html -########################################################################################## -packages: - ################################################## - # MANDATORY packages (won't compile without them!) - ################################################## - - #### 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}" - - ################################################## - # OPTIONAL packages (comment if you don't want them) - ################################################## - - #### HOME ASSISTANT API (choose encryption or no encryption options) #### - common_api: !include - file: common/api_common.yaml - #file: common/api_common_noencryption.yaml - vars: - local_api_key: "${api_key}" - - #### WEB PORTAL #### - #common_webportal: !include - # file: common/webportal_common.yaml - - #### MQTT #### - common_mqtt: !include - file: common/mqtt_common.yaml - vars: - local_device_name: "${device_name}" - - #### SNTP (Only use if you want/need accurate timeclocks) #### - common_sntp: !include - file: common/sntp_common.yaml - - #### DIAGNOSTICS Lite Sensors #### - common_lite_sensors: !include - file: common/sensors_common_lite.yaml - - #### DIAGNOSTICS General Sensors #### - common_general_sensors: !include - file: common/sensors_common.yaml - - #### DEBUGGING Sensors #### - # A count of various resets - #common_resetcount_debugging: !include - # file: common/resetcount_common.yaml - - # Device Specific included packages - common_athompowermonV1: !include - file: common/athompowermonv1_common.yaml - vars: - local_friendly_name: "${friendly_name}" - local_current_limit: "${current_limit}" - -########################################################################################## -# 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}" - name_add_mac_suffix: False - min_version: 2024.6.0 - project: - name: "${project_name}" - version: "${project_version}" - #platformio_options: - # build_unflags: - # - -std=gnu++20 - # - -std=gnu++2a - # build_flags: - # - -std=gnu++11 - # - -Os - # - -Wl,--gc-sections - # - -fno-exceptions - # - -fno-rtti - -########################################################################################## -# ESP Platform and Framework -# https://esphome.io/components/esp32.html -########################################################################################## -esp8266: - board: esp8285 - restore_from_flash: true # mainly for calculating cumulative energy, but not that important here - #framework: - # version: 2.7.4 - -preferences: - flash_write_interval: 10min - -mdns: - disabled: True # binary size saving - -########################################################################################## -# ESPHome Logging Enable -# https://esphome.io/components/logger.html -########################################################################################## -logger: - level: "${log_level}" #INFO Level suggested, or DEBUG for testing - baud_rate: 0 #set to 0 for no logging via UART, needed if you are using it for other serial things (eg PZEM) - #esp8266_store_log_strings_in_flash: false - #tx_buffer_size: 64 - -########################################################################################## -# Global Variables for use in automations etc -# https://esphome.io/guides/automations.html?highlight=globals#global-variables -########################################################################################## -globals: - - # Tracks the time (in seconds from midnight) at the previous boot - - id: last_boot_time_s - type: int - restore_value: true - initial_value: "0" - - # Counts how many consecutive boots have occurred (within X seconds) - - id: boot_count - type: int - restore_value: true - initial_value: "0" - - # Morning On time (minutes from midnight), - - id: morning_on - type: int - restore_value: true - initial_value: "${morning_on_default}" - - # Morning Off time (minutes from midnight), - - id: morning_off - type: int - restore_value: true - initial_value: "${morning_off_default}" - - # Evening On time (minutes from midnight), - - id: evening_on - type: int - restore_value: true - initial_value: "${evening_on_default}" - - # Evening Off time (minutes from midnight), - - id: evening_off - type: int - restore_value: true - initial_value: "${evening_off_default}" - - # Boost Duration (minutes), - - id: boost_duration - type: int - restore_value: true - initial_value: "${boost_duration_default}" - - #################################################### - # operation_mode: - # 0 = OFF - # 1 = ON - # 2 = TIMER - # 3 = BOOST - #################################################### - - id: operation_mode - type: int - restore_value: true - initial_value: "2" - - #################################################### - # current_mins is set if SNTP is invalid. - # We assume user powers on the device at 12:00 noon - # => 12 * 60 = 720 minutes from midnight. - #################################################### - - id: current_mins - type: int - restore_value: true - initial_value: "720" # 720 is 12:00 Noon - - #################################################### - # boost_timer: counts minutes in BOOST mode - # After 'boost_duration' minutes, revert to TIMER. - #################################################### - - id: boost_timer - type: int - restore_value: true - initial_value: "0" - - ############################################################################## - # POOL LIGHT MODE CONTROL GLOBALS (new) - ############################################################################## - # Current assumed pool-light mode (0..max_modes). 0 means relay OFF. - - id: current_mode - type: int - restore_value: false - initial_value: "1" - - # Total modes in the cycle (edit in substitutions). - - id: max_modes - type: int - restore_value: false - initial_value: "${max_modes}" - - # Flag while we are actively pulsing to change modes (prevents scheduler interference). - - id: mode_changing - type: bool - restore_value: false - initial_value: "false" - - # Timestamp of last relay off (for delta measurement). - - id: last_off_ms - type: uint32_t - restore_value: false - initial_value: "0" - - # Millisecond threshold that separates "quick pulse" vs "reset to mode 1". - - id: reset_window_ms - type: int - restore_value: false - initial_value: "${mode_reset_window_ms}" - - # Desired target mode for the cycle script. - - id: desired_mode - type: int - restore_value: false - initial_value: "1" - - # Bitmask of ignored modes (1..max_modes). Example: ignore 4 & 5 -> 24. - - id: ignored_mask - type: int - restore_value: false - initial_value: "${ignored_modes_mask}" - - # Internal: whether a recheck is already scheduled for min OFF enforcement. - - id: recheck_pending - type: bool - restore_value: false - initial_value: "false" - -########################################################################################## -# Text Sensors -# https://esphome.io/components/text_sensor/index.html -########################################################################################## -text_sensor: - -############################ -# MQTT Subscriptions -############################ - #################################################### - # Subscribe to the Morning On time, format "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Morning On Time Setting" - id: morning_on_topic - topic: "${mqtt_timer_topic}/morning-on" # Stored in the format HH:MM - internal: True - on_value: - then: - - lambda: |- - // Expect "HH:MM" => total length = 5, with ':' - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); // "HH" - int minute = atoi(x.substr(3, 2).c_str()); // "MM" - id(morning_on) = hour * 60 + minute; - ESP_LOGI("timer","Received new Morning On: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Morning On format: %s", x.c_str()); - } - #################################################### - # Morning Off time => "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Morning Off Time Setting" - id: morning_off_topic - topic: "${mqtt_timer_topic}/morning-off" # Stored in the format HH:MM - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); - int minute = atoi(x.substr(3, 2).c_str()); - id(morning_off) = hour * 60 + minute; - ESP_LOGI("timer","Received new Morning Off: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Morning Off format: %s", x.c_str()); - } - #################################################### - # Evening On time => "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Evening On Time Setting" - id: evening_on_topic - topic: "${mqtt_timer_topic}/evening-on" # Stored in the format HH:MM - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); - int minute = atoi(x.substr(3, 2).c_str()); - id(evening_on) = hour * 60 + minute; - ESP_LOGI("timer","Received new Evening On: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Evening On format: %s", x.c_str()); - } - #################################################### - # Evening Off time => "HH:MM" - #################################################### - - platform: mqtt_subscribe - name: "Evening Off Time Setting" - id: evening_off_topic - topic: "${mqtt_timer_topic}/evening-off" # Stored in the format HH:MM - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (x.size() == 5 && x[2] == ':') { - int hour = atoi(x.substr(0, 2).c_str()); - int minute = atoi(x.substr(3, 2).c_str()); - id(evening_off) = hour * 60 + minute; - ESP_LOGI("timer","Received new Evening Off: %02d:%02d", hour, minute); - } else { - ESP_LOGW("timer","Invalid Evening Off format: %s", x.c_str()); - } - #################################################### - # Boost duration => 1 - 1439 - #################################################### - - platform: mqtt_subscribe - name: "Boost Duration" - id: boost_time_topic - topic: "${mqtt_timer_topic}/boost-time" # Stored as an integer from 1-1439 - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - // parse as integer - char *endptr; - long v = strtol(x.c_str(), &endptr, 10); - - // invalid if nothing parsed, trailing chars, or out of 0–1439 - if (endptr == x.c_str() || *endptr != '\0' || v < 0 || v > 1439) { - ESP_LOGE("boost_time", "Invalid boost_time '%s'", x.c_str()); - } else { - id(boost_duration) = static_cast(v); - } - - #################################################### - # Subscribe to operation mode: - # OFF, ON, TIMER, BOOST - #################################################### - # MQTT subscription: set mode, then immediately re-evaluate relay - - platform: mqtt_subscribe - id: timer_operation_mode_topic - topic: "${mqtt_timer_topic}/operation" - internal: True # No need to show this in Home Assistant as there is a sensor that shows the set value - on_value: - then: - - lambda: |- - if (strcasecmp(x.c_str(), "TIMER") == 0) { - id(operation_mode) = 2; - ESP_LOGI("timer","Operation mode set to TIMER"); - } else if (strcasecmp(x.c_str(), "ON") == 0) { - id(operation_mode) = 1; - ESP_LOGI("timer","Operation mode set to ON"); - } else if (strcasecmp(x.c_str(), "OFF") == 0) { - id(operation_mode) = 0; - ESP_LOGI("timer","Operation mode set to OFF"); - } else if (strcasecmp(x.c_str(), "BOOST") == 0) { - id(operation_mode) = 3; - id(boost_timer) = 0; - ESP_LOGI("timer","Operation mode set to BOOST"); - } else { - ESP_LOGW("timer","Invalid operation mode: %s", x.c_str()); - } - - script.execute: evaluate_relay_state - - ###################################################### - # Expose the current operation mode (OFF, ON, TIMER, BOOST) - ###################################################### - - platform: template - name: "Operation Mode State" - lambda: |- - // 0=OFF, 1=ON, 2=TIMER, 3=BOOST - switch (id(operation_mode)) { - case 0: return {"OFF"}; - case 1: return {"ON"}; - case 2: return {"TIMER"}; - case 3: return {"BOOST"}; - default: return {"UNKNOWN"}; - } - update_interval: 5s - - ###################################################### - # Expose the "Morning On" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Morning On Time" - lambda: |- - int hour = id(morning_on) / 60; - int minute = id(morning_on) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - - ###################################################### - # Expose the "Morning Off" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Morning Off Time" - lambda: |- - int hour = id(morning_off) / 60; - int minute = id(morning_off) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - - ###################################################### - # Expose the "Evening On" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Evening On Time" - lambda: |- - int hour = id(evening_on) / 60; - int minute = id(evening_on) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - - ###################################################### - # Expose the "Evening Off" time as a text (HH:MM) - ###################################################### - - platform: template - name: "Timeclock: Evening Off Time" - lambda: |- - int hour = id(evening_off) / 60; - int minute = id(evening_off) % 60; - // Increase to 16 for safety - char buff[16]; - snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); - return { std::string(buff) }; - update_interval: "${update_interval}" - -########################################################################################## -# BINARY SENSORS -# https://esphome.io/components/binary_sensor/ -########################################################################################## -binary_sensor: - - platform: gpio - pin: - number: GPIO03 - mode: INPUT_PULLUP - inverted: true - name: "Power Button" - id: power_button - filters: - - delayed_on: 20ms - on_click: - - min_length: 20ms - max_length: 500ms - then: - - lambda: |- - if (id(relay).state) { - // Relay is ON: turn it OFF and set mode to 2 (TIMER) - id(relay).turn_off(); - id(operation_mode) = 2; - } else { - // Relay is OFF: turn it ON and set mode to 3 (BOOST) - id(relay).turn_on(); - id(operation_mode) = 3; - } - - - platform: template - name: "Relay Status" - lambda: |- - return id(relay).state; - -########################################################################################## -# Sensors -# https://esphome.io/components/text_sensor/index.html -########################################################################################## -sensor: - - platform: template - name: "Timeclock: Boost Duration" - id: boost_duration_time - unit_of_measurement: "mins" - accuracy_decimals: "0" - update_interval: "${update_interval}" - lambda: |- - return id(boost_duration); - - - platform: template - name: "Mins from Midnight" - id: mins_from_midnight - unit_of_measurement: "mins" - accuracy_decimals: "0" - update_interval: "${update_interval}" - internal: True # No need to show this in Home Assistant - lambda: |- - return id(current_mins); - - # A value in mins if a timer is running showing how many mins left - - platform: template - name: "Timer Minutes Remaining" - id: timer_minutes_remaining - unit_of_measurement: "Mins" - update_interval: 5s - accuracy_decimals: "0" - lambda: |- - // always zero if relay is off - if (!id(relay).state) { - return 0; - } - int rem = 0; - // only calculate for mode 2 (scheduled) or mode 3 (BOOST) - if (id(operation_mode) == 2) { - int a = id(morning_off) - id(current_mins); - int b = id(evening_off) - id(current_mins); - // if a is negative, use b; otherwise pick the smaller of a or b - rem = (a < 0) ? b : (a < b ? a : b); - } - else if (id(operation_mode) == 3) { - rem = id(boost_duration) - id(boost_timer); - } - // never return negative - return rem > 0 ? rem : 0; - - -################################################################################################# -# SWITCH COMPONENT -# https://esphome.io/components/switch/ -################################################################################################# -switch: - - platform: gpio - name: "Power Output" - pin: GPIO14 - id: relay - restore_mode: RESTORE_DEFAULT_OFF # Ensures the relay is restored (or off) at boot - #internal: true # Hides the switch from Home Assistant - icon: "${relay_icon}" - # POOL LIGHT MODE CONTROL: detect quick vs long off periods and keep current_mode in sync - on_turn_off: - - lambda: |- - id(last_off_ms) = millis(); - if (!id(mode_changing)) id(current_mode) = 0; // reflect OFF in select when off normally - on_turn_on: - - lambda: |- - if (id(mode_changing)) { - return; - } - uint32_t delta = millis() - id(last_off_ms); - if (delta >= (uint32_t)id(reset_window_ms)) { - id(current_mode) = 1; - } else { - id(current_mode)++; - if (id(current_mode) > id(max_modes)) id(current_mode) = 1; // keep internal index sane - } - -################################################################################################# -# BUTTON COMPONENT -# https://esphome.io/components/button/index.html -################################################################################################# -button: - - platform: template - name: "Boost now" - id: boost_button - icon: "mdi:play-circle-outline" - on_press: - # 1) reset BOOST timer and set mode - - lambda: |- - id(boost_timer) = 0; - id(operation_mode) = 3; - # 2) immediately re-evaluate relay state - - script.execute: evaluate_relay_state - - platform: template - name: "Default timer settings" - id: default_timer_settings_button - icon: "mdi:restore" - on_press: - - lambda: |- - // Restore all timing globals to their YAML defaults - id(morning_on) = ${morning_on_default}; - id(morning_off) = ${morning_off_default}; - id(evening_on) = ${evening_on_default}; - id(evening_off) = ${evening_off_default}; - id(boost_duration)= ${boost_duration_default}; - // Reset mode to TIMER and clear any running boost - id(operation_mode)= 2; - id(boost_timer) = 0; - ESP_LOGI("timer","Default timer settings applied"); - - script.execute: evaluate_relay_state - -################################################################################################# -# SELECT COMPONENT -# https://esphome.io/components/select/index.html -################################################################################################# -select: - - platform: template - name: "Operation Mode" - id: operation_mode_select - update_interval: 5s - options: - - "OFF" - - "ON" - - "TIMER" - - "BOOST" - - # show the current mode - lambda: |- - switch (id(operation_mode)) { - case 1: return std::string("ON"); - case 2: return std::string("TIMER"); - case 3: return std::string("BOOST"); - default: return std::string("OFF"); - } - - # when changed in HA, set mode & re-evaluate - set_action: - - lambda: |- - if (x == "OFF") { id(operation_mode) = 0; } - else if (x == "ON") { id(operation_mode) = 1; } - else if (x == "TIMER") { id(operation_mode) = 2; } - else { // BOOST - id(boost_timer) = 0; - id(operation_mode) = 3; - } - - script.execute: evaluate_relay_state - - ############################################################################## - # POOL LIGHT MODE SELECT (new) - # 0=OFF -> immediately turns relay OFF and sets Operation Mode = TIMER. - # Selecting a color (>=1) forces Operation Mode = ON. - # If target < current, do a long reset to mode 1, then pulse forward. - # Ignored modes are hidden here but still counted by pulses. - ############################################################################## - ############################################################################## - # POOL LIGHT MODE SELECT (updated display logic) - # 0=OFF -> immediately turns relay OFF and sets Operation Mode = TIMER. - # Selecting a color (>=1) forces Operation Mode = ON. - # If target < current, do a long reset to mode 1, then pulse forward. - # Ignored modes are hidden here but still counted by pulses. - ############################################################################## - - platform: template - name: "Pool Light Mode" - id: pool_light_mode_select - update_interval: 250ms - # Options include all 1..16 (no ignored modes currently) plus 0=OFF - options: - - "0= OFF" - - "1= Dark Blue" - - "2= Red" - - "3= Green" - - "4= Purple" - - "5= Aqua" - #- "6= Lime" - #- "7= Cool White" - - "8= Speed Up" - - "9= Transformer" - - "10= Blurple" - - "11= Redlime" - - "12= Greenqua" - - "13= Bluequa" - #- "14= Grelime" - - "15= Redurple" - - "16= RGB Mad" - - # Show OFF only when not in a color-change sequence. - # While changing, reflect the in-flight/current mode (never 0). - lambda: |- - auto label_for = [](int m) -> std::string { - switch (m) { - case 0: return "0= OFF"; - case 1: return "1= Dark Blue"; - case 2: return "2= Red"; - case 3: return "3= Green"; - case 4: return "4= Purple"; - case 5: return "5= Aqua"; - case 6: return "6= Lime"; - case 7: return "7= Cool White"; - case 8: return "8= Speed Up"; - case 9: return "9= Transformer"; - case 10: return "10= Blurple"; - case 11: return "11= Redlime"; - case 12: return "12= Greenqua"; - case 13: return "13= Bluequa"; - case 14: return "14= Grelime"; - case 15: return "15= Redurple"; - case 16: return "16= RGB Mad"; - default: return "1= Dark Blue"; - } - }; - auto is_ignored = [&](int m) -> bool { - if (m < 1 || m > id(max_modes)) return true; - int bit = 1 << (m - 1); - return (id(ignored_mask) & bit) != 0; - }; - - // While stepping (mode_changing), don't show OFF even if relay is briefly OFF. - if (id(mode_changing)) { - int cm = id(current_mode); - if (cm <= 0) cm = 1; // during long reset, assume Mode 1 is next - // Skip ignored modes for display if ever configured later - if (!is_ignored(cm)) return label_for(cm); - // find next non-ignored just for display robustness - for (int m = cm + 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - for (int m = 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - return label_for(1); - } - - // Normal (not changing): show OFF if relay is actually OFF, else show current mode - if (!id(relay).state) return std::string("0= OFF"); - - int cm = id(current_mode); - if (cm == 0) return std::string("0= OFF"); - if (!is_ignored(cm)) return label_for(cm); - for (int m = cm + 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - for (int m = 1; m <= id(max_modes); m++) if (!is_ignored(m)) return label_for(m); - return label_for(1); - - set_action: - # First, set flags based on selection - - lambda: |- - int target = atoi(x.c_str()); - if (target < 0) target = 0; - if (target > id(max_modes)) target = id(max_modes); - - if (target == 0) { - // MODE 0: force everything OFF; UI goes back to TIMER control - id(mode_changing) = false; - id(recheck_pending) = false; - id(operation_mode) = 2; // TIMER - id(current_mode) = 0; // reflect OFF in dropdown - } else { - // defensive: ignore choosing an ignored mode (options already hide them) - int bit = 1 << (target - 1); - if ((id(ignored_mask) & bit) != 0) { - ESP_LOGW("mode","Selected target %d is ignored; ignoring request", target); - return; - } - // Color selection: force ON mode and start changer - id(operation_mode) = 1; - id(desired_mode) = target; - id(mode_changing) = true; - } - - # If OFF was selected, hard-stop any running scripts and cut power - - if: - condition: - lambda: 'return atoi(x.c_str()) == 0;' - then: - - script.stop: change_to_desired_mode - - script.stop: reset_to_mode1 - - script.stop: enforce_off_guard - - switch.turn_off: relay - else: - - script.execute: change_to_desired_mode - -################################################################################################# -# SCRIPT COMPONENT -# https://esphome.io/components/script.html -################################################################################################# -script: - # Core: evaluate and drive the relay (with enforced min OFF window) - - id: evaluate_relay_state - then: - - lambda: |- - if (id(mode_changing)) { - // do not fight the pulser - return; - } - // Determine target state - bool target_on = false; - int mode = id(operation_mode); - - if (mode == 3) { // BOOST - target_on = true; - } else if (mode == 0) { // OFF - target_on = false; - } else if (mode == 1) { // ON - target_on = true; - } else if (mode == 2) { // TIMER - bool should_on = false; - if (id(current_mins) >= id(morning_on) && id(current_mins) < id(morning_off)) - should_on = true; - if (id(current_mins) >= id(evening_on) && id(current_mins) < id(evening_off)) - should_on = true; - target_on = should_on; - } - - // Enforce minimum OFF time when turning ON normally (not during color pulses) - if (target_on) { - uint32_t delta = millis() - id(last_off_ms); - if (delta < (uint32_t) id(reset_window_ms)) { - // Still inside OFF window: wait; schedule a recheck if not already - if (!id(recheck_pending)) { - id(enforce_off_guard).execute(); - } - return; - } else { - id(relay).turn_on(); - return; - } - } else { - id(relay).turn_off(); - return; - } - - # Poll until OFF window elapsed, then re-evaluate (does not toggle the relay itself) - - id: enforce_off_guard - mode: restart - then: - - lambda: |- - id(recheck_pending) = true; - - while: - condition: - lambda: |- - if (id(mode_changing)) return false; // abort if color change starts - uint32_t delta = millis() - id(last_off_ms); - return delta < (uint32_t) id(reset_window_ms); - then: - - delay: 200ms - - lambda: |- - id(recheck_pending) = false; - - script.execute: evaluate_relay_state - - # Long reset to Mode 1 - - id: reset_to_mode1 - mode: restart - then: - - switch.turn_off: relay - - delay: ${mode_reset_off} - - switch.turn_on: relay - - delay: ${mode_pulse_on} - - lambda: |- - id(current_mode) = 1; - - # Change to desired_mode, robust from OFF and without wrap on lower targets - # Change to desired_mode, robust from OFF and without wrap on lower targets - - id: change_to_desired_mode - mode: restart - then: - # If relay is currently OFF, establish a known starting point - - if: - condition: - lambda: 'return !id(relay).state;' - then: - - lambda: |- - uint32_t delta = millis() - id(last_off_ms); - // If OFF < reset window, enforce a true reset first for predictable start - if (delta < (uint32_t) id(reset_window_ms)) { - id(reset_to_mode1).execute(); - } - - if: - condition: - lambda: 'return !id(relay).state;' - then: - - switch.turn_on: relay - - delay: ${mode_prime_on} - - lambda: |- - // If we turned ON after ≥ reset window, hardware is at Mode 1 - if ((millis() - id(last_off_ms)) >= (uint32_t) id(reset_window_ms)) { - id(current_mode) = 1; - } - - # If target is below current, do a long reset to Mode 1, then proceed - - if: - condition: - lambda: |- - return id(desired_mode) < id(current_mode); - then: - - script.execute: reset_to_mode1 - - script.wait: reset_to_mode1 - - # If already exactly at target, ensure ON and finish - - if: - condition: - lambda: 'return id(desired_mode) == id(current_mode);' - then: - - switch.turn_on: relay - - lambda: |- - id(mode_changing) = false; - - # Step forward until we reach desired (ignored modes still counted) - - while: - condition: - lambda: |- - // Allow immediate cancellation if OFF is selected mid-flight - return id(mode_changing) && (id(current_mode) < id(desired_mode)); - then: - - switch.turn_off: relay - - delay: ${mode_pulse_off} - - switch.turn_on: relay - - delay: ${mode_pulse_on} - - lambda: |- - id(current_mode)++; - - # End: make sure we leave the light ON and clear the guard - - switch.turn_on: relay - - lambda: |- - id(mode_changing) = false; - - -################################################################################################# -# INTERVAL COMPONENT -# https://esphome.io/components/interval.html -################################################################################################# -# Interval: bumps time (even if no SNTP), then calls the script to evaluate relay state -interval: - - interval: "1min" - then: - - lambda: |- - // — update current_mins via SNTP or fallback - if (!id(sntp_time).now().is_valid()) { - id(current_mins)++; - if (id(current_mins) >= 1440) id(current_mins) = 0; - } else { - auto now = id(sntp_time).now(); - id(current_mins) = now.hour * 60 + now.minute; - } - - // — if in BOOST, advance boost_timer and expire when done - if (id(operation_mode) == 3) { - id(boost_timer)++; - if (id(boost_timer) >= id(boost_duration)) { - id(operation_mode) = 2; - //id(mqtt_client).publish("${mqtt_timer_topic}/operation", "TIMER"); - } - } - - script.execute: evaluate_relay_state diff --git a/esphome/esp-poollightpower2.yaml b/esphome/esp-poollightpower2.yaml index 344ec5b..1d382bb 100644 --- a/esphome/esp-poollightpower2.yaml +++ b/esphome/esp-poollightpower2.yaml @@ -73,8 +73,8 @@ substitutions: static_ip_address: !secret esp-poollightpower2_ip # Device Settings - log_level: "NONE" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE - update_interval: "10s" # update time for for general sensors etc + log_level: "DEBUG" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE + update_interval: "60s" # update time for for general sensors etc # Device Settings relay_icon: "mdi:power-socket-au" @@ -178,13 +178,13 @@ esphome: project: name: "${project_name}" version: "${project_version}" - #platformio_options: + platformio_options: # build_unflags: # - -std=gnu++20 # - -std=gnu++2a - # build_flags: + build_flags: # - -std=gnu++11 - # - -Os + - -Os # - -Wl,--gc-sections # - -fno-exceptions # - -fno-rtti @@ -203,7 +203,7 @@ preferences: flash_write_interval: 10min mdns: - disabled: true # binary size saving + disabled: False # binary size saving ########################################################################################## # ESPHome Logging Enable diff --git a/esphome/esp-maindishwasherspower.yaml b/esphome/esp-spare1-athomv1.yaml similarity index 97% rename from esphome/esp-maindishwasherspower.yaml rename to esphome/esp-spare1-athomv1.yaml index 5f94ebc..bec1ed0 100644 --- a/esphome/esp-maindishwasherspower.yaml +++ b/esphome/esp-spare1-athomv1.yaml @@ -16,8 +16,10 @@ ########################################################################################## substitutions: # Device Naming - device_name: "esp-maindishwasherspower" - friendly_name: "Main Dishwashers Power" + #device_name: "esp-maindishwasherspower" + device_name: "esp-spare1-athomv1" + #friendly_name: "Main Dishwashers Power" + friendly_name: "Spare1 Athomv1" description_comment: "Main Dishwashers Power Monitor :: Athom Smart Plug Power Monitor V1" device_area: "Kitchen" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. diff --git a/packages/climate_aircon_controls.yaml.old20260213 b/packages/climate_aircon_controls.yaml.old20260213 deleted file mode 100644 index e6dffff..0000000 --- a/packages/climate_aircon_controls.yaml.old20260213 +++ /dev/null @@ -1,120 +0,0 @@ -# ------------------------------ -# TIMER HELPER (define in YAML so it definitely exists) -# ------------------------------ -timer: - master_bedroom_climate_timer: - name: Master Bedroom Climate Timer - duration: "00:00:00" - -# ------------------------------ -# DROPDOWN (sleep timer) -# ------------------------------ -input_select: - master_bedroom_climate_timer_duration: - name: Master Bedroom Aircon Sleep - options: - - "1 hour" - - "2 hours" - - "3 hours" - - "6 hours" - - "Cancel timer" - initial: "Cancel timer" - icon: mdi:timer-outline - -# ------------------------------ -# LIVE REMAINING (updates immediately on selection, and every minute) -# ------------------------------ -template: - - trigger: - # Update once per minute (suits hh:mm display) - - platform: time_pattern - minutes: "/1" - - # Update when timer STATE changes (idle/active/paused) - - platform: state - entity_id: timer.master_bedroom_climate_timer - - # Update immediately when user changes the dropdown - - platform: state - entity_id: input_select.master_bedroom_climate_timer_duration - - sensor: - - name: "Master Bedroom Climate Timer Remaining" - unique_id: master_bedroom_climate_timer_remaining - icon: mdi:timer - state: >- - {% set st = states('timer.master_bedroom_climate_timer') | lower %} - {% if st == 'idle' %} - 0:00 - {% else %} - {% set fa = state_attr('timer.master_bedroom_climate_timer', 'finishes_at') %} - {% if fa %} - {% set end = as_datetime(fa) %} - {% set sec = (end - now()).total_seconds() | int(0) %} - {% if sec < 0 %}{% set sec = 0 %}{% endif %} - {% set hh = (sec // 3600) | int %} - {% set mm = ((sec % 3600) // 60) | int %} - {{ hh }}:{{ '%02d' | format(mm) }} - {% else %} - 0:00 - {% endif %} - {% endif %} - -# ------------------------------ -# AUTOMATION: start/cancel timer when dropdown changes -# ------------------------------ -automation: - - alias: "Start or Cancel Master Bedroom Climate Timer" - mode: restart - trigger: - - platform: state - entity_id: input_select.master_bedroom_climate_timer_duration - - variables: - opt: "{{ states('input_select.master_bedroom_climate_timer_duration') }}" - dur_map: - "1 hour": "01:00:00" - "2 hours": "02:00:00" - "3 hours": "03:00:00" - "6 hours": "06:00:00" - - action: - - choose: - - conditions: - - condition: template - value_template: "{{ opt in dur_map }}" - sequence: - - service: timer.cancel - target: - entity_id: timer.master_bedroom_climate_timer - - delay: "00:00:01" - - service: timer.start - target: - entity_id: timer.master_bedroom_climate_timer - data: - duration: "{{ dur_map[opt] }}" - - - conditions: - - condition: state - entity_id: input_select.master_bedroom_climate_timer_duration - state: "Cancel timer" - sequence: - - service: timer.cancel - target: - entity_id: timer.master_bedroom_climate_timer - - - alias: "Turn Off Master Bedroom Climate on Timer Completion" - trigger: - - platform: event - event_type: timer.finished - event_data: - entity_id: timer.master_bedroom_climate_timer - action: - - service: climate.turn_off - target: - entity_id: climate.master_bedroom - - service: input_select.select_option - target: - entity_id: input_select.master_bedroom_climate_timer_duration - data: - option: "Cancel timer" diff --git a/packages/climate_master_bedroom_dehum.yaml.old20260213 b/packages/climate_master_bedroom_dehum.yaml.old20260213 deleted file mode 100644 index 79e5730..0000000 --- a/packages/climate_master_bedroom_dehum.yaml.old20260213 +++ /dev/null @@ -1,24 +0,0 @@ -automation: - # Automation to turn on the heat pump at 9pm in the master bedroom (if switched on with the helper) - - id: master_bedroom_offpeak_dehumidify - alias: Master Bedroom Off-Peak Dehumidify - mode: single - triggers: - - trigger: time - at: "21:00:00" - conditions: - - condition: state - entity_id: input_boolean.master_bedroom_offpeak_dehumidify - state: "on" - actions: - - action: climate.set_hvac_mode - target: - entity_id: climate.master_bedroom - data: - hvac_mode: "heat_cool" - - wait_for_trigger: - - trigger: time - at: "00:00:00" - - action: climate.turn_off - target: - entity_id: climate.master_bedroom diff --git a/packages/climate_master_bedroom_night_cycle.yaml.old b/packages/climate_master_bedroom_night_cycle.yaml.old deleted file mode 100644 index 95e8b97..0000000 --- a/packages/climate_master_bedroom_night_cycle.yaml.old +++ /dev/null @@ -1,85 +0,0 @@ -input_select: - heat_pump_temperature_threshold: - name: Master Bedroom Night Cycle Temperature Min - options: - - "18" - - "19" - - "20" - - "21" - - "22" - - "23" - initial: "20" - icon: mdi:thermometer - -input_boolean: - heat_pump_automation: - name: Enable Heat Pump Master Bedroom Night Cycling - initial: off - -automation: - - alias: "Master Bedroom Heat Pump Night Cycling" - description: "Turns the climate.master_bedroom to 'cool' for 15 minutes every hour if temperature is below the selected threshold." - trigger: - - platform: time_pattern # Use time_pattern to run the automation on a recurring schedule. - minutes: "/30" # The "0" means on the hour or "/15" means every 15 minutes. - condition: - # Ensure the automation is enabled - - condition: state - entity_id: input_boolean.heat_pump_automation - state: "on" - # Check that the current temperature is above the selected threshold - - condition: template - value_template: > - {{ states('sensor.master_bedroom_environment_zth01_temperature_2') | float > states('input_select.heat_pump_temperature_threshold') | float }} - # Actions: Define the steps of the automation. - action: - # Action 1: Turn on the heat pump by setting HVAC mode to "cool". - - service: climate.set_hvac_mode - target: - entity_id: climate.master_bedroom - data: - hvac_mode: cool - # Action 2: Keep the heat pump on for x minutes. - - delay: "00:10:00" - # Action 3: Turn off the heat pump. - # Some integrations do not support hvac_mode: off via climate.set_hvac_mode, - # so we use the dedicated climate.turn_off service. - - service: climate.turn_off - target: - entity_id: climate.master_bedroom - mode: single # Ensures only one instance of this automation runs at a time. - - # always switch off the automation at 5am - - alias: "Disable Heat Pump Automation at 5AM" - description: "Automatically turns off the heat pump automation (input_boolean.heat_pump_automation) at 5 AM." - trigger: - - platform: time - at: "04:59:00" - action: - - service: input_boolean.turn_off - target: - entity_id: input_boolean.heat_pump_automation - mode: single - - # ensure the heat pump is off at 5am if not already - - alias: "Ensure Heat Pump Off at 5 AM" - trigger: - - platform: time - at: "05:00:00" - action: - - service: climate.turn_off - target: - entity_id: climate.master_bedroom - mode: single - - # ensure the automation is off on a HA reboot - - alias: "Ensure Heat Pump Automation is Off on Startup" - description: "Turns off the heat pump automation (input_boolean.heat_pump_automation) when Home Assistant starts." - trigger: - - platform: homeassistant - event: start - action: - - service: input_boolean.turn_off - target: - entity_id: input_boolean.heat_pump_automation - mode: single diff --git a/packages/ev_charging.yaml b/packages/ev_charging.yaml index a586c50..e3dadc4 100644 --- a/packages/ev_charging.yaml +++ b/packages/ev_charging.yaml @@ -35,6 +35,15 @@ sensor: #unit_prefix: k round: 0 + - platform: filter + name: "32A EV Charger Power 1m Avg" + unique_id: ev_charger_32a_power_1m_avg + entity_id: sensor.main_house_3_phase_power_monitor_ev_wallcharger_power + filters: + - filter: time_simple_moving_average + window_size: "00:01" + precision: 3 + input_select: leaf_ev_charging_mode: icon: mdi:ev-plug-type1 @@ -65,7 +74,6 @@ automation: template: - trigger: - # Update at least every 10 seconds (and also on HA start) - platform: time_pattern seconds: "/10" - platform: homeassistant @@ -75,7 +83,7 @@ template: unique_id: ev_charger_32a_charging icon: mdi:car-electric state: >- - {{ (states('sensor.32a_ev_wallcharger_power') | float(0)) > 0.1 }} + {{ states('sensor.32a_ev_charger_power_1m_avg') | float(0) > 0.1 }} delay_on: seconds: 60 delay_off: diff --git a/packages/heat_pump_helpers.yaml.old20260213 b/packages/heat_pump_helpers.yaml.old20260213 deleted file mode 100644 index 947b147..0000000 --- a/packages/heat_pump_helpers.yaml.old20260213 +++ /dev/null @@ -1,309 +0,0 @@ -########################################################################################## -# heat_pump_helpers.yaml -# -# Panasonic Comfort Cloud (panasonic_cc) helpers for Heat/Cool control + presets. -# -# Purpose: -# - Provide "optimistic" UI feedback for cloud/slow mode changes -# - Provide one-tap scripts for Off/Heat/Cool/Dehumid -# - Provide a 3-step "Profile" cycler (Normal/Boost/Quiet) using preset_mode: -# * preset_mode: boost -> Powerful ON (we label this "Boost") -# * preset_mode: eco -> Quiet ON (we label this "Quiet") -# * preset_mode: none -> Powerful OFF + Quiet OFF (we label this "Normal") -# -# Notes: -# - Your climate entity supports preset_modes: [none, eco, boost] -# - The switch.lounge_ai_eco entity appears to be read-only (cannot be controlled), -# so "Eco" and "Eco/Quiet" profiles have been removed. -# - When turning the heat pump OFF, we also force preset_mode to "none" so that -# Boost/Quiet are cleared. -########################################################################################## - -input_select: - # Used by your dashboard buttons to "light up" instantly (optimistic state). - # When set to something other than "none", the UI can treat that as "pending". - master_bedroom_hvac_requested_mode: - name: Master Bedroom HVAC Requested Mode - options: - - "none" - - "off" - - "heat" - - "cool" - - "dry" - initial: "none" - - # Heat pump profile selector shown on the dashboard and cycled with one tap. - master_bedroom_heat_pump_profile: - name: Master Bedroom Heat Pump Profile - options: - - Normal - - Boost - - Quiet - initial: Normal - -script: - ######################################################################################## - # One-tap HVAC mode scripts - # These set the optimistic helper first, then call the real climate services. - # - # IMPORTANT: - # - Off also clears Boost/Quiet by forcing preset_mode to "none". - ######################################################################################## - - master_bedroom_hvac_off: - alias: Master Bedroom HVAC - Off - mode: restart - sequence: - # Optimistic UI: show "Off" immediately - - service: input_select.select_option - target: - entity_id: input_select.master_bedroom_hvac_requested_mode - data: - option: "off" - - # Turn the unit off (more reliable than set_hvac_mode off on many cloud integrations) - - service: climate.turn_off - target: - entity_id: climate.master_bedroom - - # Clear Panasonic preset flags: none = Boost OFF + Quiet OFF - - service: climate.set_preset_mode - target: - entity_id: climate.master_bedroom - data: - preset_mode: "none" - - master_bedroom_hvac_heat: - alias: Master Bedroom HVAC - Heat - mode: restart - sequence: - - service: input_select.select_option - target: - entity_id: input_select.master_bedroom_hvac_requested_mode - data: - option: "heat" - - - service: climate.turn_on - target: - entity_id: climate.master_bedroom - - - service: climate.set_hvac_mode - target: - entity_id: climate.master_bedroom - data: - hvac_mode: "heat" - - master_bedroom_hvac_cool: - alias: Master Bedroom HVAC - Cool - mode: restart - sequence: - - service: input_select.select_option - target: - entity_id: input_select.master_bedroom_hvac_requested_mode - data: - option: "cool" - - - service: climate.turn_on - target: - entity_id: climate.master_bedroom - - - service: climate.set_hvac_mode - target: - entity_id: climate.master_bedroom - data: - hvac_mode: "cool" - - master_bedroom_hvac_dry: - alias: Master Bedroom HVAC - Dehumid - mode: restart - sequence: - - service: input_select.select_option - target: - entity_id: input_select.master_bedroom_hvac_requested_mode - data: - option: "dry" - - - service: climate.turn_on - target: - entity_id: climate.master_bedroom - - - service: climate.set_hvac_mode - target: - entity_id: climate.master_bedroom - data: - hvac_mode: "dry" - - ######################################################################################## - # Profile logic: Apply + Cycle - # - # Profile definitions (Panasonic CC / panasonic_cc): - # - Normal -> preset_mode none (Boost OFF, Quiet OFF) - # - Boost -> preset_mode boost (Powerful ON) - # - Quiet -> preset_mode eco (Quiet ON) - # - # The profile selector is kept in-sync with the actual preset_mode via automation. - ######################################################################################## - - master_bedroom_heat_pump_profile_apply: - alias: Master Bedroom Heat Pump - Apply Profile - mode: restart - fields: - profile: - description: Profile name (Normal, Boost, Quiet) - example: Normal - sequence: - - variables: - p: "{{ profile | default(states('input_select.master_bedroom_heat_pump_profile')) }}" - preset_for_profile: >- - {% if p == 'Normal' %}none - {% elif p == 'Boost' %}boost - {% elif p == 'Quiet' %}eco - {% else %}none - {% endif %} - - # Optional: ensure the unit is on when applying a profile. - # Comment out if you want profile selection while the unit is off. - - service: climate.turn_on - target: - entity_id: climate.master_bedroom - - # Set preset explicitly every time so Boost/Quiet are mutually exclusive - - service: climate.set_preset_mode - target: - entity_id: climate.master_bedroom - data: - preset_mode: "{{ preset_for_profile }}" - - master_bedroom_heat_pump_profile_next: - alias: Master Bedroom Heat Pump - Next Profile - mode: restart - sequence: - - variables: - cur: "{{ states('input_select.master_bedroom_heat_pump_profile') }}" - nxt: >- - {% if cur == 'Normal' %}Boost - {% elif cur == 'Boost' %}Quiet - {% else %}Normal - {% endif %} - - # Store the new selection (UI) - - service: input_select.select_option - target: - entity_id: input_select.master_bedroom_heat_pump_profile - data: - option: "{{ nxt }}" - - # Apply it to the heat pump - - service: script.turn_on - target: - entity_id: script.master_bedroom_heat_pump_profile_apply - data: - variables: - profile: "{{ nxt }}" - -automation: - ######################################################################################## - # Clear "requested mode" once the climate entity catches up, so the UI reflects reality. - # This prevents two mode buttons appearing lit due to stale cloud attributes. - ######################################################################################## - - id: master_bedroom_hvac_clear_requested_mode - alias: Master Bedroom HVAC - Clear Requested Mode - mode: restart - trigger: - - platform: state - entity_id: climate.master_bedroom - - - platform: state - entity_id: input_select.master_bedroom_hvac_requested_mode - to: - - "off" - - "heat" - - "cool" - - "dry" - - condition: - - condition: template - value_template: > - {{ states('input_select.master_bedroom_hvac_requested_mode') != 'none' }} - - action: - # Small delay to allow the cloud integration to reflect the change - - delay: "00:00:03" - - - choose: - - conditions: - - condition: template - value_template: > - {% set req = states('input_select.master_bedroom_hvac_requested_mode') %} - {% set mode = states('climate.master_bedroom') %} - {% set action = (state_attr('climate.master_bedroom','hvac_action') or '') %} - {{ (req == 'off' and (mode == 'off' or action == 'off')) - or (req in ['heat','cool','dry'] and mode == req) }} - sequence: - - service: input_select.select_option - target: - entity_id: input_select.master_bedroom_hvac_requested_mode - data: - option: "none" - - default: - # Failsafe: if the cloud entity is slow or ambiguous, clear after a while anyway - - delay: "00:00:25" - - service: input_select.select_option - target: - entity_id: input_select.master_bedroom_hvac_requested_mode - data: - option: "none" - - ######################################################################################## - # Sync the Profile selector from actual device preset_mode (including changes made in - # the Panasonic app). This keeps the UI truthful. - # - # Mapping: - # - preset boost -> Boost - # - preset eco -> Quiet - # - preset none -> Normal - ######################################################################################## - - id: master_bedroom_heat_pump_profile_sync_from_entities - alias: Master Bedroom Heat Pump - Sync Profile From Entities - mode: restart - trigger: - - platform: state - entity_id: climate.master_bedroom - action: - - delay: "00:00:02" - - variables: - preset: "{{ state_attr('climate.master_bedroom', 'preset_mode') or 'none' }}" - new_profile: >- - {% if preset == 'boost' %} - Boost - {% elif preset == 'eco' %} - Quiet - {% else %} - Normal - {% endif %} - - choose: - - conditions: - - condition: template - value_template: "{{ states('input_select.master_bedroom_heat_pump_profile') != new_profile }}" - sequence: - - service: input_select.select_option - target: - entity_id: input_select.master_bedroom_heat_pump_profile - data: - option: "{{ new_profile }}" - -template: - - sensor: - - name: Master Bedroom Heat Pump Profile Tile - unique_id: master_bedroom_heat_pump_profile_tile - state: "{{ states('input_select.master_bedroom_heat_pump_profile') }}" - icon: > - {% set p = states('input_select.master_bedroom_heat_pump_profile') %} - {% if p == 'Boost' %} - mdi:rocket-launch - {% elif p == 'Quiet' %} - mdi:leaf - {% else %} - mdi:gauge - {% endif %} diff --git a/packages/heat_pump_set_point_cloud_helpers.yaml.old20260213 b/packages/heat_pump_set_point_cloud_helpers.yaml.old20260213 deleted file mode 100644 index 63a0ceb..0000000 --- a/packages/heat_pump_set_point_cloud_helpers.yaml.old20260213 +++ /dev/null @@ -1,76 +0,0 @@ -input_number: - master_bedroom_setpoint_ui: - name: Master Bedroom Setpoint UI - min: 10 - max: 30 - step: 0.5 - mode: box - unit_of_measurement: "°C" - icon: mdi:thermometer - -script: - master_bedroom_setpoint_sync_from_climate: - alias: Master Bedroom Setpoint Sync From Climate - mode: restart - sequence: - - variables: - sp: "{{ state_attr('climate.master_bedroom', 'temperature') | float(0) }}" - - service: input_number.set_value - target: - entity_id: input_number.master_bedroom_setpoint_ui - data: - value: "{{ sp }}" - - master_bedroom_setpoint_adjust: - alias: Master Bedroom Setpoint Adjust - mode: queued - fields: - step: - description: "Step in degrees C, e.g. -0.5 or 0.5" - example: -0.5 - sequence: - # ───────────────────────────── - # GUARD: only block when climate is OFF/unavailable/unknown - # (Do NOT use hvac_action == idle - Panasonic cloud often reports idle) - # ───────────────────────────── - - condition: template - value_template: > - {% set s = states('climate.master_bedroom') | lower %} - {{ s not in ['off', 'unavailable', 'unknown', 'none', ''] }} - - # ───────────────────────────── - # Compute new setpoint - # Prefer UI helper if valid, otherwise use climate temp - # ───────────────────────────── - - variables: - cur_ui: "{{ states('input_number.master_bedroom_setpoint_ui') | float(0) }}" - cur_cl: "{{ state_attr('climate.master_bedroom', 'temperature') | float(0) }}" - cur: "{{ cur_ui if cur_ui > 0 else cur_cl }}" - raw: "{{ cur + (step | float(0)) }}" - new: "{{ [30, [10, raw] | max] | min | round(1) }}" - - # Update UI immediately - - service: input_number.set_value - target: - entity_id: input_number.master_bedroom_setpoint_ui - data: - value: "{{ new }}" - - # Send to climate (cloud may lag) - - service: climate.set_temperature - target: - entity_id: climate.master_bedroom - data: - temperature: "{{ new }}" - -automation: - - id: master_bedroom_setpoint_sync - alias: Master Bedroom Setpoint Sync - mode: restart - trigger: - - platform: state - entity_id: climate.master_bedroom - - platform: homeassistant - event: start - action: - - service: script.master_bedroom_setpoint_sync_from_climate \ No newline at end of file diff --git a/packages/openhasp_lounge1_wallplate.yaml.disable b/packages/openhasp_lounge1_wallplate.yaml similarity index 82% rename from packages/openhasp_lounge1_wallplate.yaml.disable rename to packages/openhasp_lounge1_wallplate.yaml index 6c777a9..c87c35a 100644 --- a/packages/openhasp_lounge1_wallplate.yaml.disable +++ b/packages/openhasp_lounge1_wallplate.yaml @@ -10,7 +10,8 @@ input_number: max: 30 step: 0.5 mode: box - unit_of_measurement: "C" + unit_of_measurement: "℃" + initial: 21.5 ############################################################################### # AUTOMATIONS @@ -22,12 +23,12 @@ automation: mode: queued trigger: - platform: state - entity_id: sensor.lounge_inside_temperature + entity_id: sensor.lounge_environment_lounge_environment_temperature action: - service: mqtt.publish data: topic: "hasp/openhasp_1/command/p1b1.text" - payload: "{{ states('sensor.lounge_inside_temperature')|float(0)|round(1) }} C" + payload: "{{ states('sensor.lounge_environment_lounge_environment_temperature')|float(0)|round(1) }} °C" - id: hasp_setpoint_to_btn_text alias: "HASP: update setpoint (p1b2.text)" @@ -39,7 +40,9 @@ automation: - service: mqtt.publish data: topic: "hasp/openhasp_1/command/p1b2.text" - payload: "Set: {{ states('input_number.lounge_setpoint')|float(0)|round(1) }} C" + payload: "Set: {{ states('input_number.lounge_setpoint')|float(0)|round(1) }} °C" + qos: 1 + retain: true - id: hasp_heater_status_to_btn_text alias: "HASP: update heater status (p1b5.text)" @@ -90,18 +93,24 @@ automation: # --- PUSH INITIAL VALUES ON HA START / PANEL ONLINE ----------------------- - id: hasp_push_on_ha_start alias: "HASP: push texts on HA start" + mode: queued trigger: - platform: homeassistant event: start action: - service: script.hasp_push_all_texts - - id: hasp_push_on_plate_birth - alias: "HASP: push texts on panel birth" + - id: hasp_push_on_panel_online + alias: "HASP: push texts when panel goes online" + mode: queued trigger: - platform: mqtt - topic: "hasp/openhasp_1/state" + topic: "hasp/openhasp_1/status" + condition: + - condition: template + value_template: "{{ trigger.payload == 'online' }}" action: + - delay: "00:00:05" - service: script.hasp_push_all_texts ############################################################################### @@ -115,11 +124,15 @@ script: - service: mqtt.publish data: topic: "hasp/openhasp_1/command/p1b1.text" - payload: "{{ states('sensor.lounge_inside_temperature')|float(0)|round(1) }} C" + payload: "{{ states('sensor.lounge_environment_lounge_environment_temperature')|float(0)|round(1) }} °C" + - service: mqtt.publish data: topic: "hasp/openhasp_1/command/p1b2.text" - payload: "Set: {{ states('input_number.lounge_setpoint')|float(0)|round(1) }} C" + payload: "Set: {{ states('input_number.lounge_setpoint')|float(0)|round(1) }} °C" + qos: 1 + retain: true + - service: mqtt.publish data: topic: "hasp/openhasp_1/command/p1b5.text"