diff --git a/README.md b/README.md index 3ad23af..2e1ec6b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,17 @@ historical data. - I'm also looking for someone who owns an Ecowitt weather station and would be willing to help with testing the integration for these devices. +> [!WARNING] +> When that day comes, do **not** delete this integration under `Settings` -> `Devices & services` -> `Delete`. +> That action removes the entity registry records, and your history and long-term statistics have nothing left to attach to once they are gone. +> They cannot be relinked afterwards, not even by the new integration. +> +> Removing the *repository* in HACS is safe and is exactly what the migration expects. +> The config entry stays where it is, showing up as `Integration not found`, which looks broken but is the state the new integration needs in order to take your entities over with their history intact. +> +> Both actions are called "remove" in the UI and look interchangeable, so this is worth saying out loud. +> The full step-by-step procedure, with the new repository's name and URL, will be published together with the new integration. + --- ## Warning — WSLink app (applies also to SWS 12500 with firmware > 3.0) diff --git a/custom_components/sws12500/__init__.py b/custom_components/sws12500/__init__.py index aa7f3dc..089e5a6 100644 --- a/custom_components/sws12500/__init__.py +++ b/custom_components/sws12500/__init__.py @@ -56,7 +56,7 @@ from .coordinator import WeatherDataUpdateCoordinator from .data import SWSConfigEntry, SWSRuntimeData from .health_coordinator import HealthCoordinator from .legacy import update_legacy_battery_issue -from .predecessor import async_adopt_predecessor, inherit_predecessor_options +from .predecessor import async_adopt_predecessor, inherit_predecessor_options, update_predecessor_adoption_issue from .routes import Routes from .staleness import update_stale_sensors_issue @@ -151,9 +151,19 @@ def register_path( # act on rather than an unhandled traceback. except (RuntimeError, ValueError) as Ex: _LOGGER.critical("Routes cannot be added. Integration will not work as expected. %s", Ex) + # Only the duplicate-name case is a leftover previous version. aiohttp raises + # ValueError for a malformed path or a bad `{webhook_id}` pattern too, and + # RuntimeError for a router that is already frozen - telling those users to + # uninstall something they may not even have would send them off the trail. + if isinstance(Ex, ValueError) and str(Ex).startswith("Duplicate"): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="webhook_routes_taken", + ) from Ex raise ConfigEntryNotReady( - "Webhook routes are already registered by another instance of this " - "integration. Remove the previous version in HACS and restart Home Assistant." + translation_domain=DOMAIN, + translation_key="webhook_routes_failed", + translation_placeholders={"error": str(Ex)}, ) from Ex # Finally create internal route dispatcher with provided urls, while we have webhooks registered. @@ -251,7 +261,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool: # platforms then bind to those same entries by unique id, keeping their entity_id # and with it their recorder history. After `async_forward_entry_setups` the # entity_ids would already have been handed out to freshly created entities. - await async_adopt_predecessor(hass, entry) + # The outcome is reported, not dropped: a migration that stalled leaves the old entry + # behind looking broken, and the user has to be told not to delete it by hand. + adoption = await async_adopt_predecessor(hass, entry) + update_predecessor_adoption_issue(hass, entry, adoption) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/sws12500/predecessor.py b/custom_components/sws12500/predecessor.py index 18072f8..37a666e 100644 --- a/custom_components/sws12500/predecessor.py +++ b/custom_components/sws12500/predecessor.py @@ -12,7 +12,7 @@ It rewrites `platform` and `config_entry_id` and leaves `entity_id` alone. Every the user set by hand rides along for free, because the registry entry itself survives: renames, icons, areas, labels, categories, hidden and disabled state. -This constrains the upgrade procedure, and the README has to say so plainly: +This constrains the upgrade procedure: 1. Do **not** delete the old integration under Settings first. That path calls `entity_registry.async_clear_config_entry`, which removes exactly the entries this @@ -22,6 +22,10 @@ This constrains the upgrade procedure, and the README has to say so plainly: needs. 3. Add this integration. Adoption runs during setup, before any of our own entities are created, so the migrated entries are the ones our platforms bind to. + +The README carries the general form of the warning - remove the repository in HACS, never +the entry under Settings. The step-by-step procedure needs the new repository's name and +URL, which do not exist yet, so it is written there once they do. """ from __future__ import annotations @@ -31,8 +35,9 @@ import logging from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_RESTORED, STATE_UNKNOWN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er, issue_registry as ir +from homeassistant.helpers.issue_registry import IssueSeverity from .const import DOMAIN, POCASI_CZ_ENABLED, POCASI_CZ_ENABLED_LEGACY, PREDECESSOR_DOMAIN from .data import build_device_info @@ -59,6 +64,25 @@ class AdoptionResult: """Whether there was anything to do at all.""" return bool(self.adopted or self.live or self.conflicting) + @property + def skipped(self) -> list[str]: + """Every entity that stayed behind, whatever the reason.""" + return [*self.live, *self.conflicting] + + def absorb(self, other: AdoptionResult) -> None: + """Fold one predecessor entry's outcome into the running total. + + Completeness has to be decided per predecessor entry, not on the running total. + There can be more than one - the predecessor's manifest never declared + `single_config_entry` - and a single blocked entity under the first one would + otherwise condemn every later entry to be kept forever, even a fully adopted one. + """ + + self.adopted.extend(other.adopted) + self.live.extend(other.live) + self.conflicting.extend(other.conflicting) + self.predecessor_removed |= other.predecessor_removed + def _release_restored_state(hass: HomeAssistant, entity_id: str) -> bool: """Drop the placeholder state Home Assistant writes for an unloaded entity. @@ -148,21 +172,35 @@ def _adopt_devices(hass: HomeAssistant, entry: ConfigEntry, old_entry: ConfigEnt the two ever drifted, `async_get_or_create` would quietly mint a second device and strand the adopted one. - Add the new config entry before removing the old one. A device left with no config - entries is deleted, and deleting a device takes its entities with it - (`entity_registry.async_device_modified`). + Only ever add this entry, never remove the old one. Removing it is what + `hass.config_entries.async_remove` does anyway, and only that path is reached once + adoption is known to be complete. Doing it here instead would fire + `entity_registry.async_device_modified`, which deletes every entity of this device + that is still pointing at the config entry just detached - which is to say, exactly + the entities adoption deliberately refused to move, silently and before + `AdoptionResult.complete` ever gets a chance to protect them. + + A predecessor entry can own more than one device (v2.0.0pre1 created a second one per + Ecowitt channel), and only one device may hold a given identifier: passing the same + set twice raises `DeviceIdentifierCollisionError` and would fail setup for good. The + extra devices therefore keep their own identifiers and are only repointed, which + leaves them attached to this entry and reapable once they hold nothing. """ device_registry = dr.async_get(hass) identifiers = set(build_device_info(entry)["identifiers"]) for device in dr.async_entries_for_config_entry(device_registry, old_entry.entry_id): + holder = device_registry.async_get_device(identifiers=identifiers) + if holder is not None and holder.id != device.id: + device_registry.async_update_device(device.id, add_config_entry_id=entry.entry_id) + continue + device_registry.async_update_device( device.id, new_identifiers=identifiers, # type: ignore[arg-type] same 1-tuple shape as build_device_info add_config_entry_id=entry.entry_id, ) - device_registry.async_update_device(device.id, remove_config_entry_id=old_entry.entry_id) async def _adopt_entities( @@ -244,26 +282,30 @@ async def async_adopt_predecessor( ) continue - await _adopt_entities(hass, entry, old_entry, result) + # Scoped to this predecessor entry, then folded in: whether an entry may be + # removed depends on its own entities only, never on what a sibling left behind. + entry_result = AdoptionResult() + await _adopt_entities(hass, entry, old_entry, entry_result) # Before removing the old entry, not after: `async_remove` clears the entry from # its devices, and a device that loses its last config entry is deleted along # with the entities pointing at it. _adopt_devices(hass, entry, old_entry) - if not result.complete: + if entry_result.complete: + await hass.config_entries.async_remove(old_entry.entry_id) + entry_result.predecessor_removed = True + else: # Removing the entry now would delete whatever we could not carry over. # Leaving it in place costs a repair notice and keeps a retry possible. _LOGGER.error( "Adopted %s entities but %s could not be moved; the previous integration " "is left in place so nothing is lost", - len(result.adopted), - len(result.live) + len(result.conflicting), + len(entry_result.adopted), + len(entry_result.skipped), ) - continue - await hass.config_entries.async_remove(old_entry.entry_id) - result.predecessor_removed = True + result.absorb(entry_result) if result.adopted: _LOGGER.info( @@ -273,3 +315,44 @@ async def async_adopt_predecessor( ) return result + + +def _adoption_issue_id(entry: ConfigEntry) -> str: + """Return the Repairs issue id for this config entry.""" + return f"predecessor_adoption_{entry.entry_id}" + + +@callback +def update_predecessor_adoption_issue( + hass: HomeAssistant, + entry: ConfigEntry, + result: AdoptionResult, +) -> None: + """Tell the user about a migration that stalled, and what not to do about it. + + A partial run leaves the previous integration's entry in place on purpose, where it + reads as broken ("Integration not found"). The obvious reaction - deleting it under + Settings - is the one action that cannot be undone: it calls + `entity_registry.async_clear_config_entry`, and the registry entries it drops are + what the recorder history and the long-term statistics hang off. Hence a repair + notice that names the entities and spells the wrong move out. + + Cleared again as soon as a later pass finishes the job, so it cannot outlive the + problem it describes. + """ + + issue_id = _adoption_issue_id(entry) + + if result.attempted and not result.complete: + ir.async_create_issue( + hass, + DOMAIN, + issue_id=issue_id, + is_persistent=True, + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="predecessor_adoption_incomplete", + translation_placeholders={"entities": ", ".join(sorted(result.skipped))}, + ) + else: + ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id) diff --git a/custom_components/sws12500/strings.json b/custom_components/sws12500/strings.json index 74cdaad..89e47a3 100644 --- a/custom_components/sws12500/strings.json +++ b/custom_components/sws12500/strings.json @@ -648,6 +648,18 @@ "protocol_conflict": { "title": "Two incompatible protocols enabled", "description": "The legacy (PWS/WSLink) and Ecowitt endpoints are both enabled. They map onto the same sensor entities, so running both mixes up measurement units and makes readings disappear between updates. The legacy endpoint is being used and Ecowitt data is ignored until you choose one: go to Settings -> Devices & Services -> SWS 12500 -> Configure and disable either the legacy endpoint or Ecowitt." + }, + "predecessor_adoption_incomplete": { + "title": "Migration from the previous integration is not finished", + "description": "These entities could not be taken over from the previous integration yet: {entities}. The old entry is deliberately left in place so nothing is lost, even though it shows up as broken.\n\nDo NOT delete the old entry via Settings -> Devices & Services -> Delete. That removes its entity registry records, and once they are gone there is nothing left for the history and the long-term statistics to attach to - they cannot be relinked afterwards.\n\nRestart Home Assistant instead and let the migration finish. This message disappears on its own once every entity has been moved over." + } + }, + "exceptions": { + "webhook_routes_taken": { + "message": "Webhook routes are already registered by another instance of this integration. Remove the previous version in HACS and restart Home Assistant." + }, + "webhook_routes_failed": { + "message": "Webhook routes could not be registered: {error}" } }, "notify": { diff --git a/custom_components/sws12500/translations/cs.json b/custom_components/sws12500/translations/cs.json index 18998d8..5dff14f 100644 --- a/custom_components/sws12500/translations/cs.json +++ b/custom_components/sws12500/translations/cs.json @@ -648,6 +648,18 @@ "protocol_conflict": { "title": "Zapnuté dva neslučitelné protokoly", "description": "Současně je zapnutý starý endpoint (PWS/WSLink) i Ecowitt. Oba plní stejné entity senzorů, takže jejich souběh míchá měrné jednotky a hodnoty mezi aktualizacemi mizí. Používá se starý endpoint a data z Ecowittu se ignorují, dokud si nevybereš jeden: jdi do Nastavení -> Zařízení a služby -> SWS 12500 -> Konfigurovat a vypni buď starý endpoint, nebo Ecowitt." + }, + "predecessor_adoption_incomplete": { + "title": "Migrace z předchozí integrace není dokončená", + "description": "Tyto entity se zatím nepodařilo převzít z předchozí integrace: {entities}. Starý záznam je záměrně ponechaný na místě, aby se nic neztratilo, i když se tváří jako nefunkční.\n\nStarý záznam NEMAŽ přes Nastavení -> Zařízení a služby -> Smazat. Tím se odstraní jeho záznamy v registru entit, a jakmile jsou pryč, nemá se historie ani dlouhodobé statistiky na co navázat - zpětně už je připojit nelze.\n\nMísto toho restartuj Home Assistant a nech migraci doběhnout. Tahle zpráva zmizí sama, jakmile se přenesou všechny entity." + } + }, + "exceptions": { + "webhook_routes_taken": { + "message": "Webhook cesty už registruje jiná instance této integrace. Odeber předchozí verzi v HACS a restartuj Home Assistant." + }, + "webhook_routes_failed": { + "message": "Webhook cesty se nepodařilo zaregistrovat: {error}" } }, "notify": { diff --git a/custom_components/sws12500/translations/en.json b/custom_components/sws12500/translations/en.json index 74cdaad..89e47a3 100644 --- a/custom_components/sws12500/translations/en.json +++ b/custom_components/sws12500/translations/en.json @@ -648,6 +648,18 @@ "protocol_conflict": { "title": "Two incompatible protocols enabled", "description": "The legacy (PWS/WSLink) and Ecowitt endpoints are both enabled. They map onto the same sensor entities, so running both mixes up measurement units and makes readings disappear between updates. The legacy endpoint is being used and Ecowitt data is ignored until you choose one: go to Settings -> Devices & Services -> SWS 12500 -> Configure and disable either the legacy endpoint or Ecowitt." + }, + "predecessor_adoption_incomplete": { + "title": "Migration from the previous integration is not finished", + "description": "These entities could not be taken over from the previous integration yet: {entities}. The old entry is deliberately left in place so nothing is lost, even though it shows up as broken.\n\nDo NOT delete the old entry via Settings -> Devices & Services -> Delete. That removes its entity registry records, and once they are gone there is nothing left for the history and the long-term statistics to attach to - they cannot be relinked afterwards.\n\nRestart Home Assistant instead and let the migration finish. This message disappears on its own once every entity has been moved over." + } + }, + "exceptions": { + "webhook_routes_taken": { + "message": "Webhook routes are already registered by another instance of this integration. Remove the previous version in HACS and restart Home Assistant." + }, + "webhook_routes_failed": { + "message": "Webhook routes could not be registered: {error}" } }, "notify": { diff --git a/tests/test_integration_lifecycle.py b/tests/test_integration_lifecycle.py index 3976207..b9daef2 100644 --- a/tests/test_integration_lifecycle.py +++ b/tests/test_integration_lifecycle.py @@ -1,6 +1,8 @@ from __future__ import annotations from dataclasses import dataclass +import json +from pathlib import Path from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -27,6 +29,7 @@ from custom_components.sws12500.health_coordinator import HealthCoordinator from custom_components.sws12500.routes import unregistered ECOWITT_PATH = ECOWITT_URL_PREFIX + "/{webhook_id}" +COMPONENT_DIR = Path(__file__).resolve().parents[1] / "custom_components" / "sws12500" @dataclass(slots=True) @@ -116,20 +119,33 @@ async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_ht @pytest.mark.asyncio @pytest.mark.parametrize( - "router_error", + ("router_error", "expected_key"), [ - RuntimeError("router broken"), + (RuntimeError("router broken"), "webhook_routes_failed"), # aiohttp's own wording when a route *name* is taken. The names here are fixed # rather than domain-scoped, so a previous version of this integration that is # still installed holds them - the state a half-finished migration leaves behind. - ValueError("Duplicate '_default_route', already handled by "), + ( + ValueError("Duplicate '_default_route', already handled by "), + "webhook_routes_taken", + ), + # aiohttp raises ValueError for a malformed path too. Same exception type, and + # nothing whatsoever to do with a leftover previous version. + (ValueError("Path should be started with / got 'weatherhub'"), "webhook_routes_failed"), ], - ids=["runtime_error", "duplicate_route_name"], + ids=["runtime_error", "duplicate_route_name", "malformed_path"], ) async def test_register_path_raises_config_entry_not_ready_on_router_error( hass_with_http, router_error: Exception, + expected_key: str, ): + """Only a duplicate route name may be reported as a leftover previous version. + + The message is the only thing the user sees on the integrations page. Telling + somebody with a malformed path to uninstall a version they may never have installed + sends them off the trail, while the real cause sits in the log line above. + """ from homeassistant.exceptions import ConfigEntryNotReady entry = MockConfigEntry( @@ -148,9 +164,44 @@ async def test_register_path_raises_config_entry_not_ready_on_router_error( with pytest.raises(ConfigEntryNotReady) as excinfo: register_path(hass_with_http, coordinator, coordinator_health, entry) - # The message is the only thing the user sees on the integrations page, so it has to - # name the fix rather than repeat the aiohttp internals. - assert "HACS" in str(excinfo.value) + assert excinfo.value.translation_key == expected_key + assert excinfo.value.translation_domain == DOMAIN + # The original exception is what the log line carries, in both cases. + assert excinfo.value.__cause__ is router_error + + messages = json.loads((COMPONENT_DIR / "strings.json").read_text(encoding="utf-8"))["exceptions"] + shown = messages[expected_key]["message"] + if expected_key == "webhook_routes_taken": + assert "HACS" in shown + else: + assert "HACS" not in shown, "a generic router failure must not tell the user to uninstall anything" + + +@pytest.mark.asyncio +async def test_route_failure_messages_are_translated(hass, enable_custom_integrations): + """A translation key that resolves to nothing shows the user the raw key. + + Home Assistant looks exception messages up under `component..exceptions. + .message`, loading them from the integration's own `translations/` directory - + custom integrations included. This asserts the wiring, so a renamed or missing key + fails here rather than on somebody's integrations page. + """ + from homeassistant.exceptions import ConfigEntryNotReady + from homeassistant.helpers import translation + + hass.config.components.add(DOMAIN) + await translation.async_load_integrations(hass, {DOMAIN}) + + taken = ConfigEntryNotReady(translation_domain=DOMAIN, translation_key="webhook_routes_taken") + assert "HACS" in str(taken) + + failed = ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="webhook_routes_failed", + translation_placeholders={"error": "router broken"}, + ) + assert "router broken" in str(failed) + assert "HACS" not in str(failed) @pytest.mark.asyncio diff --git a/tests/test_predecessor_adoption.py b/tests/test_predecessor_adoption.py index fc1aafa..a5a9e6b 100644 --- a/tests/test_predecessor_adoption.py +++ b/tests/test_predecessor_adoption.py @@ -25,10 +25,13 @@ from custom_components.sws12500.const import ( SENSORS_TO_LOAD, ) from custom_components.sws12500.data import build_device_info -from custom_components.sws12500.predecessor import async_adopt_predecessor +from custom_components.sws12500.predecessor import ( + async_adopt_predecessor, + update_predecessor_adoption_issue, +) from homeassistant.const import ATTR_RESTORED, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er, issue_registry as ir OLD_DOMAIN = "sws12500_legacy" @@ -288,6 +291,165 @@ async def test_device_identifiers_match_what_the_platform_will_look_up( assert looked_up.id == device.id +async def test_a_refused_entity_survives_its_device_being_repointed(hass: HomeAssistant, entries) -> None: + """The quietest way to lose history, and the reason a device is never detached here. + + `entity_registry.async_device_modified` reacts to a device losing a config entry by + deleting every entity of that device still pointing at it. On a partial adoption + those are precisely the entities we refused to move - so detaching the predecessor + from the device would delete them, before `AdoptionResult.complete` ever gets to + keep the old entry alive for a retry. The entity_id is freed, the recorder history + and the long-term statistics are orphaned, and nothing is logged. + """ + old, new = entries + device_registry = dr.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=old.entry_id, + identifiers={(OLD_DOMAIN,)}, # type: ignore[arg-type] + name="Weather Station", + ) + created = _seed(hass, old, device_id=device.id) + refused = created["outside_temp"] + hass.states.async_set(refused, "21.5") # live: adoption must leave this one alone + + result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN) + + assert result.live == [refused] + registry = er.async_get(hass) + survived = registry.async_get(refused) + assert survived is not None, "the refused entity was deleted along with the device link" + assert survived.entity_id == refused + assert survived.platform == OLD_DOMAIN + assert hass.config_entries.async_get_entry(old.entry_id) is not None + + +async def test_a_second_device_does_not_collide(hass: HomeAssistant, entries) -> None: + """Two devices, one identifier set. Rewriting both would raise and never recover. + + `device_registry._validate_identifiers` refuses an identifier another device already + holds, and the exception would escape `async_setup_entry` - after the entities were + repointed and before the old entry was removed, so every restart reproduces it and + the integration never sets up again. + """ + old, new = entries + device_registry = dr.async_get(hass) + primary = device_registry.async_get_or_create( + config_entry_id=old.entry_id, + identifiers={(OLD_DOMAIN,)}, # type: ignore[arg-type] + name="Weather Station", + ) + secondary = device_registry.async_get_or_create( + config_entry_id=old.entry_id, + identifiers={(OLD_DOMAIN, "ecowitt_ch1")}, + name="Channel 1", + ) + created = _seed(hass, old, device_id=primary.id) + registry = er.async_get(hass) + extra = registry.async_get_or_create( + "sensor", OLD_DOMAIN, "ch1_temp", config_entry=old, device_id=secondary.id + ) + + result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN) + + assert result.complete + for entity_id in (*created.values(), extra.entity_id): + assert registry.async_get(entity_id) is not None, f"{entity_id} disappeared" + + # One device holds the shared identifiers, the other keeps its own and is repointed. + holder = device_registry.async_get_device(identifiers=set(build_device_info(new)["identifiers"])) + assert holder is not None + assert holder.id == primary.id + kept = device_registry.async_get(secondary.id) + assert kept is not None + assert kept.identifiers == {(OLD_DOMAIN, "ecowitt_ch1")} + assert new.entry_id in kept.config_entries + + +# --------------------------------------------------------------------------- +# More than one predecessor entry +# --------------------------------------------------------------------------- + + +async def test_a_blocked_entry_does_not_hold_back_a_clean_one(hass: HomeAssistant, entries) -> None: + """Completeness is per predecessor entry; the predecessor never had `single_config_entry`. + + Decided on the running total, one blocked entity under the first entry would condemn + every later one to be kept forever - broken "Integration not found" rows that no + retry can ever clear, because the same blocker reproduces the state every time. + """ + old, new = entries + created = _seed(hass, old) + blocked = created["outside_temp"] + hass.states.async_set(blocked, "21.5") + + second = MockConfigEntry(domain=OLD_DOMAIN, title="Old 2") + second.add_to_hass(hass) + registry = er.async_get(hass) + clean = registry.async_get_or_create("sensor", OLD_DOMAIN, "second_station_temp", config_entry=second) + + result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN) + + assert hass.config_entries.async_get_entry(second.entry_id) is None, ( + "a fully adopted entry must be removed even when a sibling was blocked" + ) + assert hass.config_entries.async_get_entry(old.entry_id) is not None + assert result.predecessor_removed + assert not result.complete + assert registry.async_get_entity_id("sensor", DOMAIN, "second_station_temp") == clean.entity_id + + +# --------------------------------------------------------------------------- +# Reporting - the user has to be told, and told what not to do +# --------------------------------------------------------------------------- + + +async def test_a_stalled_migration_raises_a_repair_issue(hass: HomeAssistant, entries) -> None: + """A log line is not enough: the obvious reaction to the leftover entry destroys history.""" + old, new = entries + created = _seed(hass, old) + blocked = created["outside_temp"] + hass.states.async_set(blocked, "21.5") + + result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN) + update_predecessor_adoption_issue(hass, new, result) + + issue = ir.async_get(hass).async_get_issue(DOMAIN, f"predecessor_adoption_{new.entry_id}") + assert issue is not None + assert issue.translation_key == "predecessor_adoption_incomplete" + assert issue.translation_placeholders == {"entities": blocked} + + +async def test_a_finished_migration_raises_no_issue(hass: HomeAssistant, entries) -> None: + """Nothing was left behind, so there is nothing to warn about.""" + old, new = entries + _seed(hass, old) + + result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN) + update_predecessor_adoption_issue(hass, new, result) + + assert ir.async_get(hass).async_get_issue(DOMAIN, f"predecessor_adoption_{new.entry_id}") is None + + +async def test_the_issue_is_cleared_once_the_migration_finishes(hass: HomeAssistant, entries) -> None: + """Otherwise the notice outlives the problem and the user learns to ignore it.""" + old, new = entries + created = _seed(hass, old) + blocked = created["outside_temp"] + hass.states.async_set(blocked, "21.5") + + first = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN) + update_predecessor_adoption_issue(hass, new, first) + issue_id = f"predecessor_adoption_{new.entry_id}" + assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None + + hass.states.async_remove(blocked) + second = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN) + update_predecessor_adoption_issue(hass, new, second) + + assert second.complete + assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None + + # --------------------------------------------------------------------------- # Repeatability # ---------------------------------------------------------------------------