fix(document): Document duplicate-route failure in migration warning
parent
0407cb049d
commit
9402d69d49
|
|
@ -42,6 +42,7 @@ historical data.
|
|||
>
|
||||
> Removing the *repository* in HACS is safe and is exactly what the migration expects.
|
||||
> The config entry stays where it is, showing up as `Integration not found`, which looks broken but is the state the new integration needs in order to take your entities over with their history intact.
|
||||
> Do it before you install the new integration: as long as the old version is still on disk it holds the webhook routes, and the new one refuses to start with `Webhook routes are already registered by another instance of this integration`.
|
||||
>
|
||||
> Both actions are called "remove" in the UI and look interchangeable, so this is worth saying out loud.
|
||||
> The full step-by-step procedure, with the new repository's name and URL, will be published together with the new integration.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
"""End-to-end proof that the rename does not cost a single measurement.
|
||||
|
||||
`test_predecessor_adoption.py` proves the registry side of the move: the `entity_id`
|
||||
does not change. This file closes the loop the user actually cares about by putting a
|
||||
real recorder behind it - short-term history *and* long-term statistics are written for
|
||||
the predecessor's entity, the new integration is then set up exactly as a user would set
|
||||
it up (config flow options, real HTTP upload from the station), and the same rows are
|
||||
read back afterwards through the same API the History and Statistics panels use.
|
||||
|
||||
The scenario mirrors the documented upgrade procedure:
|
||||
|
||||
1. The old integration ran and recorded measurements.
|
||||
2. Its repository was swapped in HACS, so its files are gone and its entities sit at
|
||||
`unavailable` with `restored: true` while its config entry stays behind.
|
||||
3. The user adds the renamed integration, typing only the credentials.
|
||||
|
||||
Set ``SWS_MIGRATION_REPORT`` to a file path to dump the before/after transcript.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from pytest_homeassistant_custom_component.components.recorder.common import async_wait_recording_done
|
||||
|
||||
from custom_components.sws12500.const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
DOMAIN,
|
||||
OUTSIDE_HUMIDITY,
|
||||
OUTSIDE_TEMP,
|
||||
SENSORS_TO_LOAD,
|
||||
WSLINK,
|
||||
WSLINK_URL,
|
||||
)
|
||||
from homeassistant.components.recorder.history import get_significant_states
|
||||
from homeassistant.components.recorder.models import StatisticMeanType
|
||||
from homeassistant.components.recorder.statistics import async_import_statistics, statistics_during_period
|
||||
from homeassistant.components.recorder.util import get_instance
|
||||
from homeassistant.const import ATTR_RESTORED, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
OLD_DOMAIN = "sws12500_legacy"
|
||||
|
||||
STATION_ID = "migration-station"
|
||||
STATION_PW = "migration-secret"
|
||||
|
||||
# The object id the old integration handed out. Deliberately unlike anything the current
|
||||
# naming scheme produces, so a recreated entity is impossible to confuse with a moved one.
|
||||
OLD_OBJECT_ID = "sws12500_outside_temp"
|
||||
OLD_ENTITY_ID = f"sensor.{OLD_OBJECT_ID}"
|
||||
|
||||
# Three readings recorded by the old integration, before anything was renamed.
|
||||
HISTORIC_READINGS = ("18.6", "19.4", "20.2")
|
||||
|
||||
# What the station uploads once the renamed integration is running.
|
||||
NEW_READING = "21.7"
|
||||
|
||||
ATTRS: dict[str, Any] = {
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"device_class": "temperature",
|
||||
"state_class": "measurement",
|
||||
"friendly_name": "Outside temperature",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _recorder(recorder_mock) -> None:
|
||||
"""Every test here needs a real recorder behind the registries."""
|
||||
|
||||
|
||||
def _upload(**overrides: str) -> dict[str, str]:
|
||||
"""A minimal WSLink upload carrying the outside temperature and humidity."""
|
||||
return {
|
||||
"wsid": STATION_ID,
|
||||
"wspw": STATION_PW,
|
||||
"apiver": "0.6",
|
||||
"t1tem": NEW_READING,
|
||||
"t1hum": "62",
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
async def _record_predecessor_history(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Run the old integration's lifetime: entities, measurements, then uninstalled."""
|
||||
old = MockConfigEntry(
|
||||
domain=OLD_DOMAIN,
|
||||
title="Sencor SWS 12500",
|
||||
options={
|
||||
API_ID: STATION_ID,
|
||||
API_KEY: STATION_PW,
|
||||
WSLINK: True,
|
||||
SENSORS_TO_LOAD: [OUTSIDE_TEMP, OUTSIDE_HUMIDITY],
|
||||
},
|
||||
)
|
||||
old.add_to_hass(hass)
|
||||
|
||||
registry = er.async_get(hass)
|
||||
registered = registry.async_get_or_create(
|
||||
"sensor",
|
||||
OLD_DOMAIN,
|
||||
OUTSIDE_TEMP,
|
||||
config_entry=old,
|
||||
suggested_object_id=OLD_OBJECT_ID,
|
||||
original_name="Outside temperature",
|
||||
)
|
||||
assert registered.entity_id == OLD_ENTITY_ID
|
||||
|
||||
for reading in HISTORIC_READINGS:
|
||||
hass.states.async_set(OLD_ENTITY_ID, reading, ATTRS)
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# Long-term statistics, the rows behind the "Statistics" graph. Keyed on the same
|
||||
# entity_id, which is the whole point of the exercise.
|
||||
start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=2)
|
||||
async_import_statistics(
|
||||
hass,
|
||||
{
|
||||
"mean_type": StatisticMeanType.ARITHMETIC,
|
||||
"has_sum": False,
|
||||
"name": None,
|
||||
"source": "recorder",
|
||||
"statistic_id": OLD_ENTITY_ID,
|
||||
"unit_class": "temperature",
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
},
|
||||
[
|
||||
{"start": start, "mean": 18.6, "min": 18.0, "max": 19.0},
|
||||
{"start": start + timedelta(hours=1), "mean": 19.8, "min": 19.4, "max": 20.2},
|
||||
],
|
||||
)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# HACS swap done, files gone: this is the placeholder state Home Assistant writes for
|
||||
# a registered entity nothing is driving any more.
|
||||
hass.states.async_set(OLD_ENTITY_ID, STATE_UNAVAILABLE, {**ATTRS, ATTR_RESTORED: True})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return old
|
||||
|
||||
|
||||
async def _history_for(hass: HomeAssistant, entity_id: str, since) -> list[str]:
|
||||
"""Read back the recorded states the History panel would draw."""
|
||||
states = await get_instance(hass).async_add_executor_job(
|
||||
get_significant_states, hass, since, None, [entity_id]
|
||||
)
|
||||
return [state.state for state in states.get(entity_id, [])]
|
||||
|
||||
|
||||
async def _statistics_for(hass: HomeAssistant, entity_id: str, since) -> list[float]:
|
||||
"""Read back the long-term statistics the Statistics panel would draw."""
|
||||
rows = await get_instance(hass).async_add_executor_job(
|
||||
statistics_during_period, hass, since, None, {entity_id}, "hour", None, {"mean"}
|
||||
)
|
||||
return [row["mean"] for row in rows.get(entity_id, [])]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def migrated(
|
||||
hass: HomeAssistant,
|
||||
enable_custom_integrations,
|
||||
hass_client_no_auth,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""The full move: recorded predecessor, renamed integration set up over the top."""
|
||||
assert await async_setup_component(hass, "http", {"http": {}})
|
||||
since = dt_util.utcnow() - timedelta(hours=3)
|
||||
|
||||
old = await _record_predecessor_history(hass)
|
||||
before = await _history_for(hass, OLD_ENTITY_ID, since)
|
||||
statistics_before = await _statistics_for(hass, OLD_ENTITY_ID, since)
|
||||
|
||||
# The rename itself. In the new repository this is a source change; here it is the
|
||||
# one thing that has to be simulated, because both domains are still `sws12500`.
|
||||
monkeypatch.setattr("custom_components.sws12500.predecessor.PREDECESSOR_DOMAIN", OLD_DOMAIN)
|
||||
|
||||
# What the user types into the new integration's config flow: the credentials, and
|
||||
# nothing else. The protocol flag and the sensor list are inherited.
|
||||
new = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Sencor SWS 12500",
|
||||
options={API_ID: STATION_ID, API_KEY: STATION_PW},
|
||||
)
|
||||
new.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(new.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The station uploads again, to the endpoint the inherited WSLINK flag opened.
|
||||
client = await hass_client_no_auth()
|
||||
response = await client.get(WSLINK_URL, params=_upload())
|
||||
assert response.status == 200
|
||||
assert await response.text() == "OK"
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
return {
|
||||
"old_entry": old,
|
||||
"new_entry": new,
|
||||
"since": since,
|
||||
"history_before": before,
|
||||
"history_after": await _history_for(hass, OLD_ENTITY_ID, since),
|
||||
"statistics_before": statistics_before,
|
||||
"statistics_after": await _statistics_for(hass, OLD_ENTITY_ID, since),
|
||||
}
|
||||
|
||||
|
||||
async def test_the_measurements_survive_the_move(hass: HomeAssistant, migrated) -> None:
|
||||
"""One series, one entity_id, spanning both integrations."""
|
||||
assert migrated["history_before"] == [*HISTORIC_READINGS, STATE_UNAVAILABLE]
|
||||
|
||||
# Everything recorded before the move is still there, and the reading the renamed
|
||||
# integration just took continues the same series.
|
||||
after = migrated["history_after"]
|
||||
assert after[: len(HISTORIC_READINGS)] == list(HISTORIC_READINGS)
|
||||
assert after[-1] == NEW_READING
|
||||
|
||||
# Long-term statistics hang off the same entity_id and are untouched.
|
||||
assert migrated["statistics_after"] == migrated["statistics_before"] == [18.6, 19.8]
|
||||
|
||||
|
||||
async def test_the_entity_is_the_same_row_now_owned_by_the_new_integration(
|
||||
hass: HomeAssistant, migrated, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
"""Moved, not recreated: same entity_id, new platform, no duplicate left behind."""
|
||||
moved = entity_registry.async_get(OLD_ENTITY_ID)
|
||||
assert moved is not None
|
||||
assert moved.platform == DOMAIN
|
||||
assert moved.config_entry_id == migrated["new_entry"].entry_id
|
||||
|
||||
# Nothing was created under the current naming scheme for the same reading, which is
|
||||
# what an unmigrated install would have produced.
|
||||
duplicates = [
|
||||
entry.entity_id
|
||||
for entry in er.async_entries_for_config_entry(entity_registry, migrated["new_entry"].entry_id)
|
||||
if entry.unique_id == OUTSIDE_TEMP and entry.entity_id != OLD_ENTITY_ID
|
||||
]
|
||||
assert duplicates == []
|
||||
|
||||
# The live state is the new upload, on the old entity_id.
|
||||
state = hass.states.get(OLD_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == NEW_READING
|
||||
|
||||
# The predecessor's config entry is gone, so Settings is clean.
|
||||
assert hass.config_entries.async_entries(OLD_DOMAIN) == []
|
||||
|
||||
|
||||
async def test_options_the_user_never_retyped_came_across(hass: HomeAssistant, migrated) -> None:
|
||||
"""The upload above only worked because WSLINK was inherited before the routes."""
|
||||
options = migrated["new_entry"].options
|
||||
assert options[WSLINK] is True
|
||||
assert OUTSIDE_TEMP in options[SENSORS_TO_LOAD]
|
||||
# And the credentials the user did retype are the ones in force.
|
||||
assert options[API_ID] == STATION_ID
|
||||
|
||||
|
||||
async def test_without_the_move_the_history_is_orphaned(
|
||||
hass: HomeAssistant,
|
||||
enable_custom_integrations,
|
||||
hass_client_no_auth,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""The counterfactual, and the shipped default: `PREDECESSOR_DOMAIN == DOMAIN`.
|
||||
|
||||
Nothing is adopted, so the new integration mints its own entity under the current
|
||||
naming scheme and the recorded series stops at `unavailable`. This is what the
|
||||
migration exists to prevent - and, until the domain actually changes, also proof that
|
||||
the adoption pass is inert rather than quietly rewriting a live installation.
|
||||
"""
|
||||
assert await async_setup_component(hass, "http", {"http": {}})
|
||||
since = dt_util.utcnow() - timedelta(hours=3)
|
||||
|
||||
old = await _record_predecessor_history(hass)
|
||||
|
||||
new = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Sencor SWS 12500",
|
||||
options={API_ID: STATION_ID, API_KEY: STATION_PW, WSLINK: True},
|
||||
)
|
||||
new.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(new.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
assert (await client.get(WSLINK_URL, params=_upload())).status == 200
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# A second entity id for the same reading; the old series ends where it ended.
|
||||
fresh = entity_registry.async_get_entity_id("sensor", DOMAIN, OUTSIDE_TEMP)
|
||||
assert fresh is not None
|
||||
assert fresh != OLD_ENTITY_ID
|
||||
assert await _history_for(hass, OLD_ENTITY_ID, since) == [*HISTORIC_READINGS, STATE_UNAVAILABLE]
|
||||
# The new entity starts from `unknown`, as any freshly created one does, and knows
|
||||
# nothing of what came before.
|
||||
assert await _history_for(hass, fresh, since) == [STATE_UNKNOWN, NEW_READING]
|
||||
|
||||
# The predecessor's entry is untouched, exactly as before this change.
|
||||
assert hass.config_entries.async_entries(OLD_DOMAIN) == [old]
|
||||
|
||||
|
||||
async def test_write_migration_report(hass: HomeAssistant, migrated, entity_registry: er.EntityRegistry) -> None:
|
||||
"""Dump the before/after transcript when SWS_MIGRATION_REPORT names a file."""
|
||||
target = os.environ.get("SWS_MIGRATION_REPORT")
|
||||
if not target:
|
||||
pytest.skip("SWS_MIGRATION_REPORT not set")
|
||||
|
||||
moved = entity_registry.async_get(OLD_ENTITY_ID)
|
||||
assert moved is not None
|
||||
lines = [
|
||||
f"Domain move: {OLD_DOMAIN} -> {DOMAIN}",
|
||||
"",
|
||||
"BEFORE (old integration recorded, then uninstalled)",
|
||||
f" entity_id : {OLD_ENTITY_ID}",
|
||||
f" platform : {OLD_DOMAIN}",
|
||||
f" recorded states : {', '.join(migrated['history_before'])}",
|
||||
f" long-term statistics : {migrated['statistics_before']}",
|
||||
"",
|
||||
f"AFTER (renamed integration set up, station uploaded {NEW_READING} C)",
|
||||
f" entity_id : {moved.entity_id}",
|
||||
f" platform : {moved.platform}",
|
||||
f" config entry : {moved.config_entry_id} ({migrated['new_entry'].domain})",
|
||||
f" recorded states : {', '.join(migrated['history_after'])}",
|
||||
f" long-term statistics : {migrated['statistics_after']}",
|
||||
f" live state : {hass.states.get(OLD_ENTITY_ID).state} C",
|
||||
f" inherited options : {WSLINK}={migrated['new_entry'].options[WSLINK]}, "
|
||||
f"{SENSORS_TO_LOAD}={migrated['new_entry'].options[SENSORS_TO_LOAD]}",
|
||||
f" predecessor entries : {hass.config_entries.async_entries(OLD_DOMAIN)}",
|
||||
"",
|
||||
"entity_id unchanged, history and statistics continue in one series.",
|
||||
]
|
||||
Path(target).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
Loading…
Reference in New Issue