91 files
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user