ESPHome Towel Rail Timer, preparing for offline use

Changes

2025-02-12 V1.0 First version

Summary

Home automation is great, but as mentioned way back in 2019 with my article Home Automation Future Thinking you really need to prepare for what happens when things go offline, eg the times where you may have no internet, or more permanently – if you sell your house.

This was the start of my mission to update a few of the remaining items in my house by giving them some offline functionality, and slightly better than ‘no function’ where I can (for the eventual new home owner).

This was also an opportunity to play with some of Open AIs new ChatGPT reasoning models, which seem pretty good at producing decent and well written code. I played with the o1 reasoning model to help create a bunch of complex ESPHome lambda templates in C++… these are fun to write, but very time consuming to write and test as they get more complex, with uploads and reboots etc. Both the o1 and o3 models are great at explaining what’s happening as they go (but are pretty slow compared to the other models), and this reasoning helps understand what it is deciding and why, which gives you the opportunity to challenge some of its decisions if needed. Some of the warnings and build errors I’d likely not have solved without this help either.

Sonoff Basic with Simple Head End Control

I have a simple Sonoff basic R1 behind a wallplate in my master bathroom, controlling a set of vertical heated towel rails (HTRs). For safety reasons, there is an on off switch to turn it off (that includes the sonoff basic device). The switch isn’t convenient, it is at low level, but that is pretty common for how HTRs are installed.

When first set up, it was running the out-of-the-box Tasmota, and I would just remotely turn it off and on using Home Assistant, or Node Red with a morning and evening sequence.

This was initially fine, but I then also added some timing via rules so that if you turned it on at the switch, the rails would switch on for 60mins then switch off again, so that you could at least get some heat if you did a switch on/off when having a shower outside of these times.

What happens if there is a power cut? Well when power comes back on, it would turn on for an hour, and also the timing functions would work again, via the automation head end, which is fine.

What happens though, if the automation functions went away completely though (eg we sold our house and removed the head end)? Well, the only way to turn on the towel rails would be to switch off the switch then turn it on again, which would give the 1 hour of heating.

Is there a better way?

Well, a normal, unsophisticated heated towel rail is on permanently, with the mains switch. So this should be the minimum, if the house was sold.

A step up from this would be to have some timing functions built in, akin to one of those addon timers you put inside a wallplate especially for the task.

An even better step up however would be to allow the use of an MQTT server, which could be the command point to tell it not only if the timer should be enabled, but what timer values should be included. That way all you have to do is have an MQTT server at a known IP address (on the same Wifi network) and set the timer values with a simple MQTT phone app or similar. But this might be asking too much of a new owner….

Looking at commercial HTR Controllers, they usually work with basic timer functions, that start when you turn them on. eg, switch on, and it goes on for 4 hours and off for 8. Some of them allow you to choose the on time by toggling it on and off.

Function Summary

These are the actions below that I decided upon. The YAML code is below and it is pretty well commented (as usual for me…) so should be mostly self explanitory.

  • It allows a heated towel rail device to work in a standalone operation
  • On startup, it will turn on for 2 hours then go into timer mode (this allows you to just turn it on to get some heat immediately)
  • The timer has a morning and evening time (but no weekday/weekend setting)
  • Default values are 5am-7am and 9pm-Midnight (as this suits our use case)
  • 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 4 on/off times via MQTT
  • You can set 4 modes ON/OFF/TIMER/STARTUP via MQTT
  • Any new timer times set via MQTT will be remembered though a reboot
  • On a reboot, the device will always turn on for the Startup Duration (STARTUP mode, default 2 hours)
  • TIMER mode will always be switched on after startup mode is complete
  • If you need it ON continuously with no MQTT, toggle power ON/OFF 4 times within 20 seconds (with ~2 secs in between to allow it to boot)

Tasmota to ESPHome

A quick note that I needed to switch from Tasmota to ESPHome. I didn’t really want to take the sonoff basic out of the wall to do this either.

You can’t usually just upload an ESPHome binary via the Tasmota web interface (at least not on the sonoff basic) as there is only 1MB of flash and the Sonoff build was already about 70% of that. It needs space for both builds before a successful flash.

There are a bunch of notes online about downgrading to earlier versions of Tasmota, and setting various SetOptions, or maybe using gz to compress the ESPHome binary, but I found I could just flash the latest Tasmota minimal binary, then straightway flash the ESPHome build.

ESPHome YAML

#############################################
#############################################
# MASTER BATHROOM HEATED TOWEL RAIL
# Controlled by a Sonoff Basic
#
# V1.0 2025-02-14 Initial Version
# 
# INSTRUCTIONS
# - It allows a heated towel rail device to work in a standalone operation
# - On startup, it will turn on for 2 hours then go into timer mode (this allows you to just turn it on to get some heat immediately)
# - The timer has a morning and evening time (but no weekday/weekend setting)
# - Default values are 5am-7am and 9pm-Midnight (as this suits our use case)
# - 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 4 on/off times via MQTT (See below commands)
# - You can set 4 modes ON/OFF/TIMER/STARTUP via MQTT
# - Any new timer times set via MQTT will be remembered though a reboot
# - On a reboot, the device will always turn on for the Startup Duration (STARTUP mode, default 2 hours)
# - TIMER mode will always be switched on after startup mode is complete
# - If you need it ON continuously with no MQTT, toggle power ON/OFF 4 times within 20 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 towel rail will go on
# mqtt_timer_topic/morning-off/08:00  : Time towel rail will go off
# mqtt_timer_topic/evening-on/09:00   : Time towel rail will go on
# mqtt_timer_topic/evening-off/00:00  : Time towel rail will go off
# mqtt_timer_topic/operation/ON       : Towel rail permanently on
# mqtt_timer_topic/operation/OFF      : Towel rail permanently off
# mqtt_timer_topic/operation/TIMER    : Towel rail will obey timer settings
# mqtt_timer_topic/operation/STARTUP  : Turn on for 2 hours then TIMER (also on startup)
#
#############################################
#############################################

#############################################
# VARIABLE SUBSTITUTIONS
# Give the device a useful name & description here
# and change values accordingly.
#############################################
substitutions:
  
  mqtt_timer_topic: "viewroad-commands/masterbath-towelrail" # Topics you will use to change stuff
  startup_duration: "120" # Minutes to stay ON in STARTUP mode before reverting to TIMER
  timezone: "Pacific/Auckland" # For setting clock with snmp

  devicename: "esp-masterbathtowelrail"
  friendly_name: "Master Bathroom Towelrail"
  description_comment: "Sonoff Basic controlling ON/OFF/Timer for the Heated Towel Rail in the Master Bathroom"

  # If NOT using a secrets file, just replace these with the passwords etc (in quotes)
  api_key: !secret esp-masterbathtowelrail_api_key # unfortunately you can't use substitutions inside secrets names
  ota_pass: !secret esp-masterbathtowelrail_ota_pass # unfortunately you can't use substitutions inside secrets names
  wifi_ssid: !secret wifi_ssid
  wifi_password: !secret wifi_password
  fallback_ap_password: !secret fallback_ap_password
  
  # Add these if we are giving it a static ip, or remove them in the Wifi section
  #static_ip_address: !secret esp-occupancyoffice_static_ip
  #static_ip_gateway: !secret esp-occupancyoffice_gateway
  #static_ip_subnet: !secret esp-occupancyoffice_subnet

  mqtt_server: !secret mqtt_server
  mqtt_username: !secret mqtt_username
  mqtt_password: !secret mqtt_password
  mqtt_topic: "esphome" #main topic for the mqtt server, call it what you like

  # Add these if we are using the internal web server (this is pretty processor intensive)
  #web_server_username: !secret web_server_username
  #web_server_password: !secret web_server_password

  update_interval: 60s # update time for for general sensors etc


#############################################
# ESPHome
# https://esphome.io/components/esphome.html
#############################################
esphome:
  name: ${devicename}
  friendly_name: ${friendly_name}
  comment: ${description_comment} #a ppears on the esphome page in HA
  on_boot:
    priority: 900  # high priority to run after globals are initialized
    then:
      - lambda: |-
          // 1) Figure out the current time in "seconds from midnight"
          //    using SNTP if available, otherwise fallback_time * 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 {
            // fallback_time is in minutes; convert to seconds
            current_time_s = id(fallback_time) * 60;
          }

          // 2) Compare with the last boot time
          int diff = current_time_s - id(last_boot_time_s);

          // If within 20 seconds, increment boot_count; otherwise reset to 1
          if (diff >= 0 && diff <= 20) {
            id(boot_count)++;
          } else {
            id(boot_count) = 1;
          }

          // Update stored last boot time
          id(last_boot_time_s) = current_time_s;

          // 3) If we've booted 4+ times in 20s => force ON mode
          if (id(boot_count) >= 4) {
            id(operation_mode) = 1;  // ON
            ESP_LOGI("power_cycle", "Detected 4 power cycles in 20s => Forcing ON mode");
          } else {
            // Otherwise do your normal startup logic:
            id(operation_mode) = 3; // on_boot -> sets operation_mode = 3 (STARTUP)
            id(startup_timer) = 0;  // and reset startup_timer = 0 (for time sync if no sntp)
            ESP_LOGI("power_cycle", "Boot count=%d => STARTUP mode", id(boot_count));
          }

#############################################
# ESP Platform and Framework
# https://esphome.io/components/esp32.html
#############################################
esp8266:
  board: esp01_1m  # The original sonoff basic

#############################################    
# ESPHome Logging Enable
# https://esphome.io/components/logger.html
############################################# 
logger:
  level: INFO  #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

#############################################    
# Enable the Home Assistant API
# https://esphome.io/components/api.html
#############################################
api:
  encryption:
    key: ${api_key}

#############################################    
# Enable Over the Air Update Capability
# https://esphome.io/components/ota.html?highlight=ota
#############################################  
ota:
  - platform: esphome
    password: ${ota_pass}

#############################################    
# Safe Mode
# Safe mode will detect boot loops
# https://esphome.io/components/safe_mode
#############################################  
safe_mode:

#############################################    
# Wifi Settings 
# https://esphome.io/components/wifi.html
#
# Power Save mode (can reduce wifi reliability)
# NONE (least power saving, Default for ESP8266)
# LIGHT (Default for ESP32)
# HIGH (most power saving)
#############################################  
wifi:
  ssid: ${wifi_ssid}
  password: ${wifi_password}
  #power_save_mode: LIGHT   # https://esphome.io/components/wifi.html#wifi-power-save-mode
  #manual_ip:               # optional static IP address
    #static_ip: ${static_ip_address}
    #gateway: ${static_ip_gateway}
    #subnet: ${static_ip_subnet}
  ap:   # Details for fallback hotspot in case wifi connection fails https://esphome.io/components/wifi.html#access-point-mode
    ssid: ${devicename} AP
    password: ${fallback_ap_password}
    ap_timeout: 30min # Time until it brings up fallback AP. default is 1min

captive_portal:  # extra fallback mechanism for when connecting if the configured WiFi fails

#############################################
# Real time clock time source for ESPHome
# If it's invalid, we fall back to an internal clock
# https://esphome.io/components/time/index.html
# https://esphome.io/components/time/sntp
#############################################
time:
  - platform: sntp
    id: sntp_time

#############################################
# MQTT Monitoring
# https://esphome.io/components/mqtt.html?highlight=mqtt
# MUST also have api enabled if you enable MQTT
#############################################
mqtt:
  broker: ${mqtt_server}
  topic_prefix: ${mqtt_topic}/${devicename}
  username: ${mqtt_username}
  password: ${mqtt_password}
  #discovery: True # enable entity discovery (true is default)
  #discover_ip: True # enable device discovery (true is default)

#############################################
# 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 10 seconds
  - id: boot_count
    type: int
    restore_value: true
    initial_value: "0"

  # Morning On time (minutes from midnight),
  # default 05:00 => 300
  - id: morning_on
    type: int
    restore_value: true
    initial_value: "300"

  # Morning Off time (minutes from midnight),
  # default 07:00 => 420
  - id: morning_off
    type: int
    restore_value: true
    initial_value: "420"

  # Evening On time (minutes from midnight),
  # default 21:00 => 1260
  - id: evening_on
    type: int
    restore_value: true
    initial_value: "1260"

  # Evening Off time (minutes from midnight),
  # default 00:00 => 0 => treat as midnight
  - id: evening_off
    type: int
    restore_value: true
    initial_value: "0"

  ####################################################
  # operation_mode:
  #  0 = OFF
  #  1 = ON
  #  2 = TIMER
  #  3 = STARTUP
  ####################################################
  - id: operation_mode
    type: int
    restore_value: false
    initial_value: "3"

  ####################################################
  # fallback_time is used if SNTP is invalid.
  # We assume user powers on the device at 12:00 noon
  # => 12 * 60 = 720 minutes from midnight.
  # Not restored, so it resets each boot.
  ####################################################
  - id: fallback_time
    type: int
    restore_value: false
    initial_value: "720"

  ####################################################
  # startup_timer: counts minutes in STARTUP mode
  # After 'startup_duration' minutes, revert to TIMER.
  # Not restored, so each boot starts fresh at 0.
  ####################################################
  - id: startup_timer
    type: int
    restore_value: false
    initial_value: "0"

#############################################
# Text Sensors
# https://esphome.io/components/text_sensor/index.html
#############################################
text_sensor:

############################
# MQTT Subscriptions
############################

  ####################################################
  # Subscribe to the Morning On time, format "HH:MM"
  # We check x.size() == 5 and x[2] == ':',
  # then parse x.substr(0,2) and x.substr(3,2)
  # std::string uses 'substr', not 'substring'.
  ####################################################
  - platform: mqtt_subscribe
    name: "Morning On Time"
    id: morning_on_topic
    topic: "${mqtt_timer_topic}/morning-on"
    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"
    id: morning_off_topic
    topic: "${mqtt_timer_topic}/morning-off"
    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"
    id: evening_on_topic
    topic: "${mqtt_timer_topic}/evening-on"
    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"
    id: evening_off_topic
    topic: "${mqtt_timer_topic}/evening-off"
    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());
            }

  ####################################################
  # Subscribe to operation mode:
  # OFF, ON, TIMER, STARTUP
  # We do case-insensitive compare using strcasecmp
  # (Requires <strings.h> typically included in ESPHome)
  ####################################################
  - platform: mqtt_subscribe
    name: "Timer Operation Mode"
    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: |-
            /*
             * In standard C++ (ESPHome), no 'equalsIgnoreCase()'.
             * We use 'strcasecmp' for case-insensitive compare.
             * Returns 0 if they match ignoring case.
             */
            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(), "STARTUP") == 0) {
              id(operation_mode) = 3;
              id(startup_timer) = 0;
              ESP_LOGI("timer","Operation mode set to STARTUP");
            } else {
              ESP_LOGW("timer","Invalid operation mode: %s", x.c_str());
            }

  ######################################################
  # Expose the current operation mode (OFF, ON, TIMER, STARTUP)
  ######################################################
  - platform: template
    name: "Operation Mode State"
    lambda: |-
      // 0=OFF, 1=ON, 2=TIMER, 3=STARTUP
      switch (id(operation_mode)) {
        case 0: return {"OFF"};
        case 1: return {"ON"};
        case 2: return {"TIMER"};
        case 3: return {"STARTUP"};
        default: return {"UNKNOWN"};
      }
    update_interval: ${update_interval}

  ######################################################
  # Expose the "Morning On" time as a text (HH:MM)
  ######################################################
  - platform: template
    name: "Morning On Time State"
    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: "Morning Off Time State"
    lambda: |-
      int hour = id(morning_off) / 60;
      int minute = id(morning_off) % 60;
      // Increase buffer size to 8 just to be safe
      // 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: "Evening On Time State"
    lambda: |-
      int hour = id(evening_on) / 60;
      int minute = id(evening_on) % 60;
      // Increase buffer size to 8 just to be safe
      // 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: "Evening Off Time State"
    lambda: |-
      int hour = id(evening_off) / 60;
      int minute = id(evening_off) % 60;
      // Increase buffer size to 8 just to be safe
      // Increase to 16 for safety
      char buff[16];
      snprintf(buff, sizeof(buff), "%02d:%02d", hour, minute);
      return { std::string(buff) };
    update_interval: ${update_interval}

  ######################################################
  # ESPHome Info
  ######################################################
  - platform: version
    name: ${friendly_name} Version
  - platform: wifi_info
    ip_address:
      name: ${friendly_name} IP Address

#############################################
# General Sensors 
# https://esphome.io/components/sensor/index.html
#############################################
sensor:
  - platform: uptime      # Uptime for this device
    name: ${friendly_name} Uptime
    update_interval: ${update_interval}  
  - platform: wifi_signal # Wifi Strength
    name: ${friendly_name} Wifi Signal
    update_interval: ${update_interval}  

####################################################
# Relay Switch (Sonoff Basic Relay on GPIO12)
####################################################
switch:
  - platform: gpio
    name: "Towel Rail Power"
    pin: GPIO12
    id: relay
    restore_mode: RESTORE_DEFAULT_OFF

####################################################
# Check every minute to decide relay state
####################################################
interval:
  - interval: ${update_interval}
    then:
      - lambda: |-
          // operation_mode:
          //   0 = OFF
          //   1 = ON
          //   2 = TIMER
          //   3 = STARTUP
          int mode = id(operation_mode);

          //////////////////////////////////////////////////
          // STARTUP MODE: Relay ON for 'startup_duration'
          // minutes, then automatically revert to TIMER.
          //////////////////////////////////////////////////
          if (mode == 3) {
            id(startup_timer)++;
            // Compare with the substitution startup_duration
            if (id(startup_timer) < (int) ${startup_duration}) {
              // Still within the STARTUP period => turn relay on
              id(relay).turn_on();
            } else {
              // After 'startup_duration' minutes => switch to TIMER
              id(operation_mode) = 2;
            }
            // Skip the rest of the logic
            return;
          }

          //////////////////////////////////////////////////
          // OFF MODE => always off
          //////////////////////////////////////////////////
          if (mode == 0) {
            id(relay).turn_off();
            return;
          }

          //////////////////////////////////////////////////
          // ON MODE => always on
          //////////////////////////////////////////////////
          if (mode == 1) {
            id(relay).turn_on();
            return;
          }

          //////////////////////////////////////////////////
          // TIMER MODE => follow morning/evening schedule
          // using SNTP if valid, else fallback_time
          //////////////////////////////////////////////////
          if (mode == 2) {
            auto now = id(sntp_time).now();
            bool have_sntp = now.is_valid();

            int current_mins;
            if (!have_sntp) {
              // SNTP not available => fallback clock
              current_mins = id(fallback_time);
              // increment the fallback clock by 1 minute
              id(fallback_time) += 1;
              // wrap around at 1440 => next day
              if (id(fallback_time) >= 1440) {
                id(fallback_time) = 0;
              }
            } else {
              // Use real time from SNTP
              current_mins = now.hour * 60 + now.minute;
            }

            bool should_on = false;

            // If evening_off == 0 => treat as midnight => 1440
            int evening_off_local = id(evening_off);
            if (evening_off_local == 0) {
              evening_off_local = 1440;
            }

            // Check morning window
            // Example: morning_on=360 => 06:00, morning_off=480 => 08:00
            // If current_mins in [360..480), should_on = true
            if (id(morning_on) < id(morning_off)) {
              if (current_mins >= id(morning_on) && current_mins < id(morning_off)) {
                should_on = true;
              }
            }

            // Check evening window
            // Example: evening_on=540 => 09:00, evening_off=1440 => midnight
            if (id(evening_on) < evening_off_local) {
              if (current_mins >= id(evening_on) && current_mins < evening_off_local) {
                should_on = true;
              }
            }

            // Final relay state based on schedule
            if (should_on) {
              id(relay).turn_on();
            } else {
              id(relay).turn_off();
            }
          }

Improvements

Some improvements I’ll potentially make:

  • tba

Leave a Reply

Your email address will not be published. Required fields are marked *

Post comment