#:########################################################################################:#
# 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
