Coverage for custom_components/supernotify/repairs.py: 94%
204 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"""Repairs for supernotify's legacy `notify: - platform: supernotify` YAML block.
3The config entry is the sole, unconditional owner of registering notify.supernotify (see
4async_setup_entry in __init__.py) - delivery/transports/scenarios/cameras/
5action_groups/snooze now live under a top-level `supernotify:` YAML key instead (see
6CONFIG_SCHEMA/async_setup in __init__.py), and recipients in the entry's recipient subentries. A leftover legacy block is inert (notify.py's
7async_get_service shim registers nothing from it) but still raises this fixable repair, which
8automates moving that config into `supernotify.yaml` plus a `supernotify: !include
9supernotify.yaml` line in configuration.yaml.
11These repairs access HomeAssistant APIs directly without going via HomeAssistantAPI
12module, since they are triggered by ConfigFlow prior to there being a Supernotify service.
13Other repairs that happen post YAML load are called via HomeAssistantAPI from their own class
14"""
16from __future__ import annotations
18import asyncio
19import json
20import logging
21import os
22import sys
23from collections.abc import Mapping
24from pathlib import Path
25from typing import TYPE_CHECKING, Any
27from homeassistant.components.repairs import RepairsFlow
28from homeassistant.config import async_check_ha_config_file
29from homeassistant.const import CONF_NAME
30from homeassistant.helpers import issue_registry as ir
31from homeassistant.util.yaml import Secrets, load_yaml_dict, save_yaml
33from . import DOMAIN, async_add_recipient_subentries, async_add_transport_subentries, async_reload_yaml_config_and_entries
34from .common import ensure_list
35from .compat import vol
36from .config_flow import extract_legacy_data, extract_legacy_options
37from .const import (
38 CONF_ACTION_GROUPS,
39 CONF_CAMERAS,
40 CONF_DELIVERY,
41 CONF_DELIVERY_DEFAULTS,
42 CONF_DEVICE_DISCOVERY,
43 CONF_DEVICE_DOMAIN,
44 CONF_DEVICE_MODEL_EXCLUDE,
45 CONF_DEVICE_MODEL_INCLUDE,
46 CONF_RECIPIENTS,
47 CONF_SCENARIOS,
48 CONF_SELECTION,
49 CONF_SNOOZE,
50 CONF_TRANSPORTS,
51)
52from .schema import RECIPIENT_SCHEMA, SUPERNOTIFY_YAML_SCHEMA, TRANSPORT_SCHEMA
54if TYPE_CHECKING:
55 # only for type checking, since RepairsFlowResult isn't exported on every supported HA version
56 from homeassistant.components.repairs import RepairsFlowResult
57 from homeassistant.core import HomeAssistant
59_LOGGER = logging.getLogger(__name__)
61ISSUE_ID = "legacy_yaml_config"
62# Raised whenever the automated migration can't proceed for any reason (invalid config
63# before we'd even touch it, a write failure, or a post-write validation failure that got
64# rolled back) - a separate, persistent (non-fixable) issue so the need for a manual migration
65# survives closing the confirm-step dialog, distinct from ISSUE_ID (which stays fixable/
66# retryable - e.g. after the user manually clears whatever blocked the automated attempt).
67MANUAL_MIGRATION_ISSUE_ID = "legacy_yaml_manual_migration_required"
68PYTHON_313_DEPRECATED_ISSUE_ID = "python_313_deprecated"
69RECIPIENT_BINARY_SENSOR_DEPRECATED_ISSUE_ID = "recipient_binary_sensor_deprecated"
70DELIVERY_TRANSPORT_BINARY_SENSOR_DEPRECATED_ISSUE_ID = "delivery_transport_binary_sensor_deprecated"
71YAML_ENTRY_OWNED_KEYS_ISSUE_ID = "yaml_entry_owned_keys"
72YAML_RECIPIENTS_ISSUE_ID = "yaml_recipients_imported"
73YAML_TRANSPORTS_ISSUE_ID = "yaml_transports_imported"
74TRANSPORT_DEPRECATED_CONFIG_ISSUE_KEY = "transport_deprecated_config"
75SUPERNOTIFY_YAML_FILENAME = "supernotify.yaml"
76CONFIGURATION_YAML_FILENAME = "configuration.yaml"
78_MANUAL_MIGRATION_REASONS: dict[str, str] = {
79 "baseline_invalid": ("configuration.yaml already has an unrelated problem, so it's not safe to edit automatically"),
80 "supernotify_yaml_exists": "a supernotify.yaml file already exists",
81 "configuration_yaml_unreadable": "configuration.yaml could not be read",
82 "supernotify_key_exists": "configuration.yaml already has a top-level supernotify: key",
83 "migrated_config_invalid": "the legacy configuration itself doesn't pass validation",
84 "write_failed": "writing the new configuration files failed",
85 "validation_failed": "the updated configuration.yaml failed validation and was rolled back",
86}
88# The keys migrated out of the legacy notify: platform block into supernotify.yaml, matching
89# schema.py's SUPERNOTIFY_YAML_SCHEMA. Recipients and transports go straight into subentries instead.
90_YAML_ONLY_KEYS = (
91 CONF_DELIVERY,
92 CONF_SCENARIOS,
93 CONF_CAMERAS,
94 CONF_ACTION_GROUPS,
95 CONF_SNOOZE,
96)
99def async_sync_entry_from_legacy_config(hass: HomeAssistant, legacy_config: dict[str, Any]) -> None:
100 """As long as the legacy `notify: - platform: supernotify` block exists, it stays
101 authoritative for the owning entry's name/template_path/media_path/etc - synced onto the
102 entry on every load - while its archive/dupe_check/housekeeping settings are migrated once,
103 for whichever the entry doesn't have yet, the options flow owning them from then on (not gated
104 behind the interactive migration flow), matching what the legacy platform loader itself
105 always did for `name:` (slugify(name or "notify") determined the service - see
106 homeassistant.components.notify.legacy.async_setup_legacy).
108 Without this, an entry auto-bootstrapped blank by async_setup (see __init__.py) - which
109 happens before anyone gets around to opening and confirming the migration repair, and is the
110 only entry that will ever exist for a "simple" install with nothing that needs that repair -
111 would keep running on defaults forever: wrong service name, ignored template_path/media_path,
112 archive disabled, no dupe_check/housekeeping, silently breaking automations and archiving on
113 every restart.
115 Deliberately a single async_update_entry call rather than one per field group: that call
116 notifies the entry's update listener (which reloads it) via a task, and Python's eager task
117 execution can start running that task immediately and synchronously - reaching as far as
118 unloading the entry (which removes its update listener) before a second, separate
119 async_update_entry call made moments later in the same batch gets a chance to run. That
120 second call then finds no listener left to notify and silently no-ops, so only the FIRST of
121 several back-to-back calls ever actually reloads - permanently losing whatever the others
122 carried, even though entry.data/entry.options themselves end up fully correct (e.g. the
123 Reconfigure screen reads right) while the *running* service silently keeps stale defaults.
124 """
125 entries = hass.config_entries.async_entries(DOMAIN)
126 if not entries:
127 return
128 entry = entries[0]
130 data = dict(entry.data)
131 legacy_name = legacy_config.get(CONF_NAME)
132 if legacy_name:
133 data[CONF_NAME] = legacy_name
134 data.update(extract_legacy_data(legacy_config))
136 # migrated once - an entry's own archive/dupe_check/housekeeping options, as set in the options
137 # flow, win over the legacy block's from then on
138 options = {**extract_legacy_options(legacy_config), **entry.options}
140 if data != entry.data or options != entry.options:
141 hass.config_entries.async_update_entry(entry, data=data, options=options)
144def async_create_legacy_yaml_issue(hass: HomeAssistant, legacy_config: dict[str, Any]) -> None:
145 """Raise (or refresh) the fixable repair for a leftover legacy notify: platform block."""
146 ir.async_create_issue(
147 hass,
148 DOMAIN,
149 ISSUE_ID,
150 is_fixable=True,
151 severity=ir.IssueSeverity.WARNING,
152 translation_key=ISSUE_ID,
153 # issue data must be flat str/int/float/None - the raw legacy config (still just plain
154 # YAML-parsed data, since notify.py's shim never runs it through any schema) is nested,
155 # so it's carried through as a JSON string and decoded back in async_create_fix_flow.
156 data={"legacy_config": json.dumps(legacy_config, default=str)},
157 )
160def async_check_yaml_entry_owned_keys(hass: HomeAssistant, keys: list[str]) -> None:
161 """Raise (or clear) a single warning naming the settings in the top-level `supernotify:` YAML
162 that are set in the integration's options instead, and so ignored there."""
163 if not keys:
164 ir.async_delete_issue(hass, DOMAIN, YAML_ENTRY_OWNED_KEYS_ISSUE_ID)
165 return
166 ir.async_create_issue(
167 hass,
168 DOMAIN,
169 YAML_ENTRY_OWNED_KEYS_ISSUE_ID,
170 is_fixable=False,
171 severity=ir.IssueSeverity.WARNING,
172 translation_key=YAML_ENTRY_OWNED_KEYS_ISSUE_ID,
173 translation_placeholders={"keys": ", ".join(sorted(keys))},
174 learn_more_url="https://supernotify.rhizomatics.org.uk/latest/configuration/yaml/",
175 )
178def async_check_yaml_transports(hass: HomeAssistant, present: bool) -> None:
179 """Raise (or clear) a warning that transports in the top-level `supernotify:` YAML were
180 imported into the integration, and are ignored there from now on."""
181 if not present:
182 ir.async_delete_issue(hass, DOMAIN, YAML_TRANSPORTS_ISSUE_ID)
183 return
184 ir.async_create_issue(
185 hass,
186 DOMAIN,
187 YAML_TRANSPORTS_ISSUE_ID,
188 is_fixable=False,
189 severity=ir.IssueSeverity.WARNING,
190 translation_key=YAML_TRANSPORTS_ISSUE_ID,
191 learn_more_url="https://supernotify.rhizomatics.org.uk/latest/transports/",
192 )
195def deprecated_transport_keys(transport: Mapping[str, Any]) -> list[str]:
196 """The deprecated settings in a transport's configuration, as written - see model.TransportConfig,
197 which still applies them, and the transport form, which moves them to their replacements"""
198 keys = [
199 key
200 for key in (CONF_DEVICE_DOMAIN, CONF_DEVICE_DISCOVERY, CONF_DEVICE_MODEL_INCLUDE, CONF_DEVICE_MODEL_EXCLUDE)
201 if key in transport
202 ]
203 if CONF_SELECTION in (transport.get(CONF_DELIVERY_DEFAULTS) or {}):
204 keys.append(f"{CONF_DELIVERY_DEFAULTS}.{CONF_SELECTION}")
205 return keys
208def async_check_transport_deprecated_config(hass: HomeAssistant, transports: Mapping[str, Mapping[str, Any]]) -> None:
209 """Raise a warning for each transport configured with deprecated settings, and clear it for
210 those without, or no longer there"""
211 prefix, suffix = "transport_", "_deprecated_config"
212 for name, transport in transports.items():
213 issue_id = f"{prefix}{name}{suffix}"
214 if keys := deprecated_transport_keys(transport):
215 ir.async_create_issue(
216 hass,
217 DOMAIN,
218 issue_id,
219 is_fixable=False,
220 severity=ir.IssueSeverity.WARNING,
221 translation_key=TRANSPORT_DEPRECATED_CONFIG_ISSUE_KEY,
222 translation_placeholders={"transport": name, "keys": ", ".join(keys)},
223 learn_more_url=f"https://supernotify.rhizomatics.org.uk/latest/transports/{name}/",
224 )
225 else:
226 ir.async_delete_issue(hass, DOMAIN, issue_id)
227 for domain, issue_id in list(ir.async_get(hass).issues):
228 if (
229 domain == DOMAIN
230 and issue_id.startswith(prefix)
231 and issue_id.endswith(suffix)
232 and issue_id[len(prefix) : -len(suffix)] not in transports
233 ):
234 ir.async_delete_issue(hass, DOMAIN, issue_id)
237def async_check_yaml_recipients(hass: HomeAssistant, present: bool) -> None:
238 """Raise (or clear) a warning that recipients in the top-level `supernotify:` YAML were
239 imported into the integration, and are ignored there from now on."""
240 if not present:
241 ir.async_delete_issue(hass, DOMAIN, YAML_RECIPIENTS_ISSUE_ID)
242 return
243 ir.async_create_issue(
244 hass,
245 DOMAIN,
246 YAML_RECIPIENTS_ISSUE_ID,
247 is_fixable=False,
248 severity=ir.IssueSeverity.WARNING,
249 translation_key=YAML_RECIPIENTS_ISSUE_ID,
250 learn_more_url="https://supernotify.rhizomatics.org.uk/latest/configuration/people/",
251 )
254def async_check_python_version(hass: HomeAssistant) -> None:
255 """Raise (or clear) a non-fixable warning that Python 3.13 is no longer supported.
257 Support for Python 3.13 (and Home Assistant versions before 2026.3.0) was dropped when
258 Home Assistant 2026.10 was released - see README.md's "Known Limitations" section. Called from
259 async_setup on every start, so upgrading the underlying Python interpreter clears the issue
260 automatically without needing a fix flow.
261 """
262 if sys.version_info >= (3, 14): # noqa: UP036
263 ir.async_delete_issue(hass, DOMAIN, PYTHON_313_DEPRECATED_ISSUE_ID)
264 else:
265 python_version = "{}.{}.{}".format(*sys.version_info[:3])
266 ir.async_create_issue(
267 hass,
268 DOMAIN,
269 PYTHON_313_DEPRECATED_ISSUE_ID,
270 is_fixable=False,
271 severity=ir.IssueSeverity.WARNING,
272 translation_key=PYTHON_313_DEPRECATED_ISSUE_ID,
273 translation_placeholders={"python_version": python_version},
274 learn_more_url="https://supernotify.rhizomatics.org.uk",
275 )
278def async_create_recipient_binary_sensor_deprecated_issue(hass: HomeAssistant) -> None:
279 """Raise a single, persistent, non-fixable warning that recipient binary_sensors are going.
281 Enabling and disabling a recipient moved to a switch entity, leaving the binary_sensor only
282 mirroring it, and there just for backward compatibility. Never deleted by us, so once the
283 user has dismissed it, Home Assistant keeps it dismissed rather than raising it again.
284 """
285 ir.async_create_issue(
286 hass,
287 DOMAIN,
288 RECIPIENT_BINARY_SENSOR_DEPRECATED_ISSUE_ID,
289 is_fixable=False,
290 is_persistent=True,
291 severity=ir.IssueSeverity.WARNING,
292 translation_key=RECIPIENT_BINARY_SENSOR_DEPRECATED_ISSUE_ID,
293 learn_more_url="https://supernotify.rhizomatics.org.uk",
294 )
297def async_create_delivery_transport_binary_sensor_deprecated_issue(hass: HomeAssistant) -> None:
298 """Raise a single, persistent, non-fixable warning that delivery and transport
299 binary_sensors are going.
301 Enabling and disabling a delivery or transport moved to a switch entity, leaving the
302 binary_sensor only mirroring it, and there just for backward compatibility. Never deleted by
303 us, so once the user has dismissed it, Home Assistant keeps it dismissed rather than raising
304 it again.
305 """
306 ir.async_create_issue(
307 hass,
308 DOMAIN,
309 DELIVERY_TRANSPORT_BINARY_SENSOR_DEPRECATED_ISSUE_ID,
310 is_fixable=False,
311 is_persistent=True,
312 severity=ir.IssueSeverity.WARNING,
313 translation_key=DELIVERY_TRANSPORT_BINARY_SENSOR_DEPRECATED_ISSUE_ID,
314 learn_more_url="https://supernotify.rhizomatics.org.uk",
315 )
318def _extract_yaml_only_config(legacy_config: dict[str, Any]) -> dict[str, Any]:
319 return {key: legacy_config[key] for key in _YAML_ONLY_KEYS if legacy_config.get(key)}
322def _async_get_migration_lock(hass: HomeAssistant) -> asyncio.Lock:
323 """A lock guarding the file-write-and-reload critical section below, distinct from a
324 ConfigEntry's own setup_lock (which HA already uses internally to serialize a single
325 entry's own setup/reload/unload) - this instead prevents two concurrent attempts at this
326 flow (e.g. a double submit, or two admin sessions) from interleaving their file writes."""
327 return hass.data.setdefault(DOMAIN, {}).setdefault("migration_lock", asyncio.Lock())
330def _load_configuration_yaml_dict(hass: HomeAssistant) -> dict[str, Any]:
331 """Parse configuration.yaml, resolving !secret references.
333 Without a Secrets object, load_yaml_dict raises HomeAssistantError("Secrets not supported
334 in this YAML file") on ANY file containing a `!secret` tag anywhere - extremely common in
335 real configs - which a caller catching that as "parse failed" would otherwise silently
336 misread as e.g. "not migrated yet" on every single load.
337 """
338 return load_yaml_dict(
339 hass.config.path(CONFIGURATION_YAML_FILENAME),
340 Secrets(Path(hass.config.config_dir)),
341 )
344def _is_already_migrated(hass: HomeAssistant) -> bool:
345 """A prior run of this same flow already wrote both files - only remains true until the
346 user removes the now-dead legacy notify: block (which stops notify.py from re-raising the
347 issue at all)."""
348 if not os.path.exists(hass.config.path(SUPERNOTIFY_YAML_FILENAME)):
349 return False
350 try:
351 parsed = _load_configuration_yaml_dict(hass)
352 except Exception:
353 return False
354 return DOMAIN in parsed
357class SupernotifyLegacyYamlRepairFlow(RepairsFlow):
358 """Migrate delivery/transports/scenarios/recipients/cameras/action_groups/snooze out
359 of the legacy `notify: - platform: supernotify` YAML block into a new top-level
360 `supernotify:` key (typically `supernotify: !include supernotify.yaml`).
361 """
363 def __init__(self, legacy_config: dict[str, Any]) -> None:
364 self._legacy_config = legacy_config
365 self._original_configuration_yaml: str | None = None
367 async def async_step_init(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult:
368 if await self.hass.async_add_executor_job(_is_already_migrated, self.hass):
369 return await self.async_step_already_migrated()
370 return await self.async_step_confirm()
372 async def async_step_already_migrated(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult:
373 # No explicit issue deletion needed - the repairs flow manager deletes it automatically
374 # once any step returns async_create_entry.
375 if user_input is not None:
376 return self.async_create_entry(data={})
377 return self.async_show_form(step_id="already_migrated")
379 async def async_step_confirm(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult:
380 if user_input is None:
381 return self.async_show_form(step_id="confirm")
383 # Serialize the whole check-write-reload sequence against a second concurrent attempt
384 # at this same flow (double submit, two admin sessions) - file writes below aren't
385 # otherwise atomic, and HA's own per-entry setup_lock only protects a single entry's
386 # setup/reload, not this flow's file I/O.
387 async with _async_get_migration_lock(self.hass):
388 # Validate before touching anything - if configuration.yaml is already broken,
389 # stop rather than risk compounding an existing problem.
390 baseline_error = await async_check_ha_config_file(self.hass)
391 if baseline_error is not None:
392 _LOGGER.warning("SUPERNOTIFY Migration aborted, configuration.yaml already invalid: %s", baseline_error)
393 self._async_raise_manual_migration_issue("baseline_invalid")
394 return self.async_show_form(step_id="confirm", errors={"base": "baseline_invalid"})
396 # Validated here, on the event loop, rather than inside _write_files (which runs in
397 # the executor): cv.template - used by cv.CONDITIONS_SCHEMA to coerce a bare Jinja
398 # string condition (e.g. a scenario's `conditions: ["{{ ... }}"]` shorthand) - needs
399 # the event-loop-bound hass context to do that; off the event loop it can't find it
400 # and rejects an otherwise perfectly valid bare-string condition.
401 migrated = _extract_yaml_only_config(self._legacy_config)
402 try:
403 SUPERNOTIFY_YAML_SCHEMA(migrated)
404 recipients = [RECIPIENT_SCHEMA(r) for r in ensure_list(self._legacy_config.get(CONF_RECIPIENTS))]
405 # kept as they were written, since the validated ones can hold templates
406 transports: dict[str, dict[str, Any]] = dict(self._legacy_config.get(CONF_TRANSPORTS) or {})
407 for transport in transports.values():
408 TRANSPORT_SCHEMA(transport)
409 except vol.Invalid as err:
410 _LOGGER.warning("SUPERNOTIFY Legacy config failed validation, not migrating: %s", err)
411 self._async_raise_manual_migration_issue("migrated_config_invalid")
412 return self.async_show_form(step_id="confirm", errors={"base": "migrated_config_invalid"})
414 write_error = await self.hass.async_add_executor_job(self._write_files, migrated)
415 if write_error is not None:
416 self._async_raise_manual_migration_issue(write_error)
417 return self.async_show_form(step_id="confirm", errors={"base": write_error})
419 # Validate again post-write - the baseline is known-good, so any failure here was
420 # caused by our own edit and gets rolled back.
421 after_error = await async_check_ha_config_file(self.hass)
422 if after_error is not None:
423 await self.hass.async_add_executor_job(self._rollback)
424 _LOGGER.warning("SUPERNOTIFY Migration rolled back, configuration.yaml became invalid: %s", after_error)
425 self._async_raise_manual_migration_issue("validation_failed")
426 return self.async_show_form(step_id="confirm", errors={"base": "validation_failed"})
428 ir.async_delete_issue(self.hass, DOMAIN, MANUAL_MIGRATION_ISSUE_ID)
429 await self._async_finish_migration(recipients, transports)
430 return self.async_create_entry(data={})
432 def _async_raise_manual_migration_issue(self, reason_key: str) -> None:
433 ir.async_create_issue(
434 self.hass,
435 DOMAIN,
436 MANUAL_MIGRATION_ISSUE_ID,
437 is_fixable=False,
438 severity=ir.IssueSeverity.WARNING,
439 translation_key=MANUAL_MIGRATION_ISSUE_ID,
440 translation_placeholders={"reason": _MANUAL_MIGRATION_REASONS.get(reason_key, reason_key)},
441 )
443 def _write_files(self, migrated: dict[str, Any]) -> str | None:
444 """Write supernotify.yaml and append the include line to configuration.yaml.
446 `migrated` is already validated (see async_step_confirm - validation needs the event
447 loop, this method doesn't have it). Blocking file I/O - runs in the executor. Returns
448 an error-translation-key string on failure (nothing written), None on success
449 (self._original_configuration_yaml is set, letting _rollback restore it if the
450 post-write config-check fails).
451 """
452 supernotify_yaml_path = self.hass.config.path(SUPERNOTIFY_YAML_FILENAME)
453 if os.path.exists(supernotify_yaml_path):
454 return "supernotify_yaml_exists"
456 configuration_yaml_path = self.hass.config.path(CONFIGURATION_YAML_FILENAME)
457 try:
458 with open(configuration_yaml_path, encoding="utf-8") as config_file:
459 original_text = config_file.read()
460 except OSError as err:
461 _LOGGER.warning("SUPERNOTIFY Could not read %s: %s", configuration_yaml_path, err)
462 return "configuration_yaml_unreadable"
464 try:
465 parsed = _load_configuration_yaml_dict(self.hass)
466 except Exception:
467 parsed = {}
468 if DOMAIN in parsed:
469 return "supernotify_key_exists"
471 try:
472 save_yaml(supernotify_yaml_path, migrated)
473 except OSError as err:
474 _LOGGER.warning("SUPERNOTIFY Could not write %s: %s", supernotify_yaml_path, err)
475 return "write_failed"
477 new_text = original_text if original_text.endswith("\n") else original_text + "\n"
478 new_text += f"\n{DOMAIN}: !include {SUPERNOTIFY_YAML_FILENAME}\n"
479 try:
480 with open(configuration_yaml_path, "w", encoding="utf-8") as config_file:
481 config_file.write(new_text)
482 except OSError as err:
483 _LOGGER.warning("SUPERNOTIFY Could not write %s: %s", configuration_yaml_path, err)
484 os.remove(supernotify_yaml_path)
485 return "write_failed"
487 self._original_configuration_yaml = original_text
488 return None
490 def _rollback(self) -> None:
491 supernotify_yaml_path = self.hass.config.path(SUPERNOTIFY_YAML_FILENAME)
492 if os.path.exists(supernotify_yaml_path):
493 os.remove(supernotify_yaml_path)
494 if self._original_configuration_yaml is not None:
495 configuration_yaml_path = self.hass.config.path(CONFIGURATION_YAML_FILENAME)
496 with open(configuration_yaml_path, "w", encoding="utf-8") as config_file:
497 config_file.write(self._original_configuration_yaml)
499 async def _async_finish_migration(self, recipients: list[dict[str, Any]], transports: dict[str, dict[str, Any]]) -> None:
500 """Reload the migrated YAML immediately (no restart needed), and make sure an entry
501 exists to own it - preserving a custom `name:` (-> notify.<name>) from the legacy
502 config, since that's what determines the actual registered action name, along with any
503 template_path/media_path/etc and archive/dupe_check/housekeeping settings the legacy
504 config carried. The legacy recipients and transports become the entry's subentries."""
505 if not self.hass.config_entries.async_entries(DOMAIN):
506 from homeassistant.config_entries import SOURCE_IMPORT
508 await self.hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=self._legacy_config)
509 else:
510 async_sync_entry_from_legacy_config(self.hass, self._legacy_config)
511 entry = self.hass.config_entries.async_entries(DOMAIN)[0]
512 async_add_recipient_subentries(self.hass, entry, recipients)
513 async_add_transport_subentries(self.hass, entry, transports)
515 await async_reload_yaml_config_and_entries(self.hass)
518async def async_create_fix_flow(hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None) -> RepairsFlow:
519 """Create the repair flow for the legacy_yaml_config issue."""
520 _ = hass, issue_id
521 legacy_config_json = (data or {}).get("legacy_config")
522 legacy_config = json.loads(legacy_config_json) if isinstance(legacy_config_json, str) else {}
523 return SupernotifyLegacyYamlRepairFlow(legacy_config)