359 lines
15 KiB
Python
359 lines
15 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:
|
|
|
|
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.
|
|
|
|
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
|
|
|
|
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, 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
|
|
|
|
_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)
|
|
|
|
@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.
|
|
|
|
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.
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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
|
|
|
|
# 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 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(entry_result.adopted),
|
|
len(entry_result.skipped),
|
|
)
|
|
|
|
result.absorb(entry_result)
|
|
|
|
if result.adopted:
|
|
_LOGGER.info(
|
|
"Adopted %s entities from %s; their history and statistics are unchanged",
|
|
len(result.adopted),
|
|
predecessor_domain,
|
|
)
|
|
|
|
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)
|