Skip to content

Instantly share code, notes, and snippets.

@AndySymons
Last active March 16, 2024 20:13
Show Gist options
  • Save AndySymons/869e9a34d84c4ddc03fc762a1fc6d88b to your computer and use it in GitHub Desktop.
Save AndySymons/869e9a34d84c4ddc03fc762a1fc6d88b to your computer and use it in GitHub Desktop.
Calendar Switch Y2. Switches one or more devices based on calendar events
### ------------------------------------------------------------------------------------------------
###
### CALENDAR SWITCH Y2
### ------------------
###
### Release 1.0 (5 Mar 2024)
### Controls one or more switched devices from a calendar
### Allows temporary manual override
### Away mode. Turns off (ignores the calendar)
### Device battery life saved by transmitting only when there is an actual change.
### Sends a notification if a switch is unavailable or not responding to settings.
### Has optional logging.
###
### Minor updates (for robustness)
### 05-Mar-2024: 1.0.01 Added detail to the trigger line of the log entries
### 08-Mar-2024: 1.0.02 Corrected the trigger line of the log entries when triggers have no entity_id (e.g. calendars)
### 08-Mar-2024: 1.0.03 Tidied up logic of ACTION[6] using `choose`, to ensure there is always a log message
### 08-Mar-2024: 1.0.04 Improved notifications. Additional unknown and unavailable checks every time a setting is needed.
### Notification automatically dismissed when device comes back online (instead of a new
### notification). Only ever one notification per device.
### 13-Mar-2024: 1.0.05 Added 'continue_on_error' and 'parallel' to device service call, 'continue_on_error' to logs
### and notifications, because failure her should not stop the main device switching function
### 14-Mar-2924: 1.0.06 Turning away mode on now cancels the manual override timer
###
### Release 2.0 (14 Mar 2024)
### 14-Mar-2924: 2.0.00 Actually just renamed from Y1 to Y2 for alignment with Calendar Switch Y2 and Zone Switch Z2
### 14-Mar-2924: 2.0.01 Added extra unknown and unavailable tests on echoblock timer end
### 16-Mar-2024: 2.2.02 Changed logging to use the new script.heating_xyz_logfile_entry (making the code more compact and maintainable)
###
### OVERVIEW
### The main action steps are as follows.
### The numbering correspnds to that used by the system log for error messages.
### ACTION[0] -- LOG THE TRIGGER. Starts a new log file entry by writing a bank line and the trigger id
### ACTIONS[1-3] -- FETCH THE CURRENT AND NEXT CALENDAR EVENTS
### Calls calendar.get_events twice, then sets local variables for later use
### ACTION[4] -- RESPOND TO TRIGGERS WHERE NECESSARY. A big choose statement on the trigger_id.
### ACTION[5] -- DETERMINE THE REQUIRED state ACCORDING TO THE STATE. A big chooose statement on the relevant states variables.
### Decides what state is required and why. Sets the 'required state' helper and the 'reason test' helper.
### ACTION[6] -- SEND state CHANGE TO THE deviceS
### Loops through all the devices for the room. If the required state is
### different from the actual one, the service call to change it is invoked and the echoblock (response timerout) timer started.
### ACTION[7] -- LOG THE REASON. Writes the last line of the log file entry
###
### ------------------------------------------------------------------------------------------------
blueprint:
name: "Calendar Switch Y2"
description: Controls one or more switched devices from a calendar, allows temporary manual override
domain: automation
### ----------------------------------------------------------------------------------------------
### INPUTS
### ----------------------------------------------------------------------------------------------
input:
switched_devices:
name: DEVICE ENTITIES - The devices to be switched
description: One or more switch entities that are to be controlled by this automation
selector:
entity:
filter:
domain: switch
multiple: true
away_mode:
name: DEVİCE ENTITY - Away switch, binary sensor or input boolean (if required)
description: A switch, input boolean, or binary sensor that switches all devices
selector:
entity:
default: []
device_calendar:
name: CALENDAR ENTITY - switch calendar (mandatory)
description: The calendar dedicated to scheduling events for this/these device(s)
selector:
entity:
filter:
domain: calendar
setting_reason:
name: HELPER - Setting reason (mandatory)
description: The global variable (helper) into which the automation writes the reason for the current setting (for use on a dashboard)
selector:
entity:
filter:
domain: input_text
required_state:
name: HELPER - Required state (mandatory)
description: The global boolean variable (helper) to hold the state required
selector:
entity:
filter:
domain: input_boolean
manual_state:
name: HELPER - Manual override state (mandatory)
description: The global boolean variable (helper) to hold the state for a manual intervention
selector:
entity:
filter:
domain: input_boolean
manual_override_timer:
name: HELPER - Manual override timer (mandatory)
description: The global variable (helper) to hold the timer for a manual intervention
selector:
entity:
filter:
domain: timer
echoblock_timer:
name: HELPER - Echoblock timer (mandatory)
description: The timer for use inside the automation to disinguish genuine manual changes of the set state from those set by the automation
selector:
entity:
filter:
domain: timer
manual_override_period:
name: PARAMETER - Manual override period
description: The time period for which a manual intervention will override the schedule
selector:
time:
default: "02:00:00"
logging_service_name:
name: LOGGING - Specify the service for logging
description: Enter service name (not filename) as a string
default: ""
mode: queued # use all triggers but avoid conflicting states
## -------------------------------------------------------------------------------------------------
## LOCAL VARİABLES
## Constants, and input variable values for use in templates
## -------------------------------------------------------------------------------------------------
variables:
# Parameters -- hold the actual value
local_logging_service_name: !input logging_service_name
# Devices and helpers -- hold the entity
local_away_mode: !input away_mode
local_device_calendar: !input device_calendar
local_manual_override_timer: !input manual_override_timer
local_manual_state: !input manual_state
local_required_state: !input required_state
local_setting_reason: !input setting_reason
# Handy constants
# identifies the source of the log messages
log_message_preamble: >
{{ this.attributes.friendly_name }}
## -------------------------------------------------------------------------------------------------
## TRIGGERS
## -------------------------------------------------------------------------------------------------
trigger:
- platform: homeassistant
event: start
id: startup
# Calendar event start
- platform: calendar
event: start
offset: "00:00:00"
entity_id: !input device_calendar
id: calendar_event_start
# Calendar event end
- platform: calendar
event: end
offset: "00:00:00"
entity_id: !input device_calendar
id: calendar_event_end
# Switch becomes unavailable
- platform: state
entity_id: !input switched_devices
to: unavailable
for:
seconds: 60 # not just a glitch
id: device_unavailable
# Switch becomes available
- platform: state
entity_id: !input switched_devices
from: unavailable
for:
seconds: 60 # not just a glitch
id: device_available
# Change in any one of the switches' state
- platform: state
entity_id: !input switched_devices
for:
seconds: 5
id: switch_state_change
# End of manual override (timer idle, unknown, or unavailable)
- platform: state
entity_id: !input manual_override_timer
from: active
id: manual_override_end
# Away switch change
- platform: state
entity_id: !input away_mode
id: away_mode_change
# Echoblock timer end -- Check whether the switches responded
- platform: state
entity_id: !input echoblock_timer
from: active
to: idle
id: echoblock_timer_end
# - platform: time_pattern
# At interval re-checks the state.
# Can be used to pick up missed events etc. Should not be necessary
# between event_start, calendar on and availability of data
# minutes: "/1"
# id: catchall_interval
action:
## -------------------------------------------------------------------------------------------------
## ACTION[0] -- LOG THE TRIGGER
## -------------------------------------------------------------------------------------------------
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: ---
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{% if trigger.id is undefined %}
{{ "Run manually." }}
{% elif trigger.entity_id is undefined %}
{{ "Triggered by: " + trigger.id | string + " (no entity id)" }}
{% else %}
{{ "Triggered by: " + trigger.id | string + " from '" + state_attr(trigger.entity_id, 'friendly_name') + "', state = " + states(trigger.entity_id) }}
{% endif %}
## -------------------------------------------------------------------------------------------------
## ACTIONS[1-3] -- FETCH THE CURRENT AND NEXT EVENTS FROM THE CALENDAR
## Current event is the one that started last
## -------------------------------------------------------------------------------------------------
- service: calendar.get_events
target:
entity_id: !input device_calendar
data:
duration:
hours: 0
minutes: 0
seconds: 1
response_variable: active_events
- service: calendar.get_events
target:
entity_id: !input device_calendar
data:
duration:
hours: 24
minutes: 0
seconds: 0
response_variable: future_events
- variables:
any_event_active: >
{{ active_events[local_device_calendar].events | count > 0 }}
current_event: >
{{ active_events[local_device_calendar].events | sort(attribute ='start') | list | last | default("") }}
any_future_event: >
{{ future_events[local_device_calendar].events | count > 0 }}
next_event: >
{{ future_events[local_device_calendar].events | sort(attribute ='start') | list | first | default ("") }}
## -------------------------------------------------------------------------------------------------
## ACTION[4] -- RESPOND TO TRIGGERS WHERE NECESSARY
## (not all triggers require an individual response)
## -------------------------------------------------------------------------------------------------
# Each choice responds to a specific trigger (where a response is needed))
- choose:
#
# ACTION[4]CHOOSE[0] Manual override start
#
- conditions:
- condition: trigger
id: switch_state_change
sequence:
- if:
- condition: state
entity_id: !input echoblock_timer
state: idle
then:
- service: timer.start
target:
entity_id: !input manual_override_timer
data:
duration: !input manual_override_period
- if:
condition: template
value_template: >
{{ states(trigger.entity_id) == "on" }}
then:
- service: input_boolean.turn_on
target:
entity_id: !input manual_state
else:
- service: input_boolean.turn_off
target:
entity_id: !input manual_state
else:
# log that the trigger is ignored
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "Set state ignored during echo block period" }}
# Stop
- stop: "trigger ignored during echoblock"
#
# ACTION[4]CHOOSE[1] Echoblock timer end
# Check whether the switches responded correctly to the new setting
#
- conditions:
- condition: trigger
id: echoblock_timer_end
sequence:
- repeat:
for_each: !input switched_devices
sequence:
- choose:
# If the device is unknown
- conditions: >
{{ states(repeat.item) == 'unknown' }}
sequence:
# Create a notification
- service: persistent_notification.create
data:
title: "Calendar Switch Y2"
message: >
{{ " The device '" + state_attr(repeat.item, 'friendly_name') | default("xxxxxx") + "' is unknown" }}
notification_id: >
{{ repeat.item }}
# to prevent duplicate notifications
continue_on_error: true
#
# log the notification
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "The device '" + state_attr(repeat.item, 'friendly_name') | default("xxxxxx") + "' is unknown" }}
#
# If the device is unavailable
- conditions: >
{{ states(repeat.item) == 'unavailable' }}
sequence:
# Create a notification
- service: persistent_notification.create
data:
title: "Calendar Switch Y2"
message: >
{{ " The device '" + state_attr(repeat.item, 'friendly_name') | default("xxxxxx") + "' is unavailable" }}
notification_id: >
{{ repeat.item }}
# to prevent duplicate notifications
continue_on_error: true
#
# log the notification
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "The device '" + state_attr(repeat.item, 'friendly_name') | default("xxxxxx") + "' is unavailable" }}
#
# The device is not responding to the command
- conditions:
- condition: template
value_template: "{{ states(repeat.item) != states(local_required_state) }}"
sequence:
# Create a notification
- service: persistent_notification.create
data:
title: "Calendar Switch Y2"
message: >
{{ "The device '" + state_attr(repeat.item, 'friendly_name') + "' is not responding correctly. Tried to set to " + states(local_required_state) | string + "; actual setting " + states(repeat.item) | string + "." }}
notification_id: "{{ repeat.item }}" # to prevent duplicate notifications for same device
# log the notification
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "The device '" + state_attr(repeat.item, 'friendly_name') + "' is not responding correctly. Tried to set to " + states(local_required_state) | string + "; actual setting " + states(repeat.item) | string + "." }}
default: # it is OK
# Dismiss any notification
- service: persistent_notification.dismiss
data:
notification_id: >
{{ repeat.item }}
# to prevent duplicate notifications
continue_on_error: true
# Log entry
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "The device '" + state_attr(repeat.item, 'friendly_name') + "' is set correctly." }}
#
# Stop. Otherwise the automation keeps repeating when a device is not responding.
#
- stop: "No further action after checking the previous setting"
# --------------------------------------------------------------------
#
# ACTION[4]CHOOSE[2] Manual override timer end
# Reset the manual setting helper
#
- conditions:
- condition: trigger
id: manual_override_end
sequence:
- service: input_boolean.turn_off
target:
entity_id: !input manual_state
#
# ACTION[3].CHOOSE[3] Away mode on
# Reset manual override
- conditions:
- condition: trigger
id: away_mode_change
sequence:
- service: timer.cancel
target:
entity_id: !input manual_override_timer
- service: input_boolean.turn_off
target:
entity_id: !input manual_state
##
## -------------------------------------------------------------------------------------------------
## ACTION[5] -- DETERMINE THE REQUIRED SWITCH STATE
## -------------------------------------------------------------------------------------------------
##
## Each choice sets the required state and the reason text, for later use
## The states are tested in order of their priority over other states
- choose:
# ACTION[5].CHOOSE[0]. If 'away' switch is on
- conditions:
- condition: template
value_template: "{{ states(local_away_mode) != '' }}" # test whether an away switch was specified
- condition: state
entity_id: !input away_mode
state: "on"
sequence:
- service: input_boolean.turn_off
target:
entity_id: !input required_state
- service: input_text.set_value
data:
value: >
{% if state_attr(local_setting_reason, 'max') < 255 %}
{{ "**Error! Setting reason helper max length must be 255**" }}
{% else %}
{{ "Turned off because away mode is turned on." }}
{% endif %}
target:
entity_id: !input setting_reason
# ACTION[5].CHOOSE[1]. If there is a manual override in operation
- conditions:
- condition: state
entity_id: !input manual_override_timer
state: active
sequence:
- if:
condition: state
entity_id: !input manual_state
state: "on"
then:
- service: input_boolean.turn_on
target:
entity_id: !input required_state
else:
- service: input_boolean.turn_off
target:
entity_id: !input required_state
- service: input_text.set_value
data:
value: >
{% if state_attr(local_setting_reason, 'max') < 255 %}
{{ "**Error! Setting reason helper max length must be 255**" }}
{% else %}
{{ "Set manually to " + states(local_manual_state) + "." }}
{% endif %}
target:
entity_id: !input setting_reason
# ACTION[5].CHOOSE[2]. If there is an active calendar event
- conditions:
- condition: template
value_template: "{{ any_event_active }}"
sequence:
- service: input_boolean.turn_on
target:
entity_id: !input required_state
- service: input_text.set_value
data:
value: >
{% if state_attr(local_setting_reason, 'max') < 255 %}
{{ "**Error! Setting reason helper max length must be 255**" }}
{% else %}
{{ "Turned on by calendar event '" + current_event.summary | trim + "' until " + as_timestamp(current_event.end) | timestamp_custom('%a %d %b %Y at %H:%M') + "." }}
{% endif %}
target:
entity_id: !input setting_reason
# ACTION[5].CHOOSE[3]. If there is no active calendar event
- conditions:
- condition: template
value_template: "{{ not any_event_active }}"
sequence:
- service: input_boolean.turn_off
target:
entity_id: !input required_state
- service: input_text.set_value
data:
value: >
{% if state_attr(local_setting_reason, 'max') < 255 %}
{{ "**Error! Setting reason helper max length must be 255**" }}
{% else %}
{% if any_future_event %}
{{ "Turned off because nothing is scheduled. The next event is '" + next_event.summary | trim + "' " + as_timestamp(next_event.start) | timestamp_custom('on %a %d %b %Y at %H:%M', false) }}
{% else %}
{{ "Turned off because nothing is scheduled. There are no events scheduled in the next 24 hours." }}
{% endif %}
{% endif %}
target:
entity_id: !input setting_reason
#ACTION[5].CHOOSE[4]. If the calendar state becomes unknown
- conditions:
- condition: state
entity_id: !input device_calendar
state: unknown
sequence:
- service: input_boolean.turn_off
target:
entity_id: !input required_state
- service: input_text.set_value
data:
value: >
{% if state_attr(local_setting_reason, 'max') < 255 %}
{{ "**Error! Setting reason helper max length must be 255**" }}
{% else %}
{{ 'Turned off because the calendar state is unknown' }}
{% endif %}
target:
entity_id: !input setting_reason
#ACTION[5].CHOOSE[5]. If the calendar becomes unavailable
- conditions:
- condition: state
entity_id: !input device_calendar
state: unavailable
sequence:
- service: input_boolean.turn_off
target:
entity_id: !input required_state
- service: input_text.set_value
data:
value: >
{% if state_attr(local_setting_reason, 'max') < 255 %}
{{ "**Error! Setting reason helper max length must be 255**" }}
{% else %}
{{ 'Turned off because the calendar is unavailable.' }}
{% endif %}
target:
entity_id: !input setting_reason
# ACTION[5].Default - should never happen!
default:
- service: input_boolean.turn_off
target:
entity_id: !input required_state
- service: input_text.set_value
data:
value: >
{% if state_attr(local_setting_reason, 'max') < 255 %}
{{ "**Error! Setting reason helper max length must be 255**" }}
{% else %}
{{ "Turned off by because device state canot be determined (program error)" }}
{% endif %}
target:
entity_id: !input setting_reason
## -------------------------------------------------------------------------------------------------
## ACTION[6] -- SEND STATE CHANGE TO THE SWITCHED DEVICES (BUT ONLY WHEN NECESSARY)
## -------------------------------------------------------------------------------------------------
# ACTION[6].SEQUENCE[0]. Check for each device in the input list whether a change to the setting is required
- repeat:
for_each: !input switched_devices
sequence:
- choose:
#
# ACTION[6].SEQUENCE[0].CHOOSE[0]
# Device unknown
- conditions: >
{{ states(repeat.item) == 'unknown' }}
sequence:
# Create a notification
- service: persistent_notification.create
data:
title: "Calendar Switch Y2"
message: >
{{ "The device '" + repeat.item | string + "' is unknown!" }}
notification_id: "{{ repeat.item }}"
# to prevent duplcate notifications
# log entry
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "The device '" + repeat.item | string + "' is unknown" }}
#
# ACTION[6].SEQUENCE[0].CHOOSE[1]
# Device unavailable
- conditions: >
{{ states(repeat.item) == 'unavailable' }}
sequence:
# Create a notification
- service: persistent_notification.create
data:
title: "Calendar Switch Y2"
message: >
{{ "The device '" + state_attr(repeat.item, 'friendly_name') | string + "' is offline" }}
notification_id: >
{{ repeat.item }}
# to prevent duplcate notifications
# Log entry
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "The device '" + state_attr(repeat.item, 'friendly_name') | string + "' is unavailable" }}
#
# ACTION[6].SEQUENCE[0].CHOOSE[2]
# Device is already in the right state
- conditions: >
{{ states(repeat.item) == states(local_required_state) }}
sequence:
# dismiss any previous notifiations as the device is now apparently online
- service: persistent_notification.dismiss
data:
notification_id: "{{ repeat.item }}"
# Log entry
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "The device '" + state_attr(repeat.item, 'friendly_name') | string + "' is already set to " + states(local_required_state) | string }}
#
# ACTION[6].SEQUENCE[0].DEFAULT
# Device state needs to be changed
default:
# parallel ensures completion even if switch.turn_on/off fails
- parallel:
# dismiss any previous notifications as the device is now apparently online
- service: persistent_notification.dismiss
data:
notification_id: "{{ repeat.item }}"
#
# Send the command
- if:
condition: template
value_template: >
{{ states(local_required_state) == "on" }}
then:
- service: switch.turn_on
continue_on_error: true
target:
entity_id: >
{{ repeat.item }}
else:
- service: switch.turn_off
continue_on_error: true
target:
entity_id: >
{{ repeat.item }}
#
# log the command
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "The device '" + state_attr(repeat.item, 'friendly_name') | string + "' is set to " + states(local_required_state) | string }}
#
# Start the echoblock timer
# Only starts if there is a change. It is OK if it starts more than once (only ending triggers this automation).
- service: timer.start
data:
duration:
seconds: 30
target:
entity_id: !input echoblock_timer
#
# ACTION[7] log the reason
#
- service: script.heating_xyz_logfile_entry
data:
notification_service: !input logging_service_name
logfile_title: Calendar Switch Y2
message_preamble: "{{ log_message_preamble }}"
message_body: >
{{ "New state: " + states(local_setting_reason) | string }}
@AndySymons
Copy link
Author

N.B. Before upgrading to this version of the blueprint, you need to install the Heating XYZ Log File Entry script

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment