Coverage for custom_components/supernotify/repairs.py: 93%

161 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 14:29 +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 

18import sys 

19from pathlib import Path 

20from typing import TYPE_CHECKING, Any 

21 

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 

28 

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 

42 

43if TYPE_CHECKING: 

44 from homeassistant.core import HomeAssistant 

45 from homeassistant.data_entry_flow import FlowResult 

46 

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 

51 

52_LOGGER = logging.getLogger(__name__) 

53 

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" 

66 

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} 

76 

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) 

89 

90 

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

98 

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. 

105 

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] 

120 

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

126 

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

128 

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

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

131 

132 

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 ) 

147 

148 

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. 

151 

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 ) 

171 

172 

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. 

175 

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 ) 

190 

191 

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. 

195 

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 ) 

211 

212 

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

215 

216 

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

223 

224 

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

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

227 

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 ) 

237 

238 

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 

250 

251 

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

257 

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

259 self._legacy_config = legacy_config 

260 self._original_configuration_yaml: str | None = None 

261 

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

266 

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

273 

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

277 

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

290 

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

303 

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

308 

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

317 

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

321 

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 ) 

332 

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

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

335 

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" 

345 

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" 

353 

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" 

360 

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" 

366 

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" 

376 

377 self._original_configuration_yaml = original_text 

378 return None 

379 

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) 

388 

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 

397 

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) 

401 

402 await async_reload_yaml_config_and_entries(self.hass) 

403 

404 

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)