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

1"""Repairs for supernotify's legacy `notify: - platform: supernotify` YAML block. 

2 

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. 

10 

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

15 

16from __future__ import annotations 

17 

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 

26 

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 

32 

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 

53 

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 

58 

59_LOGGER = logging.getLogger(__name__) 

60 

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" 

77 

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} 

87 

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) 

97 

98 

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

107 

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. 

114 

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] 

129 

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

135 

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} 

139 

140 if data != entry.data or options != entry.options: 

141 hass.config_entries.async_update_entry(entry, data=data, options=options) 

142 

143 

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 ) 

158 

159 

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 ) 

176 

177 

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 ) 

193 

194 

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 

206 

207 

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) 

235 

236 

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 ) 

252 

253 

254def async_check_python_version(hass: HomeAssistant) -> None: 

255 """Raise (or clear) a non-fixable warning that Python 3.13 is no longer supported. 

256 

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 ) 

276 

277 

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. 

280 

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 ) 

295 

296 

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. 

300 

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 ) 

316 

317 

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

320 

321 

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

328 

329 

330def _load_configuration_yaml_dict(hass: HomeAssistant) -> dict[str, Any]: 

331 """Parse configuration.yaml, resolving !secret references. 

332 

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 ) 

342 

343 

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 

355 

356 

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

362 

363 def __init__(self, legacy_config: dict[str, Any]) -> None: 

364 self._legacy_config = legacy_config 

365 self._original_configuration_yaml: str | None = None 

366 

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

371 

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

378 

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

382 

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

395 

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

413 

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

418 

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

427 

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

431 

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 ) 

442 

443 def _write_files(self, migrated: dict[str, Any]) -> str | None: 

444 """Write supernotify.yaml and append the include line to configuration.yaml. 

445 

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" 

455 

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" 

463 

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" 

470 

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" 

476 

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" 

486 

487 self._original_configuration_yaml = original_text 

488 return None 

489 

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) 

498 

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 

507 

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) 

514 

515 await async_reload_yaml_config_and_entries(self.hass) 

516 

517 

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)