This commit is contained in:
Home Assistant Version Control
2026-07-31 16:29:09 +00:00
parent d125a48b04
commit 8c53907c88
91 changed files with 1232 additions and 387 deletions
@@ -819,6 +819,44 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
# registered in async_setup_entry. Together these two listeners keep the
# dashboard area and the HA device area in sync after the initial creation
# (DeviceInfo.suggested_area only fires once, on first device creation).
@callback
def _object_entries_for_device(
hass: HomeAssistant, device: dr.DeviceEntry
) -> list[ConfigEntry]:
"""Our object entries that live on this device.
Two ways an object reaches a device page, and they need different
lookups:
* its OWN device — our config entry is listed on it;
* a LINKED device owned by another integration — we deliberately do
NOT add our config entry there any more (HA 2026.8 forbids it), so
the only record of the link is ``obj["ha_device_id"]``. Matching on
``device.config_entries`` alone would silently stop syncing exactly
the objects the user attached by hand.
Several objects may point at the same device; all of them follow it.
"""
found: list[ConfigEntry] = []
seen: set[str] = set()
# 2026.8 gives a device exactly one config entry (`config_entry_id`);
# the plural `config_entries` is a silently-deprecated compat shim
# there — no report_usage, so the deprecation gate cannot see it — and
# the only spelling 2026.7 has. Prefer the singular where it exists.
single = getattr(device, "config_entry_id", None)
owner_ids = (single,) if single is not None else tuple(getattr(device, "config_entries", ()) or ())
for ce_id in owner_ids:
ce = hass.config_entries.async_get_entry(ce_id)
if ce is not None and ce.domain == DOMAIN and ce.unique_id != GLOBAL_UNIQUE_ID:
found.append(ce)
seen.add(ce.entry_id)
for ce in hass.config_entries.async_entries(DOMAIN):
if ce.entry_id in seen or ce.unique_id == GLOBAL_UNIQUE_ID:
continue
if (ce.data.get(CONF_OBJECT) or {}).get("ha_device_id") == device.id:
found.append(ce)
return found
@callback
def _on_device_registry_update(
event: Event[dr.EventDeviceRegistryUpdatedData],
@@ -832,17 +870,12 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
device = dr.async_get(hass).async_get(device_id)
if device is None:
return
for ce_id in device.config_entries:
ce = hass.config_entries.async_get_entry(ce_id)
if ce is None or ce.domain != DOMAIN or ce.unique_id == GLOBAL_UNIQUE_ID:
continue
for ce in _object_entries_for_device(hass, device):
obj = dict(ce.data.get(CONF_OBJECT, {}))
if obj.get("area_id") == device.area_id:
return # already in sync — break the loop with the forward listener
continue # already in sync — breaks the loop with the forward listener
obj["area_id"] = device.area_id
new_data = {**ce.data, CONF_OBJECT: obj}
hass.config_entries.async_update_entry(ce, data=new_data)
return
hass.config_entries.async_update_entry(ce, data={**ce.data, CONF_OBJECT: obj})
unsub_device = hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, _on_device_registry_update)
@@ -1014,7 +1047,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
rotation tasks that were configured without an initial assignee — those
were invisible to every user filter until their first completion.
"""
if entry.version > 1 or entry.minor_version >= 4:
if entry.version > 1 or entry.minor_version >= 5:
return True
is_object_entry = entry.unique_id != GLOBAL_UNIQUE_ID
@@ -1073,6 +1106,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
data[CONF_TASKS] = new_tasks
minor = 4
# 4 → 5: stop co-owning a linked device. Until now, attaching an object to
# an existing device also put OUR config entry on that device. Home
# Assistant forbids that from 2026.8 and splits such a device into one per
# config entry on upgrade — leaving a nameless extra device holding our
# entities while the appliance's page shows none. Removing the association
# here means the split never has anything to split.
if minor < 5:
if is_object_entry and (device_id := (data.get(CONF_OBJECT) or {}).get("ha_device_id")):
from .helpers.device_link import shed_owned_devices
shed_owned_devices(hass, own_entry_id=entry.entry_id, source_device_id=device_id)
minor = 5
hass.config_entries.async_update_entry(entry, data=data, minor_version=minor)
_LOGGER.info("Migrated entry %s to minor_version %s", entry.entry_id, minor)
return True
@@ -1088,6 +1134,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
await _async_setup_shared(hass)
is_global = entry.unique_id == GLOBAL_UNIQUE_ID
# True once the object's stored device link resolved to a live device this
# setup — drives the post-platform cleanup of empty leftover own devices.
linked_device_live = False
if is_global:
# Global entry: no per-object coordinator, but a summary coordinator
@@ -1189,6 +1238,81 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
)
await store.async_save()
# A device link stored before HA 2026.8 may now point at a
# pre-migration composite id, which names no registered device — HA
# refuses to attach entities to it and tells the user to file a bug
# against us. Repoint it at the surviving device and write that back,
# so the repair happens once rather than on every start.
obj_data = entry.data.get(CONF_OBJECT) or {}
if stored_device_id := obj_data.get("ha_device_id"):
from .helpers.device_link import (
has_own_devices,
resolve_linked_device_id,
shed_owned_devices,
)
resolved = resolve_linked_device_id(hass, stored_device_id, own_entry_id=entry.entry_id)
if resolved and resolved != stored_device_id:
_LOGGER.info(
"Object %s was linked to device %s, which the 2026.8 split replaced; repointed to %s",
entry.title,
stored_device_id,
resolved,
)
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_OBJECT: {**obj_data, "ha_device_id": resolved}},
)
# An id we cannot resolve is deliberately LEFT ALONE. Clearing it
# would gain nothing — a missing device already falls back to an own
# device at render time — and would destroy the user's choice for
# good. The id may well be meaningful again later: a JSON export
# carries it to a foreign instance where it dangles until the backup
# comes home, and a device can return when its integration is
# re-added or re-enabled.
#
# Falling back is quiet, though, and a link the user set on purpose
# is worth telling them about rather than letting the object drift
# onto a device of its own unannounced. The issue clears itself the
# moment the link resolves again.
from homeassistant.helpers import issue_registry as ir
issue_id = f"device_link_lost_{entry.entry_id}"
if resolved is None:
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="device_link_lost",
translation_placeholders={"object": obj_data.get("name") or entry.title},
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id)
# A linked object owns no device. If it does, this install predates
# the rework — either it co-owned the appliance's device (split on
# upgrade) or it forked one carrying the appliance's identity. Hand
# that to Home Assistant's own repair, which also relinks the
# entities, rather than deleting devices by hand.
if has_own_devices(hass, entry.entry_id):
_LOGGER.info("Shedding device(s) %s should not own after linking", entry.title)
shed_owned_devices(hass, own_entry_id=entry.entry_id, source_device_id=resolved)
linked_device_live = resolved is not None
# #115: the battery fleet's seeded name/notes/part texts are stored
# snapshots — a fleet set up before localized seeding existed (or under
# a different UI language) keeps English notes forever while every
# runtime string around them is translated. Rewrite the ones the user
# never touched into the instance's language; edited texts stay.
if obj_data.get("battery_fleet"):
from .helpers.battery_fleet_setup import retranslate_seeded_texts
from .helpers.i18n import normalize_language
if retranslate_seeded_texts(hass, entry, normalize_language(hass)):
_LOGGER.info("Retranslated the battery fleet's seeded texts for %s", entry.title)
coordinator = MaintenanceCoordinator(hass, entry, store)
entry.runtime_data = MaintenanceSupporterData(coordinator=coordinator, store=store)
await coordinator.async_config_entry_first_refresh()
@@ -1222,6 +1346,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
if linked_device_live:
# The entities of a linked object live on the appliance'''s device, so a
# device of our own with nothing on it is a leftover — of the object'''s
# earlier unlinked life, or of the 2026.8 split — and shows up as an
# empty duplicate named after the object. The targeted shed above only
# removes duplicates of the SOURCE device, so an old own device (our
# identifiers, not a fork) survives it on both HA versions. Decidable
# only now: the platform setup above has re-attached the entities.
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
if not er.async_entries_for_device(ent_reg, device.id, include_disabled_entities=True):
_LOGGER.info("Removing empty leftover device %s of linked object %s", device.id, entry.title)
dev_reg.async_remove_device(device.id)
return True
@@ -1517,6 +1657,13 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
# as a fixable repair issue so the user can restore it (#86).
_sync_missing_global_entry_issue(hass)
return
# A "lost its device link" notice belongs to the object; deleting the object
# answers it, so it must not outlive it as an orphan in the Repairs list.
from homeassistant.helpers import issue_registry as ir
ir.async_delete_issue(hass, DOMAIN, f"device_link_lost_{entry.entry_id}")
# #111: hand any shared spare-part pool to a borrower BEFORE the Store goes
# — the stock numbers exist nowhere else. Must happen here rather than in
# the panel's delete: HA's own Configure-UI removal never reaches that path.