Files
Home-Assistant/custom_components/scheduler/actions.py
T
2026-07-08 10:43:39 -04:00

630 lines
23 KiB
Python

import logging
from homeassistant.core import (
HomeAssistant,
callback,
CoreState,
)
from homeassistant.const import (
CONF_SERVICE,
ATTR_SERVICE_DATA,
CONF_SERVICE_DATA,
CONF_DELAY,
ATTR_ENTITY_ID,
STATE_UNKNOWN,
STATE_UNAVAILABLE,
CONF_CONDITIONS,
CONF_ATTRIBUTE,
CONF_STATE,
CONF_ACTION
)
from homeassistant.components.climate import (
SERVICE_SET_TEMPERATURE,
SERVICE_SET_HVAC_MODE,
ATTR_HVAC_MODE,
ATTR_TEMPERATURE,
ATTR_TARGET_TEMP_LOW,
ATTR_TARGET_TEMP_HIGH,
DOMAIN as CLIMATE_DOMAIN,
)
from homeassistant.helpers.event import (
async_track_state_change_event,
async_call_later,
)
from homeassistant.helpers.service import async_call_from_config
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from . import const
from .store import ScheduleEntry
_LOGGER = logging.getLogger(__name__)
ACTION_WAIT = "wait"
ACTION_WAIT_STATE_CHANGE = "wait_state_change"
def parse_service_call(data: dict):
"""turn action data into a service call"""
service_call = {
CONF_ACTION: data[CONF_ACTION] if CONF_ACTION in data else data[CONF_SERVICE], # map service->action for backwards compaibility
CONF_SERVICE_DATA: data[ATTR_SERVICE_DATA],
}
if ATTR_ENTITY_ID in data and data[ATTR_ENTITY_ID]:
service_call[ATTR_ENTITY_ID] = data[ATTR_ENTITY_ID]
if (
service_call[CONF_ACTION]
== "{}.{}".format(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE)
and ATTR_HVAC_MODE in service_call[CONF_SERVICE_DATA]
and ATTR_ENTITY_ID in service_call
):
# fix for climate integrations which don't support setting hvac_mode and temperature together
# add small delay between service calls for integrations that have a long processing time
# set temperature setpoint again for integrations which lose setpoint after switching hvac_mode
_service_call = [
{
CONF_ACTION: "{}.{}".format(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE),
ATTR_ENTITY_ID: service_call[ATTR_ENTITY_ID],
CONF_SERVICE_DATA: {
ATTR_HVAC_MODE: service_call[CONF_SERVICE_DATA][ATTR_HVAC_MODE]
},
}
]
if (
ATTR_TEMPERATURE in service_call[CONF_SERVICE_DATA]
or ATTR_TARGET_TEMP_LOW in service_call[CONF_SERVICE_DATA]
or ATTR_TARGET_TEMP_HIGH in service_call[CONF_SERVICE_DATA]
):
_service_call.extend([
{
CONF_ACTION: ACTION_WAIT_STATE_CHANGE,
ATTR_ENTITY_ID: service_call[ATTR_ENTITY_ID],
CONF_SERVICE_DATA: {
CONF_DELAY: 50,
CONF_STATE: service_call[CONF_SERVICE_DATA][ATTR_HVAC_MODE]
},
},
{
CONF_ACTION: "{}.{}".format(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE),
ATTR_ENTITY_ID: service_call[ATTR_ENTITY_ID],
CONF_SERVICE_DATA: {
x: service_call[CONF_SERVICE_DATA][x]
for x in service_call[CONF_SERVICE_DATA]
if x != ATTR_HVAC_MODE
},
},
])
return _service_call
else:
return [service_call]
def entity_is_available(hass: HomeAssistant, entity, is_target_entity=False):
"""evaluate whether an entity is ready for targeting"""
state = hass.states.get(entity)
if state is None:
return False
elif state.state == STATE_UNAVAILABLE:
return False
elif state.state != STATE_UNKNOWN:
return True
elif is_target_entity:
# only reject unknown state when scheduler is initializing
coordinator = hass.data["scheduler"]["coordinator"]
if coordinator.state == const.STATE_INIT:
return False
else:
return True
else:
# for condition entities the unknown state is not allowed
return False
def action_is_available(hass: HomeAssistant, action: str):
"""evaluate whether a HA action is ready for targeting"""
if action in [ACTION_WAIT, ACTION_WAIT_STATE_CHANGE]:
return True
domain = action.split(".").pop(0)
domain_service = action.split(".").pop(1)
return hass.services.has_service(domain, domain_service)
def validate_condition(hass: HomeAssistant, condition: dict, *args):
"""Validate a condition against the current state"""
if not entity_is_available(hass, condition[ATTR_ENTITY_ID], True):
return False
state = hass.states.get(condition[ATTR_ENTITY_ID])
required = condition[const.ATTR_VALUE]
actual = state.state if state else None
if len(args):
actual = args[0]
if (
condition[const.ATTR_MATCH_TYPE]
in [
const.MATCH_TYPE_BELOW,
const.MATCH_TYPE_ABOVE,
]
and isinstance(required, str)
):
# parse condition as numeric if should be smaller or larger than X
required = float(required)
if isinstance(required, int):
try:
actual = int(float(actual))
except (ValueError, TypeError):
return False
elif isinstance(required, float):
try:
actual = float(actual)
except (ValueError, TypeError):
return False
elif isinstance(required, str):
actual = str(actual).lower()
required = required.lower()
if condition[const.ATTR_MATCH_TYPE] == const.MATCH_TYPE_EQUAL:
result = actual == required
elif condition[const.ATTR_MATCH_TYPE] == const.MATCH_TYPE_UNEQUAL:
result = actual != required
elif condition[const.ATTR_MATCH_TYPE] == const.MATCH_TYPE_BELOW:
result = actual < required
elif condition[const.ATTR_MATCH_TYPE] == const.MATCH_TYPE_ABOVE:
result = actual > required
else:
result = False
# _LOGGER.debug(
# "validating condition for {}: required={}, actual={}, match_type={}, result={}"
# .format(condition[ATTR_ENTITY_ID], required, actual, condition[const.ATTR_MATCH_TYPE], result)
# )
return result
def action_has_effect(action: dict, hass: HomeAssistant):
"""check if action has an effect on the entity"""
if ATTR_ENTITY_ID not in action:
return True
domain = action[CONF_ACTION].split(".").pop(0)
service = action[CONF_ACTION].split(".").pop(1)
state = hass.states.get(action[ATTR_ENTITY_ID])
current_state = state.state if state else None
if (
domain == CLIMATE_DOMAIN
and service in [SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE]
and state
):
if (
ATTR_HVAC_MODE in action[CONF_SERVICE_DATA]
and action[CONF_SERVICE_DATA][ATTR_HVAC_MODE] != current_state
):
return True
if ATTR_TEMPERATURE in action[CONF_SERVICE_DATA] and float(
state.attributes.get(ATTR_TEMPERATURE, 0) or 0
) != float(action[CONF_SERVICE_DATA].get(ATTR_TEMPERATURE)):
return True
if ATTR_TARGET_TEMP_LOW in action[CONF_SERVICE_DATA] and float(
state.attributes.get(ATTR_TARGET_TEMP_LOW, 0) or 0
) != float(action[CONF_SERVICE_DATA].get(ATTR_TARGET_TEMP_LOW)):
return True
if ATTR_TARGET_TEMP_HIGH in action[CONF_SERVICE_DATA] and float(
state.attributes.get(ATTR_TARGET_TEMP_HIGH, 0) or 0
) != float(action[CONF_SERVICE_DATA].get(ATTR_TARGET_TEMP_HIGH)):
return True
return False
return True
class ActionHandler:
def __init__(self, hass: HomeAssistant, schedule_id: str):
"""init"""
self.hass = hass
self._queues = {}
self._timer = None
self.id = schedule_id
async_dispatcher_connect(
self.hass, "action_queue_finished", self.async_cleanup_queues
)
async def async_queue_actions(self, data: ScheduleEntry, skip_initial_execution = False):
"""add new actions to queue"""
await self.async_empty_queue()
conditions = data[CONF_CONDITIONS]
actions = [e for x in data[const.ATTR_ACTIONS] for e in parse_service_call(x)]
condition_type = data[const.ATTR_CONDITION_TYPE]
track_conditions = data[const.ATTR_TRACK_CONDITIONS]
# create an ActionQueue object per targeted entity (such that the tasks are handled independently)
for action in actions:
entity = action[ATTR_ENTITY_ID] if ATTR_ENTITY_ID in action else "none"
if entity not in self._queues:
self._queues[entity] = ActionQueue(
self.hass, self.id, conditions, condition_type, track_conditions
)
self._queues[entity].add_action(action)
for queue in self._queues.copy().values():
await queue.async_start(skip_initial_execution)
async def async_cleanup_queues(self, id: str = None):
"""remove all objects from queue which have no remaining tasks"""
if id is not None and id != self.id or not len(self._queues.keys()):
return
# remove all items which are either finished executing
# or have all their entities available (i.e. conditions have failed beforee)
queue_items = list(self._queues.keys())
for key in queue_items:
if self._queues[key].is_finished() or (
self._queues[key].is_available() and not self._queues[key].queue_busy
):
await self._queues[key].async_clear()
self._queues.pop(key)
if not len(self._queues.keys()):
_LOGGER.debug("[{}]: Finished execution of tasks".format(self.id))
async def async_empty_queue(self, **kwargs):
"""remove all objects from queue"""
restore_time = kwargs.get("restore_time")
async def async_clear_queue(_now=None):
"""clear queue"""
if self._timer:
self._timer()
self._timer = None
while len(self._queues.keys()):
key = list(self._queues.keys())[0]
await self._queues[key].async_clear()
self._queues.pop(key)
if restore_time:
await self.async_cleanup_queues()
if not len(self._queues):
return
_LOGGER.debug(
"Waiting for unavailable entities to be restored for {} mins".format(
restore_time
)
)
self._timer = async_call_later(
self.hass, restore_time * 60, async_clear_queue
)
else:
await async_clear_queue()
class ActionQueue:
def __init__(
self,
hass: HomeAssistant,
id: str,
conditions: list,
condition_type: str,
track_conditions: bool,
):
"""create a new action queue"""
self.hass = hass
self.id = id
self._timer = None
self._action_entities = []
self._condition_entities = []
self._listeners = []
self._state_update_listener = None
self._conditions = conditions
self._condition_type = condition_type
self._queue = []
self.queue_busy = False
self._track_conditions = track_conditions
self._wait_for_available = True
for condition in conditions:
if (
ATTR_ENTITY_ID in condition
and condition[ATTR_ENTITY_ID] not in self._condition_entities
):
self._condition_entities.append(condition[ATTR_ENTITY_ID])
def add_action(self, action: dict):
"""add an action to the queue"""
if (
ATTR_ENTITY_ID in action
and action[ATTR_ENTITY_ID]
and action[ATTR_ENTITY_ID] not in self._action_entities
):
self._action_entities.append(action[ATTR_ENTITY_ID])
self._queue.append(action)
async def async_start(self, skip_initial_execution):
"""start execution of the actions in the queue"""
@callback
async def async_entity_changed(event):
"""check if actions can be processed"""
entity = event.data["entity_id"]
old_state = event.data["old_state"].state if event.data["old_state"] else None
new_state = event.data["new_state"].state if event.data["new_state"] else None
if old_state == new_state:
# no change
return
if self.queue_busy:
return
if entity not in self._condition_entities and not self._wait_for_available:
# only watch until entity becomes available in the action entities
return
if (
entity in self._condition_entities
and old_state
and new_state
and old_state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]
and new_state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]
):
conditions = list(filter(lambda e: e[ATTR_ENTITY_ID] == entity, self._conditions))
if all([
validate_condition(self.hass, item, old_state) == validate_condition(self.hass, item, new_state)
for item in conditions
]):
# ignore if state change has no effect on condition rules
return
_LOGGER.debug(
"[{}]: State of {} has changed, re-evaluating actions".format(
self.id, entity
)
)
await self.async_process_queue()
watched_entities = list(set(self._condition_entities + self._action_entities))
if len(watched_entities):
self._listeners.append(
async_track_state_change_event(
self.hass, watched_entities, async_entity_changed
)
)
if not skip_initial_execution:
await self.async_process_queue()
# trigger the queue once when HA has restarted
if self.hass.state != CoreState.running:
self._listeners.append(
async_dispatcher_connect(
self.hass, const.EVENT_STARTED, self.async_process_queue
)
)
else:
self._wait_for_available = False
async def async_clear(self):
"""clear action queue object"""
if self._timer:
self._timer()
self._timer = None
while len(self._listeners):
self._listeners.pop()()
if self._state_update_listener:
self._state_update_listener()
self._state_update_listener = None
def is_finished(self):
"""check whether all queue items are finished"""
return len(self._queue) == 0
def is_available(self):
"""check if all actions and entities involved in the task are available"""
# check actions
required_actions = [action[CONF_ACTION] for action in self._queue]
failed_action = next(
(x for x in required_actions if not action_is_available(self.hass, x)),
None,
)
if failed_action:
_LOGGER.debug(
"[{}]: Action {} is unavailable, scheduled task cannot be executed".format(
self.id, failed_action
)
)
return False
# check entities
watched_entities = list(set(self._condition_entities + self._action_entities))
failed_entity = next(
(
x
for x in watched_entities
if not entity_is_available(self.hass, x, x in self._action_entities)
),
None,
)
if failed_entity:
_LOGGER.debug(
"[{}]: Entity {} is unavailable, scheduled action cannot be executed".format(
self.id, failed_entity
)
)
return False
if self._wait_for_available:
self._wait_for_available = False
return True
async def async_process_queue(self, task_idx=0):
"""walk through the list of tasks and execute the ones that are ready"""
if self.queue_busy or not self.is_available():
return
self.queue_busy = True
# verify conditions
conditions_passed = (
(
all(validate_condition(self.hass, item) for item in self._conditions)
if self._condition_type == const.CONDITION_TYPE_AND
else any(
validate_condition(self.hass, item) for item in self._conditions
)
)
if len(self._conditions)
else True
)
if not conditions_passed and len(self._queue):
_LOGGER.debug(
"[{}]: Conditions have failed, skipping execution of actions".format(
self.id
)
)
if self._track_conditions:
# postpone tasks
self.queue_busy = False
return
else:
# abort all items in queue
while len(self._queue):
self._queue.pop()
skip_task = False
while task_idx < len(self._queue):
task = self._queue[task_idx]
if task[CONF_ACTION] in [ACTION_WAIT, ACTION_WAIT_STATE_CHANGE]:
if skip_action:
task_idx = task_idx + 1
continue
elif task[CONF_ACTION] == ACTION_WAIT_STATE_CHANGE:
state = self.hass.states.get(task[ATTR_ENTITY_ID])
if CONF_ATTRIBUTE in task[CONF_SERVICE_DATA]:
state = state.attributes.get(task[CONF_SERVICE_DATA][CONF_ATTRIBUTE])
else:
state = state.state
if state == task[CONF_SERVICE_DATA][CONF_STATE]:
_LOGGER.debug(
"[{}]: Entity {} is already set to {}, proceed with next task".format(
self.id,
task[ATTR_ENTITY_ID],
state,
)
)
task_idx = task_idx + 1
continue
@callback
async def async_timer_finished(_now):
self._timer = None
if self._state_update_listener:
self._state_update_listener()
self._state_update_listener = None
self.queue_busy = False
await self.async_process_queue(task_idx + 1)
self._timer = async_call_later(
self.hass,
task[CONF_SERVICE_DATA][CONF_DELAY],
async_timer_finished,
)
_LOGGER.debug(
"[{}]: Postponing next task for {} seconds".format(
self.id, task[CONF_SERVICE_DATA][CONF_DELAY]
)
)
@callback
async def async_entity_changed(event):
entity = event.data["entity_id"]
old_state = event.data["old_state"]
new_state = event.data["new_state"]
if CONF_ATTRIBUTE in task[CONF_SERVICE_DATA]:
old_state = old_state.attributes.get(task[CONF_SERVICE_DATA][CONF_ATTRIBUTE])
new_state = new_state.attributes.get(task[CONF_SERVICE_DATA][CONF_ATTRIBUTE])
else:
old_state = old_state.state
new_state = new_state.state
if old_state == new_state:
return
_LOGGER.debug(
"[{}]: Entity {} was updated from {} to {}".format(
self.id,
entity,
old_state,
new_state
)
)
if new_state == task[CONF_SERVICE_DATA][CONF_STATE]:
_LOGGER.debug("[{}]: Stop postponing next task".format(self.id))
if self._timer:
self._timer()
self._timer = None
self._state_update_listener()
self._state_update_listener = None
self.queue_busy = False
await self.async_process_queue(task_idx + 1)
if task[CONF_ACTION] == ACTION_WAIT_STATE_CHANGE:
self._state_update_listener = async_track_state_change_event(
self.hass, task[ATTR_ENTITY_ID], async_entity_changed
)
return
if ATTR_ENTITY_ID in task:
_LOGGER.debug(
"[{}]: Executing action {} on entity {}".format(
self.id, task[CONF_ACTION], task[ATTR_ENTITY_ID]
)
)
else:
_LOGGER.debug(
"[{}]: Executing action {}".format(self.id, task[CONF_ACTION])
)
skip_action = not action_has_effect(task, self.hass)
if skip_action:
_LOGGER.debug("[{}]: Action has no effect, skipping".format(self.id))
else:
await async_call_from_config(
self.hass,
task,
)
task_idx = task_idx + 1
self.queue_busy = False
if not self._track_conditions or not len(self._conditions):
while len(self._queue):
self._queue.pop()
async_dispatcher_send(self.hass, "action_queue_finished", self.id)
else:
_LOGGER.debug(
"[{}]: Done for now, Waiting for conditions to change".format(self.id)
)