async def async_attach_trigger(hass, config, action, automation_info, *, platform_type="template"): """Listen for state changes based on configuration.""" value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) delay_cancel = None job = HassJob(action) @callback def template_listener(event, updates): """Listen for state changes and calls action.""" nonlocal delay_cancel result = updates.pop().result if delay_cancel: # pylint: disable=not-callable delay_cancel() delay_cancel = None if not result_as_boolean(result): return entity_id = event.data.get("entity_id") from_s = event.data.get("old_state") to_s = event.data.get("new_state") @callback def call_action(*_): """Call action with right context.""" hass.async_run_hass_job( job, { "trigger": { "platform": "template", "entity_id": entity_id, "from_state": from_s, "to_state": to_s, "for": time_delta if not time_delta else period, "description": f"{entity_id} via template", } }, (to_s.context if to_s else None), ) if not time_delta: call_action() return variables = { "trigger": { "platform": platform_type, "entity_id": entity_id, "from_state": from_s, "to_state": to_s, } } try: period = cv.positive_time_period( template.render_complex(time_delta, variables)) except (exceptions.TemplateError, vol.Invalid) as ex: _LOGGER.error("Error rendering '%s' for template: %s", automation_info["name"], ex) return delay_cancel = async_call_later(hass, period.seconds, call_action) info = async_track_template_result( hass, [TrackTemplate(value_template, automation_info["variables"])], template_listener, ) unsub = info.async_remove @callback def async_remove(): """Remove state listeners async.""" unsub() if delay_cancel: # pylint: disable=not-callable delay_cancel() return async_remove
def async_track_state_change( hass: HomeAssistant, entity_ids: Union[str, Iterable[str]], action: Callable[[str, State, State], None], from_state: Union[None, str, Iterable[str]] = None, to_state: Union[None, str, Iterable[str]] = None, ) -> CALLBACK_TYPE: """Track specific state changes. entity_ids, from_state and to_state can be string or list. Use list to match multiple. Returns a function that can be called to remove the listener. If entity_ids are not MATCH_ALL along with from_state and to_state being None, async_track_state_change_event should be used instead as it is slightly faster. Must be run within the event loop. """ if from_state is not None: match_from_state = process_state_match(from_state) if to_state is not None: match_to_state = process_state_match(to_state) # Ensure it is a lowercase list with entity ids we want to match on if entity_ids == MATCH_ALL: pass elif isinstance(entity_ids, str): entity_ids = (entity_ids.lower(),) else: entity_ids = tuple(entity_id.lower() for entity_id in entity_ids) job = HassJob(action) @callback def state_change_listener(event: Event) -> None: """Handle specific state changes.""" if from_state is not None: old_state = event.data.get("old_state") if old_state is not None: old_state = old_state.state if not match_from_state(old_state): return if to_state is not None: new_state = event.data.get("new_state") if new_state is not None: new_state = new_state.state if not match_to_state(new_state): return hass.async_run_hass_job( job, event.data.get("entity_id"), event.data.get("old_state"), event.data.get("new_state"), ) if entity_ids != MATCH_ALL: # If we have a list of entity ids we use # async_track_state_change_event to route # by entity_id to avoid iterating though state change # events and creating a jobs where the most # common outcome is to return right away because # the entity_id does not match since usually # only one or two listeners want that specific # entity_id. return async_track_state_change_event(hass, entity_ids, state_change_listener) return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener)
def async_track_template( hass: HomeAssistant, template: Template, action: Callable[[str, Optional[State], Optional[State]], None], variables: Optional[TemplateVarsType] = None, ) -> Callable[[], None]: """Add a listener that fires when a a template evaluates to 'true'. Listen for the result of the template becoming true, or a true-like string result, such as 'On', 'Open', or 'Yes'. If the template results in an error state when the value changes, this will be logged and not passed through. If the initial check of the template is invalid and results in an exception, the listener will still be registered but will only fire if the template result becomes true without an exception. Action arguments ---------------- entity_id ID of the entity that triggered the state change. old_state The old state of the entity that changed. new_state New state of the entity that changed. Parameters ---------- hass Home assistant object. template The template to calculate. action Callable to call with results. See above for arguments. variables Variables to pass to the template. Returns ------- Callable to unregister the listener. """ job = HassJob(action) @callback def _template_changed_listener( event: Event, updates: List[TrackTemplateResult] ) -> None: """Check if condition is correct and run action.""" track_result = updates.pop() template = track_result.template last_result = track_result.last_result result = track_result.result if isinstance(result, TemplateError): _LOGGER.error( "Error while processing template: %s", template.template, exc_info=result, ) return if ( not isinstance(last_result, TemplateError) and result_as_boolean(last_result) or not result_as_boolean(result) ): return hass.async_run_hass_job( job, event.data.get("entity_id"), event.data.get("old_state"), event.data.get("new_state"), ) info = async_track_template_result( hass, [TrackTemplate(template, variables)], _template_changed_listener ) return info.async_remove
def __init__( self, hass: HomeAssistant, sequence: Sequence[dict[str, Any]], name: str, domain: str, *, # Used in "Running <running_description>" log message running_description: str | None = None, change_listener: Callable[..., Any] | None = None, script_mode: str = DEFAULT_SCRIPT_MODE, max_runs: int = DEFAULT_MAX, max_exceeded: str = DEFAULT_MAX_EXCEEDED, logger: logging.Logger | None = None, log_exceptions: bool = True, top_level: bool = True, variables: ScriptVariables | None = None, ) -> None: """Initialize the script.""" all_scripts = hass.data.get(DATA_SCRIPTS) if not all_scripts: all_scripts = hass.data[DATA_SCRIPTS] = [] hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, partial(_async_stop_scripts_at_shutdown, hass)) self._top_level = top_level if top_level: all_scripts.append({ "instance": self, "started_before_shutdown": not hass.is_stopping }) if DATA_SCRIPT_BREAKPOINTS not in hass.data: hass.data[DATA_SCRIPT_BREAKPOINTS] = {} self._hass = hass self.sequence = sequence template.attach(hass, self.sequence) self.name = name self.domain = domain self.running_description = running_description or f"{domain} script" self._change_listener = change_listener self._change_listener_job = (None if change_listener is None else HassJob(change_listener)) self.script_mode = script_mode self._set_logger(logger) self._log_exceptions = log_exceptions self.last_action = None self.last_triggered: datetime | None = None self._runs: list[_ScriptRun] = [] self.max_runs = max_runs self._max_exceeded = max_exceeded if script_mode == SCRIPT_MODE_QUEUED: self._queue_lck = asyncio.Lock() self._config_cache: dict[set[tuple], Callable[..., bool]] = {} self._repeat_script: dict[int, Script] = {} self._choose_data: dict[int, _ChooseData] = {} self._referenced_entities: set[str] | None = None self._referenced_devices: set[str] | None = None self._referenced_areas: set[str] | None = None self.variables = variables self._variables_dynamic = template.is_complex(variables) if self._variables_dynamic: template.attach(hass, variables)
def async_track_same_state( hass: HomeAssistant, period: timedelta, action: Callable[..., None], async_check_same_func: Callable[[str, Optional[State], Optional[State]], bool], entity_ids: Union[str, Iterable[str]] = MATCH_ALL, ) -> CALLBACK_TYPE: """Track the state of entities for a period and run an action. If async_check_func is None it use the state of orig_value. Without entity_ids we track all state changes. """ async_remove_state_for_cancel: Optional[CALLBACK_TYPE] = None async_remove_state_for_listener: Optional[CALLBACK_TYPE] = None job = HassJob(action) @callback def clear_listener() -> None: """Clear all unsub listener.""" nonlocal async_remove_state_for_cancel, async_remove_state_for_listener if async_remove_state_for_listener is not None: async_remove_state_for_listener() async_remove_state_for_listener = None if async_remove_state_for_cancel is not None: async_remove_state_for_cancel() async_remove_state_for_cancel = None @callback def state_for_listener(now: Any) -> None: """Fire on state changes after a delay and calls action.""" nonlocal async_remove_state_for_listener async_remove_state_for_listener = None clear_listener() hass.async_run_hass_job(job) @callback def state_for_cancel_listener(event: Event) -> None: """Fire on changes and cancel for listener if changed.""" entity: str = event.data["entity_id"] from_state: Optional[State] = event.data.get("old_state") to_state: Optional[State] = event.data.get("new_state") if not async_check_same_func(entity, from_state, to_state): clear_listener() async_remove_state_for_listener = async_track_point_in_utc_time( hass, state_for_listener, dt_util.utcnow() + period ) if entity_ids == MATCH_ALL: async_remove_state_for_cancel = hass.bus.async_listen( EVENT_STATE_CHANGED, state_for_cancel_listener ) else: async_remove_state_for_cancel = async_track_state_change_event( hass, [entity_ids] if isinstance(entity_ids, str) else entity_ids, state_for_cancel_listener, ) return clear_listener
async def async_attach_trigger(hass, config, action, automation_info, *, platform_type="template"): """Listen for state changes based on configuration.""" trigger_id = automation_info.get("trigger_id") if automation_info else None value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) delay_cancel = None job = HassJob(action) armed = False # Arm at setup if the template is already false. try: if not result_as_boolean( value_template.async_render(automation_info["variables"])): armed = True except exceptions.TemplateError as ex: _LOGGER.warning( "Error initializing 'template' trigger for '%s': %s", automation_info["name"], ex, ) @callback def template_listener(event, updates): """Listen for state changes and calls action.""" nonlocal delay_cancel, armed result = updates.pop().result if isinstance(result, exceptions.TemplateError): _LOGGER.warning( "Error evaluating 'template' trigger for '%s': %s", automation_info["name"], result, ) return if delay_cancel: # pylint: disable=not-callable delay_cancel() delay_cancel = None if not result_as_boolean(result): armed = True return # Only fire when previously armed. if not armed: return # Fire! armed = False entity_id = event and event.data.get("entity_id") from_s = event and event.data.get("old_state") to_s = event and event.data.get("new_state") if entity_id is not None: description = f"{entity_id} via template" else: description = "time change or manual update via template" template_variables = { "platform": platform_type, "entity_id": entity_id, "from_state": from_s, "to_state": to_s, } trigger_variables = { "for": time_delta, "description": description, "id": trigger_id, } @callback def call_action(*_): """Call action with right context.""" nonlocal trigger_variables hass.async_run_hass_job( job, {"trigger": { **template_variables, **trigger_variables }}, (to_s.context if to_s else None), ) if not time_delta: call_action() return try: period = cv.positive_time_period( template.render_complex(time_delta, {"trigger": template_variables})) except (exceptions.TemplateError, vol.Invalid) as ex: _LOGGER.error("Error rendering '%s' for template: %s", automation_info["name"], ex) return trigger_variables["for"] = period delay_cancel = async_call_later(hass, period.total_seconds(), call_action) info = async_track_template_result( hass, [TrackTemplate(value_template, automation_info["variables"])], template_listener, ) unsub = info.async_remove @callback def async_remove(): """Remove state listeners async.""" unsub() if delay_cancel: # pylint: disable=not-callable delay_cancel() return async_remove
entity_ids = config[CONF_ENTITY_ID] if (from_state := config.get(CONF_FROM)) is None: from_state = MATCH_ALL if (to_state := config.get(CONF_TO)) is None: to_state = MATCH_ALL time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) # If neither CONF_FROM or CONF_TO are specified, # fire on all changes to the state or an attribute match_all = CONF_FROM not in config and CONF_TO not in config unsub_track_same = {} period: dict[str, timedelta] = {} match_from_state = process_state_match(from_state) match_to_state = process_state_match(to_state) attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) trigger_data = automation_info["trigger_data"] _variables = automation_info["variables"] or {} @callback def state_automation_listener(event: Event): """Listen for state changes and calls action.""" entity: str = event.data["entity_id"] from_s: State | None = event.data.get("old_state") to_s: State | None = event.data.get("new_state") if from_s is None: old_value = None elif attribute is None: old_value = from_s.state
async def async_attach_trigger( hass, config, action, automation_info, *, platform_type="event" ): """Listen for events based on configuration.""" event_types = config.get(CONF_EVENT_TYPE) removes = [] event_data_schema = None if config.get(CONF_EVENT_DATA): event_data_schema = vol.Schema( { vol.Required(key): value for key, value in config.get(CONF_EVENT_DATA).items() }, extra=vol.ALLOW_EXTRA, ) event_context_schema = None if config.get(CONF_EVENT_CONTEXT): event_context_schema = vol.Schema( { vol.Required(key): _schema_value(value) for key, value in config.get(CONF_EVENT_CONTEXT).items() }, extra=vol.ALLOW_EXTRA, ) job = HassJob(action) @callback def handle_event(event): """Listen for events and calls the action when data matches.""" try: # Check that the event data and context match the configured # schema if one was provided if event_data_schema: event_data_schema(event.data) if event_context_schema: event_context_schema(event.context.as_dict()) except vol.Invalid: # If event doesn't match, skip event return hass.async_run_hass_job( job, { "trigger": { "platform": platform_type, "event": event, "description": f"event '{event.event_type}'", } }, event.context, ) removes = [ hass.bus.async_listen(event_type, handle_event) for event_type in event_types ] @callback def remove_listen_events(): """Remove event listeners.""" for remove in removes: remove() return remove_listen_events
async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, automation_info: AutomationTriggerInfo, *, platform_type: str = "zone", ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" trigger_data = automation_info["trigger_data"] entity_id: list[str] = config[CONF_ENTITY_ID] zone_entity_id = config.get(CONF_ZONE) event = config.get(CONF_EVENT) job = HassJob(action) @callback def zone_automation_listener(zone_event): """Listen for state changes and calls action.""" entity = zone_event.data.get("entity_id") from_s = zone_event.data.get("old_state") to_s = zone_event.data.get("new_state") if ( from_s and not location.has_location(from_s) or not location.has_location(to_s) ): return if not (zone_state := hass.states.get(zone_entity_id)): _LOGGER.warning( "Automation '%s' is referencing non-existing zone '%s' in a zone trigger", automation_info["name"], zone_entity_id, ) return from_match = condition.zone(hass, zone_state, from_s) if from_s else False to_match = condition.zone(hass, zone_state, to_s) if to_s else False if ( event == EVENT_ENTER and not from_match and to_match or event == EVENT_LEAVE and from_match and not to_match ): description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" hass.async_run_hass_job( job, { "trigger": { **trigger_data, "platform": platform_type, "entity_id": entity, "from_state": from_s, "to_state": to_s, "zone": zone_state, "event": event, "description": description, } }, to_s.context, )
async def _keepalive(self, *_): if self._stream.access_token != self._running_stream_id: return await self._disconnect() async_call_later(self._hass, timedelta(seconds=1), HassJob(self._keepalive))
for job in entity_callbacks[entity_id][:]: try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error while processing state change for %s", entity_id) hass.data[TRACK_STATE_CHANGE_LISTENER] = hass.bus.async_listen( EVENT_STATE_CHANGED, _async_state_change_dispatcher, event_filter=_async_state_change_filter, ) job = HassJob(action) for entity_id in entity_ids: entity_callbacks.setdefault(entity_id, []).append(job) @callback def remove_listener() -> None: """Remove state change listener.""" _async_remove_indexed_listeners( hass, TRACK_STATE_CHANGE_CALLBACKS, TRACK_STATE_CHANGE_LISTENER, entity_ids, job, )
def _try_reconnect(self): _LOGGER.debug(f'Trying to reconnect in {RECONNECTION_DELAY} seconds') self._unsub_reconnect = async_call_later(self._hass, RECONNECTION_DELAY, HassJob(self._connect))
async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" entities = {} removes = [] job = HassJob(action) @callback def time_automation_listener(description, now, *, entity_id=None): """Listen for time changes and calls action.""" hass.async_run_hass_job( job, { "trigger": { "platform": "time", "now": now, "description": description, "entity_id": entity_id, } }, ) @callback def update_entity_trigger_event(event): """update_entity_trigger from the event.""" return update_entity_trigger(event.data["entity_id"], event.data["new_state"]) @callback def update_entity_trigger(entity_id, new_state=None): """Update the entity trigger for the entity_id.""" # If a listener was already set up for entity, remove it. remove = entities.get(entity_id) if remove: remove() removes.remove(remove) remove = None # Check state of entity. If valid, set up a listener. if new_state: has_date = new_state.attributes["has_date"] if has_date: year = new_state.attributes["year"] month = new_state.attributes["month"] day = new_state.attributes["day"] has_time = new_state.attributes["has_time"] if has_time: hour = new_state.attributes["hour"] minute = new_state.attributes["minute"] second = new_state.attributes["second"] else: # If no time then use midnight. hour = minute = second = 0 if has_date: # If input_datetime has date, then track point in time. trigger_dt = dt_util.DEFAULT_TIME_ZONE.localize( datetime(year, month, day, hour, minute, second) ) # Only set up listener if time is now or in the future. if trigger_dt >= dt_util.now(): remove = async_track_point_in_time( hass, partial( time_automation_listener, f"time set in {entity_id}", entity_id=entity_id, ), trigger_dt, ) elif has_time: # Else if it has time, then track time change. remove = async_track_time_change( hass, partial( time_automation_listener, f"time set in {entity_id}", entity_id=entity_id, ), hour=hour, minute=minute, second=second, ) # Was a listener set up? if remove: removes.append(remove) entities[entity_id] = remove for at_time in config[CONF_AT]: if isinstance(at_time, str): # input_datetime entity update_entity_trigger(at_time, new_state=hass.states.get(at_time)) else: # datetime.time removes.append( async_track_time_change( hass, partial(time_automation_listener, "time"), hour=at_time.hour, minute=at_time.minute, second=at_time.second, ) ) # Track state changes of any entities. removes.append( async_track_state_change_event( hass, list(entities), update_entity_trigger_event ) ) @callback def remove_track_time_changes(): """Remove tracked time changes.""" for remove in removes: remove() return remove_track_time_changes
def function(self, function: Callable[..., Awaitable[Any]]) -> None: """Update the function being wrapped by the Debouncer.""" self._function = function if self._job is None or function != self._job.target: self._job = HassJob(function)
def async_track_entity_registry_updated_event( hass: HomeAssistant, entity_ids: str | Iterable[str], action: Callable[[Event], Any], ) -> Callable[[], None]: """Track specific entity registry updated events indexed by entity_id. Similar to async_track_state_change_event. """ entity_ids = _async_string_to_lower_list(entity_ids) if not entity_ids: return _remove_empty_listener entity_callbacks = hass.data.setdefault( TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {}) if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in hass.data: @callback def _async_entity_registry_updated_filter(event: Event) -> bool: """Filter entity registry updates by entity_id.""" entity_id = event.data.get("old_entity_id", event.data["entity_id"]) return entity_id in entity_callbacks @callback def _async_entity_registry_updated_dispatcher(event: Event) -> None: """Dispatch entity registry updates by entity_id.""" entity_id = event.data.get("old_entity_id", event.data["entity_id"]) if entity_id not in entity_callbacks: return for job in entity_callbacks[entity_id][:]: try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error while processing entity registry update for %s", entity_id, ) hass.data[ TRACK_ENTITY_REGISTRY_UPDATED_LISTENER] = hass.bus.async_listen( EVENT_ENTITY_REGISTRY_UPDATED, _async_entity_registry_updated_dispatcher, event_filter=_async_entity_registry_updated_filter, ) job = HassJob(action) for entity_id in entity_ids: entity_callbacks.setdefault(entity_id, []).append(job) @callback def remove_listener() -> None: """Remove state change listener.""" _async_remove_indexed_listeners( hass, TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, entity_ids, job, ) return remove_listener
async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" trigger_id = automation_info.get("trigger_id") if automation_info else None entities = {} removes = [] job = HassJob(action) @callback def time_automation_listener(description, now, *, entity_id=None): """Listen for time changes and calls action.""" hass.async_run_hass_job( job, { "trigger": { "platform": "time", "now": now, "description": description, "entity_id": entity_id, "id": trigger_id, } }, ) @callback def update_entity_trigger_event(event): """update_entity_trigger from the event.""" return update_entity_trigger(event.data["entity_id"], event.data["new_state"]) @callback def update_entity_trigger(entity_id, new_state=None): """Update the entity trigger for the entity_id.""" # If a listener was already set up for entity, remove it. remove = entities.pop(entity_id, None) if remove: remove() remove = None if not new_state: return # Check state of entity. If valid, set up a listener. if new_state.domain == "input_datetime": has_date = new_state.attributes["has_date"] if has_date: year = new_state.attributes["year"] month = new_state.attributes["month"] day = new_state.attributes["day"] has_time = new_state.attributes["has_time"] if has_time: hour = new_state.attributes["hour"] minute = new_state.attributes["minute"] second = new_state.attributes["second"] else: # If no time then use midnight. hour = minute = second = 0 if has_date: # If input_datetime has date, then track point in time. trigger_dt = datetime( year, month, day, hour, minute, second, tzinfo=dt_util.DEFAULT_TIME_ZONE, ) # Only set up listener if time is now or in the future. if trigger_dt >= dt_util.now(): remove = async_track_point_in_time( hass, partial( time_automation_listener, f"time set in {entity_id}", entity_id=entity_id, ), trigger_dt, ) elif has_time: # Else if it has time, then track time change. remove = async_track_time_change( hass, partial( time_automation_listener, f"time set in {entity_id}", entity_id=entity_id, ), hour=hour, minute=minute, second=second, ) elif (new_state.domain == "sensor" and new_state.attributes.get(ATTR_DEVICE_CLASS) == sensor.DEVICE_CLASS_TIMESTAMP and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)): trigger_dt = dt_util.parse_datetime(new_state.state) if trigger_dt is not None and trigger_dt > dt_util.utcnow(): remove = async_track_point_in_time( hass, partial( time_automation_listener, f"time set in {entity_id}", entity_id=entity_id, ), trigger_dt, ) # Was a listener set up? if remove: entities[entity_id] = remove to_track = [] for at_time in config[CONF_AT]: if isinstance(at_time, str): # entity to_track.append(at_time) update_entity_trigger(at_time, new_state=hass.states.get(at_time)) else: # datetime.time removes.append( async_track_time_change( hass, partial(time_automation_listener, "time"), hour=at_time.hour, minute=at_time.minute, second=at_time.second, )) # Track state changes of any entities. removes.append( async_track_state_change_event(hass, to_track, update_entity_trigger_event)) @callback def remove_track_time_changes(): """Remove tracked time changes.""" for remove in entities.values(): remove() for remove in removes: remove() return remove_track_time_changes
async def async_attach_trigger( hass: HomeAssistant, config, action, automation_info, *, platform_type: str = "state", ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO, MATCH_ALL) time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) match_all = from_state == MATCH_ALL and to_state == MATCH_ALL unsub_track_same = {} period: dict[str, timedelta] = {} match_from_state = process_state_match(from_state) match_to_state = process_state_match(to_state) attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) _variables = {} if automation_info: _variables = automation_info.get("variables") or {} @callback def state_automation_listener(event: Event): """Listen for state changes and calls action.""" entity: str = event.data["entity_id"] from_s: State | None = event.data.get("old_state") to_s: State | None = event.data.get("new_state") if from_s is None: old_value = None elif attribute is None: old_value = from_s.state else: old_value = from_s.attributes.get(attribute) if to_s is None: new_value = None elif attribute is None: new_value = to_s.state else: new_value = to_s.attributes.get(attribute) # When we listen for state changes with `match_all`, we # will trigger even if just an attribute changes. When # we listen to just an attribute, we should ignore all # other attribute changes. if attribute is not None and old_value == new_value: return if (not match_from_state(old_value) or not match_to_state(new_value) or (not match_all and old_value == new_value)): return @callback def call_action(): """Call action with right context.""" hass.async_run_hass_job( job, { "trigger": { "platform": platform_type, "entity_id": entity, "from_state": from_s, "to_state": to_s, "for": time_delta if not time_delta else period[entity], "attribute": attribute, "description": f"state of {entity}", } }, event.context, ) if not time_delta: call_action() return trigger_info = { "trigger": { "platform": "state", "entity_id": entity, "from_state": from_s, "to_state": to_s, } } variables = {**_variables, **trigger_info} try: period[entity] = cv.positive_time_period( template.render_complex(time_delta, variables)) except (exceptions.TemplateError, vol.Invalid) as ex: _LOGGER.error("Error rendering '%s' for template: %s", automation_info["name"], ex) return def _check_same_state(_, _2, new_st: State): if new_st is None: return False if attribute is None: cur_value = new_st.state else: cur_value = new_st.attributes.get(attribute) if CONF_FROM in config and CONF_TO not in config: return cur_value != old_value return cur_value == new_value unsub_track_same[entity] = async_track_same_state( hass, period[entity], call_action, _check_same_state, entity_ids=entity, ) unsub = async_track_state_change_event(hass, entity_id, state_automation_listener) @callback def async_remove(): """Remove state listeners async.""" unsub() for async_remove in unsub_track_same.values(): async_remove() unsub_track_same.clear() return async_remove
async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, automation_info: AutomationTriggerInfo, *, platform_type: str = PLATFORM_TYPE, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" nodes: set[Node] = set() if ATTR_DEVICE_ID in config: nodes.update({ async_get_node_from_device_id(hass, device_id) for device_id in config[ATTR_DEVICE_ID] }) if ATTR_ENTITY_ID in config: nodes.update({ async_get_node_from_entity_id(hass, entity_id) for entity_id in config[ATTR_ENTITY_ID] }) event_source = config[ATTR_EVENT_SOURCE] event_name = config[ATTR_EVENT] event_data_filter = config.get(ATTR_EVENT_DATA, {}) unsubs = [] job = HassJob(action) trigger_data = automation_info["trigger_data"] @callback def async_on_event(event_data: dict, device: dr.DeviceEntry | None = None) -> None: """Handle event.""" for key, val in event_data_filter.items(): if key not in event_data: return if (config[ATTR_PARTIAL_DICT_MATCH] and isinstance(event_data[key], dict) and isinstance(event_data_filter[key], dict)): for key2, val2 in event_data_filter[key].items(): if key2 not in event_data[ key] or event_data[key][key2] != val2: return continue if event_data[key] != val: return payload = { **trigger_data, CONF_PLATFORM: platform_type, ATTR_EVENT_SOURCE: event_source, ATTR_EVENT: event_name, ATTR_EVENT_DATA: event_data, } primary_desc = f"Z-Wave JS '{event_source}' event '{event_name}' was emitted" if device: device_name = device.name_by_user or device.name payload[ATTR_DEVICE_ID] = device.id home_and_node_id = get_home_and_node_id_from_device_entry(device) assert home_and_node_id payload[ATTR_NODE_ID] = home_and_node_id[1] payload["description"] = f"{primary_desc} on {device_name}" else: payload["description"] = primary_desc payload[ "description"] = f"{payload['description']} with event data: {event_data}" hass.async_run_hass_job(job, {"trigger": payload}) dev_reg = dr.async_get(hass) if not nodes: entry_id = config[ATTR_CONFIG_ENTRY_ID] client: Client = hass.data[DOMAIN][entry_id][DATA_CLIENT] if event_source == "controller": source = client.driver.controller else: source = client.driver unsubs.append(source.on(event_name, async_on_event)) for node in nodes: device_identifier = get_device_id(node.client, node) device = dev_reg.async_get_device({device_identifier}) assert device # We need to store the device for the callback unsubs.append( node.on(event_name, functools.partial(async_on_event, device=device))) @callback def async_remove() -> None: """Remove state listeners async.""" for unsub in unsubs: unsub() unsubs.clear() return async_remove
async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, automation_info: AutomationTriggerInfo, *, platform_type: str = PLATFORM_TYPE, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" nodes: set[Node] = set() if ATTR_DEVICE_ID in config: nodes.update({ async_get_node_from_device_id(hass, device_id) for device_id in config.get(ATTR_DEVICE_ID, []) }) if ATTR_ENTITY_ID in config: nodes.update({ async_get_node_from_entity_id(hass, entity_id) for entity_id in config.get(ATTR_ENTITY_ID, []) }) from_value = config[ATTR_FROM] to_value = config[ATTR_TO] command_class = config[ATTR_COMMAND_CLASS] property_ = config[ATTR_PROPERTY] endpoint = config.get(ATTR_ENDPOINT) property_key = config.get(ATTR_PROPERTY_KEY) unsubs = [] job = HassJob(action) trigger_data = automation_info["trigger_data"] @callback def async_on_value_updated(value: Value, device: dr.DeviceEntry, event: Event) -> None: """Handle value update.""" event_value: Value = event["value"] if event_value != value: return # Get previous value and its state value if it exists prev_value_raw = event["args"]["prevValue"] prev_value = value.metadata.states.get(str(prev_value_raw), prev_value_raw) # Get current value and its state value if it exists curr_value_raw = event["args"]["newValue"] curr_value = value.metadata.states.get(str(curr_value_raw), curr_value_raw) # Check from and to values against previous and current values respectively for value_to_eval, raw_value_to_eval, match in ( (prev_value, prev_value_raw, from_value), (curr_value, curr_value_raw, to_value), ): if (match != MATCH_ALL and value_to_eval != match and not (isinstance(match, list) and (value_to_eval in match or raw_value_to_eval in match)) and raw_value_to_eval != match): return device_name = device.name_by_user or device.name payload = { **trigger_data, CONF_PLATFORM: platform_type, ATTR_DEVICE_ID: device.id, ATTR_NODE_ID: value.node.node_id, ATTR_COMMAND_CLASS: value.command_class, ATTR_COMMAND_CLASS_NAME: value.command_class_name, ATTR_PROPERTY: value.property_, ATTR_PROPERTY_NAME: value.property_name, ATTR_ENDPOINT: endpoint, ATTR_PROPERTY_KEY: value.property_key, ATTR_PROPERTY_KEY_NAME: value.property_key_name, ATTR_PREVIOUS_VALUE: prev_value, ATTR_PREVIOUS_VALUE_RAW: prev_value_raw, ATTR_CURRENT_VALUE: curr_value, ATTR_CURRENT_VALUE_RAW: curr_value_raw, "description": f"Z-Wave value {value_id} updated on {device_name}", } hass.async_run_hass_job(job, {"trigger": payload}) dev_reg = dr.async_get(hass) for node in nodes: device_identifier = get_device_id(node.client, node) device = dev_reg.async_get_device({device_identifier}) assert device value_id = get_value_id(node, command_class, property_, endpoint, property_key) value = node.values[value_id] # We need to store the current value and device for the callback unsubs.append( node.on( "value updated", functools.partial(async_on_value_updated, value, device), )) @callback def async_remove() -> None: """Remove state listeners async.""" for unsub in unsubs: unsub() unsubs.clear() return async_remove
async def async_attach_trigger(hass, config, action, automation_info, *, platform_type="numeric_state") -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" entity_ids = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) value_template = config.get(CONF_VALUE_TEMPLATE) unsub_track_same = {} armed_entities = set() period: dict = {} attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) trigger_data = automation_info["trigger_data"] _variables = automation_info["variables"] or {} if value_template is not None: value_template.hass = hass def variables(entity_id): """Return a dict with trigger variables.""" trigger_info = { "trigger": { "platform": "numeric_state", "entity_id": entity_id, "below": below, "above": above, "attribute": attribute, } } return {**_variables, **trigger_info} @callback def check_numeric_state(entity_id, from_s, to_s): """Return whether the criteria are met, raise ConditionError if unknown.""" return condition.async_numeric_state(hass, to_s, below, above, value_template, variables(entity_id), attribute) # Each entity that starts outside the range is already armed (ready to fire). for entity_id in entity_ids: try: if not check_numeric_state(entity_id, None, entity_id): armed_entities.add(entity_id) except exceptions.ConditionError as ex: _LOGGER.warning( "Error initializing '%s' trigger: %s", automation_info["name"], ex, ) @callback def state_automation_listener(event): """Listen for state changes and calls action.""" entity_id = event.data.get("entity_id") from_s = event.data.get("old_state") to_s = event.data.get("new_state") @callback def call_action(): """Call action with right context.""" hass.async_run_hass_job( job, { "trigger": { **trigger_data, "platform": platform_type, "entity_id": entity_id, "below": below, "above": above, "from_state": from_s, "to_state": to_s, "for": time_delta if not time_delta else period[entity_id], "description": f"numeric state of {entity_id}", } }, to_s.context, ) @callback def check_numeric_state_no_raise(entity_id, from_s, to_s): """Return True if the criteria are now met, False otherwise.""" try: return check_numeric_state(entity_id, from_s, to_s) except exceptions.ConditionError: # This is an internal same-state listener so we just drop the # error. The same error will be reached and logged by the # primary async_track_state_change_event() listener. return False try: matching = check_numeric_state(entity_id, from_s, to_s) except exceptions.ConditionError as ex: _LOGGER.warning("Error in '%s' trigger: %s", automation_info["name"], ex) return if not matching: armed_entities.add(entity_id) elif entity_id in armed_entities: armed_entities.discard(entity_id) if time_delta: try: period[entity_id] = cv.positive_time_period( template.render_complex(time_delta, variables(entity_id))) except (exceptions.TemplateError, vol.Invalid) as ex: _LOGGER.error( "Error rendering '%s' for template: %s", automation_info["name"], ex, ) return unsub_track_same[entity_id] = async_track_same_state( hass, period[entity_id], call_action, entity_ids=entity_id, async_check_same_func=check_numeric_state_no_raise, ) else: call_action() unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener) @callback def async_remove(): """Remove state listeners async.""" unsub() for async_remove in unsub_track_same.values(): async_remove() unsub_track_same.clear() return async_remove
async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" number = config.get(CONF_NUMBER) held_more_than = config.get(CONF_HELD_MORE_THAN) held_less_than = config.get(CONF_HELD_LESS_THAN) pressed_time = None cancel_pressed_more_than: Callable = None job = HassJob(action) @callback def call_action(): """Call action with right context.""" hass.async_run_hass_job( job, { "trigger": { CONF_PLATFORM: "litejet", CONF_NUMBER: number, CONF_HELD_MORE_THAN: held_more_than, CONF_HELD_LESS_THAN: held_less_than, "description": f"litejet switch #{number}", } }, ) # held_more_than and held_less_than: trigger on released (if in time range) # held_more_than: trigger after pressed with calculation # held_less_than: trigger on released with calculation # neither: trigger on pressed @callback def pressed_more_than_satisfied(now): """Handle the LiteJet's switch's button pressed >= held_more_than.""" call_action() def pressed(): """Handle the press of the LiteJet switch's button.""" nonlocal cancel_pressed_more_than, pressed_time nonlocal held_less_than, held_more_than pressed_time = dt_util.utcnow() if held_more_than is None and held_less_than is None: hass.add_job(call_action) if held_more_than is not None and held_less_than is None: cancel_pressed_more_than = track_point_in_utc_time( hass, pressed_more_than_satisfied, dt_util.utcnow() + held_more_than ) def released(): """Handle the release of the LiteJet switch's button.""" nonlocal cancel_pressed_more_than, pressed_time nonlocal held_less_than, held_more_than # pylint: disable=not-callable if cancel_pressed_more_than is not None: cancel_pressed_more_than() cancel_pressed_more_than = None held_time = dt_util.utcnow() - pressed_time if ( held_less_than is not None and held_time < held_less_than and (held_more_than is None or held_time > held_more_than) ): hass.add_job(call_action) system = hass.data[DOMAIN] system.on_switch_pressed(number, pressed) system.on_switch_released(number, released) @callback def async_remove(): """Remove all subscriptions used for this trigger.""" system.unsubscribe(pressed) system.unsubscribe(released) return async_remove
await self._fetch_events(now) self._listen_next_calendar_event() async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach trigger for the specified calendar.""" entity_id = config[CONF_ENTITY_ID] event_type = config[CONF_EVENT] component: EntityComponent = hass.data[DOMAIN] if not (entity := component.get_entity(entity_id)) or not isinstance( entity, CalendarEntity): raise HomeAssistantError( f"Entity does not exist {entity_id} or is not a calendar entity") trigger_data = { **automation_info["trigger_data"], "platform": DOMAIN, "event": event_type, } listener = CalendarEventListener(hass, HassJob(action), trigger_data, entity, event_type) await listener.async_attach() return listener.async_detach
def change_listener(self, change_listener: Callable[..., Any]) -> None: """Update the change_listener.""" self._change_listener = change_listener if (self._change_listener_job is None or change_listener != self._change_listener_job.target): self._change_listener_job = HassJob(change_listener)
def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig): """Enable state reporting.""" checker = None unsub_pending: CALLBACK_TYPE | None = None pending = deque([{}]) async def report_states(now=None): """Report the states.""" nonlocal pending nonlocal unsub_pending pending.append({}) # We will report all batches except last one because those are finalized. while len(pending) > 1: await google_config.async_report_state_all( {"devices": {"states": pending.popleft()}} ) # If things got queued up in last batch while we were reporting, schedule ourselves again if pending[0]: unsub_pending = async_call_later( hass, REPORT_STATE_WINDOW, report_states_job ) else: unsub_pending = None report_states_job = HassJob(report_states) async def async_entity_state_listener(changed_entity, old_state, new_state): nonlocal unsub_pending if not hass.is_running: return if not new_state: return if not google_config.should_expose(new_state): return entity = GoogleEntity(hass, google_config, new_state) if not entity.is_supported(): return try: entity_data = entity.query_serialize() except SmartHomeError as err: _LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code) return if not checker.async_is_significant_change(new_state, extra_arg=entity_data): return _LOGGER.debug("Scheduling report state for %s: %s", changed_entity, entity_data) # If a significant change is already scheduled and we have another significant one, # let's create a new batch of changes if changed_entity in pending[-1]: pending.append({}) pending[-1][changed_entity] = entity_data if unsub_pending is None: unsub_pending = async_call_later( hass, REPORT_STATE_WINDOW, report_states_job ) @callback def extra_significant_check( hass: HomeAssistant, old_state: str, old_attrs: dict, old_extra_arg: dict, new_state: str, new_attrs: dict, new_extra_arg: dict, ): """Check if the serialized data has changed.""" return old_extra_arg != new_extra_arg async def initial_report(_now): """Report initially all states.""" nonlocal unsub, checker entities = {} checker = await create_checker(hass, DOMAIN, extra_significant_check) for entity in async_get_entities(hass, google_config): if not entity.should_expose(): continue try: entity_data = entity.query_serialize() except SmartHomeError: continue # Tell our significant change checker that we're reporting # So it knows with subsequent changes what was already reported. if not checker.async_is_significant_change( entity.state, extra_arg=entity_data ): continue entities[entity.entity_id] = entity_data if not entities: return await google_config.async_report_state_all({"devices": {"states": entities}}) unsub = hass.helpers.event.async_track_state_change( MATCH_ALL, async_entity_state_listener ) unsub = async_call_later(hass, INITIAL_REPORT_DELAY, initial_report) @callback def unsub_all(): unsub() if unsub_pending: unsub_pending() # pylint: disable=not-callable return unsub_all
def async_track_utc_time_change( hass: HomeAssistant, action: Callable[..., None], hour: Optional[Any] = None, minute: Optional[Any] = None, second: Optional[Any] = None, local: bool = False, ) -> CALLBACK_TYPE: """Add a listener that will fire if time matches a pattern.""" job = HassJob(action) # We do not have to wrap the function with time pattern matching logic # if no pattern given if all(val is None for val in (hour, minute, second)): @callback def time_change_listener(event: Event) -> None: """Fire every time event that comes in.""" hass.async_run_hass_job(job, event.data[ATTR_NOW]) return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener) matching_seconds = dt_util.parse_time_expression(second, 0, 59) matching_minutes = dt_util.parse_time_expression(minute, 0, 59) matching_hours = dt_util.parse_time_expression(hour, 0, 23) next_time: datetime = dt_util.utcnow() def calculate_next(now: datetime) -> None: """Calculate and set the next time the trigger should fire.""" nonlocal next_time localized_now = dt_util.as_local(now) if local else now next_time = dt_util.find_next_time_expression_time( localized_now, matching_seconds, matching_minutes, matching_hours ) # Make sure rolling back the clock doesn't prevent the timer from # triggering. cancel_callback: Optional[asyncio.TimerHandle] = None calculate_next(next_time) @callback def pattern_time_change_listener() -> None: """Listen for matching time_changed events.""" nonlocal next_time, cancel_callback now = pattern_utc_now() hass.async_run_hass_job(job, dt_util.as_local(now) if local else now) calculate_next(now + timedelta(seconds=1)) cancel_callback = hass.loop.call_at( -time.time() + hass.loop.time() + next_time.timestamp() + MAX_TIME_TRACKING_ERROR, pattern_time_change_listener, ) # We always get time.time() first to avoid time.time() # ticking forward after fetching hass.loop.time() # and callback being scheduled a few microseconds early. # # Since we loose additional time calling `hass.loop.time()` # we add MAX_TIME_TRACKING_ERROR to ensure # we always schedule the call within the time window between # second and the next second. # # For example: # If the clock ticks forward 30 microseconds when fectching # `hass.loop.time()` and we want the event to fire at exactly # 03:00:00.000000, the event would actually fire around # 02:59:59.999970. To ensure we always fire sometime between # 03:00:00.000000 and 03:00:00.999999 we add # MAX_TIME_TRACKING_ERROR to make up for the time # lost fetching the time. This ensures we do not fire the # event before the next time pattern match which would result # in the event being fired again since we would otherwise # potentially fire early. # cancel_callback = hass.loop.call_at( -time.time() + hass.loop.time() + next_time.timestamp() + MAX_TIME_TRACKING_ERROR, pattern_time_change_listener, ) @callback def unsub_pattern_time_change_listener() -> None: """Cancel the call_later.""" nonlocal cancel_callback assert cancel_callback is not None cancel_callback.cancel() return unsub_pattern_time_change_listener
async def async_schedule_discovery(self, delay: int): self._unsub_send_discovery = async_call_later(self._hass, delay, HassJob(self.async_send_discovery))
def async_track_state_change_event( hass: HomeAssistant, entity_ids: Union[str, Iterable[str]], action: Callable[[Event], Any], ) -> Callable[[], None]: """Track specific state change events indexed by entity_id. Unlike async_track_state_change, async_track_state_change_event passes the full event to the callback. In order to avoid having to iterate a long list of EVENT_STATE_CHANGED and fire and create a job for each one, we keep a dict of entity ids that care about the state change events so we can do a fast dict lookup to route events. """ entity_ids = _async_string_to_lower_list(entity_ids) if not entity_ids: return _remove_empty_listener entity_callbacks = hass.data.setdefault(TRACK_STATE_CHANGE_CALLBACKS, {}) if TRACK_STATE_CHANGE_LISTENER not in hass.data: @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" entity_id = event.data.get("entity_id") if entity_id not in entity_callbacks: return for job in entity_callbacks[entity_id][:]: try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error while processing state changed for %s", entity_id ) hass.data[TRACK_STATE_CHANGE_LISTENER] = hass.bus.async_listen( EVENT_STATE_CHANGED, _async_state_change_dispatcher ) job = HassJob(action) for entity_id in entity_ids: entity_callbacks.setdefault(entity_id, []).append(job) @callback def remove_listener() -> None: """Remove state change listener.""" _async_remove_indexed_listeners( hass, TRACK_STATE_CHANGE_CALLBACKS, TRACK_STATE_CHANGE_LISTENER, entity_ids, job, ) return remove_listener
async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" trigger_data = automation_info["trigger_data"] topic = config[CONF_TOPIC] wanted_payload = config.get(CONF_PAYLOAD) value_template = config.get(CONF_VALUE_TEMPLATE) encoding = config[CONF_ENCODING] or None qos = config[CONF_QOS] job = HassJob(action) variables = None if automation_info: variables = automation_info.get("variables") template.attach(hass, wanted_payload) if wanted_payload: wanted_payload = wanted_payload.async_render(variables, limited=True, parse_result=False) template.attach(hass, topic) if isinstance(topic, template.Template): topic = topic.async_render(variables, limited=True, parse_result=False) topic = mqtt.util.valid_subscribe_topic(topic) template.attach(hass, value_template) @callback def mqtt_automation_listener(mqttmsg): """Listen for MQTT messages.""" payload = mqttmsg.payload if value_template is not None: payload = value_template.async_render_with_possible_json_value( payload, error_value=None, ) if wanted_payload is None or wanted_payload == payload: data = { **trigger_data, "platform": "mqtt", "topic": mqttmsg.topic, "payload": mqttmsg.payload, "qos": mqttmsg.qos, "description": f"mqtt topic {mqttmsg.topic}", } with suppress(ValueError): data["payload_json"] = json.loads(mqttmsg.payload) hass.async_run_hass_job(job, {"trigger": data}) _LOGGER.debug("Attaching MQTT trigger for topic: '%s', payload: '%s'", topic, wanted_payload) remove = await mqtt.async_subscribe(hass, topic, mqtt_automation_listener, encoding=encoding, qos=qos) return remove
async def async_attach_trigger(hass, config, action, automation_info, *, platform_type="event"): """Listen for events based on configuration.""" variables = None if automation_info: variables = automation_info.get("variables") template.attach(hass, config[CONF_EVENT_TYPE]) event_types = template.render_complex(config[CONF_EVENT_TYPE], variables, limited=True) removes = [] event_data_schema = None if CONF_EVENT_DATA in config: # Render the schema input template.attach(hass, config[CONF_EVENT_DATA]) event_data = {} event_data.update( template.render_complex(config[CONF_EVENT_DATA], variables, limited=True)) # Build the schema event_data_schema = vol.Schema( {vol.Required(key): value for key, value in event_data.items()}, extra=vol.ALLOW_EXTRA, ) event_context_schema = None if CONF_EVENT_CONTEXT in config: # Render the schema input template.attach(hass, config[CONF_EVENT_CONTEXT]) event_context = {} event_context.update( template.render_complex(config[CONF_EVENT_CONTEXT], variables, limited=True)) # Build the schema event_context_schema = vol.Schema( { vol.Required(key): _schema_value(value) for key, value in event_context.items() }, extra=vol.ALLOW_EXTRA, ) job = HassJob(action) @callback def handle_event(event): """Listen for events and calls the action when data matches.""" try: # Check that the event data and context match the configured # schema if one was provided if event_data_schema: event_data_schema(event.data) if event_context_schema: event_context_schema(event.context.as_dict()) except vol.Invalid: # If event doesn't match, skip event return hass.async_run_hass_job( job, { "trigger": { "platform": platform_type, "event": event, "description": f"event '{event.event_type}'", } }, event.context, ) removes = [ hass.bus.async_listen(event_type, handle_event) for event_type in event_types ] @callback def remove_listen_events(): """Remove event listeners.""" for remove in removes: remove() return remove_listen_events
async def async_attach_trigger(hass, config, action, automation_info, *, platform_type="numeric_state") -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" entity_ids = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) value_template = config.get(CONF_VALUE_TEMPLATE) unsub_track_same = {} entities_triggered = set() period: dict = {} attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) if value_template is not None: value_template.hass = hass def variables(entity_id): """Return a dict with trigger variables.""" return { "trigger": { "platform": "numeric_state", "entity_id": entity_id, "below": below, "above": above, "attribute": attribute, } } @callback def check_numeric_state(entity_id, from_s, to_s): """Return True if criteria are now met.""" if to_s is None: return False return condition.async_numeric_state(hass, to_s, below, above, value_template, variables(entity_id), attribute) @callback def state_automation_listener(event): """Listen for state changes and calls action.""" entity_id = event.data.get("entity_id") from_s = event.data.get("old_state") to_s = event.data.get("new_state") @callback def call_action(): """Call action with right context.""" hass.async_run_hass_job( job, { "trigger": { "platform": platform_type, "entity_id": entity_id, "below": below, "above": above, "from_state": from_s, "to_state": to_s, "for": time_delta if not time_delta else period[entity_id], "description": f"numeric state of {entity_id}", } }, to_s.context, ) matching = check_numeric_state(entity_id, from_s, to_s) if not matching: entities_triggered.discard(entity_id) elif entity_id not in entities_triggered: entities_triggered.add(entity_id) if time_delta: try: period[entity_id] = cv.positive_time_period( template.render_complex(time_delta, variables(entity_id))) except (exceptions.TemplateError, vol.Invalid) as ex: _LOGGER.error( "Error rendering '%s' for template: %s", automation_info["name"], ex, ) entities_triggered.discard(entity_id) return unsub_track_same[entity_id] = async_track_same_state( hass, period[entity_id], call_action, entity_ids=entity_id, async_check_same_func=check_numeric_state, ) else: call_action() unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener) @callback def async_remove(): """Remove state listeners async.""" unsub() for async_remove in unsub_track_same.values(): async_remove() unsub_track_same.clear() return async_remove