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) )