def async_track_time_interval( opp: OpenPeerPower, action: Callable[..., Awaitable[None] | None], interval: timedelta, ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" remove = None interval_listener_job = None job = OppJob(action) def next_interval() -> datetime: """Return the next interval.""" return dt_util.utcnow() + interval @callback def interval_listener(now: datetime) -> None: """Handle elapsed intervals.""" nonlocal remove nonlocal interval_listener_job remove = async_track_point_in_utc_time( opp, interval_listener_job, next_interval() # type: ignore ) opp.async_run_opp_job(job, now) interval_listener_job = OppJob(interval_listener) remove = async_track_point_in_utc_time(opp, interval_listener_job, next_interval()) def remove_listener() -> None: """Remove interval listener.""" remove() # type: ignore return remove_listener
def _attach_trigger( opp: OpenPeerPower, config: ConfigType, action: AutomationActionType, event_type, automation_info: dict, ): trigger_id = automation_info.get("trigger_id") if automation_info else None job = OppJob(action) @callback def _handle_event(event: Event): if event.data[ATTR_ENTITY_ID] == config[CONF_ENTITY_ID]: opp.async_run_opp_job( job, { "trigger": { **config, "description": event_type, "id": trigger_id } }, event.context, ) return opp.bus.async_listen(event_type, _handle_event)
def __init__( self, opp: OpenPeerPower, logger: Logger, *, cooldown: float, immediate: bool, function: Callable[..., Awaitable[Any]] | None = None, ) -> None: """Initialize debounce. immediate: indicate if the function needs to be called right away and wait <cooldown> until executing next invocation. function: optional and can be instantiated later. """ self.opp = opp self.logger = logger self._function = function self.cooldown = cooldown self.immediate = immediate self._timer_task: asyncio.TimerHandle | None = None self._execute_at_end_of_timer: bool = False self._execute_lock = asyncio.Lock() self._job: OppJob | None = None if function is None else OppJob( function)
async def async_attach_trigger(opp, config, action, automation_info): """Listen for state changes based on configuration.""" trigger_id = automation_info.get("trigger_id") if automation_info else None hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) seconds = config.get(CONF_SECONDS) job = OppJob(action) # If larger units are specified, default the smaller units to zero if minutes is None and hours is not None: minutes = 0 if seconds is None and minutes is not None: seconds = 0 @callback def time_automation_listener(now): """Listen for time changes and calls action.""" opp.async_run_opp_job( job, { "trigger": { "platform": "time_pattern", "now": now, "description": "time pattern", "id": trigger_id, } }, ) return async_track_time_change(opp, time_automation_listener, hour=hours, minute=minutes, second=seconds)
async def async_attach_trigger(opp, config, action, automation_info): """Listen for tag_scanned events based on configuration.""" trigger_id = automation_info.get("trigger_id") if automation_info else None tag_ids = set(config[TAG_ID]) device_ids = set(config[DEVICE_ID]) if DEVICE_ID in config else None job = OppJob(action) async def handle_event(event): """Listen for tag scan events and calls the action when data matches.""" if event.data.get(TAG_ID) not in tag_ids or ( device_ids is not None and event.data.get(DEVICE_ID) not in device_ids ): return task = opp.async_run_opp_job( job, { "trigger": { "platform": DOMAIN, "event": event, "description": "Tag scanned", "id": trigger_id, } }, event.context, ) if task: await task return opp.bus.async_listen(EVENT_TAG_SCANNED, handle_event)
async def async_attach_trigger( opp: OpenPeerPower, config: ConfigType, action: AutomationActionType, automation_info: dict, ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_id = automation_info.get("trigger_id") if automation_info else None job = OppJob(action) if config[CONF_TYPE] == "turn_on": entity_id = config[CONF_ENTITY_ID] @callback def _handle_event(event: Event): if event.data[ATTR_ENTITY_ID] == entity_id: opp.async_run_opp_job( job, { "trigger": { **config, "description": f"{DOMAIN} - {entity_id}", "id": trigger_id, } }, event.context, ) return opp.bus.async_listen(EVENT_TURN_ON, _handle_event) return lambda: None
def async_track_sunset( opp: OpenPeerPower, action: Callable[..., None], offset: timedelta | None = None ) -> CALLBACK_TYPE: """Add a listener that will fire a specified offset from sunset daily.""" listener = SunListener(opp, OppJob(action), SUN_EVENT_SUNSET, offset) listener.async_attach() return listener.async_detach
def async_dispatcher_connect(opp: OpenPeerPower, signal: str, target: Callable[..., Any]) -> Callable[[], None]: """Connect a callable function to a signal. This method must be run in the event loop. """ if DATA_DISPATCHER not in opp.data: opp.data[DATA_DISPATCHER] = {} job = OppJob( catch_log_exception( target, lambda *args: "Exception in {} when dispatching '{}': {}".format( # Functions wrapped in partial do not have a __name__ getattr(target, "__name__", None) or str(target), signal, args, ), )) opp.data[DATA_DISPATCHER].setdefault(signal, []).append(job) @callback def async_remove_dispatcher() -> None: """Remove signal listener.""" try: opp.data[DATA_DISPATCHER][signal].remove(job) except (KeyError, ValueError): # KeyError is key target listener did not exist # ValueError if listener did not exist within signal _LOGGER.warning("Unable to remove unknown dispatcher %s", target) return async_remove_dispatcher
def async_subscribe_connection_status(opp, connection_status_callback): """Subscribe to MQTT connection changes.""" connection_status_callback_job = OppJob(connection_status_callback) async def connected(): task = opp.async_run_opp_job(connection_status_callback_job, True) if task: await task async def disconnected(): task = opp.async_run_opp_job(connection_status_callback_job, False) if task: await task subscriptions = { "connect": async_dispatcher_connect(opp, MQTT_CONNECTED, connected), "disconnect": async_dispatcher_connect(opp, MQTT_DISCONNECTED, disconnected), } @callback def unsubscribe(): subscriptions["connect"]() subscriptions["disconnect"]() return unsubscribe
async def async_attach_trigger(opp, config, action, automation_info): """Listen for events based on configuration.""" trigger_id = automation_info.get("trigger_id") if automation_info else None event = config.get(CONF_EVENT) offset = config.get(CONF_OFFSET) description = event if offset: description = f"{description} with offset" job = OppJob(action) @callback def call_action(): """Call action with right context.""" opp.async_run_opp_job( job, { "trigger": { "platform": "sun", "event": event, "offset": offset, "description": description, "id": trigger_id, } }, ) if event == SUN_EVENT_SUNRISE: return async_track_sunrise(opp, call_action, offset) return async_track_sunset(opp, call_action, offset)
def async_track_utc_time_change( opp: OpenPeerPower, action: Callable[..., Awaitable[None] | None], hour: Any | None = None, minute: Any | None = None, second: Any | None = None, local: bool = False, ) -> CALLBACK_TYPE: """Add a listener that will fire if time matches a pattern.""" job = OppJob(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.""" opp.async_run_opp_job(job, event.data[ATTR_NOW]) return opp.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) def calculate_next(now: datetime) -> datetime: """Calculate and set the next time the trigger should fire.""" localized_now = dt_util.as_local(now) if local else now return dt_util.find_next_time_expression_time( localized_now, matching_seconds, matching_minutes, matching_hours ) time_listener: CALLBACK_TYPE | None = None @callback def pattern_time_change_listener(_: datetime) -> None: """Listen for matching time_changed events.""" nonlocal time_listener now = time_tracker_utcnow() opp.async_run_opp_job(job, dt_util.as_local(now) if local else now) time_listener = async_track_point_in_utc_time( opp, pattern_time_change_listener, calculate_next(now + timedelta(seconds=1)), ) time_listener = async_track_point_in_utc_time( opp, pattern_time_change_listener, calculate_next(dt_util.utcnow()) ) @callback def unsub_pattern_time_change_listener() -> None: """Cancel the time listener.""" assert time_listener is not None time_listener() return unsub_pattern_time_change_listener
async def async_attach_trigger(opp, config, action, automation_info): """Listen for state changes based on configuration.""" trigger_id = automation_info.get("trigger_id") if automation_info else None source = config.get(CONF_SOURCE).lower() zone_entity_id = config.get(CONF_ZONE) trigger_event = config.get(CONF_EVENT) job = OppJob(action) @callback def state_change_listener(event): """Handle specific state changes.""" # Skip if the event's source does not match the trigger's source. from_state = event.data.get("old_state") to_state = event.data.get("new_state") if not source_match(from_state, source) and not source_match( to_state, source): return zone_state = opp.states.get(zone_entity_id) if zone_state is None: _LOGGER.warning( "Unable to execute automation %s: Zone %s not found", automation_info["name"], zone_entity_id, ) return from_match = (condition.zone(opp, zone_state, from_state) if from_state else False) to_match = condition.zone(opp, zone_state, to_state) if to_state else False if (trigger_event == EVENT_ENTER and not from_match and to_match or trigger_event == EVENT_LEAVE and from_match and not to_match): opp.async_run_opp_job( job, { "trigger": { "platform": "geo_location", "source": source, "entity_id": event.data.get("entity_id"), "from_state": from_state, "to_state": to_state, "zone": zone_state, "event": trigger_event, "description": f"geo_location - {source}", "id": trigger_id, } }, event.context, ) return async_track_state_change_filtered( opp, TrackStates(False, set(), {DOMAIN}), state_change_listener).async_remove
async def async_attach_trigger(opp, config, action, automation_info, *, platform_type: str = "zone") -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" trigger_id = automation_info.get("trigger_id") if automation_info else None entity_id = config.get(CONF_ENTITY_ID) zone_entity_id = config.get(CONF_ZONE) event = config.get(CONF_EVENT) job = OppJob(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 zone_state = opp.states.get(zone_entity_id) from_match = condition.zone(opp, zone_state, from_s) if from_s else False to_match = condition.zone(opp, 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]}" opp.async_run_opp_job( job, { "trigger": { "platform": platform_type, "entity_id": entity, "from_state": from_s, "to_state": to_s, "zone": zone_state, "event": event, "description": description, "id": trigger_id, } }, to_s.context, ) return async_track_state_change_event(opp, entity_id, zone_automation_listener)
def async_attach(self, action: AutomationActionType, variables: dict[str, Any]): """Attach a device trigger for turn on.""" @callback def _remove(): del self._actions[_remove] self._update() job = OppJob(action) self._actions[_remove] = (job, variables) self._update() return _remove
def async_track_point_in_time( opp: OpenPeerPower, action: OppJob | Callable[..., Awaitable[None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in time.""" job = action if isinstance(action, OppJob) else OppJob(action) @callback def utc_converter(utc_now: datetime) -> None: """Convert passed in UTC now to local now.""" opp.async_run_opp_job(job, dt_util.as_local(utc_now)) return async_track_point_in_utc_time(opp, utc_converter, point_in_time)
def async_track_state_removed_domain( opp: OpenPeerPower, domains: str | Iterable[str], action: Callable[[Event], Any], ) -> Callable[[], None]: """Track state change events when an entity is removed from domains.""" domains = _async_string_to_lower_list(domains) if not domains: return _remove_empty_listener domain_callbacks = opp.data.setdefault(TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {}) if TRACK_STATE_REMOVED_DOMAIN_LISTENER not in opp.data: @callback def _async_state_change_filter(event: Event) -> bool: """Filter state changes by entity_id.""" return event.data.get("new_state") is None @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" if event.data.get("new_state") is not None: return _async_dispatch_domain_event(opp, event, domain_callbacks) opp.data[TRACK_STATE_REMOVED_DOMAIN_LISTENER] = opp.bus.async_listen( EVENT_STATE_CHANGED, _async_state_change_dispatcher, event_filter=_async_state_change_filter, ) job = OppJob(action) for domain in domains: domain_callbacks.setdefault(domain, []).append(job) @callback def remove_listener() -> None: """Remove state change listener.""" _async_remove_indexed_listeners( opp, TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, TRACK_STATE_REMOVED_DOMAIN_LISTENER, domains, job, ) return remove_listener
def async_track_point_in_utc_time( opp: OpenPeerPower, action: OppJob | Callable[..., Awaitable[None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in UTC time.""" # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) # Since this is called once, we accept a OppJob so we can avoid # having to figure out how to call the action every time its called. cancel_callback: asyncio.TimerHandle | None = None @callback def run_action(job: OppJob) -> None: """Call the action.""" nonlocal cancel_callback now = time_tracker_utcnow() # Depending on the available clock support (including timer hardware # and the OS kernel) it can happen that we fire a little bit too early # as measured by utcnow(). That is bad when callbacks have assumptions # about the current time. Thus, we rearm the timer for the remaining # time. delta = (utc_point_in_time - now).total_seconds() if delta > 0: _LOGGER.debug("Called %f seconds too early, rearming", delta) cancel_callback = opp.loop.call_later(delta, run_action, job) return opp.async_run_opp_job(job, utc_point_in_time) job = action if isinstance(action, OppJob) else OppJob(action) delta = utc_point_in_time.timestamp() - time.time() cancel_callback = opp.loop.call_later(delta, run_action, job) @callback def unsub_point_in_time_listener() -> None: """Cancel the call_later.""" assert cancel_callback is not None cancel_callback.cancel() return unsub_point_in_time_listener
async def async_attach_trigger(opp, config, action, automation_info): """Trigger based on incoming webhooks.""" trigger_id = automation_info.get("trigger_id") if automation_info else None webhook_id = config.get(CONF_WEBHOOK_ID) job = OppJob(action) opp.components.webhook.async_register( automation_info["domain"], automation_info["name"], webhook_id, partial(_handle_webhook, job, trigger_id), ) @callback def unregister(): """Unregister webhook.""" opp.components.webhook.async_unregister(webhook_id) return unregister
async def async_attach_trigger(opp, config, action, automation_info): """Listen for events based on configuration.""" trigger_id = automation_info.get("trigger_id") if automation_info else None event = config.get(CONF_EVENT) job = OppJob(action) if event == EVENT_SHUTDOWN: @callback def opp_shutdown(event): """Execute when Open Peer Power is shutting down.""" opp.async_run_opp_job( job, { "trigger": { "platform": "openpeerpower", "event": event, "description": "Open Peer Power stopping", "id": trigger_id, } }, event.context, ) return opp.bus.async_listen_once(EVENT_OPENPEERPOWER_STOP, opp_shutdown) # Automation are enabled while opp is starting up, fire right away # Check state because a config reload shouldn't trigger it. if automation_info["open_peer_power_start"]: opp.async_run_opp_job( job, { "trigger": { "platform": "openpeerpower", "event": event, "description": "Open Peer Power starting", "id": trigger_id, } }, ) return lambda: None
def __init__( self, opp: OpenPeerPower, track_templates: Iterable[TrackTemplate], action: Callable, ) -> None: """Handle removal / refresh of tracker init.""" self.opp = opp self._job = OppJob(action) for track_template_ in track_templates: track_template_.template.opp = opp self._track_templates = track_templates self._last_result: dict[Template, str | TemplateError] = {} self._rate_limit = KeyedRateLimit(opp) self._info: dict[Template, RenderInfo] = {} self._track_state_changes: _TrackStateChangeFiltered | None = None self._time_listeners: dict[Template, Callable] = {}
async def async_subscribe( self, topic: str, msg_callback: MessageCallbackType, qos: int, encoding: str | None = None, ) -> Callable[[], None]: """Set up a subscription to a topic with the provided qos. This method is a coroutine. """ if not isinstance(topic, str): raise OpenPeerPowerError("Topic needs to be a string!") subscription = Subscription(topic, _matcher_for_topic(topic), OppJob(msg_callback), qos, encoding) self.subscriptions.append(subscription) self._matching_subscriptions.cache_clear() # Only subscribe if currently connected. if self.connected: self._last_subscribe = time.time() await self._async_perform_subscription(topic, qos) @callback def async_remove() -> None: """Remove subscription.""" if subscription not in self.subscriptions: raise OpenPeerPowerError("Can't remove subscription twice") self.subscriptions.remove(subscription) self._matching_subscriptions.cache_clear() if any(other.topic == topic for other in self.subscriptions): # Other subscriptions on topic remaining - don't unsubscribe. return # Only unsubscribe if currently connected. if self.connected: self.opp.async_create_task(self._async_unsubscribe(topic)) return async_remove
def async_track_entity_registry_updated_event( opp: OpenPeerPower, 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 = opp.data.setdefault(TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {}) if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in opp.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: opp.async_run_opp_job(job, event) except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error while processing entity registry update for %s", entity_id, ) opp.data[TRACK_ENTITY_REGISTRY_UPDATED_LISTENER] = opp.bus.async_listen( EVENT_ENTITY_REGISTRY_UPDATED, _async_entity_registry_updated_dispatcher, event_filter=_async_entity_registry_updated_filter, ) job = OppJob(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( opp, TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, entity_ids, job, ) return remove_listener
def async_enable_report_state(opp: OpenPeerPower, 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(opp, REPORT_STATE_WINDOW, report_states_job) else: unsub_pending = None report_states_job = OppJob(report_states) async def async_entity_state_listener(changed_entity, old_state, new_state): nonlocal unsub_pending if not opp.is_running: return if not new_state: return if not google_config.should_expose(new_state): return entity = GoogleEntity(opp, 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(opp, REPORT_STATE_WINDOW, report_states_job) @callback def extra_significant_check( opp: OpenPeerPower, 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 inital_report(_now): """Report initially all states.""" nonlocal unsub, checker entities = {} checker = await create_checker(opp, DOMAIN, extra_significant_check) for entity in async_get_entities(opp, 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 = opp.helpers.event.async_track_state_change( MATCH_ALL, async_entity_state_listener) unsub = async_call_later(opp, INITIAL_REPORT_DELAY, inital_report) @callback def unsub_all(): unsub() if unsub_pending: unsub_pending() # pylint: disable=not-callable return unsub_all
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 = OppJob(change_listener)
def __init__( self, opp: OpenPeerPower, 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 = opp.data.get(DATA_SCRIPTS) if not all_scripts: all_scripts = opp.data[DATA_SCRIPTS] = [] opp.bus.async_listen_once( EVENT_OPENPEERPOWER_STOP, partial(_async_stop_scripts_at_shutdown, opp)) self._top_level = top_level if top_level: all_scripts.append({ "instance": self, "started_before_shutdown": not opp.is_stopping }) if DATA_SCRIPT_BREAKPOINTS not in opp.data: opp.data[DATA_SCRIPT_BREAKPOINTS] = {} self._opp = opp self.sequence = sequence template.attach(opp, 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 OppJob(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(opp, variables)
def async_track_same_state( opp: OpenPeerPower, period: timedelta, action: Callable[..., Awaitable[None] | None], async_check_same_func: Callable[[str, State | None, State | None], bool], entity_ids: 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: CALLBACK_TYPE | None = None async_remove_state_for_listener: CALLBACK_TYPE | None = None job = OppJob(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() opp.async_run_opp_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: State | None = event.data.get("old_state") to_state: State | None = 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( opp, state_for_listener, dt_util.utcnow() + period ) if entity_ids == MATCH_ALL: async_remove_state_for_cancel = opp.bus.async_listen( EVENT_STATE_CHANGED, state_for_cancel_listener ) else: async_remove_state_for_cancel = async_track_state_change_event( opp, [entity_ids] if isinstance(entity_ids, str) else entity_ids, state_for_cancel_listener, ) return clear_listener
def async_track_template( opp: OpenPeerPower, template: Template, action: Callable[[str, State | None, State | None], Awaitable[None] | None], variables: TemplateVarsType | None = 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 ---------- opp Open Peer Power 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 = OppJob(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 opp.async_run_opp_job( job, event and event.data.get("entity_id"), event and event.data.get("old_state"), event and event.data.get("new_state"), ) info = async_track_template_result( opp, [TrackTemplate(template, variables)], _template_changed_listener ) return info.async_remove
def async_track_state_change( opp: OpenPeerPower, entity_ids: str | Iterable[str], action: Callable[[str, State, State], Awaitable[None] | None], from_state: None | str | Iterable[str] = None, to_state: 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 = OppJob(action) @callback def state_change_filter(event: Event) -> bool: """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 False 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 False return True @callback def state_change_dispatcher(event: Event) -> None: """Handle specific state changes.""" opp.async_run_opp_job( job, event.data.get("entity_id"), event.data.get("old_state"), event.data.get("new_state"), ) @callback def state_change_listener(event: Event) -> None: """Handle specific state changes.""" if not state_change_filter(event): return state_change_dispatcher(event) 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(opp, entity_ids, state_change_listener) return opp.bus.async_listen( EVENT_STATE_CHANGED, state_change_dispatcher, event_filter=state_change_filter )
async def async_attach_trigger(opp, 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 = OppJob(action) @callback def time_automation_listener(description, now, *, entity_id=None): """Listen for time changes and calls action.""" opp.async_run_opp_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( opp, 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( opp, 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( opp, 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=opp.states.get(at_time)) else: # datetime.time removes.append( async_track_time_change( opp, 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(opp, 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
def async_track_state_change_event( opp: OpenPeerPower, entity_ids: 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 = opp.data.setdefault(TRACK_STATE_CHANGE_CALLBACKS, {}) if TRACK_STATE_CHANGE_LISTENER not in opp.data: @callback def _async_state_change_filter(event: Event) -> bool: """Filter state changes by entity_id.""" return event.data.get("entity_id") in entity_callbacks @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: opp.async_run_opp_job(job, event) except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error while processing state change for %s", entity_id ) opp.data[TRACK_STATE_CHANGE_LISTENER] = opp.bus.async_listen( EVENT_STATE_CHANGED, _async_state_change_dispatcher, event_filter=_async_state_change_filter, ) job = OppJob(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( opp, TRACK_STATE_CHANGE_CALLBACKS, TRACK_STATE_CHANGE_LISTENER, entity_ids, job, ) return remove_listener