Coverage for custom_components/supernotify/__init__.py: 95%
94 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
1"""The Supernotify integration"""
3from __future__ import annotations
5import logging
6from typing import TYPE_CHECKING
8import voluptuous as vol
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
17if TYPE_CHECKING:
18 from homeassistant.config_entries import ConfigEntry
19 from homeassistant.core import HomeAssistant, ServiceCall
20 from homeassistant.helpers.typing import ConfigType
22 from .notify import SuperNotificationService, SupernotifyEngine
24 # entry.runtime_data is typed against the engine, not the legacy SuperNotificationService shim
25 # (still used below to register/unregister notify.supernotify itself) - see SuperNotificationService's
26 # docstring in notify.py for why that split exists.
27 type SupernotifyConfigEntry = ConfigEntry[SupernotifyEngine]
29DOMAIN = "supernotify"
31TEMPLATE_DIR: str = "supernotify/templates"
32MEDIA_DIR: str = "supernotify/media"
33ARCHIVE_DIR: str = "supernotify/archive"
35_LOGGER = logging.getLogger(__name__)
37NOTIFY_SERVICE_NAME = "supernotify"
39# Key under hass.data[DOMAIN] holding the validated top-level `supernotify:` YAML section
40# (delivery/transports/scenarios/recipients/cameras/action_groups/links/snooze - the 8 keys not
41# yet configurable via ConfigFlow). Populated by async_setup, read by _entry_full_config.
42KEY_YAML_CONFIG = "yaml_config"
44# NOTIFY carries the main notify.supernotify action and per-recipient notify entities.
45# BINARY_SENSOR/SENSOR/SWITCH carry the scenario/recipient state, notification/failure counters
46# and scenario control as real entities (binary_sensor.py/sensor.py/switch.py) - see issue #175
47# "Part B". BUTTON carries the reset overrides button (button.py). Their async_setup_entry read
48# entry.runtime_data, so they must be forwarded to only after it's set.
49PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.BUTTON]
51# Deferred import: schema.py imports ARCHIVE_DIR/MEDIA_DIR/TEMPLATE_DIR back from this module, so it can
52# only be imported here once those (and DOMAIN) are already defined above.
53from .schema import SUPERNOTIFY_YAML_SCHEMA # noqa: E402, RUF100, I001
55CONFIG_SCHEMA: vol.Schema = vol.Schema(
56 {vol.Optional(DOMAIN, default=dict): SUPERNOTIFY_YAML_SCHEMA},
57 extra=vol.ALLOW_EXTRA,
58)
61async def async_reload_yaml_config_and_entries(hass: HomeAssistant) -> None:
62 """Re-read the top-level `supernotify:` YAML section from disk and reload every entry.
64 Shared by the supernotify.reload service below and repairs.py's migration flow (which needs
65 the freshly-migrated `supernotify.yaml` picked up immediately, without a restart).
66 """
67 new_config = await async_integration_yaml_config(hass, DOMAIN)
68 hass.data.setdefault(DOMAIN, {})[KEY_YAML_CONFIG] = (new_config or {}).get(DOMAIN, {})
69 for entry in hass.config_entries.async_entries(DOMAIN):
70 await hass.config_entries.async_reload(entry.entry_id)
73async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
74 """Stash the top-level `supernotify:` YAML section and wire up supernotify.reload.
76 This is the only place that YAML section is ever read for a running instance - editing it
77 and calling supernotify.reload (or the repairs.py migration flow) re-reads it and reloads
78 the config entry, which is the sole, unconditional owner of notify.supernotify.
79 """
80 hass.data.setdefault(DOMAIN, {})[KEY_YAML_CONFIG] = config.get(DOMAIN, {})
82 from .repairs import async_check_python_version
84 async_check_python_version(hass)
86 async def _async_reload(_call: ServiceCall) -> None:
87 await async_reload_yaml_config_and_entries(hass)
89 async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _async_reload)
91 if not hass.config_entries.async_entries(DOMAIN):
92 from homeassistant.config_entries import SOURCE_IMPORT
94 hass.async_create_task(hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data={}))
96 return True
99def _entry_full_config(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> ConfigType:
100 """Fold a config entry's data/options and the stashed top-level YAML section into a full
101 FULL_CONFIG_SCHEMA config dict.
103 entry.data holds the flat "user" step fields; entry.options holds the nested
104 archive/dupe_check/housekeeping sections from the options flow - both validated here via
105 CONFIG_ENTRY_SCHEMA. hass.data[DOMAIN] holds the delivery/transports/scenarios/recipients/
106 cameras/action_groups/links/snooze YAML section (see async_setup) - already validated once
107 by CONFIG_SCHEMA when that YAML was first loaded, so it's merged in as-is rather than
108 re-validated (a second pass would reject values schema validation already coerced, e.g.
109 cv.template's Template objects - see CONFIG_ENTRY_SCHEMA's docstring in schema.py).
110 """
111 from .schema import CONFIG_ENTRY_SCHEMA
113 yaml_config = hass.data.get(DOMAIN, {}).get(KEY_YAML_CONFIG, {})
114 return {**CONFIG_ENTRY_SCHEMA({**entry.data, **entry.options}), **yaml_config}
117# Delivery names auto-configured under the old "DEFAULT_x" naming (see
118# DeliveryRegistry.resolve_name()), long since replaced by plain transport names - their
119# binary_sensor entities are orphaned now that nothing in current use is named with that prefix.
120LEGACY_DEFAULT_DELIVERY_NAMES = ("notify_entity", "email", "mobile_push", "smtp")
123def _async_remove_legacy_default_entities(hass: HomeAssistant) -> None:
124 """One-time cleanup of binary_sensor entities left behind by the old 'DEFAULT_x'
125 auto-configured delivery naming - a harmless no-op once they're gone.
127 Removing the registry entry alone doesn't clear its last-known state - these were never
128 backed by a real Entity/EntityPlatform (earlier versions wrote them directly to the registry
129 and state machine), so nothing else ever calls hass.states.async_remove() for them either. Left alone, the state lingers in the state
130 machine (and so still shows in the UI/history) until a full restart, even though the
131 registry entry is genuinely gone.
132 """
133 entity_registry = er.async_get(hass)
134 for name in LEGACY_DEFAULT_DELIVERY_NAMES:
135 entity_id = entity_registry.async_get_entity_id(Platform.BINARY_SENSOR, DOMAIN, f"delivery_DEFAULT_{name}")
136 if entity_id:
137 _LOGGER.info("SUPERNOTIFY Removing orphaned legacy entity %s", entity_id)
138 entity_registry.async_remove(entity_id)
139 hass.states.async_remove(entity_id)
142async def async_setup_entry(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> bool:
143 from .actions import async_describe_configured_names, async_register_engine_actions
144 from .const import CONF_LLM_TOOLS, CONF_SENTENCE_COMMANDS
145 from .engine import build_supernotify_engine
146 from .notification import set_version
147 from .notify import SuperNotificationService
149 _async_remove_legacy_default_entities(hass)
151 integration: Integration = await async_get_integration(hass, DOMAIN)
152 set_version(str(integration.version) if integration.version else "unknown")
154 full_config = _entry_full_config(hass, entry)
155 engine: SupernotifyEngine = build_supernotify_engine(hass, full_config)
156 try:
157 await engine.initialize()
158 except Exception as err:
159 _LOGGER.exception("SUPERNOTIFY Failed to initialize, will retry")
160 raise ConfigEntryNotReady(f"SUPERNOTIFY Failed to initialize: {err}") from err
162 entry.runtime_data = engine
163 entry.async_on_unload(entry.add_update_listener(_async_update_listener))
165 # Add NotifyEntities via notify.py, plus the scenario/recipient entities and notification/
166 # failure counter sensors (binary_sensor.py/sensor.py/switch.py) - see PLATFORMS above.
167 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
169 # Only once the counter sensors have restored their last values, so nothing is counted
170 # before then and lost
171 async_register_engine_actions(hass, engine, full_config)
172 await async_describe_configured_names(hass, engine)
174 if entry.options.get(CONF_LLM_TOOLS, {}).get(CONF_SENTENCE_COMMANDS):
175 from .sentences import async_register_sentences
177 if remove_sentences := await async_register_sentences(hass, engine):
178 entry.async_on_unload(remove_sentences)
180 ## Legacy Notification Service set-up
182 # Matches the slugify(conf_name or SERVICE_NOTIFY) logic the legacy notify platform loader
183 # used to apply to a YAML `name:` field, so an existing custom notify.<name> action (e.g.
184 # `name: SuperNotifier` -> notify.supernotifier) keeps working once this entry becomes the
185 # sole owner of registering it - see repairs.py, which carries a migrated entry's name over.
186 service_name = slugify(entry.data.get(CONF_NAME) or NOTIFY_SERVICE_NAME)
188 if hass.services.has_service("notify", service_name):
189 _LOGGER.warning(
190 "SUPERNOTIFY notify.%s is already registered - not registering it again from this config entry",
191 service_name,
192 )
193 return True
195 notify_service: SuperNotificationService = SuperNotificationService(engine)
196 await notify_service.async_setup(hass, service_name, service_name)
197 await notify_service.async_register_services()
198 # without this, notify.<service_name> outlives the entry - reload (e.g. an options update,
199 # or repairs.py's legacy-name migration) leaves it bound to a shut-down engine and
200 # permanently blocks re-registration, since a later setup sees has_service() above already
201 # true and skips wiring the new engine's notify service up at all
202 entry.async_on_unload(notify_service.async_unregister_services)
204 return True
207async def _async_update_listener(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> None:
208 """Reload the entry so archive/dupe_check/housekeeping options apply immediately."""
209 await hass.config_entries.async_reload(entry.entry_id)
212async def async_unload_entry(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> bool:
213 from .actions import async_unregister_engine_actions
215 unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
216 engine: SupernotifyEngine | None = getattr(entry, "runtime_data", None)
217 if engine is not None:
218 engine.shutdown()
219 async_unregister_engine_actions(hass)
220 return unload_ok