886 lines
33 KiB
YAML
886 lines
33 KiB
YAML
#:########################################################################################:#
|
|
# 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
|