276 lines
12 KiB
Python
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
|