Coverage for custom_components/supernotify/__init__.py: 95%

94 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 14:29 +0000

1"""The Supernotify integration""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from typing import TYPE_CHECKING 

7 

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 

16 

17if TYPE_CHECKING: 

18 from homeassistant.config_entries import ConfigEntry 

19 from homeassistant.core import HomeAssistant, ServiceCall 

20 from homeassistant.helpers.typing import ConfigType 

21 

22 from .notify import SuperNotificationService, SupernotifyEngine 

23 

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] 

28 

29DOMAIN = "supernotify" 

30 

31TEMPLATE_DIR: str = "supernotify/templates" 

32MEDIA_DIR: str = "supernotify/media" 

33ARCHIVE_DIR: str = "supernotify/archive" 

34 

35_LOGGER = logging.getLogger(__name__) 

36 

37NOTIFY_SERVICE_NAME = "supernotify" 

38 

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" 

43 

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] 

50 

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 

54 

55CONFIG_SCHEMA: vol.Schema = vol.Schema( 

56 {vol.Optional(DOMAIN, default=dict): SUPERNOTIFY_YAML_SCHEMA}, 

57 extra=vol.ALLOW_EXTRA, 

58) 

59 

60 

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. 

63 

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) 

71 

72 

73async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 

74 """Stash the top-level `supernotify:` YAML section and wire up supernotify.reload. 

75 

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, {}) 

81 

82 from .repairs import async_check_python_version 

83 

84 async_check_python_version(hass) 

85 

86 async def _async_reload(_call: ServiceCall) -> None: 

87 await async_reload_yaml_config_and_entries(hass) 

88 

89 async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _async_reload) 

90 

91 if not hass.config_entries.async_entries(DOMAIN): 

92 from homeassistant.config_entries import SOURCE_IMPORT 

93 

94 hass.async_create_task(hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data={})) 

95 

96 return True 

97 

98 

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. 

102 

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 

112 

113 yaml_config = hass.data.get(DOMAIN, {}).get(KEY_YAML_CONFIG, {}) 

114 return {**CONFIG_ENTRY_SCHEMA({**entry.data, **entry.options}), **yaml_config} 

115 

116 

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") 

121 

122 

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. 

126 

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) 

140 

141 

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 

148 

149 _async_remove_legacy_default_entities(hass) 

150 

151 integration: Integration = await async_get_integration(hass, DOMAIN) 

152 set_version(str(integration.version) if integration.version else "unknown") 

153 

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 

161 

162 entry.runtime_data = engine 

163 entry.async_on_unload(entry.add_update_listener(_async_update_listener)) 

164 

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) 

168 

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) 

173 

174 if entry.options.get(CONF_LLM_TOOLS, {}).get(CONF_SENTENCE_COMMANDS): 

175 from .sentences import async_register_sentences 

176 

177 if remove_sentences := await async_register_sentences(hass, engine): 

178 entry.async_on_unload(remove_sentences) 

179 

180 ## Legacy Notification Service set-up 

181 

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) 

187 

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 

194 

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) 

203 

204 return True 

205 

206 

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) 

210 

211 

212async def async_unload_entry(hass: HomeAssistant, entry: SupernotifyConfigEntry) -> bool: 

213 from .actions import async_unregister_engine_actions 

214 

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