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

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

11 

12from __future__ import annotations 

13 

14import asyncio 

15import json 

16import logging 

17import os 

18from pathlib import Path 

19from typing import TYPE_CHECKING, Any 

20 

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 

27 

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 

41 

42if TYPE_CHECKING: 

43 from homeassistant.core import HomeAssistant 

44 from homeassistant.data_entry_flow import FlowResult 

45 

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 

50 

51_LOGGER = logging.getLogger(__name__) 

52 

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" 

62 

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} 

72 

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) 

85 

86 

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

94 

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. 

101 

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] 

116 

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

122 

123 options = {**entry.options, **extract_legacy_options(legacy_config)} 

124 

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

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

127 

128 

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 ) 

143 

144 

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

147 

148 

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

155 

156 

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

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

159 

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 ) 

169 

170 

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 

182 

183 

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

189 

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

191 self._legacy_config = legacy_config 

192 self._original_configuration_yaml: str | None = None 

193 

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

198 

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

205 

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

209 

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

222 

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

235 

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

240 

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

249 

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

253 

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 ) 

264 

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

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

267 

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" 

277 

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" 

285 

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" 

292 

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" 

298 

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" 

308 

309 self._original_configuration_yaml = original_text 

310 return None 

311 

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) 

320 

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 

329 

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) 

333 

334 await async_reload_yaml_config_and_entries(self.hass) 

335 

336 

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)