Example #1
0
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
Example #2
0
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)
Example #3
0
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)
Example #5
0
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
Example #6
0
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
Example #7
0
    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
Example #8
0
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
Example #9
0
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,
            )
Example #10
0
    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))
Example #11
0
            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,
        )
Example #12
0
 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))
Example #13
0
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
Example #14
0
 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)
Example #15
0
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
Example #16
0
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
Example #17
0
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
Example #18
0
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
Example #19
0
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
Example #20
0
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
Example #21
0
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
Example #22
0
        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
Example #25
0
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
Example #26
0
 async def async_schedule_discovery(self, delay: int):
     self._unsub_send_discovery = async_call_later(self._hass, delay, HassJob(self.async_send_discovery))
Example #27
0
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
Example #28
0
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
Example #29
0
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
Example #30
0
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