Coverage for custom_components / supernotify / hass_api.py: 88%
469 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-11 22:18 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-11 22:18 +0000
1from __future__ import annotations
3import logging
4from dataclasses import dataclass
5from functools import partial
6from typing import TYPE_CHECKING, Any
8import voluptuous as vol
9from homeassistant.components.person import ATTR_USER_ID
10from homeassistant.const import CONF_ACTION, CONF_DEVICE_ID
11from homeassistant.helpers.aiohttp_client import async_get_clientsession
12from homeassistant.helpers.event import async_track_state_change_event, async_track_time_change
13from homeassistant.util import slugify
15if TYPE_CHECKING:
16 import asyncio
17 from collections.abc import Callable, Iterable, Iterator
19 import aiohttp
20 from anyio import Path
21 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, Service, ServiceResponse, State
22 from homeassistant.helpers.entity import Entity
23 from homeassistant.helpers.entity_registry import EntityRegistry
24 from homeassistant.helpers.typing import ConfigType
25 from homeassistant.util.event_type import EventType
27 from .schema import ConditionsFunc
29import socket
30import threading
31from contextlib import contextmanager
32from typing import TYPE_CHECKING, cast
34import homeassistant.components.trace
35from homeassistant.components import mqtt
36from homeassistant.components.group import expand_entity_ids
37from homeassistant.components.trace.const import DATA_TRACE
38from homeassistant.components.trace.models import ActionTrace
39from homeassistant.components.trace.util import async_store_trace
40from homeassistant.core import Context as HomeAssistantContext
41from homeassistant.core import HomeAssistant, SupportsResponse
42from homeassistant.exceptions import ConditionError, ConditionErrorContainer, IntegrationError
43from homeassistant.helpers import condition as condition
44from homeassistant.helpers import device_registry as dr
45from homeassistant.helpers import entity_registry as er
46from homeassistant.helpers import issue_registry as ir
47from homeassistant.helpers.json import json_dumps
48from homeassistant.helpers.network import get_url
49from homeassistant.helpers.template import Template
50from homeassistant.helpers.trace import trace_get, trace_path
51from homeassistant.helpers.typing import ConfigType
53from . import DOMAIN
54from .const import CONF_DEVICE_LABELS, CONF_DEVICE_TRACKER, CONF_MOBILE_APP_ID
55from .model import ConditionVariables, SelectionRule
57if TYPE_CHECKING:
58 from homeassistant.core import HomeAssistant
59 from homeassistant.helpers.device_registry import DeviceEntry, DeviceRegistry
60 from homeassistant.helpers.typing import ConfigType
62# avoid importing from homeassistant.components.mobile_app.const and triggering dependency chain
64CONF_USER_ID = "user_id"
65ATTR_OS_NAME = "os_name"
66ATTR_OS_VERSION = "os_version"
67ATTR_APP_VERSION = "app_version"
68ATTR_DEVICE_NAME = "device_name"
69ATTR_MANUFACTURER = "manufacturer"
70ATTR_MODEL = "model"
72_LOGGER = logging.getLogger(__name__)
75@dataclass
76class DeviceInfo:
77 device_id: str
78 device_labels: list[str] | None = None
79 mobile_app_id: str | None = None
80 device_name: str | None = None
81 device_tracker: str | None = None
82 action: str | None = None
83 user_id: str | None = None
84 area_id: str | None = None
85 manufacturer: str | None = None
86 model: str | None = None
87 os_name: str | None = None
88 os_version: str | None = None
89 app_version: str | None = None
90 identifiers: set[tuple[str, str]] | None = None
92 def as_dict(self) -> dict[str, str | list[str] | None]:
93 return {
94 CONF_MOBILE_APP_ID: self.mobile_app_id,
95 ATTR_DEVICE_NAME: self.device_name,
96 CONF_DEVICE_ID: self.device_id,
97 CONF_USER_ID: self.user_id,
98 CONF_DEVICE_TRACKER: self.device_tracker,
99 CONF_ACTION: self.action,
100 ATTR_OS_NAME: self.os_name,
101 ATTR_OS_VERSION: self.os_version,
102 ATTR_APP_VERSION: self.app_version,
103 ATTR_MANUFACTURER: self.manufacturer,
104 ATTR_MODEL: self.model,
105 CONF_DEVICE_LABELS: self.device_labels,
106 }
108 def __eq__(self, other: Any) -> bool:
109 """Test support"""
110 return other is not None and other.as_dict() == self.as_dict()
113class HomeAssistantAPI:
114 def __init__(self, hass: HomeAssistant) -> None:
115 self._hass: HomeAssistant = hass
116 self.internal_url: str = ""
117 self.external_url: str = ""
118 self.language: str = ""
119 self.hass_name: str = "!UNDEFINED!"
120 self._entity_registry: er.EntityRegistry | None = None
121 self._device_registry: dr.DeviceRegistry | None = None
122 self._service_info: dict[tuple[str, str], Any] = {}
123 self.unsubscribes: list[CALLBACK_TYPE] = []
124 self.mobile_apps_by_tracker: dict[str, DeviceInfo] = {}
125 self.mobile_apps_by_app_id: dict[str, DeviceInfo] = {}
126 self.mobile_apps_by_device_id: dict[str, DeviceInfo] = {}
127 self.mobile_apps_by_user_id: dict[str, list[DeviceInfo]] = {}
129 def initialize(self) -> None:
130 self.hass_name = self._hass.config.location_name
131 self.language = self._hass.config.language
132 try:
133 self.internal_url = get_url(self._hass, prefer_external=False)
134 except Exception as e:
135 self.internal_url = f"http://{socket.gethostname()}"
136 _LOGGER.warning("SUPERNOTIFY could not get internal hass url, defaulting to %s: %s", self.internal_url, e)
137 try:
138 self.external_url = get_url(self._hass, prefer_external=True)
139 except Exception as e:
140 _LOGGER.warning("SUPERNOTIFY could not get external hass url, defaulting to internal url: %s", e)
141 self.external_url = self.internal_url
143 self.build_mobile_app_cache()
145 _LOGGER.debug(
146 "SUPERNOTIFY Configured for HomeAssistant instance %s at %s , %s",
147 self.hass_name,
148 self.internal_url,
149 self.external_url,
150 )
152 if not self.internal_url or not self.internal_url.startswith("http"):
153 _LOGGER.warning("SUPERNOTIFY invalid internal hass url %s", self.internal_url)
155 def disconnect(self) -> None:
156 while self.unsubscribes:
157 unsub = self.unsubscribes.pop()
158 try:
159 _LOGGER.debug("SUPERNOTIFY unsubscribing: %s", unsub)
160 unsub()
161 except Exception as e:
162 _LOGGER.error("SUPERNOTIFY failed to unsubscribe: %s", e)
163 _LOGGER.debug("SUPERNOTIFY disconnection complete")
165 def subscribe_event(self, event: EventType | str, callback: Callable) -> None:
166 self.unsubscribes.append(self._hass.bus.async_listen(event, callback))
168 def subscribe_state(self, entity_ids: str | Iterable[str], callback: Callable) -> None:
169 self.unsubscribes.append(async_track_state_change_event(self._hass, entity_ids, callback))
171 def subscribe_time(self, hour: int, minute: int, second: int, callback: Callable) -> None:
172 self.unsubscribes.append(async_track_time_change(self._hass, callback, hour=hour, minute=minute, second=second))
174 def in_hass_loop(self) -> bool:
175 return self._hass is not None and self._hass.loop_thread_id == threading.get_ident()
177 def get_state(self, entity_id: str) -> State | None:
178 return self._hass.states.get(entity_id)
180 def is_state(self, entity_id: str, state: str) -> bool:
181 return self._hass.states.is_state(entity_id, state)
183 def set_state(self, entity_id: str, state: str | int | bool, attributes: dict[str, Any] | None = None) -> None:
184 if self.in_hass_loop():
185 self._hass.states.async_set(entity_id, str(state), attributes=attributes)
186 else:
187 self._hass.states.set(entity_id, str(state), attributes=attributes)
189 def has_service(self, domain: str, service: str) -> bool:
190 return self._hass.services.has_service(domain, service)
192 def entity_ids_for_domain(self, domain: str) -> list[str]:
193 return self._hass.states.async_entity_ids(domain)
195 def domain_entity(self, domain: str, entity_id: str) -> Entity | None:
196 # TODO: must be a better hass method than this
197 return self._hass.data.get(domain, {}).get_entity(entity_id)
199 def create_job(self, func: Callable, *args: Any) -> asyncio.Future[Any]:
200 """Wrap a blocking function call in a HomeAssistant awaitable job"""
201 return self._hass.async_add_executor_job(func, *args)
203 def fire_event(self, event_name: str, event_data: dict[str, Any] | None = None) -> None:
204 self._hass.bus.async_fire(event_name, event_data)
206 async def call_service(
207 self,
208 domain: str,
209 service: str,
210 service_data: dict[str, Any] | None = None,
211 target: dict[str, Any] | None = None,
212 return_response: bool | None = None,
213 blocking: bool | None = None,
214 debug: bool = False,
215 ) -> ServiceResponse | None:
217 if return_response is None or blocking is None:
218 # unknown service, for example defined in generic action, check if it supports response
219 supports_response: SupportsResponse = self.service_info(domain, service)
220 if supports_response == SupportsResponse.NONE:
221 return_response = False
222 elif supports_response == SupportsResponse.ONLY:
223 return_response = True
224 else:
225 return_response = debug
226 blocking = return_response or debug
228 response: ServiceResponse | None = await self._hass.services.async_call(
229 domain,
230 service,
231 service_data=service_data,
232 blocking=blocking,
233 context=None,
234 target=target,
235 return_response=return_response,
236 )
237 if response is not None and debug:
238 _LOGGER.info("SUPERNOTIFY Service %s.%s response: %s", domain, service, response)
239 return response
241 def coerce_schema(self, domain: str, service: str, data: ConfigType) -> ConfigType:
242 if not data:
243 return data
244 try:
245 if (domain, service) not in self._service_info:
246 self.service_info(domain, service)
247 service_info = self._service_info.get((domain, service))
248 if not service_info:
249 _LOGGER.info("SUPERNOTIFY No service found to pre-validate action data for %s.%s", domain, service)
250 return data
251 if not service_info.get("schema"):
252 _LOGGER.info("SUPERNOTIFY No vol schema found to pre-validate action data for %s.%s", domain, service)
253 return data
254 service_schema = service_info["schema"]
256 while service_schema is not None and not isinstance(service_schema, vol.Schema):
257 # e.g. entity services get schema wrapped in an vol.All
258 if hasattr(service_schema, "validators") and hasattr(service_schema.validators, "__iter__"):
259 # e.g. vol.All — strip extras using first dict Schema sub-validator only
260 # (don't run the full chain; other validators may require target fields not in data)
261 service_schema = next(
262 (v for v in service_schema.validators if isinstance(v, vol.Schema) or hasattr(v, "validators")), None
263 )
264 else:
265 service_schema = None
266 if not isinstance(service_schema, vol.Schema):
267 service_schema = None
268 _LOGGER.info("SUPERNOTIFY Unable to find schema for %s.%s", domain, service)
270 if service_schema:
271 coercing_schema = service_schema.extend(
272 {},
273 extra=vol.REMOVE_EXTRA if service_schema.extra == vol.PREVENT_EXTRA else service_schema.extra,
274 required=service_schema.required,
275 )
276 cleaned = coercing_schema(data)
277 else:
278 return data
279 if cleaned != data:
280 _LOGGER.debug("SUPERNOTIFY Coerced data for %s.%s from %s->%s", domain, service, data, cleaned)
281 return cleaned
282 except Exception:
283 _LOGGER.exception("SUPERNOTIFY Unable to coerce %s.%s schema for %s", domain, service, data)
284 return data
286 def service_info(self, domain: str, service: str) -> SupportsResponse:
287 supports_response: SupportsResponse | None = None
288 try:
289 if (domain, service) not in self._service_info:
290 service_objs: dict[str, Service] = self._hass.services.async_services_for_domain(domain)
291 service_obj: Service | None = service_objs.get(service)
292 if service_obj:
293 self._service_info[domain, service] = {
294 "supports_response": service_obj.supports_response,
295 "schema": service_obj.schema,
296 }
297 service_info: dict[str, Any] = self._service_info.get((domain, service), {})
298 supports_response = service_info.get("supports_response")
299 if supports_response is None:
300 _LOGGER.debug("SUPERNOTIFY Unable to find service info for %s.%s", domain, service)
302 except Exception as e:
303 _LOGGER.warning("SUPERNOTIFY Unable to get service info for %s.%s: %s", domain, service, e)
304 return supports_response or SupportsResponse.NONE # default to no response
306 def find_service(self, domain: str, module: str) -> str | None:
307 try:
308 service_objs: dict[str, Service] = self._hass.services.async_services_for_domain(domain)
309 if service_objs:
310 for service, domain_obj in service_objs.items():
311 if domain_obj.job and domain_obj.job.target:
312 target_module: str | None = (
313 domain_obj.job.target.__self__.__module__
314 if hasattr(domain_obj.job.target, "__self__")
315 else domain_obj.job.target.__module__
316 )
317 if target_module == module:
318 _LOGGER.debug("SUPERNOTIFY Found service %s for domain %s", service, domain)
319 return f"{domain}.{service}"
321 _LOGGER.debug("SUPERNOTIFY Unable to find service for %s", domain)
322 except Exception as e:
323 _LOGGER.warning("SUPERNOTIFY Unable to find service for %s: %s", domain, e)
324 return None
326 def http_session(self) -> aiohttp.ClientSession:
327 """Client aiohttp session for async web requests"""
328 return async_get_clientsession(self._hass)
330 def expand_group(self, entity_ids: str | list[str]) -> list[str]:
331 return expand_entity_ids(self._hass, entity_ids)
333 def template(self, template_format: str) -> Template:
334 return Template(template_format, self._hass)
336 async def register_web_path(self, media_web_path: Path | None, url_prefix: str) -> bool:
337 if media_web_path is None:
338 return False
339 try:
340 from homeassistant.components.http import StaticPathConfig
342 await self._hass.http.async_register_static_paths([
343 StaticPathConfig(url_prefix, str(media_web_path), cache_headers=False)
344 ])
345 return True
346 except Exception as e:
347 _LOGGER.error("SUPERNOTIFY Unable to register media web exposed path for %s: %s", media_web_path, e)
348 return False
350 async def trace_conditions(
351 self,
352 conditions: ConditionsFunc,
353 condition_variables: ConditionVariables,
354 trace_name: str | None = None,
355 ) -> tuple[bool | None, ActionTrace | None]:
357 result: bool | None = None
358 this_trace: ActionTrace | None = None
359 if DATA_TRACE not in self._hass.data:
360 _LOGGER.warning("SUPERNOTIFY tracing not configured, attempting to set up")
362 await homeassistant.components.trace.async_setup(self._hass, {}) # type: ignore
363 with trace_action(self._hass, trace_name or "anon_condition") as cond_trace:
364 cond_trace.set_trace(trace_get())
365 this_trace = cond_trace
366 with trace_path(["condition", "conditions"]) as _tp:
367 result = self.evaluate_conditions(conditions, condition_variables)
368 _LOGGER.debug(cond_trace.as_dict())
369 return result, this_trace
371 async def build_conditions(
372 self, condition_config: list[ConfigType], strict: bool = False, validate: bool = False, name: str = DOMAIN
373 ) -> ConditionsFunc | None:
374 capturing_logger: ConditionErrorLoggingAdaptor = ConditionErrorLoggingAdaptor(_LOGGER)
375 condition_variables: ConditionVariables = ConditionVariables()
376 cond_list: list[ConfigType]
377 try:
378 if validate:
379 cond_list = cast(
380 "list[ConfigType]", await condition.async_validate_conditions_config(self._hass, condition_config)
381 )
382 else:
383 cond_list = condition_config
384 except Exception as e:
385 _LOGGER.exception("SUPERNOTIFY Conditions validation failed: %s", e)
386 raise
387 try:
388 if strict:
389 force_strict_template_mode(cond_list, undo=False)
391 test: ConditionsFunc = await condition.async_conditions_from_config(
392 self._hass, cond_list, cast("logging.Logger", capturing_logger), name
393 )
394 if test is None:
395 raise IntegrationError(f"Invalid condition {condition_config}")
396 test(condition_variables.as_dict())
397 return test
398 except Exception as e:
399 _LOGGER.exception("SUPERNOTIFY Conditions eval failed: %s", e)
400 raise
401 finally:
402 if strict:
403 force_strict_template_mode(condition_config, undo=True)
404 if strict and capturing_logger.condition_errors and len(capturing_logger.condition_errors) > 0:
405 for exception in capturing_logger.condition_errors:
406 _LOGGER.warning("SUPERNOTIFY Invalid condition %s:%s", condition_config, exception)
407 raise capturing_logger.condition_errors[0]
409 def evaluate_conditions(
410 self,
411 conditions: ConditionsFunc,
412 condition_variables: ConditionVariables,
413 ) -> bool | None:
414 try:
415 if not condition_variables:
416 _LOGGER.warning("SUPERNOTIFY No cond vars provided for condition")
417 return conditions(condition_variables.as_dict() if condition_variables is not None else None)
418 except Exception as e:
419 _LOGGER.error("SUPERNOTIFY Condition eval failed: %s", e)
420 raise
422 def abs_url(self, fragment: str | None, prefer_external: bool = True) -> str | None:
423 base_url = self.external_url if prefer_external else self.internal_url
424 if fragment:
425 if fragment.startswith("http"):
426 return fragment
427 if fragment.startswith("/"):
428 return base_url + fragment
429 return base_url + "/" + fragment
430 return None
432 def raise_issue(
433 self,
434 issue_id: str,
435 issue_key: str,
436 issue_map: dict[str, str],
437 severity: ir.IssueSeverity = ir.IssueSeverity.WARNING,
438 learn_more_url: str = "https://supernotify.rhizomatics.org.uk",
439 is_fixable: bool = False,
440 ) -> None:
441 ir.async_create_issue(
442 self._hass,
443 DOMAIN,
444 issue_id,
445 translation_key=issue_key,
446 translation_placeholders=issue_map,
447 severity=severity,
448 learn_more_url=learn_more_url,
449 is_fixable=is_fixable,
450 )
452 def mobile_app_by_tracker(self, device_tracker: str) -> DeviceInfo | None:
453 return self.mobile_apps_by_tracker.get(device_tracker)
455 def mobile_app_by_id(self, mobile_app_id: str) -> DeviceInfo | None:
456 mobile_app_id = mobile_app_id.replace("notify.", "", 1) if mobile_app_id.startswith("notify.") else mobile_app_id
457 return self.mobile_apps_by_app_id.get(mobile_app_id)
459 def mobile_app_by_device_id(self, device_id: str) -> DeviceInfo | None:
460 return self.mobile_apps_by_device_id.get(device_id)
462 def mobile_app_by_user_id(self, user_id: str) -> list[DeviceInfo] | None:
463 return self.mobile_apps_by_user_id.get(user_id)
465 def build_mobile_app_cache(self) -> None:
466 """All enabled mobile apps"""
467 ent_reg: EntityRegistry | None = self.entity_registry()
468 if not ent_reg:
469 _LOGGER.warning("SUPERNOTIFY Unable to discover devices for - no entity registry found")
470 return
472 found: int = 0
473 complete: int = 0
474 for mobile_app_info in self.discover_devices("mobile_app"):
475 try:
476 mobile_app_id: str = f"mobile_app_{slugify(mobile_app_info.device_name)}"
477 device_tracker: str | None = None
478 notify_action: str | None = None
479 if self.has_service("notify", mobile_app_id):
480 notify_action = f"notify.{mobile_app_id}"
481 else:
482 _LOGGER.warning("SUPERNOTIFY Unable to find notify action <%s>", mobile_app_id)
484 registry_entries = ent_reg.entities.get_entries_for_device_id(mobile_app_info.device_id)
485 for reg_entry in registry_entries:
486 if reg_entry.platform == "mobile_app" and reg_entry.domain == "device_tracker":
487 device_tracker = reg_entry.entity_id
489 if device_tracker and notify_action:
490 complete += 1
492 mobile_app_info.mobile_app_id = mobile_app_id
493 mobile_app_info.device_tracker = device_tracker
494 mobile_app_info.action = notify_action
496 found += 1
497 self.mobile_apps_by_app_id[mobile_app_id] = mobile_app_info
498 self.mobile_apps_by_device_id[mobile_app_info.device_id] = mobile_app_info
499 if device_tracker:
500 self.mobile_apps_by_tracker[device_tracker] = mobile_app_info
501 if mobile_app_info.user_id is not None:
502 self.mobile_apps_by_user_id.setdefault(mobile_app_info.user_id, [])
503 self.mobile_apps_by_user_id[mobile_app_info.user_id].append(mobile_app_info)
505 except Exception as e:
506 _LOGGER.error("SUPERNOTIFY Failure examining device %s: %s", mobile_app_info, e)
508 _LOGGER.info(f"SUPERNOTIFY Found {found} enabled mobile app devices, {complete} complete config")
510 def device_config_info(self, device: DeviceEntry) -> dict[str, str | None]:
511 results: dict[str, str | None] = {ATTR_OS_NAME: None, ATTR_OS_VERSION: None, CONF_USER_ID: None, ATTR_APP_VERSION: None}
512 for config_entry_id in device.config_entries:
513 config_entry = self._hass.config_entries.async_get_entry(config_entry_id)
514 if config_entry and config_entry.data:
515 for attr in results:
516 results[attr] = config_entry.data.get(attr) or results[attr]
517 return results
519 def discover_devices(
520 self,
521 discover_domain: str,
522 device_model_select: SelectionRule | None = None,
523 device_manufacturer_select: SelectionRule | None = None,
524 device_os_select: SelectionRule | None = None,
525 device_area_select: SelectionRule | None = None,
526 device_label_select: SelectionRule | None = None,
527 ) -> list[DeviceInfo]:
528 devices: list[DeviceInfo] = []
529 dev_reg: DeviceRegistry | None = self.device_registry()
530 if dev_reg is None or not hasattr(dev_reg, "devices"):
531 _LOGGER.warning(f"SUPERNOTIFY Unable to discover devices for {discover_domain} - no device registry found")
532 return []
534 all_devs = enabled_devs = found_devs = skipped_devs = 0
535 for dev in dev_reg.devices.values():
536 all_devs += 1
538 if dev.disabled:
539 _LOGGER.debug("SUPERNOTIFY excluded disabled device %s", dev.name)
540 else:
541 enabled_devs += 1
542 for identifier in dev.identifiers:
543 if identifier and len(identifier) > 1 and identifier[0] == discover_domain:
544 _LOGGER.debug("SUPERNOTIFY discovered %s device %s for id %s", dev.model, dev.name, identifier)
545 found_devs += 1
546 if device_model_select is not None and not device_model_select.match(dev.model):
547 _LOGGER.debug("SUPERNOTIFY Skipped dev %s, no model %s match", dev.name, dev.model)
548 skipped_devs += 1
549 continue
550 if device_manufacturer_select is not None and not device_manufacturer_select.match(dev.manufacturer):
551 _LOGGER.debug("SUPERNOTIFY Skipped dev %s, no manufacturer %s match", dev.name, dev.manufacturer)
552 skipped_devs += 1
553 continue
554 device_config_info = self.device_config_info(dev)
555 if device_os_select is not None and not device_os_select.match(device_config_info[ATTR_OS_NAME]):
556 _LOGGER.debug(
557 "SUPERNOTIFY Skipped dev %s, no OS %s match", dev.name, device_config_info[ATTR_OS_NAME]
558 )
559 skipped_devs += 1
560 continue
561 if device_area_select is not None and not device_area_select.match(dev.area_id):
562 _LOGGER.debug("SUPERNOTIFY Skipped dev %s, no area %s match", dev.name, dev.area_id)
563 skipped_devs += 1
564 continue
565 if device_label_select is not None and not device_label_select.match(dev.labels):
566 _LOGGER.debug("SUPERNOTIFY Skipped dev %s, no label %s match", dev.name, dev.labels)
567 skipped_devs += 1
568 continue
569 devices.append(
570 DeviceInfo(
571 device_id=dev.id,
572 device_name=dev.name,
573 manufacturer=dev.manufacturer,
574 model=dev.model,
575 area_id=dev.area_id,
576 user_id=device_config_info[ATTR_USER_ID],
577 os_name=device_config_info[ATTR_OS_NAME],
578 os_version=device_config_info[ATTR_OS_VERSION],
579 app_version=device_config_info[ATTR_APP_VERSION],
580 device_labels=list(dev.labels) if dev.labels else [],
581 identifiers=dev.identifiers,
582 )
583 )
585 elif identifier:
586 # HomeKit has triples for identifiers, other domains may behave similarly
587 _LOGGER.debug("SUPERNOTIFY Ignoring device %s id: %s", dev.name, identifier)
588 else:
589 _LOGGER.debug( # type: ignore
590 "SUPERNOTIFY Unexpected %s device %s without id", dev.model, dev.name
591 )
593 _LOGGER.debug(f"SUPERNOTIFY {discover_domain} device discovery, all={all_devs},enabled={enabled_devs} ")
594 _LOGGER.debug(f"SUPERNOTIFY {discover_domain} skipped={skipped_devs}, found={found_devs}")
596 return devices
598 def domain_for_device(self, device_id: str, domains: list[str]) -> str | None:
599 # discover domain from device registry
600 verified_domain: str | None = None
601 device_registry = self.device_registry()
602 if device_registry:
603 device: DeviceEntry | None = device_registry.async_get(device_id)
604 if device:
605 matching_domains = [d for d, _id in device.identifiers if d in domains]
606 if matching_domains:
607 # TODO: limited to first domain found, unlikely to be more
608 return matching_domains[0]
609 _LOGGER.warning(
610 "SUPERNOTIFY A target that looks like a device_id can't be matched to supported integration: %s",
611 device_id,
612 )
613 return verified_domain
615 def entity_registry(self) -> er.EntityRegistry | None:
616 """Hass entity registry is weird, every component ends up creating its own, with a store, subscribing
617 to all entities, so do it once here
618 """ # noqa: D205
619 if self._entity_registry is not None:
620 return self._entity_registry
621 try:
622 self._entity_registry = er.async_get(self._hass)
623 except Exception as e:
624 _LOGGER.warning("SUPERNOTIFY Unable to get entity registry: %s", e)
625 return self._entity_registry
627 def device_registry(self) -> dr.DeviceRegistry | None:
628 """Hass device registry is weird, every component ends up creating its own, with a store, subscribing
629 to all devices, so do it once here
630 """ # noqa: D205
631 if self._device_registry is not None:
632 return self._device_registry
633 try:
634 self._device_registry = dr.async_get(self._hass)
635 except Exception as e:
636 _LOGGER.warning("SUPERNOTIFY Unable to get device registry: %s", e)
637 return self._device_registry
639 async def mqtt_available(self, raise_on_error: bool = True) -> bool:
640 try:
641 return await mqtt.async_wait_for_mqtt_client(self._hass) is True
642 except Exception:
643 _LOGGER.exception("SUPERNOTIFY MQTT integration failed on available check")
644 if raise_on_error:
645 raise
646 return False
648 async def mqtt_publish(
649 self, topic: str, payload: Any = None, qos: int = 0, retain: bool = False, raise_on_error: bool = True
650 ) -> None:
651 try:
652 await mqtt.async_publish(
653 self._hass,
654 topic=topic,
655 payload=json_dumps(payload),
656 qos=qos,
657 retain=retain,
658 )
659 except Exception:
660 _LOGGER.exception(f"SUPERNOTIFY MQTT publish failed to {topic}")
661 if raise_on_error:
662 raise
665class ConditionErrorLoggingAdaptor(logging.LoggerAdapter):
666 def __init__(self, *args: Any, **kwargs: Any) -> None:
667 super().__init__(*args, **kwargs)
668 self.condition_errors: list[ConditionError] = []
670 def capture(self, args: Any) -> None:
671 if args and isinstance(args, list | tuple):
672 for arg in args:
673 if isinstance(arg, ConditionErrorContainer):
674 self.condition_errors.extend(arg.errors)
675 elif isinstance(arg, ConditionError):
676 self.condition_errors.append(arg)
678 def error(self, msg: Any, *args: object, **kwargs: Any) -> None:
679 self.capture(args)
680 self.logger.error(msg, args, kwargs)
682 def warning(self, msg: Any, *args: Any, **kwargs: Any) -> None:
683 self.capture(args)
684 self.logger.warning(msg, args, kwargs)
687def force_strict_template_mode(conditions: list[ConfigType], undo: bool = False) -> None:
688 class TemplateWrapper:
689 def __init__(self, obj: Template) -> None:
690 self._obj = obj
692 def __getattr__(self, name: str) -> Any:
693 if name == "async_render_to_info":
694 return partial(self._obj.async_render_to_info, strict=True)
695 return getattr(self._obj, name)
697 def __setattr__(self, name: str, value: Any) -> None:
698 super().__setattr__(name, value)
700 def __repr__(self) -> str:
701 return self._obj.__repr__() if self._obj else "NULL TEMPLATE"
703 def wrap_template(cond: ConfigType, undo: bool) -> ConfigType:
704 for key, val in cond.items():
705 if not undo and isinstance(val, Template) and hasattr(val, "_env"):
706 cond[key] = TemplateWrapper(val)
707 elif undo and isinstance(val, TemplateWrapper):
708 cond[key] = val._obj
709 elif isinstance(val, dict):
710 wrap_template(val, undo)
711 return cond
713 if conditions is not None:
714 conditions = [wrap_template(condition, undo) for condition in conditions]
717@contextmanager
718def trace_action(
719 hass: HomeAssistant,
720 item_id: str,
721 config: dict[str, Any] | None = None,
722 context: HomeAssistantContext | None = None,
723 stored_traces: int = 5,
724) -> Iterator[ActionTrace]:
725 """Trace execution of a condition"""
726 trace = ActionTrace(item_id, config, None, context or HomeAssistantContext())
727 async_store_trace(hass, trace, stored_traces)
729 try:
730 yield trace
731 except Exception as ex:
732 if item_id:
733 trace.set_error(ex)
734 raise
735 finally:
736 if item_id:
737 trace.finished()