Honor failed platform unload; derive labels from the snapshot

A False platform unload now returns False and retains the runtime
record so live entities keep their database. Sensor names and device
labels re-derive from the current snapshot so child renames
propagate; unique_ids stay child-id based.
This commit is contained in:
2026-09-19 11:53:27 -04:00
parent 00409a76db
commit 7a0c8d1885
3 changed files with 87 additions and 20 deletions
+11 -1
View File
@@ -578,7 +578,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Platforms unload FIRST (mirroring HA conventions): entity
# listeners must not outlive the database and coordinator the
# teardown below closes.
await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
platforms_unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if platforms_unloaded is False and runtime_data is not None:
# Home Assistant KEPT one or more platforms loaded — their
# entities still live and must keep their database. Restore
# the runtime record popped above and report failure so HA
# retries the unload later instead of leaving live entities
# backed by a closed database.
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
return False
unload_error: BaseException | None = None
if runtime_data is not None:
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
+32 -19
View File
@@ -101,14 +101,20 @@ def _instance_payload(
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
"""Base for the per-child day sensors.
Registry identity and the child's NestQuest device grouping are
built from the child's database id; the day's numbers are read
from the child's CURRENT snapshot so every refresh lands
coherently. A child absent from the latest snapshot (a
Registry identity is built from the child's database id and never
changes; the DISPLAYED name and device label are derived from the
child's CURRENT snapshot on every read, so a rename through
``manage_child`` propagates on the next refresh without orphaning
the entity's history. A child absent from the latest snapshot (a
deactivation; removal is a later task's scope) yields ``None``
states rather than stale counts.
"""
#: Per-subclass label appended to the name (e.g. ``quests due
#: today``), set as a class attribute so ``name`` composes from the
#: current snapshot.
_name_suffix: str = ""
def __init__(
self,
coordinator: NestQuestCoordinator,
@@ -116,11 +122,8 @@ class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
) -> None:
super().__init__(coordinator)
self._child_id = child.child_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"child_{child.child_id}")},
name=f"NestQuest {child.child_name}",
manufacturer="NestQuest",
)
self._fallback_name = child.child_name
self._attr_unique_id = None # set by the subclass
@property
def _child(self) -> ChildDaySnapshot | None:
@@ -133,11 +136,28 @@ class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
return child
return None
def _displayed_child_name(self) -> str:
child = self._child
return child.child_name if child is not None else self._fallback_name
@property
def name(self) -> str | None:
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, f"child_{self._child_id}")},
name=f"NestQuest {self._displayed_child_name()}",
manufacturer="NestQuest",
)
class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
"""How many quests the child has due today, with the day payload."""
_attr_state_class = SensorStateClass.MEASUREMENT
_name_suffix = "quests due today"
def __init__(
self,
@@ -145,7 +165,6 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_name = f"NestQuest {child.child_name} quests due today"
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_quests_due_today"
)
@@ -179,6 +198,7 @@ class NestQuestQuestsCompletedTodaySensor(_NestQuestChildDaySensor):
"""How many of the child's quests were completed today."""
_attr_state_class = SensorStateClass.MEASUREMENT
_name_suffix = "quests completed today"
def __init__(
self,
@@ -186,9 +206,6 @@ class NestQuestQuestsCompletedTodaySensor(_NestQuestChildDaySensor):
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_name = (
f"NestQuest {child.child_name} quests completed today"
)
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_quests_completed_today"
)
@@ -205,6 +222,7 @@ class NestQuestQuestsRemainingTodaySensor(_NestQuestChildDaySensor):
"""How many of the child's quests are still owed today."""
_attr_state_class = SensorStateClass.MEASUREMENT
_name_suffix = "quests remaining today"
def __init__(
self,
@@ -212,9 +230,6 @@ class NestQuestQuestsRemainingTodaySensor(_NestQuestChildDaySensor):
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_name = (
f"NestQuest {child.child_name} quests remaining today"
)
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_quests_remaining_today"
)
@@ -233,6 +248,7 @@ class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_device_class = SensorDeviceClass.PERCENTAGE
_attr_native_unit_of_measurement = "%"
_name_suffix = "completion pct today"
def __init__(
self,
@@ -240,9 +256,6 @@ class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor):
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_name = (
f"NestQuest {child.child_name} completion pct today"
)
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_completion_pct_today"
)
+44
View File
@@ -339,3 +339,47 @@ async def test_unload_unloads_platforms(hass, make_entry) -> None:
assert await async_setup_entry(hass, entry) is True
assert await async_unload_entry(hass, entry) is True
assert hass.config_entries.unloaded_platforms == ["sensor"]
async def test_rename_propagates_to_name_and_device_label(
hass, make_entry
) -> None:
"""A manage_child rename shows on the next refresh: the name and
device label re-derive from the snapshot while the unique_id (and
therefore history) is untouched."""
from custom_components.nestquest.children import edit_child
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
hass, make_entry
)
database = entry.runtime_data.database
due_sensor = _sensor(hass, ada.id, "quests_due_today")
original_unique_id = due_sensor.unique_id
assert due_sensor.name.startswith("NestQuest Ada ")
await edit_child(database, ada.id, display_name="Mirren")
await coordinator.async_refresh()
assert due_sensor.name.startswith("NestQuest Mirren ")
assert due_sensor.device_info.name == "NestQuest Mirren"
assert due_sensor.unique_id == original_unique_id
async def test_failed_platform_unload_returns_false_and_retains_runtime(
hass, make_entry
) -> None:
"""When HA reports platforms still loaded, the unload must NOT
close the database the live entities read: it returns False and
keeps the runtime record for a retry."""
entry, _coordinator, _children = await _setup_seeded_entry(
hass, make_entry
)
database = entry.runtime_data.database
async def _refuse_unload(entry_arg, platforms):
return False
hass.config_entries.async_unload_platforms = _refuse_unload
assert await async_unload_entry(hass, entry) is False
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
assert database.connected is True