630 lines
23 KiB
Python
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)
|
|
)
|