From 7a0c8d1885c9dafba6fd111631c9affebf47cd2a Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:53:27 -0400 Subject: [PATCH] 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. --- custom_components/nestquest/__init__.py | 12 +++++- custom_components/nestquest/sensor.py | 51 ++++++++++++++++--------- tests/test_sensors.py | 44 +++++++++++++++++++++ 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 84e1074..33f1449 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -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) diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index 8f05fa1..08fbd0c 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -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" ) diff --git a/tests/test_sensors.py b/tests/test_sensors.py index a8d712f..c054740 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -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 \ No newline at end of file