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

437 lines
20 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.
The registry does hand a deleted entity back on recreation, but only within one
integration: deleted entries are held under `(domain, platform, unique_id)`, where
`platform` is the owning integration's domain, and `async_get_or_create` restores the old
`entity_id` only when that whole key matches again and the id is still free. Deleting and
re-adding the *same* integration therefore keeps its history on its own, which is why the
README can tell PWS users to reinstall in place. A domain change never matches the key, so
that path is not even consulted, and this module is what makes the move deterministic
instead. The restore is time limited as well: `ORPHANED_ENTITY_KEEP_SECONDS` drops the
deleted records 30 days after the config entry goes away.
`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 typing import Final
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__)
# How many stranded entities the repair notice names before it falls back to the count.
ISSUE_ENTITY_SAMPLE: Final = 5
@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. So
exactly one of them can be the device our platforms resolve, and which one must not
be left to the order the registry happens to yield. Once the domain really changes,
nothing holds the target identifiers yet, so a per-channel row that happened to come
first would become the station device and every adopted entity would be moved onto
it, stranding the real one with the user's name, area and labels on it. The device
carrying the most entities wins instead, ties broken on `device.id` so the outcome
does not depend on storage order at all. The others keep their own identifiers and
are only repointed.
A device carrying no entities is left alone entirely. Attaching this entry to it
would keep it alive forever: `device_registry.async_cleanup` subtracts
`references_config_entries` from the orphan set, so a device linked to any live
config entry is never reaped, and an empty device page would sit under this
integration for good. Left untouched, `async_remove(old_entry)` takes its last config
entry away and deletes it. Nothing rides on it - the remove branch of
`entity_registry.async_device_modified` only deletes entities whose `config_entry_id`
is one of the removed device's own, and by this point the adopted ones point at this
entry.
That count is taken before our platforms run, so it catches a device that is already
empty when adoption starts, but not one adoption itself empties. A predecessor still
on v2.0.0pre1 arrives with its per-channel Ecowitt device still holding entities, so
it reads as populated and gets this entry attached; its entities are then adopted and
`async_forward_entry_setups` re-homes them onto the canonical device, because
`EcowittBridge` and `WeatherSensor` both take their `device_info` from
`build_device_info`. The row is left empty, still referenced by a live config entry,
and `async_cleanup` therefore never reaps it. The residue is cosmetic only - the
`entity_id` values do not change, so no history and no long-term statistics are
involved - and it is left alone deliberately, because closing it would mean another
pass after platform setup for a configuration that does not exist in the field.
"""
device_registry = dr.async_get(hass)
registry = er.async_get(hass)
identifiers = set(build_device_info(entry)["identifiers"])
devices = dr.async_entries_for_config_entry(device_registry, old_entry.entry_id)
held = {
device.id: len(er.async_entries_for_device(registry, device.id, include_disabled_entities=True))
for device in devices
}
populated = [device for device in devices if held[device.id]]
canonical = min(populated, key=lambda device: (-held[device.id], device.id), default=None)
for device in populated:
holder = device_registry.async_get_device(identifiers=identifiers)
if device is not canonical or (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,
)
# Counted as left behind rather than passed over in silence. This is the
# run where *nothing* moved, so it is also where deleting the old entry
# by hand would cost the most - exactly what the repair notice is for.
registry = er.async_get(hass)
result.live.extend(
old.entity_id for old in er.async_entries_for_config_entry(registry, 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}"
def _sample_skipped(skipped: list[str]) -> str:
"""Render the stranded entities short enough to read at a glance.
A predecessor that refuses to unload strands its whole station at once, and a WSLink
station carries on the order of eighty sensor keys. Spelled out in full, the list
would push the one sentence that matters - do not delete the old entry - off the
bottom of the notice. Cut here rather than in the translations, so no language has to
solve it again; the total travels beside it as its own placeholder.
"""
shown = sorted(skipped)[:ISSUE_ENTITY_SAMPLE]
if len(skipped) > len(shown):
shown.append("...")
return ", ".join(shown)
@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. That warning leads
the notice; the list of what is still stranded comes underneath it, sampled rather
than spelled 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": _sample_skipped(result.skipped),
"count": str(len(result.skipped)),
},
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id)