SWS-12500-custom-component/custom_components/sws12500/predecessor.py

276 lines
12 KiB
Python

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