"""Adopt the predecessor integration's entities without losing their history. Home Assistant keys recorder history and long-term statistics on `entity_id`, not on the integration domain: `recorder/entity_registry.py` renames both `states_meta.entity_id` and `statistics_meta.statistic_id` when - and only when - an `entity_id` changes. A domain change therefore costs nothing *provided the registry entries are carried over rather than recreated*. A recreated entity gets a fresh `entity_id` (or the same one with an `_2` suffix, if the old entry still holds it) and its history is orphaned. `entity_registry.async_update_entity_platform` is the supported way to carry them over. It rewrites `platform` and `config_entry_id` and leaves `entity_id` alone. Everything 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: 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 module needs. Once they are gone the history cannot be relinked. 2. Swap the repository in HACS and restart. The old entry stays behind in `SETUP_ERROR` ("Integration not found"), which is expected and is the state adoption 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. """ from __future__ import annotations from dataclasses import dataclass, field 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 .const import DOMAIN, POCASI_CZ_ENABLED, POCASI_CZ_ENABLED_LEGACY, PREDECESSOR_DOMAIN from .data import build_device_info _LOGGER = logging.getLogger(__name__) @dataclass(slots=True) class AdoptionResult: """Outcome of one adoption pass, so the caller can report it and retry.""" adopted: list[str] = field(default_factory=list) live: list[str] = field(default_factory=list) conflicting: list[str] = field(default_factory=list) predecessor_removed: bool = False @property def complete(self) -> bool: """Whether every entity of the predecessor was carried over.""" return not self.live and not self.conflicting @property def attempted(self) -> bool: """Whether there was anything to do at all.""" return bool(self.adopted or self.live or self.conflicting) def _release_restored_state(hass: HomeAssistant, entity_id: str) -> bool: """Drop the placeholder state Home Assistant writes for an unloaded entity. At startup `entity_registry._write_unavailable_states` gives every registered but unloaded entity a state of `unavailable` carrying `ATTR_RESTORED`. That is exactly the state the predecessor's entities are in once its files are gone from disk - and `async_update_entity_platform` accepts only a missing state or `unknown`, so without clearing the placeholder first every single adoption would raise `ValueError`. Returns False when a state is present that is *not* a placeholder. That means some integration is still driving this entity, and moving it out from under a live platform would leave two owners for one registry entry. """ state = hass.states.get(entity_id) if state is None or state.state == STATE_UNKNOWN: return True if not state.attributes.get(ATTR_RESTORED): return False hass.states.async_remove(entity_id) return True def inherit_predecessor_options( hass: HomeAssistant, entry: ConfigEntry, *, predecessor_domain: str | None = None, ) -> bool: """Carry the predecessor's settings over, without overwriting fresh input. Deliberately separate from `async_adopt_predecessor`, and deliberately run earlier: setup reads the options while wiring up the coordinator and the webhook routes, so inheriting an option after that point would leave the entry running on the wrong protocol until the next reload. It is also additive and reversible in a way the rest of adoption is not, which is why it can safely run before the routes are proven. Adopting the registry alone would not be enough anyway. `SENSORS_TO_LOAD` decides which entities the platform creates, so an empty one leaves every adopted entry showing "no longer provided" until auto-discovery has seen a payload - and derived sensors like the wind azimut are never in a payload at all, so those come back only on the next restart. The forwarding credentials and the learned probe types would likewise have to be entered again. Merged the other way round from what reads naturally: the predecessor fills gaps only. Anything the user just entered in this integration's config flow is newer by definition and wins. The version 1 spelling of the Pocasi Meteo flag is translated on the way in, because `async_migrate_entry` runs against this entry's own version - already current - and would never look at a key copied in afterwards. """ predecessor_domain = predecessor_domain or PREDECESSOR_DOMAIN if predecessor_domain == DOMAIN: return False if not (old_entries := hass.config_entries.async_entries(predecessor_domain)): return False inherited: dict[str, object] = {} for old_entry in old_entries: inherited.update(old_entry.options) if POCASI_CZ_ENABLED_LEGACY in inherited: legacy_value = inherited.pop(POCASI_CZ_ENABLED_LEGACY) inherited.setdefault(POCASI_CZ_ENABLED, legacy_value) merged = {**inherited, **entry.options} if merged == dict(entry.options): return False gained = len(merged) - len(entry.options) hass.config_entries.async_update_entry(entry, options=merged) _LOGGER.debug("Inherited %s settings from the previous integration", gained) return True def _adopt_devices(hass: HomeAssistant, entry: ConfigEntry, old_entry: ConfigEntry) -> None: """Repoint the predecessor's devices at this entry, keeping the same device rows. The identifiers come from `build_device_info` rather than being rebuilt here, so the device this leaves behind is by construction the one our platforms will look up. If 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`). """ 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): 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( hass: HomeAssistant, entry: ConfigEntry, old_entry: ConfigEntry, result: AdoptionResult, ) -> None: """Move every registry entry of `old_entry` onto this integration.""" registry = er.async_get(hass) for old in er.async_entries_for_config_entry(registry, old_entry.entry_id): # Home Assistant does not check this itself: the duplicate-unique_id guard in # `_async_update_entity` only runs when `new_unique_id` is passed, so changing # just the platform onto an already-taken key would leave two registry entries # sharing one index slot. Happens when a user sets this integration up fresh # and only then tries to adopt. if (clash := registry.async_get_entity_id(old.domain, DOMAIN, old.unique_id)) is not None: _LOGGER.warning( "Cannot adopt %s: unique id %r is already used by %s", old.entity_id, old.unique_id, clash, ) result.conflicting.append(old.entity_id) continue if not _release_restored_state(hass, old.entity_id): _LOGGER.warning( "Cannot adopt %s: it still has a live state, so an integration is driving it", old.entity_id, ) result.live.append(old.entity_id) continue registry.async_update_entity_platform(old.entity_id, DOMAIN, new_config_entry_id=entry.entry_id) result.adopted.append(old.entity_id) async def async_adopt_predecessor( hass: HomeAssistant, entry: ConfigEntry, *, predecessor_domain: str | None = None, ) -> AdoptionResult: """Carry the predecessor integration's entities over to this one. Safe to call on every setup: with no predecessor entry left it is a dictionary lookup. Safe to call again after a partial run, because each entity is decided independently and an already-adopted one is simply no longer listed under the old config entry. """ result = AdoptionResult() predecessor_domain = predecessor_domain or PREDECESSOR_DOMAIN if predecessor_domain == DOMAIN: # Nothing was renamed yet, so there is no predecessor to adopt from. return result if not (old_entries := hass.config_entries.async_entries(predecessor_domain)): return result # Normally already done by setup, before the options were read. Repeated here so a # caller that forgets cannot silently drop the settings - it is a no-op second time. inherit_predecessor_options(hass, entry, predecessor_domain=predecessor_domain) for old_entry in old_entries: if old_entry.state is ConfigEntryState.LOADED: # Its entities cannot be migrated while loaded, and both integrations would # otherwise fight over the same webhook routes. Unload rather than refuse: # the user is mid-migration and this entry is on its way out anyway. if not await hass.config_entries.async_unload(old_entry.entry_id): _LOGGER.error( "Could not unload the previous integration (%s); adoption skipped. " "Restart Home Assistant and try again", old_entry.entry_id, ) continue await _adopt_entities(hass, entry, old_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: # 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), ) continue await hass.config_entries.async_remove(old_entry.entry_id) result.predecessor_removed = True if result.adopted: _LOGGER.info( "Adopted %s entities from %s; their history and statistics are unchanged", len(result.adopted), predecessor_domain, ) return result