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

1"""The Supernotify integration""" 

2 

3from __future__ import annotations 

4 

5import copy 

6import logging 

7from typing import TYPE_CHECKING, Any 

8 

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 

17from .compat import vol 

18 

19if TYPE_CHECKING: 

20 from homeassistant.config_entries import ConfigEntry 

21 from homeassistant.core import HomeAssistant, ServiceCall 

22 from homeassistant.helpers.typing import ConfigType 

23 

24 from .notify import SuperNotificationService, SupernotifyEngine 

25 

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] 

30 

31DOMAIN = "supernotify" 

32 

33TEMPLATE_DIR: str = "supernotify/templates" 

34MEDIA_DIR: str = "media/supernotify" 

35ARCHIVE_DIR: str = "supernotify/archive" 

36 

37_LOGGER = logging.getLogger(__name__) 

38 

39NOTIFY_SERVICE_NAME = "supernotify" 

40 

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" 

45 

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] 

52 

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 

56 

57CONFIG_SCHEMA: vol.Schema = vol.Schema( 

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

59 extra=vol.ALLOW_EXTRA, 

60) 

61 

62 

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. 

65 

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) 

73 

74 

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

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

77 

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

83 

84 from .repairs import async_check_python_version 

85 

86 async_check_python_version(hass) 

87 

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

89 await async_reload_yaml_config_and_entries(hass) 

90 

91 async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _async_reload) 

92 

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

94 from homeassistant.config_entries import SOURCE_IMPORT 

95 

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

97 

98 return True 

99 

100 

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. 

104 

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

112 

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 

120 

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 } 

140 

141 

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 

146 

147 from homeassistant.config_entries import ConfigSubentry 

148 from homeassistant.const import CONF_ALIAS 

149 

150 from .const import CONF_PERSON, SUBENTRY_TYPE_RECIPIENT, recipient_subentry_title 

151 from .people import recipient_key 

152 

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) 

175 

176 

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 

183 

184 from homeassistant.config_entries import ConfigSubentry 

185 

186 from .const import SUBENTRY_TYPE_TRANSPORT, transport_subentry_title 

187 

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) 

209 

210 

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 

218 

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

222 

223 

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 

227 

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 ) 

232 

233 

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 

239 

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 ) 

259 

260 

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 

266 

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

270 

271 

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

276 

277 

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. 

281 

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) 

295 

296 

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 

303 

304 _async_remove_legacy_default_entities(hass) 

305 

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

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

308 

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 

322 

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

330 

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) 

334 

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) 

339 

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

341 from .sentences import async_register_sentences 

342 

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

344 entry.async_on_unload(remove_sentences) 

345 

346 ## Legacy Notification Service set-up 

347 

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) 

353 

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 

360 

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) 

369 

370 return True 

371 

372 

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) 

376 

377 

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

379 from .actions import async_unregister_engine_actions 

380 

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