fix(review): fix(review): lead repair notice with warning, cap entity list

ecowitt_support
SchiZzA 2026-07-26 23:22:12 +02:00
parent c510b64260
commit 0407cb049d
No known key found for this signature in database
5 changed files with 85 additions and 8 deletions

View File

@ -32,6 +32,7 @@ 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
@ -44,6 +45,9 @@ 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:
@ -201,6 +205,18 @@ def _adopt_devices(hass: HomeAssistant, entry: ConfigEntry, old_entry: ConfigEnt
`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)
@ -354,6 +370,22 @@ def _adoption_issue_id(entry: ConfigEntry) -> str:
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,
@ -366,8 +398,9 @@ def update_predecessor_adoption_issue(
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.
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.
@ -384,7 +417,10 @@ def update_predecessor_adoption_issue(
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="predecessor_adoption_incomplete",
translation_placeholders={"entities": ", ".join(sorted(result.skipped))},
translation_placeholders={
"entities": _sample_skipped(result.skipped),
"count": str(len(result.skipped)),
},
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id)

View File

@ -651,7 +651,7 @@
},
"predecessor_adoption_incomplete": {
"title": "Migration from the previous integration is not finished",
"description": "These entities could not be taken over from the previous integration yet: {entities}. The old entry is deliberately left in place so nothing is lost, even though it shows up as broken.\n\nDo NOT delete the old entry via Settings -> Devices & Services -> Delete. That removes its entity registry records, and once they are gone there is nothing left for the history and the long-term statistics to attach to - they cannot be relinked afterwards.\n\nRestart Home Assistant instead and let the migration finish. This message disappears on its own once every entity has been moved over."
"description": "Do NOT delete the old entry via Settings -> Devices & Services -> Delete. That removes its entity registry records, and once they are gone there is nothing left for the history and the long-term statistics to attach to - they cannot be relinked afterwards.\n\nRestart Home Assistant instead and let the migration finish. The old entry is deliberately left in place so nothing is lost, even though it shows up as broken. This message disappears on its own once every entity has been moved over.\n\nStill waiting to be taken over, {count} in total: {entities}"
}
},
"exceptions": {

View File

@ -651,7 +651,7 @@
},
"predecessor_adoption_incomplete": {
"title": "Migrace z předchozí integrace není dokončená",
"description": "Tyto entity se zatím nepodařilo převzít z předchozí integrace: {entities}. Starý záznam je záměrně ponechaný na místě, aby se nic neztratilo, i když se tváří jako nefunkční.\n\nStarý záznam NEMAŽ přes Nastavení -> Zařízení a služby -> Smazat. Tím se odstraní jeho záznamy v registru entit, a jakmile jsou pryč, nemá se historie ani dlouhodobé statistiky na co navázat - zpětně už je připojit nelze.\n\nMísto toho restartuj Home Assistant a nech migraci doběhnout. Tahle zpráva zmizí sama, jakmile se přenesou všechny entity."
"description": "Starý záznam NEMAŽ přes Nastavení -> Zařízení a služby -> Smazat. Tím se odstraní jeho záznamy v registru entit, a jakmile jsou pryč, nemá se historie ani dlouhodobé statistiky na co navázat - zpětně už je připojit nelze.\n\nMísto toho restartuj Home Assistant a nech migraci doběhnout. Starý záznam je záměrně ponechaný na místě, aby se nic neztratilo, i když se tváří jako nefunkční. Tahle zpráva zmizí sama, jakmile se přenesou všechny entity.\n\nZatím nepřevzato, celkem {count}: {entities}"
}
},
"exceptions": {

View File

@ -651,7 +651,7 @@
},
"predecessor_adoption_incomplete": {
"title": "Migration from the previous integration is not finished",
"description": "These entities could not be taken over from the previous integration yet: {entities}. The old entry is deliberately left in place so nothing is lost, even though it shows up as broken.\n\nDo NOT delete the old entry via Settings -> Devices & Services -> Delete. That removes its entity registry records, and once they are gone there is nothing left for the history and the long-term statistics to attach to - they cannot be relinked afterwards.\n\nRestart Home Assistant instead and let the migration finish. This message disappears on its own once every entity has been moved over."
"description": "Do NOT delete the old entry via Settings -> Devices & Services -> Delete. That removes its entity registry records, and once they are gone there is nothing left for the history and the long-term statistics to attach to - they cannot be relinked afterwards.\n\nRestart Home Assistant instead and let the migration finish. The old entry is deliberately left in place so nothing is lost, even though it shows up as broken. This message disappears on its own once every entity has been moved over.\n\nStill waiting to be taken over, {count} in total: {entities}"
}
},
"exceptions": {

View File

@ -490,7 +490,7 @@ async def test_a_stalled_migration_raises_a_repair_issue(hass: HomeAssistant, en
issue = ir.async_get(hass).async_get_issue(DOMAIN, f"predecessor_adoption_{new.entry_id}")
assert issue is not None
assert issue.translation_key == "predecessor_adoption_incomplete"
assert issue.translation_placeholders == {"entities": blocked}
assert issue.translation_placeholders == {"entities": blocked, "count": "1"}
async def test_a_predecessor_that_will_not_unload_raises_a_repair_issue(
@ -522,7 +522,48 @@ async def test_a_predecessor_that_will_not_unload_raises_a_repair_issue(
assert not result.complete
issue = ir.async_get(hass).async_get_issue(DOMAIN, f"predecessor_adoption_{new.entry_id}")
assert issue is not None
assert issue.translation_placeholders == {"entities": ", ".join(sorted(created.values()))}
assert issue.translation_placeholders == {
"entities": ", ".join(sorted(created.values())),
"count": str(len(created)),
}
async def test_a_whole_station_is_summarised_not_spelled_out(
hass: HomeAssistant, entries, monkeypatch
) -> None:
"""A refused unload strands every sensor at once, and a WSLink station has dozens.
Rendered in full, the list buries the one sentence the notice exists for. The sample
stays short and the total rides alongside it, so the user can still tell a couple of
stuck entities apart from the entire station.
"""
from homeassistant.config_entries import ConfigEntryState
from custom_components.sws12500.predecessor import ISSUE_ENTITY_SAMPLE
old, new = entries
registry = er.async_get(hass)
seeded = [
registry.async_get_or_create("sensor", OLD_DOMAIN, f"key_{index:02d}", config_entry=old).entity_id
for index in range(ISSUE_ENTITY_SAMPLE * 3)
]
old.mock_state(hass, ConfigEntryState.LOADED)
async def _refuse(*_a: Any, **_kw: Any) -> bool:
return False
monkeypatch.setattr(hass.config_entries, "async_unload", _refuse)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
update_predecessor_adoption_issue(hass, new, result)
issue = ir.async_get(hass).async_get_issue(DOMAIN, f"predecessor_adoption_{new.entry_id}")
assert issue is not None
placeholders = issue.translation_placeholders
assert placeholders is not None
listed = placeholders["entities"].split(", ")
assert listed == [*sorted(seeded)[:ISSUE_ENTITY_SAMPLE], "..."]
assert placeholders["count"] == str(len(seeded))
async def test_a_finished_migration_raises_no_issue(hass: HomeAssistant, entries) -> None: