############################################# ############################################# # 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 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(); } }