#:########################################################################################:# # TITLE: MASTER BATHROOM UNDERFLOOR HEATING # zorruno.com layout v1.1 2026 #:########################################################################################:# # REPO: # https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-mainbathunderfloorheat.yaml #:########################################################################################:# # VERSIONS: # V1.0 2026-05-24 Initial version based on Master Bathroom Heated Towel Rail timer/boost logic #:########################################################################################:# # HARDWARE: # - Controlled by a Sonoff Mini R4 Extreme # - Small ESP32 based device with 1 relay # - Relay output uses GPIO26 on the standard Sonoff Mini R4 Extreme # - Blue status LED uses GPIO19 # - Local onboard button and external S1/S2 switch input are intentionally not used in this build # - This firmware only enables/disables power to the underfloor heating controller # - The underfloor heating thermostat, floor temperature limit, and any thermal safety cutout are external # - Pinout/schematic reference: # https://devices.esphome.io/devices/sonoff-minir4-extreme/ #:########################################################################################:# # OPERATION NOTES: # - This device controls the Master Bathroom underfloor heating controller relay # - It allows standalone timer style operation without Home Assistant # - TIMER mode uses a morning and evening time window, with no weekday/weekend split # - Morning Timer Enable and Evening Timer Enable switches gate their timer windows # - Morning and evening timer enable switches default to ON and are restored after reboot # - Default timer values are set in substitutions, and changed values are remembered in flash # - Default morning timer is 04:00 to 10:00 by default # - Default evening timer is 17:00 to 23:00 by default # - BOOST mode turns the relay ON for the configured boost duration, then returns to TIMER mode # - Default BOOST duration is 120 minutes by default # - On startup or reboot, the device enters BOOST mode by default # - Turning the "Underfloor Heating Power" HA switch ON starts BOOST mode by default # - Turning the "Underfloor Heating Power" HA switch OFF sets operation mode to OFF # - Operation Mode can be selected from HA as OFF, ON, TIMER, or BOOST # - ON means permanently on until changed # - OFF means permanently off until changed # - TIMER means follow the enabled morning/evening schedule windows # - BOOST means on for the boost duration, then return to TIMER # - It uses SNTP for accurate time when WiFi and networking are available # - If no network time is available, it uses the internal fallback minute counter # - To reset the fallback timer after a full network outage, power cycle the device at 12pm midday # - If a network and MQTT broker are available, operation and timer settings can be changed via MQTT # - Any new timer values set via MQTT are remembered through a reboot # - If you need it ON continuously with no MQTT, toggle power ON/OFF 4 times within 30 seconds # with about 2 seconds between toggles to allow it to boot #:########################################################################################:# # MQTT COMMANDS: # Use 24 hour HH:MM format for time setting. There is no weekday/weekend setting. # # mqtt_timer_topic/morning-on/04:00 : Morning time the device will turn on in TIMER mode # mqtt_timer_topic/morning-off/10:00 : Morning time the device will turn off in TIMER mode # mqtt_timer_topic/evening-on/17:00 : Evening time the device will turn on in TIMER mode # mqtt_timer_topic/evening-off/23:00 : Evening time the device will turn off in TIMER mode # mqtt_timer_topic/boost-time/120 : BOOST duration in minutes (1-1439) # mqtt_timer_topic/morning-enable/ON : Enable morning timer window # mqtt_timer_topic/morning-enable/OFF : Disable morning timer window # mqtt_timer_topic/evening-enable/ON : Enable evening timer window # mqtt_timer_topic/evening-enable/OFF : Disable evening timer window # mqtt_timer_topic/operation/ON : Device permanently on # mqtt_timer_topic/operation/OFF : Device permanently off # mqtt_timer_topic/operation/TIMER : Device will obey enabled timer settings # mqtt_timer_topic/operation/BOOST : Device will turn on for BOOST duration then return to TIMER # # operation_mode: # 0 = OFF # 1 = ON # 2 = TIMER # 3 = BOOST #:########################################################################################:# # OFFLINE NOTES: # a) Home Assistant OFFLINE, but Network and MQTT ONLINE # - Underfloor heating will continue to follow its assigned schedule # - Schedule times, timer enables, and operation mode can still be changed via MQTT # b) HA/MQTT OFFLINE # - Underfloor heating will remember its previous timer settings and mode # - If internet/WiFi is still available, SNTP time will remain accurate # c) Entire WiFi/Network OFFLINE # - Underfloor heating will continue with the last remembered timer settings # - Timeclocks will drift without SNTP and can be reset by power cycling at 12pm midday #:########################################################################################:# #:########################################################################################:# # SUBSTITUTIONS: 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-mainbathunderfloorheat" friendly_name: "Master Bathroom Underfloor Heating" description_comment: "Sonoff Mini R4 controlling ON/OFF/TIMER/BOOST for the Master Bathroom underfloor heating controller (Layout V1.1)" device_area: "Main Bathroom" # Allows ESP device to be automatically linked to an Area in Home Assistant. # Project Naming project_name: "Sonoff Technologies.Sonoff Mini R4 Extreme" 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-mainbathunderfloorheat_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:heating-coil" log_level: "ERROR" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG, VERBOSE, VERY_VERBOSE update_interval: "60s" # Update time for general sensors etc # Timer Settings mqtt_timer_topic: "viewroad-commands/masterbath-underfloorheat" boost_duration_default: "120" # Minutes to stay ON in BOOST mode before reverting to TIMER morning_on_default: "240" # Default in minutes from midnight. 04:00 => 240 morning_off_default: "600" # Default in minutes from midnight. 10:00 => 600 evening_on_default: "1020" # Default in minutes from midnight. 17:00 => 1020 evening_off_default: "1380" # Default in minutes from midnight. 23:00 => 1380 #:########################################################################################:# # PACKAGES: Included Common Packages # https://esphome.io/components/packages.html #:########################################################################################:# packages: #### WIFI, Network, Static/DHCP/IPV6, 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_current_ip_address: "${current_ip_address}" local_ota_pass: "${ota_pass}" #### 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}" #### MQTT #### common_mqtt: !include 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) #### 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 #diag_debug: !include common/include_debug_diag_sensors.yaml #diag_resetcount: !include common/include_resetcount_diag_sensors.yaml #:########################################################################################:# # ESPHOME: # https://esphome.io/components/esphome.html #:########################################################################################:# esphome: name: "${device_name}" friendly_name: "${friendly_name}" comment: "${description_comment}" # Appears on the esphome page in HA area: "${device_area}" project: name: "${project_name}" version: "${project_version}" on_boot: priority: 600 then: # This evaluates if the device is power cycled 4+ times within 30 seconds. # If so, just change to ON mode permanently. - lambda: |- // 1) Figure out the current time in seconds from midnight // using SNTP if available, otherwise current_mins * 60. bool have_sntp = id(sntp_time).now().is_valid(); int current_time_s = 0; if (have_sntp) { auto now = id(sntp_time).now(); current_time_s = now.hour * 3600 + now.minute * 60 + now.second; } else { // current_mins is in minutes; convert to seconds. current_time_s = id(current_mins) * 60; } // 2) Compare with the last boot time. int diff = current_time_s - id(last_boot_time_s); // If within 30 seconds, increment boot_count; otherwise reset to 1. if (diff >= 0 && diff <= 30) { id(boot_count)++; } else { id(boot_count) = 1; } // Update stored last boot time. id(last_boot_time_s) = current_time_s; // 3) If booted 4+ times within 30 seconds, force ON mode. if (id(boot_count) >= 4) { id(operation_mode) = 1; // ON ESP_LOGI("power_cycle", "Detected 4 power cycles within 30s. Forcing ON mode."); } else { // Normal startup logic: start in BOOST mode. id(operation_mode) = 3; id(boost_timer) = 0; ESP_LOGI("power_cycle", "Boot count=%d. Starting in BOOST mode.", id(boot_count)); } - script.execute: evaluate_relay_state #:########################################################################################:# # ESP PLATFORM AND FRAMEWORK: # https://esphome.io/components/esp32/ #:########################################################################################:# esp32: variant: esp32 framework: type: arduino preferences: flash_write_interval: 5min mdns: disabled: true #:########################################################################################:# # LOGGING: ESPHome Logging Enable # https://esphome.io/components/logger.html #:########################################################################################:# logger: level: "${log_level}" # INFO suggested for testing, ERROR for normal use baud_rate: 0 #:########################################################################################:# # STATUS LED COMPONENT: # https://esphome.io/components/status_led.html #:########################################################################################:# status_led: pin: number: GPIO19 inverted: true #:########################################################################################:# # SWITCH COMPONENT: # https://esphome.io/components/switch/ #:########################################################################################:# switch: #################################################### # Internal physical relay # Standard Sonoff Mini R4 Extreme relay output is GPIO26. # This is kept internal so HA does not bypass the timer/boost mode logic. #################################################### - platform: gpio id: relay pin: GPIO26 restore_mode: RESTORE_DEFAULT_OFF internal: true #################################################### # User-facing power switch # ON starts BOOST mode by default. # OFF sets operation mode OFF. #################################################### - platform: template name: "Underfloor Heating Power" id: underfloor_heating_power icon: "${relay_icon}" lambda: |- return id(relay).state; turn_on_action: - lambda: |- id(boost_timer) = 0; id(operation_mode) = 3; - script.execute: evaluate_relay_state turn_off_action: - lambda: |- id(boost_timer) = 0; id(operation_mode) = 0; - script.execute: evaluate_relay_state #################################################### # Timer Enable Switches # These gate the schedule windows in TIMER mode only. # Default and restored state is ON. #################################################### - platform: template name: "Morning Timer Enable" id: morning_timer_enable restore_mode: RESTORE_DEFAULT_ON icon: "mdi:timer-outline" optimistic: true on_turn_on: - script.execute: evaluate_relay_state on_turn_off: - script.execute: evaluate_relay_state - platform: template name: "Evening Timer Enable" id: evening_timer_enable restore_mode: RESTORE_DEFAULT_ON icon: "mdi:timer-outline" optimistic: true on_turn_on: - script.execute: evaluate_relay_state on_turn_off: - script.execute: evaluate_relay_state #:########################################################################################:# # GLOBAL VARIABLES: # https://esphome.io/guides/automations.html#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 the power-cycle window. - id: boot_count type: int restore_value: true initial_value: "0" # Morning On time in minutes from midnight. - id: morning_on type: int restore_value: true initial_value: "${morning_on_default}" # Morning Off time in minutes from midnight. - id: morning_off type: int restore_value: true initial_value: "${morning_off_default}" # Evening On time in minutes from midnight. - id: evening_on type: int restore_value: true initial_value: "${evening_on_default}" # Evening Off time in minutes from midnight. - id: evening_off type: int restore_value: true initial_value: "${evening_off_default}" # Boost Duration in 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" #################################################### # boost_timer: counts minutes in BOOST mode. # After boost_duration minutes, revert to TIMER. # Not restored, so each boot starts fresh at 0. #################################################### - id: boost_timer type: int restore_value: false initial_value: "0" #:########################################################################################:# # TEXT SENSOR COMPONENT: # https://esphome.io/components/text_sensor/index.html #:########################################################################################:# text_sensor: ############################ # MQTT Subscriptions ############################ ############################ # Morning On time => "HH:MM" ############################ - platform: mqtt_subscribe name: "Morning On Time Setting" id: morning_on_topic topic: "${mqtt_timer_topic}/morning-on" internal: true on_value: then: - lambda: |- int h = 0; int m = 0; if (sscanf(x.c_str(), "%2d:%2d", &h, &m) == 2 && h >= 0 && h <= 23 && m >= 0 && m <= 59) { id(morning_on) = h * 60 + m; ESP_LOGI("timer", "Received new Morning On: %02d:%02d", h, m); } else { ESP_LOGW("timer", "Invalid Morning On format: %s", x.c_str()); } - script.execute: evaluate_relay_state ############################ # Morning Off time => "HH:MM" ############################ - platform: mqtt_subscribe name: "Morning Off Time Setting" id: morning_off_topic topic: "${mqtt_timer_topic}/morning-off" internal: true on_value: then: - lambda: |- int h = 0; int m = 0; if (sscanf(x.c_str(), "%2d:%2d", &h, &m) == 2 && h >= 0 && h <= 23 && m >= 0 && m <= 59) { id(morning_off) = h * 60 + m; ESP_LOGI("timer", "Received new Morning Off: %02d:%02d", h, m); } else { ESP_LOGW("timer", "Invalid Morning Off format: %s", x.c_str()); } - script.execute: evaluate_relay_state ############################ # Evening On time => "HH:MM" ############################ - platform: mqtt_subscribe name: "Evening On Time Setting" id: evening_on_topic topic: "${mqtt_timer_topic}/evening-on" internal: true on_value: then: - lambda: |- int h = 0; int m = 0; if (sscanf(x.c_str(), "%2d:%2d", &h, &m) == 2 && h >= 0 && h <= 23 && m >= 0 && m <= 59) { id(evening_on) = h * 60 + m; ESP_LOGI("timer", "Received new Evening On: %02d:%02d", h, m); } else { ESP_LOGW("timer", "Invalid Evening On format: %s", x.c_str()); } - script.execute: evaluate_relay_state ############################ # Evening Off time => "HH:MM" ############################ - platform: mqtt_subscribe name: "Evening Off Time Setting" id: evening_off_topic topic: "${mqtt_timer_topic}/evening-off" internal: true on_value: then: - lambda: |- int h = 0; int m = 0; if (sscanf(x.c_str(), "%2d:%2d", &h, &m) == 2 && h >= 0 && h <= 23 && m >= 0 && m <= 59) { id(evening_off) = h * 60 + m; ESP_LOGI("timer", "Received new Evening Off: %02d:%02d", h, m); } else { ESP_LOGW("timer", "Invalid Evening Off format: %s", x.c_str()); } - script.execute: evaluate_relay_state ############################ # Boost duration => integer minutes (1-1439) ############################ - platform: mqtt_subscribe name: "Boost Duration Setting" id: boost_time_topic topic: "${mqtt_timer_topic}/boost-time" internal: true on_value: then: - lambda: |- int v = 0; if (sscanf(x.c_str(), "%d", &v) == 1 && v >= 1 && v <= 1439) { id(boost_duration) = v; ESP_LOGI("boost_time", "Received new Boost Duration: %d min", v); } else { ESP_LOGW("boost_time", "Invalid boost_time '%s'", x.c_str()); } ############################ # Morning timer enable => ON/OFF ############################ - platform: mqtt_subscribe name: "Morning Timer Enable Setting" id: morning_timer_enable_topic topic: "${mqtt_timer_topic}/morning-enable" internal: true on_value: then: - lambda: |- if (x == "ON" || x == "on" || x == "1" || x == "true" || x == "TRUE" || x == "ENABLE" || x == "enable") { id(morning_timer_enable).turn_on(); ESP_LOGI("timer", "Morning timer enabled via MQTT."); } else if (x == "OFF" || x == "off" || x == "0" || x == "false" || x == "FALSE" || x == "DISABLE" || x == "disable") { id(morning_timer_enable).turn_off(); ESP_LOGI("timer", "Morning timer disabled via MQTT."); } else { ESP_LOGW("timer", "Invalid morning-enable value: %s", x.c_str()); } ############################ # Evening timer enable => ON/OFF ############################ - platform: mqtt_subscribe name: "Evening Timer Enable Setting" id: evening_timer_enable_topic topic: "${mqtt_timer_topic}/evening-enable" internal: true on_value: then: - lambda: |- if (x == "ON" || x == "on" || x == "1" || x == "true" || x == "TRUE" || x == "ENABLE" || x == "enable") { id(evening_timer_enable).turn_on(); ESP_LOGI("timer", "Evening timer enabled via MQTT."); } else if (x == "OFF" || x == "off" || x == "0" || x == "false" || x == "FALSE" || x == "DISABLE" || x == "disable") { id(evening_timer_enable).turn_off(); ESP_LOGI("timer", "Evening timer disabled via MQTT."); } else { ESP_LOGW("timer", "Invalid evening-enable value: %s", x.c_str()); } #################################################### # Subscribe to operation mode: OFF, ON, TIMER, BOOST #################################################### - platform: mqtt_subscribe id: timer_operation_mode_topic topic: "${mqtt_timer_topic}/operation" internal: true on_value: then: - lambda: |- // Check only the first character for mode. char c = x.c_str()[0]; if (c == 'T' || c == 't') { id(operation_mode) = 2; } else if (c == 'O' || c == 'o') { // second letter N/n -> ON, F/f -> OFF if (x.size() > 1 && (x[1] == 'N' || x[1] == 'n')) { id(operation_mode) = 1; } else { id(operation_mode) = 0; } } else if (c == 'B' || c == 'b') { id(operation_mode) = 3; id(boost_timer) = 0; } else { ESP_LOGW("timer", "Invalid mode: %s", x.c_str()); } - script.execute: evaluate_relay_state #################################################### # Expose the current operation mode as text. #################################################### - platform: template name: "Operation Mode State" lambda: |- 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 HH:MM. #################################################### - platform: template name: "Timeclock: Morning On Time" lambda: |- int hour = id(morning_on) / 60; int minute = id(morning_on) % 60; 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 HH:MM. #################################################### - platform: template name: "Timeclock: Morning Off Time" lambda: |- int hour = id(morning_off) / 60; int minute = id(morning_off) % 60; 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 HH:MM. #################################################### - platform: template name: "Timeclock: Evening On Time" lambda: |- int hour = id(evening_on) / 60; int minute = id(evening_on) % 60; 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 HH:MM. #################################################### - platform: template name: "Timeclock: Evening Off Time" lambda: |- int hour = id(evening_off) / 60; int minute = id(evening_off) % 60; char buff[16]; snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute); return { std::string(buff) }; update_interval: "${update_interval}" #:########################################################################################:# # NUMBER COMPONENT: # https://esphome.io/components/number/index.html #:########################################################################################:# number: # A hidden value for setting operation mode via HTTP/API if needed. - platform: template name: "Operation Mode Number" id: operation_mode_number internal: true min_value: 0 max_value: 3 step: 1 optimistic: true set_action: - lambda: |- int new_mode = int(x); if (new_mode < 0) new_mode = 0; if (new_mode > 3) new_mode = 3; if (new_mode == 3) id(boost_timer) = 0; id(operation_mode) = new_mode; - script.execute: evaluate_relay_state #:########################################################################################:# # BINARY SENSOR COMPONENT: # https://esphome.io/components/binary_sensor/ #:########################################################################################:# binary_sensor: - platform: template name: "Relay Status" id: relay_status device_class: power lambda: |- return id(relay).state; #:########################################################################################:# # SENSOR COMPONENT: # https://esphome.io/components/sensor/index.html #:########################################################################################:# sensor: - platform: template name: "Timeclock: Boost Duration" id: boost_duration_time unit_of_measurement: "min" device_class: duration 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: "min" accuracy_decimals: 0 update_interval: "${update_interval}" internal: true lambda: |- return id(current_mins); # A value in minutes if a timer is running, showing how many minutes are left. - platform: template name: "Timer Minutes Remaining" id: timer_minutes_remaining unit_of_measurement: "min" device_class: duration update_interval: 5s accuracy_decimals: 0 lambda: |- if (!id(relay).state) { return 0; } auto in_window = [](int now_mins, int start_mins, int end_mins) -> bool { if (start_mins == end_mins) return false; if (start_mins < end_mins) return now_mins >= start_mins && now_mins < end_mins; return now_mins >= start_mins || now_mins < end_mins; }; auto minutes_until = [](int now_mins, int end_mins) -> int { int diff = end_mins - now_mins; if (diff <= 0) diff += 1440; return diff; }; int rem = 0; if (id(operation_mode) == 2) { if (id(morning_timer_enable).state && in_window(id(current_mins), id(morning_on), id(morning_off))) { rem = minutes_until(id(current_mins), id(morning_off)); } if (id(evening_timer_enable).state && in_window(id(current_mins), id(evening_on), id(evening_off))) { int evening_rem = minutes_until(id(current_mins), id(evening_off)); rem = (rem == 0) ? evening_rem : (evening_rem < rem ? evening_rem : rem); } } else if (id(operation_mode) == 3) { rem = id(boost_duration) - id(boost_timer); } return rem > 0 ? rem : 0; #:########################################################################################:# # 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: - lambda: |- id(boost_timer) = 0; id(operation_mode) = 3; - script.execute: evaluate_relay_state - platform: template name: "Default timer settings" id: default_timer_settings_button icon: "mdi:restore" on_press: - lambda: |- 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}; id(operation_mode) = 2; id(boost_timer) = 0; ESP_LOGI("timer", "Default timer settings applied."); - switch.turn_on: morning_timer_enable - switch.turn_on: evening_timer_enable - 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" 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"); } 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 { id(boost_timer) = 0; id(operation_mode) = 3; } - script.execute: evaluate_relay_state #:########################################################################################:# # SCRIPT COMPONENT: # https://esphome.io/components/script.html #:########################################################################################:# script: - id: evaluate_relay_state then: - lambda: |- int mode = id(operation_mode); // BOOST just forces the relay on. if (mode == 3) { id(relay).turn_on(); id(underfloor_heating_power).publish_state(id(relay).state); return; } // OFF is always off. if (mode == 0) { id(relay).turn_off(); id(underfloor_heating_power).publish_state(id(relay).state); return; } // ON is always on. if (mode == 1) { id(relay).turn_on(); id(underfloor_heating_power).publish_state(id(relay).state); return; } // TIMER follows schedule windows, gated by the enable switches. bool should_on = false; auto in_window = [](int now_mins, int start_mins, int end_mins) -> bool { if (start_mins == end_mins) return false; if (start_mins < end_mins) return now_mins >= start_mins && now_mins < end_mins; return now_mins >= start_mins || now_mins < end_mins; }; if (id(morning_timer_enable).state && in_window(id(current_mins), id(morning_on), id(morning_off))) { should_on = true; } if (id(evening_timer_enable).state && in_window(id(current_mins), id(evening_on), id(evening_off))) { should_on = true; } if (should_on) { id(relay).turn_on(); } else { id(relay).turn_off(); } id(underfloor_heating_power).publish_state(id(relay).state); #:########################################################################################:# # INTERVAL COMPONENT: # https://esphome.io/components/interval.html #:########################################################################################:# interval: # Bumps time using SNTP when available, otherwise uses the fallback minute counter. # Then calls the relay evaluation script. - interval: "1min" then: - lambda: |- 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 (id(operation_mode) == 3) { id(boost_timer)++; if (id(boost_timer) >= id(boost_duration)) { id(operation_mode) = 2; } } - script.execute: evaluate_relay_state