Coverage for custom_components/supernotify/repairs.py: 93%
161 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"""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
18import sys
19from pathlib import Path
20from typing import TYPE_CHECKING, Any
22import voluptuous as vol
23from homeassistant.components.repairs import RepairsFlow
24from homeassistant.config import async_check_ha_config_file
25from homeassistant.const import CONF_NAME
26from homeassistant.helpers import issue_registry as ir
27from homeassistant.util.yaml import Secrets, load_yaml_dict, save_yaml
29from . import DOMAIN, async_reload_yaml_config_and_entries
30from .config_flow import extract_legacy_data, extract_legacy_options
31from .const import (
32 CONF_ACTION_GROUPS,
33 CONF_CAMERAS,
34 CONF_DELIVERY,
35 CONF_LINKS,
36 CONF_RECIPIENTS,
37 CONF_SCENARIOS,
38 CONF_SNOOZE,
39 CONF_TRANSPORTS,
40)
41from .schema import SUPERNOTIFY_YAML_SCHEMA
43if TYPE_CHECKING:
44 from homeassistant.core import HomeAssistant
45 from homeassistant.data_entry_flow import FlowResult
47 # RepairsFlowResult isn't exported on every supported HA version (it's just
48 # FlowResult[FlowContext, str] under the hood) - FlowResult itself is a much older, more
49 # stable export, so use that directly rather than depending on the alias's presence.
50 RepairsFlowResult = FlowResult
52_LOGGER = logging.getLogger(__name__)
54ISSUE_ID = "legacy_yaml_config"
55# Raised whenever the automated migration can't proceed for any reason (invalid config
56# before we'd even touch it, a write failure, or a post-write validation failure that got
57# rolled back) - a separate, persistent (non-fixable) issue so the need for a manual migration
58# survives closing the confirm-step dialog, distinct from ISSUE_ID (which stays fixable/
59# retryable - e.g. after the user manually clears whatever blocked the automated attempt).
60MANUAL_MIGRATION_ISSUE_ID = "legacy_yaml_manual_migration_required"
61PYTHON_313_DEPRECATED_ISSUE_ID = "python_313_deprecated"
62RECIPIENT_BINARY_SENSOR_DEPRECATED_ISSUE_ID = "recipient_binary_sensor_deprecated"
63DELIVERY_TRANSPORT_BINARY_SENSOR_DEPRECATED_ISSUE_ID = "delivery_transport_binary_sensor_deprecated"
64SUPERNOTIFY_YAML_FILENAME = "supernotify.yaml"
65CONFIGURATION_YAML_FILENAME = "configuration.yaml"
67_MANUAL_MIGRATION_REASONS: dict[str, str] = {
68 "baseline_invalid": ("configuration.yaml already has an unrelated problem, so it's not safe to edit automatically"),
69 "supernotify_yaml_exists": "a supernotify.yaml file already exists",
70 "configuration_yaml_unreadable": "configuration.yaml could not be read",
71 "supernotify_key_exists": "configuration.yaml already has a top-level supernotify: key",
72 "migrated_config_invalid": "the legacy configuration itself doesn't pass validation",
73 "write_failed": "writing the new configuration files failed",
74 "validation_failed": "the updated configuration.yaml failed validation and was rolled back",
75}
77# The 8 keys migrated out of the legacy notify: platform block, matching schema.py's
78# SUPERNOTIFY_YAML_SCHEMA.
79_YAML_ONLY_KEYS = (
80 CONF_DELIVERY,
81 CONF_TRANSPORTS,
82 CONF_SCENARIOS,
83 CONF_RECIPIENTS,
84 CONF_CAMERAS,
85 CONF_ACTION_GROUPS,
86 CONF_LINKS,
87 CONF_SNOOZE,
88)
91def async_sync_entry_from_legacy_config(hass: HomeAssistant, legacy_config: dict[str, Any]) -> None:
92 """As long as the legacy `notify: - platform: supernotify` block exists, it stays
93 authoritative for the owning entry's name/template_path/media_path/etc and
94 archive/dupe_check/housekeeping settings - synced onto the entry on every load (not gated
95 behind the interactive migration flow), matching what the legacy platform loader itself
96 always did for `name:` (slugify(name or "notify") determined the service - see
97 homeassistant.components.notify.legacy.async_setup_legacy).
99 Without this, an entry auto-bootstrapped blank by async_setup (see __init__.py) - which
100 happens before anyone gets around to opening and confirming the migration repair, and is the
101 only entry that will ever exist for a "simple" install with nothing that needs that repair -
102 would keep running on defaults forever: wrong service name, ignored template_path/media_path,
103 archive disabled, no dupe_check/housekeeping, silently breaking automations and archiving on
104 every restart.
106 Deliberately a single async_update_entry call rather than one per field group: that call
107 notifies the entry's update listener (which reloads it) via a task, and Python's eager task
108 execution can start running that task immediately and synchronously - reaching as far as
109 unloading the entry (which removes its update listener) before a second, separate
110 async_update_entry call made moments later in the same batch gets a chance to run. That
111 second call then finds no listener left to notify and silently no-ops, so only the FIRST of
112 several back-to-back calls ever actually reloads - permanently losing whatever the others
113 carried, even though entry.data/entry.options themselves end up fully correct (e.g. the
114 Reconfigure screen reads right) while the *running* service silently keeps stale defaults.
115 """
116 entries = hass.config_entries.async_entries(DOMAIN)
117 if not entries:
118 return
119 entry = entries[0]
121 data = dict(entry.data)
122 legacy_name = legacy_config.get(CONF_NAME)
123 if legacy_name:
124 data[CONF_NAME] = legacy_name
125 data.update(extract_legacy_data(legacy_config))
127 options = {**entry.options, **extract_legacy_options(legacy_config)}
129 if data != entry.data or options != entry.options:
130 hass.config_entries.async_update_entry(entry, data=data, options=options)
133def async_create_legacy_yaml_issue(hass: HomeAssistant, legacy_config: dict[str, Any]) -> None:
134 """Raise (or refresh) the fixable repair for a leftover legacy notify: platform block."""
135 ir.async_create_issue(
136 hass,
137 DOMAIN,
138 ISSUE_ID,
139 is_fixable=True,
140 severity=ir.IssueSeverity.WARNING,
141 translation_key=ISSUE_ID,
142 # issue data must be flat str/int/float/None - the raw legacy config (still just plain
143 # YAML-parsed data, since notify.py's shim never runs it through any schema) is nested,
144 # so it's carried through as a JSON string and decoded back in async_create_fix_flow.
145 data={"legacy_config": json.dumps(legacy_config, default=str)},
146 )
149def async_check_python_version(hass: HomeAssistant) -> None:
150 """Raise (or clear) a non-fixable warning once Python 3.13 support is on borrowed time.
152 Support for Python 3.13 (and Home Assistant versions before 2026.3.0) is dropped when
153 Home Assistant 2026.10 is released - see README.md's "MAJOR CHANGE v2" section. Called from
154 async_setup on every start, so upgrading the underlying Python interpreter clears the issue
155 automatically without needing a fix flow.
156 """
157 if sys.version_info >= (3, 14):
158 ir.async_delete_issue(hass, DOMAIN, PYTHON_313_DEPRECATED_ISSUE_ID)
159 else:
160 python_version = "{}.{}.{}".format(*sys.version_info[:3])
161 ir.async_create_issue(
162 hass,
163 DOMAIN,
164 PYTHON_313_DEPRECATED_ISSUE_ID,
165 is_fixable=False,
166 severity=ir.IssueSeverity.WARNING,
167 translation_key=PYTHON_313_DEPRECATED_ISSUE_ID,
168 translation_placeholders={"python_version": python_version},
169 learn_more_url="https://supernotify.rhizomatics.org.uk",
170 )
173def async_create_recipient_binary_sensor_deprecated_issue(hass: HomeAssistant) -> None:
174 """Raise a single, persistent, non-fixable warning that recipient binary_sensors are going.
176 Enabling and disabling a recipient moved to a switch entity, leaving the binary_sensor only
177 mirroring it, and there just for backward compatibility. Never deleted by us, so once the
178 user has dismissed it, Home Assistant keeps it dismissed rather than raising it again.
179 """
180 ir.async_create_issue(
181 hass,
182 DOMAIN,
183 RECIPIENT_BINARY_SENSOR_DEPRECATED_ISSUE_ID,
184 is_fixable=False,
185 is_persistent=True,
186 severity=ir.IssueSeverity.WARNING,
187 translation_key=RECIPIENT_BINARY_SENSOR_DEPRECATED_ISSUE_ID,
188 learn_more_url="https://supernotify.rhizomatics.org.uk",
189 )
192def async_create_delivery_transport_binary_sensor_deprecated_issue(hass: HomeAssistant) -> None:
193 """Raise a single, persistent, non-fixable warning that delivery and transport
194 binary_sensors are going.
196 Enabling and disabling a delivery or transport moved to a switch entity, leaving the
197 binary_sensor only mirroring it, and there just for backward compatibility. Never deleted by
198 us, so once the user has dismissed it, Home Assistant keeps it dismissed rather than raising
199 it again.
200 """
201 ir.async_create_issue(
202 hass,
203 DOMAIN,
204 DELIVERY_TRANSPORT_BINARY_SENSOR_DEPRECATED_ISSUE_ID,
205 is_fixable=False,
206 is_persistent=True,
207 severity=ir.IssueSeverity.WARNING,
208 translation_key=DELIVERY_TRANSPORT_BINARY_SENSOR_DEPRECATED_ISSUE_ID,
209 learn_more_url="https://supernotify.rhizomatics.org.uk",
210 )
213def _extract_yaml_only_config(legacy_config: dict[str, Any]) -> dict[str, Any]:
214 return {key: legacy_config[key] for key in _YAML_ONLY_KEYS if legacy_config.get(key)}
217def _async_get_migration_lock(hass: HomeAssistant) -> asyncio.Lock:
218 """A lock guarding the file-write-and-reload critical section below, distinct from a
219 ConfigEntry's own setup_lock (which HA already uses internally to serialize a single
220 entry's own setup/reload/unload) - this instead prevents two concurrent attempts at this
221 flow (e.g. a double submit, or two admin sessions) from interleaving their file writes."""
222 return hass.data.setdefault(DOMAIN, {}).setdefault("migration_lock", asyncio.Lock())
225def _load_configuration_yaml_dict(hass: HomeAssistant) -> dict[str, Any]:
226 """Parse configuration.yaml, resolving !secret references.
228 Without a Secrets object, load_yaml_dict raises HomeAssistantError("Secrets not supported
229 in this YAML file") on ANY file containing a `!secret` tag anywhere - extremely common in
230 real configs - which a caller catching that as "parse failed" would otherwise silently
231 misread as e.g. "not migrated yet" on every single load.
232 """
233 return load_yaml_dict(
234 hass.config.path(CONFIGURATION_YAML_FILENAME),
235 Secrets(Path(hass.config.config_dir)),
236 )
239def _is_already_migrated(hass: HomeAssistant) -> bool:
240 """A prior run of this same flow already wrote both files - only remains true until the
241 user removes the now-dead legacy notify: block (which stops notify.py from re-raising the
242 issue at all)."""
243 if not os.path.exists(hass.config.path(SUPERNOTIFY_YAML_FILENAME)):
244 return False
245 try:
246 parsed = _load_configuration_yaml_dict(hass)
247 except Exception:
248 return False
249 return DOMAIN in parsed
252class SupernotifyLegacyYamlRepairFlow(RepairsFlow):
253 """Migrate delivery/transports/scenarios/recipients/cameras/action_groups/links/snooze out
254 of the legacy `notify: - platform: supernotify` YAML block into a new top-level
255 `supernotify:` key (typically `supernotify: !include supernotify.yaml`).
256 """
258 def __init__(self, legacy_config: dict[str, Any]) -> None:
259 self._legacy_config = legacy_config
260 self._original_configuration_yaml: str | None = None
262 async def async_step_init(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult:
263 if await self.hass.async_add_executor_job(_is_already_migrated, self.hass):
264 return await self.async_step_already_migrated()
265 return await self.async_step_confirm()
267 async def async_step_already_migrated(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult:
268 # No explicit issue deletion needed - the repairs flow manager deletes it automatically
269 # once any step returns async_create_entry.
270 if user_input is not None:
271 return self.async_create_entry(data={})
272 return self.async_show_form(step_id="already_migrated")
274 async def async_step_confirm(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult:
275 if user_input is None:
276 return self.async_show_form(step_id="confirm")
278 # Serialize the whole check-write-reload sequence against a second concurrent attempt
279 # at this same flow (double submit, two admin sessions) - file writes below aren't
280 # otherwise atomic, and HA's own per-entry setup_lock only protects a single entry's
281 # setup/reload, not this flow's file I/O.
282 async with _async_get_migration_lock(self.hass):
283 # Validate before touching anything - if configuration.yaml is already broken,
284 # stop rather than risk compounding an existing problem.
285 baseline_error = await async_check_ha_config_file(self.hass)
286 if baseline_error is not None:
287 _LOGGER.warning("SUPERNOTIFY Migration aborted, configuration.yaml already invalid: %s", baseline_error)
288 self._async_raise_manual_migration_issue("baseline_invalid")
289 return self.async_show_form(step_id="confirm", errors={"base": "baseline_invalid"})
291 # Validated here, on the event loop, rather than inside _write_files (which runs in
292 # the executor): cv.template - used by cv.CONDITIONS_SCHEMA to coerce a bare Jinja
293 # string condition (e.g. a scenario's `conditions: ["{{ ... }}"]` shorthand) - needs
294 # the event-loop-bound hass context to do that; off the event loop it can't find it
295 # and rejects an otherwise perfectly valid bare-string condition.
296 migrated = _extract_yaml_only_config(self._legacy_config)
297 try:
298 SUPERNOTIFY_YAML_SCHEMA(migrated)
299 except vol.Invalid as err:
300 _LOGGER.warning("SUPERNOTIFY Legacy config failed validation, not migrating: %s", err)
301 self._async_raise_manual_migration_issue("migrated_config_invalid")
302 return self.async_show_form(step_id="confirm", errors={"base": "migrated_config_invalid"})
304 write_error = await self.hass.async_add_executor_job(self._write_files, migrated)
305 if write_error is not None:
306 self._async_raise_manual_migration_issue(write_error)
307 return self.async_show_form(step_id="confirm", errors={"base": write_error})
309 # Validate again post-write - the baseline is known-good, so any failure here was
310 # caused by our own edit and gets rolled back.
311 after_error = await async_check_ha_config_file(self.hass)
312 if after_error is not None:
313 await self.hass.async_add_executor_job(self._rollback)
314 _LOGGER.warning("SUPERNOTIFY Migration rolled back, configuration.yaml became invalid: %s", after_error)
315 self._async_raise_manual_migration_issue("validation_failed")
316 return self.async_show_form(step_id="confirm", errors={"base": "validation_failed"})
318 ir.async_delete_issue(self.hass, DOMAIN, MANUAL_MIGRATION_ISSUE_ID)
319 await self._async_finish_migration()
320 return self.async_create_entry(data={})
322 def _async_raise_manual_migration_issue(self, reason_key: str) -> None:
323 ir.async_create_issue(
324 self.hass,
325 DOMAIN,
326 MANUAL_MIGRATION_ISSUE_ID,
327 is_fixable=False,
328 severity=ir.IssueSeverity.WARNING,
329 translation_key=MANUAL_MIGRATION_ISSUE_ID,
330 translation_placeholders={"reason": _MANUAL_MIGRATION_REASONS.get(reason_key, reason_key)},
331 )
333 def _write_files(self, migrated: dict[str, Any]) -> str | None:
334 """Write supernotify.yaml and append the include line to configuration.yaml.
336 `migrated` is already validated (see async_step_confirm - validation needs the event
337 loop, this method doesn't have it). Blocking file I/O - runs in the executor. Returns
338 an error-translation-key string on failure (nothing written), None on success
339 (self._original_configuration_yaml is set, letting _rollback restore it if the
340 post-write config-check fails).
341 """
342 supernotify_yaml_path = self.hass.config.path(SUPERNOTIFY_YAML_FILENAME)
343 if os.path.exists(supernotify_yaml_path):
344 return "supernotify_yaml_exists"
346 configuration_yaml_path = self.hass.config.path(CONFIGURATION_YAML_FILENAME)
347 try:
348 with open(configuration_yaml_path, encoding="utf-8") as config_file:
349 original_text = config_file.read()
350 except OSError as err:
351 _LOGGER.warning("SUPERNOTIFY Could not read %s: %s", configuration_yaml_path, err)
352 return "configuration_yaml_unreadable"
354 try:
355 parsed = _load_configuration_yaml_dict(self.hass)
356 except Exception:
357 parsed = {}
358 if DOMAIN in parsed:
359 return "supernotify_key_exists"
361 try:
362 save_yaml(supernotify_yaml_path, migrated)
363 except OSError as err:
364 _LOGGER.warning("SUPERNOTIFY Could not write %s: %s", supernotify_yaml_path, err)
365 return "write_failed"
367 new_text = original_text if original_text.endswith("\n") else original_text + "\n"
368 new_text += f"\n{DOMAIN}: !include {SUPERNOTIFY_YAML_FILENAME}\n"
369 try:
370 with open(configuration_yaml_path, "w", encoding="utf-8") as config_file:
371 config_file.write(new_text)
372 except OSError as err:
373 _LOGGER.warning("SUPERNOTIFY Could not write %s: %s", configuration_yaml_path, err)
374 os.remove(supernotify_yaml_path)
375 return "write_failed"
377 self._original_configuration_yaml = original_text
378 return None
380 def _rollback(self) -> None:
381 supernotify_yaml_path = self.hass.config.path(SUPERNOTIFY_YAML_FILENAME)
382 if os.path.exists(supernotify_yaml_path):
383 os.remove(supernotify_yaml_path)
384 if self._original_configuration_yaml is not None:
385 configuration_yaml_path = self.hass.config.path(CONFIGURATION_YAML_FILENAME)
386 with open(configuration_yaml_path, "w", encoding="utf-8") as config_file:
387 config_file.write(self._original_configuration_yaml)
389 async def _async_finish_migration(self) -> None:
390 """Reload the migrated YAML immediately (no restart needed), and make sure an entry
391 exists to own it - preserving a custom `name:` (-> notify.<name>) from the legacy
392 config, since that's what determines the actual registered action name, along with any
393 template_path/media_path/etc and archive/dupe_check/housekeeping settings the legacy
394 config carried."""
395 if not self.hass.config_entries.async_entries(DOMAIN):
396 from homeassistant.config_entries import SOURCE_IMPORT
398 await self.hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=self._legacy_config)
399 else:
400 async_sync_entry_from_legacy_config(self.hass, self._legacy_config)
402 await async_reload_yaml_config_and_entries(self.hass)
405async def async_create_fix_flow(hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None) -> RepairsFlow:
406 """Create the repair flow for the legacy_yaml_config issue."""
407 _ = hass, issue_id
408 legacy_config_json = (data or {}).get("legacy_config")
409 legacy_config = json.loads(legacy_config_json) if isinstance(legacy_config_json, str) else {}
410 return SupernotifyLegacyYamlRepairFlow(legacy_config)