Coverage for custom_components/supernotify/hass_api.py: 97%
613 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +0000
1from __future__ import annotations
3import logging
4from collections.abc import Mapping
5from dataclasses import dataclass, field
6from functools import partial
7from typing import TYPE_CHECKING, Any
9import voluptuous as vol
10from homeassistant.components.person import ATTR_USER_ID
11from homeassistant.const import (
12 ATTR_AREA_ID,
13 ATTR_ENTITY_ID,
14 ATTR_FLOOR_ID,
15 ATTR_LABEL_ID,
16 CONF_ACTION,
17 CONF_DEVICE_ID,
18)
19from homeassistant.helpers.aiohttp_client import async_get_clientsession
20from homeassistant.helpers.entity_registry import RegistryEntry
21from homeassistant.helpers.event import async_track_state_change_event, async_track_time_change, async_track_time_interval
22from homeassistant.helpers.storage import Store
23from homeassistant.helpers.target import TargetSelection, async_extract_referenced_entity_ids
24from homeassistant.util import slugify
26if TYPE_CHECKING:
27 import asyncio
28 from collections.abc import Callable, Iterable, Iterator
30 import aiohttp
31 from anyio import Path
32 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, Service, ServiceResponse, State
33 from homeassistant.helpers.entity_registry import EntityRegistry
34 from homeassistant.helpers.typing import ConfigType
35 from homeassistant.util.event_type import EventType
37 from .schema import ConditionsFunc
39import socket
40import threading
41from contextlib import contextmanager
42from datetime import timedelta
43from typing import TYPE_CHECKING, cast
45import homeassistant.components.camera as ha_camera
46import homeassistant.components.image as ha_image
47import homeassistant.components.trace
48from homeassistant.components.group import DOMAIN as GROUP_DOMAIN
49from homeassistant.components.group import expand_entity_ids
50from homeassistant.components.trace.const import DATA_TRACE
51from homeassistant.components.trace.models import ActionTrace
52from homeassistant.components.trace.util import async_store_trace
53from homeassistant.core import Context as HomeAssistantContext
54from homeassistant.core import HomeAssistant, SupportsResponse
55from homeassistant.exceptions import ConditionError, ConditionErrorContainer, HomeAssistantError, IntegrationError
56from homeassistant.helpers import condition as condition_helper
57from homeassistant.helpers import device_registry as dr
58from homeassistant.helpers import entity_registry as er
59from homeassistant.helpers import issue_registry as ir
60from homeassistant.helpers.json import json_dumps
61from homeassistant.helpers.network import get_url
62from homeassistant.helpers.template import Template
63from homeassistant.helpers.trace import trace_get, trace_path
64from homeassistant.helpers.typing import ConfigType
66from . import DOMAIN
67from .const import CONF_DEVICE_LABELS, CONF_DEVICE_TRACKER, CONF_MOBILE_APP_ID
68from .model import ConditionVariables, SelectionRule
70if TYPE_CHECKING:
71 from homeassistant.helpers.device_registry import DeviceEntry, DeviceRegistry
73# avoid importing from homeassistant.components.mobile_app.const and triggering dependency chain
75CONF_USER_ID = "user_id"
76ATTR_OS_NAME = "os_name"
77ATTR_OS_VERSION = "os_version"
78ATTR_APP_VERSION = "app_version"
79ATTR_DEVICE_NAME = "device_name"
80ATTR_MANUFACTURER = "manufacturer"
81ATTR_MODEL = "model"
83_LOGGER = logging.getLogger(__name__)
86@dataclass
87class TrackedDeviceDetails:
88 device_id: str
89 device_labels: list[str] | None = None
90 mobile_app_id: str | None = None
91 device_name: str | None = None
92 device_tracker: str | None = None
93 action: str | None = None
94 user_id: str | None = None
95 area_id: str | None = None
96 manufacturer: str | None = None
97 model: str | None = None
98 os_name: str | None = None
99 os_version: str | None = None
100 app_version: str | None = None
101 identifiers: set[tuple[str, str]] | None = None
103 def as_dict(self) -> dict[str, str | list[str] | None]:
104 return {
105 CONF_MOBILE_APP_ID: self.mobile_app_id,
106 ATTR_DEVICE_NAME: self.device_name,
107 CONF_DEVICE_ID: self.device_id,
108 CONF_USER_ID: self.user_id,
109 CONF_DEVICE_TRACKER: self.device_tracker,
110 CONF_ACTION: self.action,
111 ATTR_OS_NAME: self.os_name,
112 ATTR_OS_VERSION: self.os_version,
113 ATTR_APP_VERSION: self.app_version,
114 ATTR_MANUFACTURER: self.manufacturer,
115 ATTR_MODEL: self.model,
116 CONF_DEVICE_LABELS: self.device_labels,
117 }
119 def __eq__(self, other: object) -> bool:
120 """Test support"""
121 as_dict = getattr(other, "as_dict", None)
122 return other is not None and callable(as_dict) and as_dict() == self.as_dict()
125@dataclass
126class TargetSelectorResolution:
127 """Outcome of resolving area/floor/label selectors to the entities they reference"""
129 entity_ids: list[str] = field(default_factory=list)
130 missing_areas: list[str] = field(default_factory=list)
131 missing_floors: list[str] = field(default_factory=list)
132 missing_labels: list[str] = field(default_factory=list)
134 def has_missing(self) -> bool:
135 return bool(self.missing_areas or self.missing_floors or self.missing_labels)
138def ha_device_info(entry_id: str) -> dr.DeviceInfo:
139 """Home Assistant device-registry DeviceInfo for the single 'SuperNotify' device.
141 Groups the platform entities in binary_sensor.py/sensor.py/switch.py (scenario/recipient
142 state and control, notification/failure counters) under one device in the HA device
143 registry. Unrelated to `TrackedDeviceDetails` above, which describes a discovered mobile_app
144 device for targeting.
145 """
146 return dr.DeviceInfo(identifiers={(DOMAIN, entry_id)}, name="SuperNotify", manufacturer="SuperNotify")
149class HomeAssistantAPI:
150 def __init__(self, hass: HomeAssistant) -> None:
151 self._hass: HomeAssistant = hass
152 self.internal_url: str = ""
153 self.external_url: str = ""
154 self.language: str = ""
155 self.hass_name: str = "!UNDEFINED!"
156 self.__entity_registry: er.EntityRegistry | None = None
157 self.__device_registry: dr.DeviceRegistry | None = None
158 self._service_info: dict[tuple[str, str], Any] = {}
159 self.unsubscribes: list[CALLBACK_TYPE] = []
160 self._mobile_apps_by_tracker: dict[str, TrackedDeviceDetails] = {}
161 self._mobile_apps_by_app_id: dict[str, TrackedDeviceDetails] = {}
162 self._mobile_apps_by_device_id: dict[str, TrackedDeviceDetails] = {}
163 self._mobile_apps_by_user_id: dict[str, list[TrackedDeviceDetails]] = {}
165 def initialize(self) -> None:
166 self.hass_name = self._hass.config.location_name
167 self.language = self._hass.config.language
168 try:
169 self.internal_url = get_url(self._hass, prefer_external=False)
170 except Exception as e:
171 self.internal_url = f"http://{socket.gethostname()}"
172 _LOGGER.warning("SUPERNOTIFY Internal hass url not available, defaulting to %s: %s", self.internal_url, e)
173 try:
174 self.external_url = get_url(self._hass, prefer_external=True)
175 except Exception as e:
176 _LOGGER.warning("SUPERNOTIFY External hass url not available, defaulting to internal url: %s", e)
177 self.external_url = self.internal_url
179 self.build_mobile_app_cache()
181 _LOGGER.debug(
182 "SUPERNOTIFY Configured for HomeAssistant instance %s at %s , %s",
183 self.hass_name,
184 self.internal_url,
185 self.external_url,
186 )
188 if not self.internal_url or not self.internal_url.startswith("http"):
189 _LOGGER.warning("SUPERNOTIFY Invalid internal hass url %s", self.internal_url)
191 def disconnect(self) -> None:
192 while self.unsubscribes:
193 unsub = self.unsubscribes.pop()
194 try:
195 _LOGGER.debug("SUPERNOTIFY Unsubscribing: %.100s", unsub)
196 unsub()
197 except Exception as e:
198 _LOGGER.error("SUPERNOTIFY Failed to unsubscribe: %s", e)
199 _LOGGER.debug("SUPERNOTIFY Disconnection complete")
201 def subscribe_event(self, event: EventType | str, callback: Callable) -> None:
202 self.unsubscribes.append(self._hass.bus.async_listen(event, callback))
204 def subscribe_state(self, entity_ids: str | Iterable[str], callback: Callable) -> None:
205 self.unsubscribes.append(async_track_state_change_event(self._hass, entity_ids, callback))
207 def subscribe_time(self, hour: int, minute: int, second: int, callback: Callable) -> None:
208 self.unsubscribes.append(async_track_time_change(self._hass, callback, hour=hour, minute=minute, second=second))
210 def subscribe_interval(self, seconds: int, callback: Callable) -> None:
211 self.unsubscribes.append(async_track_time_interval(self._hass, callback, timedelta(seconds=seconds)))
213 def in_hass_loop(self) -> bool:
214 return self.hass_avail("loop_thread_id") and self._hass.loop_thread_id == threading.get_ident()
216 def get_state(self, entity_id: str) -> State | None:
217 return self._hass.states.get(entity_id)
219 def is_state(self, entity_id: str, state: str) -> bool:
220 return self._hass.states.is_state(entity_id, state)
222 def has_service(self, domain: str, service: str) -> bool:
223 return self._hass.services.has_service(domain, service)
225 def entity_ids_for_domain(self, domain: str) -> list[str]:
226 return self._hass.states.async_entity_ids(domain)
228 async def async_real_user_ids(self) -> dict[str, str]:
229 """Active, non-system HA user ids mapped to their login username - for discovering
230 recipients that have no Person record (see CONF_USER_ID in const.py), excluding
231 internal accounts like Supervisor/Home Assistant Content that aren't real people."""
232 users = await self._hass.auth.async_get_users()
233 result: dict[str, str] = {}
234 for user in users:
235 if not user.is_active or user.system_generated:
236 continue
237 # same lookup HA's own user-management API uses (homeassistant/components/config/auth.py)
238 username = next(
239 (cred.data.get("username") for cred in user.credentials if cred.auth_provider_type == "homeassistant"),
240 None,
241 )
242 result[user.id] = username or user.name or user.id
243 return result
245 def platform_for_entity(self, entity_id: str) -> str | None:
246 """The integration that registered this entity (RegistryEntry.platform), if any."""
247 entity_registry: EntityRegistry | None = self._entity_registry()
248 if entity_registry:
249 reg_entry: RegistryEntry | None = entity_registry.async_get(entity_id) if entity_registry else None
250 if reg_entry:
251 return reg_entry.platform
252 return None
254 def entity_ids_for_platform(
255 self, domain: str, platform: str, device_model_select: str | list[str] | dict | SelectionRule | None = None
256 ) -> list[str]:
257 """entity_ids in `domain` (e.g. "notify") registered by a specific integration.
259 Reads the entity registry directly (not the state machine), so a freshly
260 registered entity counts even before it has reported a first state.
262 `device_model_select` optionally filters by the backing device's model (e.g.
263 `{"exclude": ["Speaker Group"]}`), same include/exclude rule shape used elsewhere.
264 """
265 entity_registry = self._entity_registry()
266 if not entity_registry:
267 return []
268 entries: list[RegistryEntry] = [
269 e for e in entity_registry.entities.values() if e.domain == domain and e.platform == platform
270 ]
271 if device_model_select is not None:
272 model_filter = SelectionRule(device_model_select)
273 entries = [e for e in entries if model_filter.match(self._device_model(e.device_id))]
274 return [e.entity_id for e in entries]
276 def _device_model(self, device_id: str | None) -> str | None:
277 if device_id is None:
278 return None
279 dev_entry = self.find_device(device_id)
280 return dev_entry.model if dev_entry else None
282 async def async_get_camera_image(self, entity_id: str, timeout: int = 10) -> ha_camera.Image | None:
283 """Fetch a still image directly from a camera entity, via HA's own camera component API,
284 rather than triggering the camera.snapshot service and polling the filesystem for the
285 resulting file to appear."""
286 try:
287 return await ha_camera.async_get_image(self._hass, entity_id, timeout=timeout)
288 except HomeAssistantError as e:
289 _LOGGER.warning("SUPERNOTIFY Unable to get camera image for %s: %s", entity_id, e)
290 return None
292 async def async_get_image_entity_image(self, entity_id: str, timeout: int = 10) -> ha_image.Image | None:
293 """Fetch a still image directly from an image entity, via HA's own image component API."""
294 try:
295 return await ha_image.async_get_image(self._hass, entity_id, timeout=timeout)
296 except HomeAssistantError as e:
297 _LOGGER.warning("SUPERNOTIFY Unable to get image from entity %s: %s", entity_id, e)
298 return None
300 async def load_storage(self, key: str, version: int = 1) -> Any: # ruff: ignore[any-type]
301 """Load integration state previously persisted to Home Assistant's .storage/ area,
302 via HA's own Store helper, or None if nothing has been persisted yet for this key."""
303 try:
304 return await Store[Any](self._hass, version, key).async_load()
305 except Exception as e:
306 _LOGGER.warning("SUPERNOTIFY Unable to load storage %s: %s", key, e)
307 return None
309 def save_storage(self, key: str, data: Any, version: int = 1) -> None: # ruff: ignore[any-type]
310 """Persist integration state to Home Assistant's .storage/ area, via HA's own Store
311 helper. Fire-and-forget: the write happens in a tracked background task rather than
312 blocking the caller, since this is called from both sync and async contexts."""
313 store: Store[Any] = Store(self._hass, version, key)
314 self._hass.async_create_task(store.async_save(data), f"supernotify_save_{key}")
316 def create_job(self, func: Callable, *args: Any) -> asyncio.Future[Any]:
317 """Wrap a blocking function call in a HomeAssistant awaitable job"""
318 return self._hass.async_add_executor_job(func, *args)
320 def fire_event(
321 self, event_name: str, event_data: dict[str, Any] | None = None, context: HomeAssistantContext | None = None
322 ) -> None:
323 self._hass.bus.async_fire(event_name, event_data, context=context)
325 async def call_service(
326 self,
327 domain: str,
328 service: str,
329 service_data: dict[str, Any] | None = None,
330 target: dict[str, Any] | None = None,
331 return_response: bool | None = None,
332 blocking: bool | None = None,
333 debug: bool = False,
334 context: HomeAssistantContext | None = None,
335 ) -> ServiceResponse | None:
337 if return_response is None or blocking is None:
338 # unknown service, for example defined in generic action, check if it supports response
339 supports_response: SupportsResponse = self.service_info(domain, service)
340 if supports_response == SupportsResponse.NONE:
341 return_response = False
342 elif supports_response == SupportsResponse.ONLY:
343 return_response = True
344 else:
345 return_response = debug
346 blocking = return_response or debug
348 response: ServiceResponse | None = await self._hass.services.async_call(
349 domain,
350 service,
351 service_data=service_data,
352 blocking=blocking,
353 context=context,
354 target=target,
355 return_response=return_response,
356 )
357 if response is not None and debug:
358 _LOGGER.info("SUPERNOTIFY Service %s.%s response: %s", domain, service, response)
359 return response
361 def coerce_schema(self, domain: str, service: str, data: ConfigType) -> ConfigType:
362 if not data:
363 return data
364 try:
365 if (domain, service) not in self._service_info:
366 self.service_info(domain, service)
367 service_info = self._service_info.get((domain, service))
368 if not service_info:
369 _LOGGER.info("SUPERNOTIFY No service found to pre-validate action data for %s.%s", domain, service)
370 return data
371 if not service_info.get("schema"):
372 _LOGGER.info("SUPERNOTIFY No vol schema found to pre-validate action data for %s.%s", domain, service)
373 return data
374 service_schema = service_info["schema"]
376 while service_schema is not None and not (
377 isinstance(service_schema, vol.Schema) and isinstance(service_schema.schema, dict)
378 ):
379 if isinstance(service_schema, vol.Schema):
380 # e.g. entity services get schema wrapped as vol.Schema(vol.All(...))
381 service_schema = service_schema.schema
382 elif hasattr(service_schema, "validators") and hasattr(service_schema.validators, "__iter__"):
383 # e.g. vol.All — strip extras using first dict Schema sub-validator only
384 # (don't run the full chain; other validators may require target fields not in data)
385 service_schema = next(
386 (v for v in service_schema.validators if isinstance(v, vol.Schema) or hasattr(v, "validators")), None
387 )
388 else:
389 service_schema = None
390 if not (isinstance(service_schema, vol.Schema) and isinstance(service_schema.schema, dict)):
391 service_schema = None
392 _LOGGER.info("SUPERNOTIFY Unable to find schema for %s.%s", domain, service)
394 if service_schema:
395 coercing_schema = service_schema.extend(
396 {},
397 extra=vol.REMOVE_EXTRA if service_schema.extra == vol.PREVENT_EXTRA else service_schema.extra,
398 required=service_schema.required,
399 )
400 cleaned = coercing_schema(data)
401 else:
402 return data
403 if cleaned != data:
404 _LOGGER.debug("SUPERNOTIFY Coerced data for %s.%s from %s->%s", domain, service, data, cleaned)
405 return cleaned
406 except Exception:
407 _LOGGER.exception("SUPERNOTIFY Unable to coerce %s.%s schema for %s", domain, service, data)
408 return data
410 def service_info(self, domain: str, service: str) -> SupportsResponse:
411 supports_response: SupportsResponse | None = None
412 try:
413 if (domain, service) not in self._service_info:
414 service_objs: dict[str, Service] = self._hass.services.async_services_for_domain(domain)
415 service_obj: Service | None = service_objs.get(service)
416 if service_obj:
417 self._service_info[domain, service] = {
418 "supports_response": service_obj.supports_response,
419 "schema": service_obj.schema,
420 }
421 service_info: dict[str, Any] = self._service_info.get((domain, service), {})
422 supports_response = service_info.get("supports_response")
423 if supports_response is None:
424 _LOGGER.debug("SUPERNOTIFY Unable to find service info for %s.%s", domain, service)
426 except Exception as e:
427 _LOGGER.warning("SUPERNOTIFY Unable to get service info for %s.%s: %s", domain, service, e)
428 return supports_response or SupportsResponse.NONE # default to no response
430 def resolve_target_selectors(
431 self,
432 area_ids: list[str] | None = None,
433 floor_ids: list[str] | None = None,
434 label_ids: list[str] | None = None,
435 ) -> TargetSelectorResolution:
436 """Resolve HA area/floor/label selectors to the entities they reference, through the same
437 core helper as HA entity actions, so groups are expanded and entities inherit the area of
438 their device.
439 """
440 resolution = TargetSelectorResolution()
441 if not (area_ids or floor_ids or label_ids):
442 return resolution
443 selection: dict[str, list[str]] = {}
444 if area_ids:
445 selection[ATTR_AREA_ID] = list(area_ids)
446 if floor_ids:
447 selection[ATTR_FLOOR_ID] = list(floor_ids)
448 if label_ids:
449 selection[ATTR_LABEL_ID] = list(label_ids)
450 try:
451 selected = async_extract_referenced_entity_ids(self._hass, TargetSelection(selection), expand_group=True)
452 resolution.entity_ids = sorted(selected.referenced | selected.indirectly_referenced)
453 resolution.missing_areas = sorted(selected.missing_areas)
454 resolution.missing_floors = sorted(selected.missing_floors)
455 resolution.missing_labels = sorted(selected.missing_labels)
456 except Exception as e:
457 _LOGGER.warning("SUPERNOTIFY Unable to resolve target selectors %s: %s", selection, e)
458 return resolution
460 def find_service(self, domain: str, module: str) -> str | None:
461 try:
462 service_objs: dict[str, Service] = self._hass.services.async_services_for_domain(domain)
463 if service_objs:
464 for service, domain_obj in service_objs.items():
465 if domain_obj.job and domain_obj.job.target:
466 target = domain_obj.job.target
467 bound_self = getattr(target, "__self__", None)
468 target_module: str | None = bound_self.__module__ if bound_self is not None else target.__module__
469 if target_module == module:
470 # Legacy notify platforms with a targets property (e.g. alexa_media_player)
471 # register extra per-target services (notify.<platform>_<device>) that share
472 # this same bound method but hard-code their own target, ignoring any target:
473 # passed by the caller - skip those and hold out for the base platform service.
474 registered_targets = getattr(bound_self, "registered_targets", None)
475 if registered_targets is not None and service in registered_targets:
476 continue
477 _LOGGER.debug("SUPERNOTIFY Found service %s for domain %s in %s", service, domain, module)
478 return f"{domain}.{service}"
480 _LOGGER.debug("SUPERNOTIFY Unable to find service for %s, module %s", domain, module)
481 except Exception as e:
482 _LOGGER.warning("SUPERNOTIFY Unable to find service for %s, module %s : %s", domain, module, e)
483 return None
485 def find_config_entry_data(self, domain: str) -> Mapping[str, Any] | None:
486 """Return the data of the first enabled, non-ignored config entry for domain, if any."""
487 if not self.hass_avail("config_entries"):
488 return None
489 try:
490 entries = self._hass.config_entries.async_entries(domain, include_ignore=False, include_disabled=False)
491 if entries:
492 return entries[0].data
493 except Exception as e:
494 _LOGGER.warning("SUPERNOTIFY Unable to find config entry for %s: %s", domain, e)
495 return None
497 def http_session(self) -> aiohttp.ClientSession:
498 """Client aiohttp session for async web requests"""
499 return async_get_clientsession(self._hass)
501 def expand_group(self, entity_ids: str | list[str]) -> list[str]:
502 return expand_entity_ids(self._hass, entity_ids)
504 def group_members(self, entity_id: str, _seen: set[str] | None = None) -> list[str] | None:
505 """Fully expanded members of a `group.*` helper or a platform group (media_player, light... groups
506 created by the group integration expose members in an `entity_id` state attribute). None if not a group.
508 Other entities, e.g. `scene.*` or min/max `sensor.*`, also expose an `entity_id` attribute, so anything
509 outside the `group` domain is only treated as a group if the entity registry says its platform is `group`.
510 """
511 if not self.hass_avail("states"):
512 return None
513 state = self._hass.states.get(entity_id)
514 members = state.attributes.get(ATTR_ENTITY_ID) if state else None
515 if not isinstance(members, (list, tuple)):
516 return None
517 if entity_id.partition(".")[0] != GROUP_DOMAIN and self.platform_for_entity(entity_id) != GROUP_DOMAIN:
518 return None
519 seen: set[str] = _seen if _seen is not None else set()
520 seen.add(entity_id)
521 expanded: list[str] = []
522 for member in members:
523 if member in seen:
524 continue
525 nested = self.group_members(member, seen)
526 for e in nested if nested is not None else [member]:
527 if e not in expanded:
528 expanded.append(e)
529 return expanded
531 def template(self, template_format: str) -> Template:
532 return Template(template_format, self._hass)
534 def hass_avail(self, property: str) -> bool:
535 """Guard for HA functionality, largely for tests or docgen"""
536 return self._hass is not None and getattr(self._hass, property, None) is not None
538 async def register_web_path(self, media_web_path: Path | None, url_prefix: str) -> bool:
539 if media_web_path is None or not self.hass_avail("http"):
540 return False
541 try:
542 from homeassistant.components.http import StaticPathConfig
544 await self._hass.http.async_register_static_paths([
545 StaticPathConfig(url_prefix, str(media_web_path), cache_headers=False)
546 ])
547 return True
548 except Exception as e:
549 _LOGGER.error("SUPERNOTIFY Unable to register media web exposed path for %s: %s", media_web_path, e)
550 return False
552 async def trace_conditions(
553 self,
554 conditions: ConditionsFunc,
555 condition_variables: ConditionVariables,
556 trace_name: str | None = None,
557 ) -> tuple[bool | None, ActionTrace | None]:
559 result: bool | None = None
560 this_trace: ActionTrace | None = None
561 if DATA_TRACE not in self._hass.data:
562 _LOGGER.warning("SUPERNOTIFY Tracing not configured, attempting to set up")
564 await homeassistant.components.trace.async_setup(self._hass, {}) # type: ignore
565 with trace_action(self._hass, trace_name or "anon_condition") as cond_trace:
566 cond_trace.set_trace(trace_get())
567 this_trace = cond_trace
568 with trace_path(["condition", "conditions"]) as _tp:
569 result = self.evaluate_conditions(conditions, condition_variables)
570 _LOGGER.debug(cond_trace.as_dict())
571 return result, this_trace
573 async def build_conditions(
574 self, condition_config: list[ConfigType], strict: bool = False, validate: bool = False, name: str = DOMAIN
575 ) -> ConditionsFunc | None:
576 capturing_logger: ConditionErrorLoggingAdaptor = ConditionErrorLoggingAdaptor(_LOGGER)
577 condition_variables: ConditionVariables = ConditionVariables()
578 cond_list: list[ConfigType]
579 try:
580 if validate:
581 cond_list = cast(
582 "list[ConfigType]", await condition_helper.async_validate_conditions_config(self._hass, condition_config)
583 )
584 else:
585 cond_list = condition_config
586 except Exception:
587 _LOGGER.exception("SUPERNOTIFY Conditions validation failed")
588 raise
589 try:
590 if strict:
591 force_strict_template_mode(cond_list, undo=False)
593 test: ConditionsFunc = await condition_helper.async_conditions_from_config(
594 self._hass, cond_list, cast("logging.Logger", capturing_logger), name
595 )
596 if test is None:
597 raise IntegrationError(f"Invalid condition {condition_config}")
598 test(condition_variables.as_dict())
599 return test
600 except Exception:
601 _LOGGER.exception("SUPERNOTIFY Conditions eval failed")
602 raise
603 finally:
604 if strict:
605 force_strict_template_mode(condition_config, undo=True)
606 if strict and capturing_logger.condition_errors and len(capturing_logger.condition_errors) > 0:
607 for exception in capturing_logger.condition_errors:
608 _LOGGER.warning("SUPERNOTIFY Invalid condition %s:%s", condition_config, exception)
609 raise capturing_logger.condition_errors[0]
611 def evaluate_conditions(
612 self,
613 conditions: ConditionsFunc,
614 condition_variables: ConditionVariables,
615 ) -> bool | None:
616 try:
617 if not condition_variables:
618 _LOGGER.warning("SUPERNOTIFY No cond vars provided for condition")
619 return conditions(condition_variables.as_dict() if condition_variables is not None else None)
620 except Exception as e:
621 _LOGGER.error("SUPERNOTIFY Condition eval failed: %s", e)
622 raise
624 def abs_url(self, fragment: str | None, prefer_external: bool = True) -> str | None:
625 base_url = self.external_url if prefer_external else self.internal_url
626 if fragment:
627 if fragment.startswith("http"):
628 return fragment
629 if fragment.startswith("/"):
630 return base_url + fragment
631 return base_url + "/" + fragment
632 return None
634 def raise_issue(
635 self,
636 issue_id: str,
637 issue_key: str,
638 issue_map: dict[str, str],
639 severity: ir.IssueSeverity = ir.IssueSeverity.WARNING,
640 learn_more_url: str = "https://supernotify.rhizomatics.org.uk",
641 is_fixable: bool = False,
642 ) -> None:
643 ir.async_create_issue(
644 self._hass,
645 DOMAIN,
646 issue_id,
647 translation_key=issue_key,
648 translation_placeholders=issue_map,
649 severity=severity,
650 learn_more_url=learn_more_url,
651 is_fixable=is_fixable,
652 )
654 def mobile_app_by_tracker(self, device_tracker: str) -> TrackedDeviceDetails | None:
655 return self._mobile_apps_by_tracker.get(device_tracker)
657 def mobile_app_by_id(self, mobile_app_id: str) -> TrackedDeviceDetails | None:
658 mobile_app_id = mobile_app_id.replace("notify.", "", 1) if mobile_app_id.startswith("notify.") else mobile_app_id
659 return self._mobile_apps_by_app_id.get(mobile_app_id)
661 def mobile_app_by_device_id(self, device_id: str) -> TrackedDeviceDetails | None:
662 return self._mobile_apps_by_device_id.get(device_id)
664 def mobile_app_by_user_id(self, user_id: str) -> list[TrackedDeviceDetails] | None:
665 return self._mobile_apps_by_user_id.get(user_id)
667 def build_mobile_app_cache(self) -> None:
668 """All enabled mobile apps"""
669 entity_registry: EntityRegistry | None = self._entity_registry()
670 if not entity_registry:
671 _LOGGER.warning("SUPERNOTIFY Unable to discover devices for - no entity registry found")
672 return
674 found: int = 0
675 complete: int = 0
676 for mobile_app_info in self.discover_devices("mobile_app"):
677 try:
678 mobile_app_id: str = f"mobile_app_{slugify(mobile_app_info.device_name)}"
679 device_tracker: str | None = None
680 notify_action: str | None = None
681 if self.has_service("notify", mobile_app_id):
682 notify_action = f"notify.{mobile_app_id}"
683 else:
684 _LOGGER.warning("SUPERNOTIFY Unable to find notify action <%s>", mobile_app_id)
686 registry_entries = entity_registry.entities.get_entries_for_device_id(mobile_app_info.device_id)
687 for reg_entry in registry_entries:
688 if reg_entry.platform == "mobile_app" and reg_entry.domain == "device_tracker":
689 device_tracker = reg_entry.entity_id
691 if device_tracker and notify_action:
692 complete += 1
694 mobile_app_info.mobile_app_id = mobile_app_id
695 mobile_app_info.device_tracker = device_tracker
696 mobile_app_info.action = notify_action
698 found += 1
699 self._mobile_apps_by_app_id[mobile_app_id] = mobile_app_info
700 self._mobile_apps_by_device_id[mobile_app_info.device_id] = mobile_app_info
701 if device_tracker:
702 self._mobile_apps_by_tracker[device_tracker] = mobile_app_info
703 if mobile_app_info.user_id is not None:
704 self._mobile_apps_by_user_id.setdefault(mobile_app_info.user_id, [])
705 self._mobile_apps_by_user_id[mobile_app_info.user_id].append(mobile_app_info)
707 except Exception as e:
708 _LOGGER.error("SUPERNOTIFY Failure examining device %s: %s", mobile_app_info, e)
710 _LOGGER.info(f"SUPERNOTIFY Found {found} enabled mobile app devices, {complete} complete config")
712 def device_config_info(self, device: DeviceEntry) -> dict[str, str | None]:
713 results: dict[str, str | None] = {ATTR_OS_NAME: None, ATTR_OS_VERSION: None, CONF_USER_ID: None, ATTR_APP_VERSION: None}
714 try:
715 # HA 2026.8+ restricts devices to a single config entry
716 config_entry_ids: Iterable[str] = (device.config_entry_id,) # type: ignore[attr-defined]
717 except AttributeError:
718 # pre-2026.8: config_entry_id doesn't exist yet, fall back to deprecated plural set
719 config_entry_ids = device.config_entries
720 for config_entry_id in config_entry_ids:
721 config_entry = self._hass.config_entries.async_get_entry(config_entry_id)
722 if config_entry and config_entry.data:
723 for attr, value in results.items():
724 results[attr] = config_entry.data.get(attr) or value
725 return results
727 def discover_devices(
728 self,
729 discover_domain: str,
730 device_model_select: SelectionRule | None = None,
731 device_manufacturer_select: SelectionRule | None = None,
732 device_os_select: SelectionRule | None = None,
733 device_area_select: SelectionRule | None = None,
734 device_label_select: SelectionRule | None = None,
735 ) -> list[TrackedDeviceDetails]:
736 devices: list[TrackedDeviceDetails] = []
737 dev_reg: DeviceRegistry | None = self._device_registry()
738 if dev_reg is None or not hasattr(dev_reg, "devices"):
739 _LOGGER.warning(f"SUPERNOTIFY Unable to discover devices for {discover_domain} - no device registry found")
740 return []
742 all_devs = enabled_devs = found_devs = skipped_devs = 0
743 all_ha_devices: Iterable[DeviceEntry]
744 if isinstance(dev_reg.devices, Mapping):
745 # 2026.8 HA and prior
746 all_ha_devices = dev_reg.devices.values()
747 else:
748 all_ha_devices = dev_reg.devices # type: ignore[assignment]
749 for dev in all_ha_devices:
750 all_devs += 1
752 if dev.disabled:
753 _LOGGER.debug("SUPERNOTIFY Excluded disabled device %s", dev.name)
754 else:
755 enabled_devs += 1
756 for identifier in dev.identifiers:
757 if identifier and len(identifier) > 1 and identifier[0] == discover_domain:
758 _LOGGER.debug("SUPERNOTIFY Discovered %s device %s for id %s", dev.model, dev.name, identifier)
759 found_devs += 1
760 if device_model_select is not None and not device_model_select.match(dev.model):
761 _LOGGER.debug("SUPERNOTIFY Skipped dev %s, no model %s match", dev.name, dev.model)
762 skipped_devs += 1
763 continue
764 if device_manufacturer_select is not None and not device_manufacturer_select.match(dev.manufacturer):
765 _LOGGER.debug("SUPERNOTIFY Skipped dev %s, no manufacturer %s match", dev.name, dev.manufacturer)
766 skipped_devs += 1
767 continue
768 device_config_info = self.device_config_info(dev)
769 if device_os_select is not None and not device_os_select.match(device_config_info[ATTR_OS_NAME]):
770 _LOGGER.debug(
771 "SUPERNOTIFY Skipped dev %s, no OS %s match", dev.name, device_config_info[ATTR_OS_NAME]
772 )
773 skipped_devs += 1
774 continue
775 if device_area_select is not None and not device_area_select.match(dev.area_id):
776 _LOGGER.debug("SUPERNOTIFY Skipped dev %s, no area %s match", dev.name, dev.area_id)
777 skipped_devs += 1
778 continue
779 if device_label_select is not None and not device_label_select.match(dev.labels):
780 _LOGGER.debug("SUPERNOTIFY Skipped dev %s, no label %s match", dev.name, dev.labels)
781 skipped_devs += 1
782 continue
783 devices.append(
784 TrackedDeviceDetails(
785 device_id=dev.id,
786 device_name=dev.name,
787 manufacturer=dev.manufacturer,
788 model=dev.model,
789 area_id=dev.area_id,
790 user_id=device_config_info[ATTR_USER_ID],
791 os_name=device_config_info[ATTR_OS_NAME],
792 os_version=device_config_info[ATTR_OS_VERSION],
793 app_version=device_config_info[ATTR_APP_VERSION],
794 device_labels=list(dev.labels) if dev.labels else [],
795 identifiers=dev.identifiers,
796 )
797 )
799 elif identifier:
800 # HomeKit has triples for identifiers, other domains may behave similarly
801 _LOGGER.debug("SUPERNOTIFY Ignoring device %s id: %s", dev.name, identifier)
802 else:
803 _LOGGER.debug( # type: ignore
804 "SUPERNOTIFY Unexpected %s device %s without id", dev.model, dev.name
805 )
807 _LOGGER.debug(f"SUPERNOTIFY {discover_domain} device discovery, all={all_devs},enabled={enabled_devs} ")
808 _LOGGER.debug(f"SUPERNOTIFY {discover_domain} skipped={skipped_devs}, found={found_devs}")
810 return devices
812 def domain_for_device(self, device_id: str, domains: list[str]) -> str | None:
813 # discover domain from device registry
814 verified_domain: str | None = None
815 device_registry: DeviceRegistry | None = self._device_registry()
816 if device_registry:
817 device: DeviceEntry | None = self.find_device(device_id)
818 if device:
819 matching_domains = [d for d, _id in device.identifiers if d in domains]
820 if matching_domains:
821 # TODO: limited to first domain found, unlikely to be more
822 return matching_domains[0]
823 _LOGGER.warning(
824 "SUPERNOTIFY A target that looks like a device_id can't be matched to supported integration: %s",
825 device_id,
826 )
827 return verified_domain
829 def _entity_registry(self) -> er.EntityRegistry | None:
830 """Hass entity registry is weird, every component ends up creating its own, with a store, subscribing
831 to all entities, so do it once here
832 """
833 if self.__entity_registry is not None:
834 return self.__entity_registry
835 try:
836 self.__entity_registry = er.async_get(self._hass)
837 except Exception as e:
838 _LOGGER.warning("SUPERNOTIFY Unable to get entity registry: %s", e)
839 return self.__entity_registry
841 def _device_registry(self) -> dr.DeviceRegistry | None:
842 """Hass device registry is weird, every component ends up creating its own, with a store, subscribing
843 to all devices, so do it once here
844 """
845 if self.__device_registry is not None:
846 return self.__device_registry
847 try:
848 self.__device_registry = dr.async_get(self._hass)
849 except Exception as e:
850 _LOGGER.warning("SUPERNOTIFY Unable to get device registry: %s", e)
851 return self.__device_registry
853 def find_device(self, device_id: str) -> DeviceEntry | None:
854 reg: DeviceRegistry | None = self._device_registry()
855 if reg is None:
856 return None
857 try:
858 return reg.async_get(device_id, include_child_devices=False) # type: ignore[call-arg]
859 except TypeError:
860 # older HA
861 return cast("DeviceEntry|None", reg.async_get(device_id))
863 def is_own_device(self, device_id: str) -> bool:
864 """True if `device_id` is this integration's own 'SuperNotify' device (see `ha_device_info`)"""
865 device = self.find_device(device_id)
866 return device is not None and any(domain == DOMAIN for domain, _identifier in device.identifiers)
868 async def mqtt_available(self, raise_on_error: bool = True) -> bool:
869 from homeassistant.components import mqtt
871 try:
872 return await mqtt.async_wait_for_mqtt_client(self._hass) is True
873 except Exception:
874 _LOGGER.exception("SUPERNOTIFY MQTT integration failed on available check")
875 if raise_on_error:
876 raise
877 return False
879 async def mqtt_publish(
880 self,
881 topic: str,
882 payload: Any = None, # ruff: ignore[any-type]
883 qos: int = 0,
884 retain: bool = False,
885 raise_on_error: bool = True,
886 ) -> None:
887 from homeassistant.components import mqtt
889 try:
890 await mqtt.async_publish(
891 self._hass,
892 topic=topic,
893 payload=json_dumps(payload),
894 qos=qos,
895 retain=retain,
896 )
897 except Exception:
898 _LOGGER.exception(f"SUPERNOTIFY MQTT publish failed to {topic}")
899 if raise_on_error:
900 raise
903class ConditionErrorLoggingAdaptor(logging.LoggerAdapter):
904 def __init__(self, *args: Any, **kwargs: Any) -> None:
905 super().__init__(*args, **kwargs)
906 self.condition_errors: list[ConditionError] = []
908 def capture(self, args: list | tuple | None) -> None:
909 if args and isinstance(args, list | tuple):
910 for arg in args:
911 if isinstance(arg, ConditionErrorContainer):
912 self.condition_errors.extend(arg.errors)
913 elif isinstance(arg, ConditionError):
914 self.condition_errors.append(arg)
916 def error(self, msg: object, *args: object, **kwargs: Any) -> None:
917 self.capture(args)
918 self.logger.error(msg, *args, **kwargs)
920 def warning(self, msg: object, *args: Any, **kwargs: Any) -> None:
921 self.capture(args)
922 self.logger.warning(msg, *args, **kwargs)
925class TemplateWrapper:
926 def __init__(self, obj: Template) -> None:
927 self._obj = obj
929 def __getattr__(self, name: str) -> Any: # ruff: ignore[any-type]
930 if name == "async_render_to_info":
931 return partial(self._obj.async_render_to_info, strict=True)
932 return getattr(self._obj, name)
934 def __setattr__(self, name: str, value: Any) -> None: # ruff: ignore[any-type]
935 super().__setattr__(name, value)
937 def __repr__(self) -> str:
938 return self._obj.__repr__() if self._obj else "NULL TEMPLATE"
941def force_strict_template_mode(conditions: list[ConfigType], undo: bool = False) -> None:
942 def wrap_template(cond: ConfigType, undo: bool) -> ConfigType:
943 for key, val in cond.items():
944 if not undo and isinstance(val, Template) and hasattr(val, "_env"):
945 cond[key] = TemplateWrapper(val)
946 elif undo and isinstance(val, TemplateWrapper):
947 cond[key] = val._obj
948 elif isinstance(val, dict):
949 wrap_template(val, undo)
950 return cond
952 if conditions is not None:
953 conditions = [wrap_template(condition, undo) for condition in conditions]
956@contextmanager
957def trace_action(
958 hass: HomeAssistant,
959 item_id: str,
960 config: dict[str, Any] | None = None,
961 context: HomeAssistantContext | None = None,
962 stored_traces: int = 5,
963) -> Iterator[ActionTrace]:
964 """Trace execution of a condition"""
965 trace = ActionTrace(item_id, config, None, context or HomeAssistantContext())
966 async_store_trace(hass, trace, stored_traces)
968 try:
969 yield trace
970 except Exception as ex:
971 if item_id:
972 trace.set_error(ex)
973 raise
974 finally:
975 if item_id:
976 trace.finished()