Coverage for custom_components/supernotify/repairs.py: 92%
148 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-01 18:25 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-01 18:25 +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/recipients/cameras/
5action_groups/links/snooze now live under a top-level `supernotify:` YAML key instead (see
6CONFIG_SCHEMA/async_setup in __init__.py). 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.
10"""
12from __future__ import annotations
14import asyncio
15import json
16import logging
17import os
18from pathlib import Path
19from typing import TYPE_CHECKING, Any
21import voluptuous as vol
22from homeassistant.components.repairs import RepairsFlow
23from homeassistant.config import async_check_ha_config_file
24from homeassistant.const import CONF_NAME
25from homeassistant.helpers import issue_registry as ir
26from homeassistant.util.yaml import Secrets, load_yaml_dict, save_yaml
28from . import DOMAIN, async_reload_yaml_config_and_entries
29from .config_flow import extract_legacy_data, extract_legacy_options
30from .const import (
31 CONF_ACTION_GROUPS,
32 CONF_CAMERAS,
33 CONF_DELIVERY,
34 CONF_LINKS,
35 CONF_RECIPIENTS,
36 CONF_SCENARIOS,
37 CONF_SNOOZE,
38 CONF_TRANSPORTS,
39)
40from .schema import SUPERNOTIFY_YAML_SCHEMA
42if TYPE_CHECKING:
43 from homeassistant.core import HomeAssistant
44 from homeassistant.data_entry_flow import FlowResult
46 # RepairsFlowResult isn't exported on every supported HA version (it's just
47 # FlowResult[FlowContext, str] under the hood) - FlowResult itself is a much older, more
48 # stable export, so use that directly rather than depending on the alias's presence.
49 RepairsFlowResult = FlowResult
51_LOGGER = logging.getLogger(__name__)
53ISSUE_ID = "legacy_yaml_config"
54# Raised whenever the automated migration can't proceed for any reason (invalid config
55# before we'd even touch it, a write failure, or a post-write validation failure that got
56# rolled back) - a separate, persistent (non-fixable) issue so the need for a manual migration
57# survives closing the confirm-step dialog, distinct from ISSUE_ID (which stays fixable/
58# retryable - e.g. after the user manually clears whatever blocked the automated attempt).
59MANUAL_MIGRATION_ISSUE_ID = "legacy_yaml_manual_migration_required"
60SUPERNOTIFY_YAML_FILENAME = "supernotify.yaml"
61CONFIGURATION_YAML_FILENAME = "configuration.yaml"
63_MANUAL_MIGRATION_REASONS: dict[str, str] = {
64 "baseline_invalid": ("configuration.yaml already has an unrelated problem, so it's not safe to edit automatically"),
65 "supernotify_yaml_exists": "a supernotify.yaml file already exists",
66 "configuration_yaml_unreadable": "configuration.yaml could not be read",
67 "supernotify_key_exists": "configuration.yaml already has a top-level supernotify: key",
68 "migrated_config_invalid": "the legacy configuration itself doesn't pass validation",
69 "write_failed": "writing the new configuration files failed",
70 "validation_failed": "the updated configuration.yaml failed validation and was rolled back",
71}
73# The 8 keys migrated out of the legacy notify: platform block, matching schema.py's
74# SUPERNOTIFY_YAML_SCHEMA.
75_YAML_ONLY_KEYS = (
76 CONF_DELIVERY,
77 CONF_TRANSPORTS,
78 CONF_SCENARIOS,
79 CONF_RECIPIENTS,
80 CONF_CAMERAS,
81 CONF_ACTION_GROUPS,
82 CONF_LINKS,
83 CONF_SNOOZE,
84)
87def async_sync_entry_from_legacy_config(hass: HomeAssistant, legacy_config: dict[str, Any]) -> None:
88 """As long as the legacy `notify: - platform: supernotify` block exists, it stays
89 authoritative for the owning entry's name/template_path/media_path/etc and
90 archive/dupe_check/housekeeping settings - synced onto the entry on every load (not gated
91 behind the interactive migration flow), matching what the legacy platform loader itself
92 always did for `name:` (slugify(name or "notify") determined the service - see
93 homeassistant.components.notify.legacy.async_setup_legacy).
95 Without this, an entry auto-bootstrapped blank by async_setup (see __init__.py) - which
96 happens before anyone gets around to opening and confirming the migration repair, and is the
97 only entry that will ever exist for a "simple" install with nothing that needs that repair -
98 would keep running on defaults forever: wrong service name, ignored template_path/media_path,
99 archive disabled, no dupe_check/housekeeping, silently breaking automations and archiving on
100 every restart.
102 Deliberately a single async_update_entry call rather than one per field group: that call
103 notifies the entry's update listener (which reloads it) via a task, and Python's eager task
104 execution can start running that task immediately and synchronously - reaching as far as
105 unloading the entry (which removes its update listener) before a second, separate
106 async_update_entry call made moments later in the same batch gets a chance to run. That
107 second call then finds no listener left to notify and silently no-ops, so only the FIRST of
108 several back-to-back calls ever actually reloads - permanently losing whatever the others
109 carried, even though entry.data/entry.options themselves end up fully correct (e.g. the
110 Reconfigure screen reads right) while the *running* service silently keeps stale defaults.
111 """
112 entries = hass.config_entries.async_entries(DOMAIN)
113 if not entries:
114 return
115 entry = entries[0]
117 data = dict(entry.data)
118 legacy_name = legacy_config.get(CONF_NAME)
119 if legacy_name:
120 data[CONF_NAME] = legacy_name
121 data.update(extract_legacy_data(legacy_config))
123 options = {**entry.options, **extract_legacy_options(legacy_config)}
125 if data != entry.data or options != entry.options:
126 hass.config_entries.async_update_entry(entry, data=data, options=options)
129def async_create_legacy_yaml_issue(hass: HomeAssistant, legacy_config: dict[str, Any]) -> None:
130 """Raise (or refresh) the fixable repair for a leftover legacy notify: platform block."""
131 ir.async_create_issue(
132 hass,
133 DOMAIN,
134 ISSUE_ID,
135 is_fixable=True,
136 severity=ir.IssueSeverity.WARNING,
137 translation_key=ISSUE_ID,
138 # issue data must be flat str/int/float/None - the raw legacy config (still just plain
139 # YAML-parsed data, since notify.py's shim never runs it through any schema) is nested,
140 # so it's carried through as a JSON string and decoded back in async_create_fix_flow.
141 data={"legacy_config": json.dumps(legacy_config, default=str)},
142 )
145def _extract_yaml_only_config(legacy_config: dict[str, Any]) -> dict[str, Any]:
146 return {key: legacy_config[key] for key in _YAML_ONLY_KEYS if legacy_config.get(key)}
149def _async_get_migration_lock(hass: HomeAssistant) -> asyncio.Lock:
150 """A lock guarding the file-write-and-reload critical section below, distinct from a
151 ConfigEntry's own setup_lock (which HA already uses internally to serialize a single
152 entry's own setup/reload/unload) - this instead prevents two concurrent attempts at this
153 flow (e.g. a double submit, or two admin sessions) from interleaving their file writes."""
154 return hass.data.setdefault(DOMAIN, {}).setdefault("migration_lock", asyncio.Lock())
157def _load_configuration_yaml_dict(hass: HomeAssistant) -> dict[str, Any]:
158 """Parse configuration.yaml, resolving !secret references.
160 Without a Secrets object, load_yaml_dict raises HomeAssistantError("Secrets not supported
161 in this YAML file") on ANY file containing a `!secret` tag anywhere - extremely common in
162 real configs - which a caller catching that as "parse failed" would otherwise silently
163 misread as e.g. "not migrated yet" on every single load.
164 """
165 return load_yaml_dict(
166 hass.config.path(CONFIGURATION_YAML_FILENAME),
167 Secrets(Path(hass.config.config_dir)),
168 )
171def _is_already_migrated(hass: HomeAssistant) -> bool:
172 """A prior run of this same flow already wrote both files - only remains true until the
173 user removes the now-dead legacy notify: block (which stops notify.py from re-raising the
174 issue at all)."""
175 if not os.path.exists(hass.config.path(SUPERNOTIFY_YAML_FILENAME)):
176 return False
177 try:
178 parsed = _load_configuration_yaml_dict(hass)
179 except Exception:
180 return False
181 return DOMAIN in parsed
184class SupernotifyLegacyYamlRepairFlow(RepairsFlow):
185 """Migrate delivery/transports/scenarios/recipients/cameras/action_groups/links/snooze out
186 of the legacy `notify: - platform: supernotify` YAML block into a new top-level
187 `supernotify:` key (typically `supernotify: !include supernotify.yaml`).
188 """
190 def __init__(self, legacy_config: dict[str, Any]) -> None:
191 self._legacy_config = legacy_config
192 self._original_configuration_yaml: str | None = None
194 async def async_step_init(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult:
195 if await self.hass.async_add_executor_job(_is_already_migrated, self.hass):
196 return await self.async_step_already_migrated()
197 return await self.async_step_confirm()
199 async def async_step_already_migrated(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult:
200 # No explicit issue deletion needed - the repairs flow manager deletes it automatically
201 # once any step returns async_create_entry.
202 if user_input is not None:
203 return self.async_create_entry(data={})
204 return self.async_show_form(step_id="already_migrated")
206 async def async_step_confirm(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult:
207 if user_input is None:
208 return self.async_show_form(step_id="confirm")
210 # Serialize the whole check-write-reload sequence against a second concurrent attempt
211 # at this same flow (double submit, two admin sessions) - file writes below aren't
212 # otherwise atomic, and HA's own per-entry setup_lock only protects a single entry's
213 # setup/reload, not this flow's file I/O.
214 async with _async_get_migration_lock(self.hass):
215 # Validate before touching anything - if configuration.yaml is already broken,
216 # stop rather than risk compounding an existing problem.
217 baseline_error = await async_check_ha_config_file(self.hass)
218 if baseline_error is not None:
219 _LOGGER.warning("SUPERNOTIFY Migration aborted, configuration.yaml already invalid: %s", baseline_error)
220 self._async_raise_manual_migration_issue("baseline_invalid")
221 return self.async_show_form(step_id="confirm", errors={"base": "baseline_invalid"})
223 # Validated here, on the event loop, rather than inside _write_files (which runs in
224 # the executor): cv.template - used by cv.CONDITIONS_SCHEMA to coerce a bare Jinja
225 # string condition (e.g. a scenario's `conditions: ["{{ ... }}"]` shorthand) - needs
226 # the event-loop-bound hass context to do that; off the event loop it can't find it
227 # and rejects an otherwise perfectly valid bare-string condition.
228 migrated = _extract_yaml_only_config(self._legacy_config)
229 try:
230 SUPERNOTIFY_YAML_SCHEMA(migrated)
231 except vol.Invalid as err:
232 _LOGGER.warning("SUPERNOTIFY Legacy config failed validation, not migrating: %s", err)
233 self._async_raise_manual_migration_issue("migrated_config_invalid")
234 return self.async_show_form(step_id="confirm", errors={"base": "migrated_config_invalid"})
236 write_error = await self.hass.async_add_executor_job(self._write_files, migrated)
237 if write_error is not None:
238 self._async_raise_manual_migration_issue(write_error)
239 return self.async_show_form(step_id="confirm", errors={"base": write_error})
241 # Validate again post-write - the baseline is known-good, so any failure here was
242 # caused by our own edit and gets rolled back.
243 after_error = await async_check_ha_config_file(self.hass)
244 if after_error is not None:
245 await self.hass.async_add_executor_job(self._rollback)
246 _LOGGER.warning("SUPERNOTIFY Migration rolled back, configuration.yaml became invalid: %s", after_error)
247 self._async_raise_manual_migration_issue("validation_failed")
248 return self.async_show_form(step_id="confirm", errors={"base": "validation_failed"})
250 ir.async_delete_issue(self.hass, DOMAIN, MANUAL_MIGRATION_ISSUE_ID)
251 await self._async_finish_migration()
252 return self.async_create_entry(data={})
254 def _async_raise_manual_migration_issue(self, reason_key: str) -> None:
255 ir.async_create_issue(
256 self.hass,
257 DOMAIN,
258 MANUAL_MIGRATION_ISSUE_ID,
259 is_fixable=False,
260 severity=ir.IssueSeverity.WARNING,
261 translation_key=MANUAL_MIGRATION_ISSUE_ID,
262 translation_placeholders={"reason": _MANUAL_MIGRATION_REASONS.get(reason_key, reason_key)},
263 )
265 def _write_files(self, migrated: dict[str, Any]) -> str | None:
266 """Write supernotify.yaml and append the include line to configuration.yaml.
268 `migrated` is already validated (see async_step_confirm - validation needs the event
269 loop, this method doesn't have it). Blocking file I/O - runs in the executor. Returns
270 an error-translation-key string on failure (nothing written), None on success
271 (self._original_configuration_yaml is set, letting _rollback restore it if the
272 post-write config-check fails).
273 """
274 supernotify_yaml_path = self.hass.config.path(SUPERNOTIFY_YAML_FILENAME)
275 if os.path.exists(supernotify_yaml_path):
276 return "supernotify_yaml_exists"
278 configuration_yaml_path = self.hass.config.path(CONFIGURATION_YAML_FILENAME)
279 try:
280 with open(configuration_yaml_path, encoding="utf-8") as config_file:
281 original_text = config_file.read()
282 except OSError as err:
283 _LOGGER.warning("SUPERNOTIFY Could not read %s: %s", configuration_yaml_path, err)
284 return "configuration_yaml_unreadable"
286 try:
287 parsed = _load_configuration_yaml_dict(self.hass)
288 except Exception:
289 parsed = {}
290 if DOMAIN in parsed:
291 return "supernotify_key_exists"
293 try:
294 save_yaml(supernotify_yaml_path, migrated)
295 except OSError as err:
296 _LOGGER.warning("SUPERNOTIFY Could not write %s: %s", supernotify_yaml_path, err)
297 return "write_failed"
299 new_text = original_text if original_text.endswith("\n") else original_text + "\n"
300 new_text += f"\n{DOMAIN}: !include {SUPERNOTIFY_YAML_FILENAME}\n"
301 try:
302 with open(configuration_yaml_path, "w", encoding="utf-8") as config_file:
303 config_file.write(new_text)
304 except OSError as err:
305 _LOGGER.warning("SUPERNOTIFY Could not write %s: %s", configuration_yaml_path, err)
306 os.remove(supernotify_yaml_path)
307 return "write_failed"
309 self._original_configuration_yaml = original_text
310 return None
312 def _rollback(self) -> None:
313 supernotify_yaml_path = self.hass.config.path(SUPERNOTIFY_YAML_FILENAME)
314 if os.path.exists(supernotify_yaml_path):
315 os.remove(supernotify_yaml_path)
316 if self._original_configuration_yaml is not None:
317 configuration_yaml_path = self.hass.config.path(CONFIGURATION_YAML_FILENAME)
318 with open(configuration_yaml_path, "w", encoding="utf-8") as config_file:
319 config_file.write(self._original_configuration_yaml)
321 async def _async_finish_migration(self) -> None:
322 """Reload the migrated YAML immediately (no restart needed), and make sure an entry
323 exists to own it - preserving a custom `name:` (-> notify.<name>) from the legacy
324 config, since that's what determines the actual registered action name, along with any
325 template_path/media_path/etc and archive/dupe_check/housekeeping settings the legacy
326 config carried."""
327 if not self.hass.config_entries.async_entries(DOMAIN):
328 from homeassistant.config_entries import SOURCE_IMPORT
330 await self.hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=self._legacy_config)
331 else:
332 async_sync_entry_from_legacy_config(self.hass, self._legacy_config)
334 await async_reload_yaml_config_and_entries(self.hass)
337async def async_create_fix_flow(hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None) -> RepairsFlow:
338 """Create the repair flow for the legacy_yaml_config issue."""
339 _ = hass, issue_id
340 legacy_config_json = (data or {}).get("legacy_config")
341 legacy_config = json.loads(legacy_config_json) if isinstance(legacy_config_json, str) else {}
342 return SupernotifyLegacyYamlRepairFlow(legacy_config)