Coverage for custom_components/supernotify/__init__.py: 97%
172 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
1"""The Supernotify integration"""
3from __future__ import annotations
5import copy
6import logging
7from typing import TYPE_CHECKING, Any
9from homeassistant.const import CONF_NAME, SERVICE_RELOAD, Platform
10from homeassistant.exceptions import ConfigEntryNotReady
11from homeassistant.helpers import entity_registry as er
12from homeassistant.helpers.reload import async_integration_yaml_config
13from homeassistant.helpers.service import async_register_admin_service
14from homeassistant.loader import Integration, async_get_integration
15from homeassistant.util import slugify
17from .compat import vol
19if TYPE_CHECKING:
20 from homeassistant.config_entries import ConfigEntry
21 from homeassistant.core import HomeAssistant, ServiceCall
22 from homeassistant.helpers.typing import ConfigType
24 from .notify import SuperNotificationService, SupernotifyEngine
26 # entry.runtime_data is typed against the engine, not the legacy SuperNotificationService shim
27 # (still used below to register/unregister notify.supernotify itself) - see SuperNotificationService's
28 # docstring in notify.py for why that split exists.
29 type SupernotifyConfigEntry = ConfigEntry[SupernotifyEngine]
31DOMAIN = "supernotify"
33TEMPLATE_DIR: str = "supernotify/templates"
34MEDIA_DIR: str = "media/supernotify"
35ARCHIVE_DIR: str = "supernotify/archive"
37_LOGGER = logging.getLogger(__name__)
39NOTIFY_SERVICE_NAME = "supernotify"
41# Key under hass.data[DOMAIN] holding the validated top-level `supernotify:` YAML section
42# (delivery/transports/scenarios/recipients/cameras/action_groups/snooze - the 8 keys not
43# yet configurable via ConfigFlow). Populated by async_setup, read by _entry_full_config.
44KEY_YAML_CONFIG = "yaml_config"
46# NOTIFY carries the main notify.supernotify action and per-recipient notify entities.
47# BINARY_SENSOR/SENSOR/SWITCH carry the scenario/recipient state, notification/failure counters
48# and scenario control as real entities (binary_sensor.py/sensor.py/switch.py) - see issue #175
49# "Part B". BUTTON carries the reset overrides button (button.py). Their async_setup_entry read
50# entry.runtime_data, so they must be forwarded to only after it's set.
51PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.BUTTON]
53# Deferred import: schema.py imports ARCHIVE_DIR/MEDIA_DIR/TEMPLATE_DIR back from this module, so it can
54# only be imported here once those (and DOMAIN) are already defined above.
55from .schema import SUPERNOTIFY_YAML_SCHEMA # noqa: E402, RUF100, I001
57CONFIG_SCHEMA: vol.Schema = vol.Schema(
58 {vol.Optional(DOMAIN, default=dict): SUPERNOTIFY_YAML_SCHEMA},
59 extra=vol.ALLOW_EXTRA,
60)
63async def async_reload_yaml_config_and_entries(hass: HomeAssistant) -> None:
64 """Re-read the top-level `supernotify:` YAML section from disk and reload every entry.
66 Shared by the supernotify.reload service below and repairs.py's migration flow (which needs
67 the freshly-migrated `supernotify.yaml` picked up immediately, without a restart).
68 """
69 new_config = await async_integration_yaml_config(hass, DOMAIN)
70 hass.data.setdefault(DOMAIN, {})[KEY_YAML_CONFIG] = (new_config or {}).get(DOMAIN, {})
71 for entry in hass.config_entries.async_entries(DOMAIN):
72 await hass.config_entries.async_reload(entry.entry_id)
75async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
76 """Stash the top-level `supernotify:` YAML section and wire up supernotify.reload.
78 This is the only place that YAML section is ever read for a running instance - editing it
79 and calling supernotify.reload (or the repairs.py migration flow) re-reads it and reloads
80 the config entry, which is the sole, unconditional owner of notify.supernotify.
81 """
82 hass.data.setdefault(DOMAIN, {})[KEY_YAML_CONFIG] = config.get(DOMAIN, {})
84 from .repairs import async_check_python_version
86 async_check_python_version(hass)
88 async def _async_reload(_call: ServiceCall) -> None:
89 await async_reload_yaml_config_and_entries(hass)
91 async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _async_reload)
93 if not hass.config_entries.async_entries(DOMAIN):
94 from homeassistant.config_entries import SOURCE_IMPORT
96 hass.async_create_task(hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data={}))
98 return True
101def _entry_full_config(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> ConfigType:
102 """Fold a config entry's data/options and the stashed top-level YAML section into a full
103 FULL_CONFIG_SCHEMA config dict.
105 entry.data holds the flat "user" step fields; entry.options holds the nested
106 archive/dupe_check/housekeeping sections from the options flow - both validated here via
107 CONFIG_ENTRY_SCHEMA. hass.data[DOMAIN] holds the delivery/transports/scenarios/recipients/
108 cameras/action_groups/snooze YAML section (see async_setup) - already validated once
109 by CONFIG_SCHEMA when that YAML was first loaded, so it's merged in as-is rather than
110 re-validated (a second pass would reject values schema validation already coerced, e.g.
111 cv.template's Template objects - see CONFIG_ENTRY_SCHEMA's docstring in schema.py).
113 Settings the entry owns, like `archive`, are ignored in that YAML section, with a repair
114 issue naming them, rather than overriding the entry unvalidated. Recipients and transports
115 come from the entry's subentries, the YAML ones having been imported into those already.
116 """
117 from .const import CONF_RECIPIENTS, CONF_TRANSPORTS, SUBENTRY_TYPE_RECIPIENT, SUBENTRY_TYPE_TRANSPORT
118 from .repairs import async_check_yaml_entry_owned_keys
119 from .schema import CONFIG_ENTRY_SCHEMA, ENTRY_OWNED_KEYS
121 yaml_config = hass.data.get(DOMAIN, {}).get(KEY_YAML_CONFIG, {})
122 async_check_yaml_entry_owned_keys(hass, [key for key in yaml_config if key in ENTRY_OWNED_KEYS])
123 yaml_only = {
124 key: value
125 for key, value in yaml_config.items()
126 if key not in ENTRY_OWNED_KEYS and key not in (CONF_RECIPIENTS, CONF_TRANSPORTS)
127 }
128 # copies, as the engine adds defaults to what it's given, which would otherwise change the
129 # subentries' own data
130 recipients = [copy.deepcopy(dict(s.data)) for s in entry.subentries.values() if s.subentry_type == SUBENTRY_TYPE_RECIPIENT]
131 transports = {
132 s.unique_id: copy.deepcopy(dict(s.data))
133 for s in entry.subentries.values()
134 if s.subentry_type == SUBENTRY_TYPE_TRANSPORT
135 }
136 return {
137 **CONFIG_ENTRY_SCHEMA({**entry.data, **entry.options, CONF_RECIPIENTS: recipients, CONF_TRANSPORTS: transports}),
138 **yaml_only,
139 }
142def async_add_recipient_subentries(hass: HomeAssistant, entry: SupernotifyConfigEntry, recipients: list[dict]) -> None:
143 """Add a recipient subentry for each of these validated recipients that has none yet,
144 keyed by recipient_key(), and titled by its alias, else its person's name"""
145 from types import MappingProxyType
147 from homeassistant.config_entries import ConfigSubentry
148 from homeassistant.const import CONF_ALIAS
150 from .const import CONF_PERSON, SUBENTRY_TYPE_RECIPIENT, recipient_subentry_title
151 from .people import recipient_key
153 existing = set()
154 for subentry in [s for s in entry.subentries.values() if s.subentry_type == SUBENTRY_TYPE_RECIPIENT]:
155 existing.add(subentry.unique_id)
156 # titled without its type's prefix in 2.18.0 betas
157 if subentry.title != (title := recipient_subentry_title(subentry.title)):
158 hass.config_entries.async_update_subentry(entry, subentry, title=title)
159 for recipient in recipients:
160 key = recipient_key(recipient)
161 if key is None or key in existing:
162 continue
163 person = hass.states.get(recipient[CONF_PERSON]) if recipient.get(CONF_PERSON) else None
164 _LOGGER.info("SUPERNOTIFY Adding recipient %s", key)
165 hass.config_entries.async_add_subentry(
166 entry,
167 ConfigSubentry(
168 data=MappingProxyType(dict(recipient)),
169 subentry_type=SUBENTRY_TYPE_RECIPIENT,
170 title=recipient_subentry_title(str(recipient.get(CONF_ALIAS) or (person.name if person else key))),
171 unique_id=key,
172 ),
173 )
174 existing.add(key)
177def async_add_transport_subentries(
178 hass: HomeAssistant, entry: SupernotifyConfigEntry, transports: dict[str, dict[str, Any]]
179) -> None:
180 """Add a transport subentry for each of these transports, by name, that has none yet, its
181 configuration as it would be written in YAML, and titled with its name, as shown"""
182 from types import MappingProxyType
184 from homeassistant.config_entries import ConfigSubentry
186 from .const import SUBENTRY_TYPE_TRANSPORT, transport_subentry_title
188 existing = set()
189 for subentry in [s for s in entry.subentries.values() if s.subentry_type == SUBENTRY_TYPE_TRANSPORT]:
190 existing.add(subentry.unique_id)
191 # titled by an alias before 2.18.0 was released, which is now the transport device's name,
192 # and without its type's prefix in its betas
193 if subentry.unique_id and subentry.title != (title := transport_subentry_title(subentry.unique_id)):
194 hass.config_entries.async_update_subentry(entry, subentry, title=title)
195 for name, transport in transports.items():
196 if name in existing:
197 continue
198 _LOGGER.info("SUPERNOTIFY Adding transport %s", name)
199 hass.config_entries.async_add_subentry(
200 entry,
201 ConfigSubentry(
202 data=MappingProxyType(dict(transport)),
203 subentry_type=SUBENTRY_TYPE_TRANSPORT,
204 title=transport_subentry_title(name),
205 unique_id=name,
206 ),
207 )
208 existing.add(name)
211def _async_import_yaml_transports(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> None:
212 """Import the top-level YAML's transports into subentries, with a repair asking for the YAML
213 ones to be removed. That YAML has been validated already, so is turned back into what it was
214 validated from, for storing and validating again when loaded"""
215 from .const import CONF_TRANSPORTS
216 from .repairs import async_check_yaml_transports
217 from .schema import as_raw_config
219 transports: dict[str, dict[str, Any]] = hass.data.get(DOMAIN, {}).get(KEY_YAML_CONFIG, {}).get(CONF_TRANSPORTS) or {}
220 async_add_transport_subentries(hass, entry, as_raw_config(transports))
221 async_check_yaml_transports(hass, bool(transports))
224def _async_check_transport_deprecated_config(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> None:
225 from .const import SUBENTRY_TYPE_TRANSPORT
226 from .repairs import async_check_transport_deprecated_config
228 async_check_transport_deprecated_config(
229 hass,
230 {str(s.unique_id): s.data for s in entry.subentries.values() if s.subentry_type == SUBENTRY_TYPE_TRANSPORT},
231 )
234def _async_merge_user_recipients(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> None:
235 """A recipient discovered as a user, before the user was given a Person, becomes that Person's
236 recipient, keeping what was set on it - or is merged into the Person's recipient, if that has
237 been added since, so no one is listed twice"""
238 from .const import CONF_PERSON, CONF_USER_ID, SUBENTRY_TYPE_RECIPIENT
240 person_for_user = {
241 state.attributes[CONF_USER_ID]: state.entity_id
242 for state in hass.states.async_all("person")
243 if isinstance(state.attributes.get(CONF_USER_ID), str)
244 }
245 recipients = {s.unique_id: s for s in entry.subentries.values() if s.subentry_type == SUBENTRY_TYPE_RECIPIENT}
246 for subentry in list(recipients.values()):
247 user_id = subentry.data.get(CONF_USER_ID)
248 if CONF_PERSON in subentry.data or (person := person_for_user.get(user_id)) is None:
249 continue
250 if existing := recipients.get(person):
251 _LOGGER.info("SUPERNOTIFY Merging recipient for user %s into %s", user_id, person)
252 hass.config_entries.async_update_subentry(entry, existing, data={**subentry.data, **existing.data})
253 hass.config_entries.async_remove_subentry(entry, subentry.subentry_id)
254 else:
255 _LOGGER.info("SUPERNOTIFY Recipient for user %s is now %s", user_id, person)
256 hass.config_entries.async_update_subentry(
257 entry, subentry, data={**subentry.data, CONF_PERSON: person}, unique_id=person
258 )
261def _async_import_yaml_recipients(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> None:
262 """Import the top-level YAML's recipients into subentries, with a repair asking for the YAML
263 ones to be removed"""
264 from .const import CONF_RECIPIENTS
265 from .repairs import async_check_yaml_recipients
267 recipients: list[dict] = hass.data.get(DOMAIN, {}).get(KEY_YAML_CONFIG, {}).get(CONF_RECIPIENTS) or []
268 async_add_recipient_subentries(hass, entry, recipients)
269 async_check_yaml_recipients(hass, bool(recipients))
272# Delivery names auto-configured under the old "DEFAULT_x" naming (see
273# DeliveryRegistry.resolve_name()), long since replaced by plain transport names - their
274# binary_sensor entities are orphaned now that nothing in current use is named with that prefix.
275LEGACY_DEFAULT_DELIVERY_NAMES = ("notify_entity", "email", "mobile_push", "smtp")
278def _async_remove_legacy_default_entities(hass: HomeAssistant) -> None:
279 """One-time cleanup of binary_sensor entities left behind by the old 'DEFAULT_x'
280 auto-configured delivery naming - a harmless no-op once they're gone.
282 Removing the registry entry alone doesn't clear its last-known state - these were never
283 backed by a real Entity/EntityPlatform (earlier versions wrote them directly to the registry
284 and state machine), so nothing else ever calls hass.states.async_remove() for them either. Left alone, the state lingers in the state
285 machine (and so still shows in the UI/history) until a full restart, even though the
286 registry entry is genuinely gone.
287 """
288 entity_registry = er.async_get(hass)
289 for name in LEGACY_DEFAULT_DELIVERY_NAMES:
290 entity_id = entity_registry.async_get_entity_id(Platform.BINARY_SENSOR, DOMAIN, f"delivery_DEFAULT_{name}")
291 if entity_id:
292 _LOGGER.info("SUPERNOTIFY Removing orphaned legacy entity %s", entity_id)
293 entity_registry.async_remove(entity_id)
294 hass.states.async_remove(entity_id)
297async def async_setup_entry(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> bool:
298 from .actions import async_describe_configured_names, async_register_engine_actions
299 from .const import CONF_LLM_TOOLS, CONF_SENTENCE_COMMANDS
300 from .engine import build_supernotify_engine
301 from .notification import set_version
302 from .notify import SuperNotificationService
304 _async_remove_legacy_default_entities(hass)
306 integration: Integration = await async_get_integration(hass, DOMAIN)
307 set_version(str(integration.version) if integration.version else "unknown")
309 _async_merge_user_recipients(hass, entry)
310 _async_import_yaml_recipients(hass, entry)
311 _async_import_yaml_transports(hass, entry)
312 _async_check_transport_deprecated_config(hass, entry)
313 full_config = _entry_full_config(hass, entry)
314 engine: SupernotifyEngine = build_supernotify_engine(hass, full_config)
315 try:
316 await engine.initialize()
317 except Exception as err:
318 _LOGGER.exception("SUPERNOTIFY Failed to initialize, will retry")
319 raise ConfigEntryNotReady(
320 translation_domain=DOMAIN, translation_key="setup_failed", translation_placeholders={"error": str(err)}
321 ) from err
323 entry.runtime_data = engine
324 # every recipient discovered gets a subentry, so all are listed and can be changed, and
325 # switched off with `enabled` - before the update listener, as the engine has them already
326 async_add_recipient_subentries(hass, entry, list(engine.context.people_registry.discovered.values()))
327 # likewise every transport in use, whether configured or set up automatically
328 async_add_transport_subentries(hass, entry, {name: {} for name in engine.context.delivery_registry.transports})
329 entry.async_on_unload(entry.add_update_listener(_async_update_listener))
331 # Add NotifyEntities via notify.py, plus the scenario/recipient entities and notification/
332 # failure counter sensors (binary_sensor.py/sensor.py/switch.py) - see PLATFORMS above.
333 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
335 # Only once the counter sensors have restored their last values, so nothing is counted
336 # before then and lost
337 async_register_engine_actions(hass, engine, full_config)
338 await async_describe_configured_names(hass, engine)
340 if entry.options.get(CONF_LLM_TOOLS, {}).get(CONF_SENTENCE_COMMANDS):
341 from .sentences import async_register_sentences
343 if remove_sentences := await async_register_sentences(hass, engine):
344 entry.async_on_unload(remove_sentences)
346 ## Legacy Notification Service set-up
348 # Matches the slugify(conf_name or SERVICE_NOTIFY) logic the legacy notify platform loader
349 # used to apply to a YAML `name:` field, so an existing custom notify.<name> action (e.g.
350 # `name: SuperNotifier` -> notify.supernotifier) keeps working once this entry becomes the
351 # sole owner of registering it - see repairs.py, which carries a migrated entry's name over.
352 service_name = slugify(entry.data.get(CONF_NAME) or NOTIFY_SERVICE_NAME)
354 if hass.services.has_service("notify", service_name):
355 _LOGGER.warning(
356 "SUPERNOTIFY notify.%s is already registered - not registering it again from this config entry",
357 service_name,
358 )
359 return True
361 notify_service: SuperNotificationService = SuperNotificationService(engine)
362 await notify_service.async_setup(hass, service_name, service_name)
363 await notify_service.async_register_services()
364 # without this, notify.<service_name> outlives the entry - reload (e.g. an options update,
365 # or repairs.py's legacy-name migration) leaves it bound to a shut-down engine and
366 # permanently blocks re-registration, since a later setup sees has_service() above already
367 # true and skips wiring the new engine's notify service up at all
368 entry.async_on_unload(notify_service.async_unregister_services)
370 return True
373async def _async_update_listener(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> None:
374 """Reload the entry so archive/dupe_check/housekeeping options apply immediately."""
375 await hass.config_entries.async_reload(entry.entry_id)
378async def async_unload_entry(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> bool:
379 from .actions import async_unregister_engine_actions
381 unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
382 engine: SupernotifyEngine | None = getattr(entry, "runtime_data", None)
383 if engine is not None:
384 engine.shutdown()
385 async_unregister_engine_actions(hass)
386 return unload_ok