Skip to content

Commit 1660d3b

Browse files
authored
Add stale device removal to Ghost integration (#165134)
1 parent 2ef81a5 commit 1660d3b

File tree

3 files changed

+90
-32
lines changed

3 files changed

+90
-32
lines changed

homeassistant/components/ghost/quality_scale.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,7 @@ rules:
7272
repair-issues:
7373
status: exempt
7474
comment: No repair scenarios identified for this integration.
75-
stale-devices:
76-
status: todo
77-
comment: Remove newsletter entities when newsletter is removed
75+
stale-devices: done
7876

7977
# Platinum
8078
async-dependency: done

homeassistant/components/ghost/sensor.py

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
SensorEntityDescription,
1313
SensorStateClass,
1414
)
15+
from homeassistant.const import Platform
1516
from homeassistant.core import HomeAssistant, callback
17+
from homeassistant.helpers import entity_registry as er
1618
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
1719
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1820
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -210,36 +212,67 @@ async def async_setup_entry(
210212

211213
async_add_entities(entities)
212214

215+
# Remove stale newsletter entities left over from previous runs.
216+
entity_registry = er.async_get(hass)
217+
prefix = f"{entry.unique_id}_newsletter_"
218+
active_newsletters = {
219+
newsletter_id
220+
for newsletter_id, newsletter in coordinator.data.newsletters.items()
221+
if newsletter.get("status") == "active"
222+
}
223+
for entity_entry in er.async_entries_for_config_entry(
224+
entity_registry, entry.entry_id
225+
):
226+
if (
227+
entity_entry.unique_id.startswith(prefix)
228+
and entity_entry.unique_id[len(prefix) :] not in active_newsletters
229+
):
230+
entity_registry.async_remove(entity_entry.entity_id)
231+
213232
newsletter_added: set[str] = set()
214233

215234
@callback
216-
def _async_add_newsletter_entities() -> None:
217-
"""Add newsletter entities when new newsletters appear."""
235+
def _async_update_newsletter_entities() -> None:
236+
"""Add new and remove stale newsletter entities."""
218237
nonlocal newsletter_added
219238

220-
new_newsletters = {
239+
active_newsletters = {
221240
newsletter_id
222241
for newsletter_id, newsletter in coordinator.data.newsletters.items()
223242
if newsletter.get("status") == "active"
224-
} - newsletter_added
225-
226-
if not new_newsletters:
227-
return
228-
229-
async_add_entities(
230-
GhostNewsletterSensorEntity(
231-
coordinator,
232-
entry,
233-
newsletter_id,
234-
coordinator.data.newsletters[newsletter_id].get("name", "Newsletter"),
243+
}
244+
245+
new_newsletters = active_newsletters - newsletter_added
246+
247+
if new_newsletters:
248+
async_add_entities(
249+
GhostNewsletterSensorEntity(
250+
coordinator,
251+
entry,
252+
newsletter_id,
253+
coordinator.data.newsletters[newsletter_id].get(
254+
"name", "Newsletter"
255+
),
256+
)
257+
for newsletter_id in new_newsletters
235258
)
236-
for newsletter_id in new_newsletters
237-
)
238-
newsletter_added |= new_newsletters
239-
240-
_async_add_newsletter_entities()
259+
newsletter_added.update(new_newsletters)
260+
261+
removed_newsletters = newsletter_added - active_newsletters
262+
if removed_newsletters:
263+
entity_registry = er.async_get(hass)
264+
for newsletter_id in removed_newsletters:
265+
unique_id = f"{entry.unique_id}_newsletter_{newsletter_id}"
266+
entity_id = entity_registry.async_get_entity_id(
267+
Platform.SENSOR, DOMAIN, unique_id
268+
)
269+
if entity_id:
270+
entity_registry.async_remove(entity_id)
271+
newsletter_added -= removed_newsletters
272+
273+
_async_update_newsletter_entities()
241274
entry.async_on_unload(
242-
coordinator.async_add_listener(_async_add_newsletter_entities)
275+
coordinator.async_add_listener(_async_update_newsletter_entities)
243276
)
244277

245278

@@ -310,9 +343,10 @@ def _get_newsletter_by_id(self) -> dict[str, Any] | None:
310343
@property
311344
def available(self) -> bool:
312345
"""Return True if the entity is available."""
313-
if not super().available or self.coordinator.data is None:
314-
return False
315-
return self._newsletter_id in self.coordinator.data.newsletters
346+
return (
347+
super().available
348+
and self._newsletter_id in self.coordinator.data.newsletters
349+
)
316350

317351
@property
318352
def native_value(self) -> int | None:

tests/components/ghost/test_sensor.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,14 @@ async def test_revenue_sensors_not_created_without_stripe(
7676
assert hass.states.get("sensor.test_ghost_arr") is None
7777

7878

79-
async def test_newsletter_sensor_not_found(
79+
async def test_newsletter_sensor_removed_when_stale(
8080
hass: HomeAssistant,
81+
entity_registry: er.EntityRegistry,
8182
mock_ghost_api: AsyncMock,
8283
mock_config_entry: MockConfigEntry,
8384
freezer: FrozenDateTimeFactory,
8485
) -> None:
85-
"""Test newsletter sensor when newsletter is removed."""
86+
"""Test newsletter sensor is removed when newsletter disappears."""
8687
await setup_integration(hass, mock_config_entry)
8788

8889
# Verify newsletter sensor exists
@@ -97,10 +98,35 @@ async def test_newsletter_sensor_not_found(
9798
async_fire_time_changed(hass)
9899
await hass.async_block_till_done(wait_background_tasks=True)
99100

100-
# Sensor should now be unavailable (newsletter not found)
101-
state = hass.states.get("sensor.test_ghost_weekly_subscribers")
102-
assert state is not None
103-
assert state.state == STATE_UNAVAILABLE
101+
# Entity should be removed from state and registry
102+
assert hass.states.get("sensor.test_ghost_weekly_subscribers") is None
103+
assert entity_registry.async_get("sensor.test_ghost_weekly_subscribers") is None
104+
105+
106+
async def test_newsletter_sensor_removed_on_reload(
107+
hass: HomeAssistant,
108+
entity_registry: er.EntityRegistry,
109+
mock_ghost_api: AsyncMock,
110+
mock_config_entry: MockConfigEntry,
111+
) -> None:
112+
"""Test stale newsletter sensor is removed when integration reloads."""
113+
await setup_integration(hass, mock_config_entry)
114+
115+
# Verify newsletter sensor exists
116+
assert entity_registry.async_get("sensor.test_ghost_weekly_subscribers") is not None
117+
118+
# Unload the integration
119+
await hass.config_entries.async_unload(mock_config_entry.entry_id)
120+
await hass.async_block_till_done()
121+
122+
# Newsletter is gone when integration reloads
123+
mock_ghost_api.get_newsletters.return_value = []
124+
125+
await hass.config_entries.async_setup(mock_config_entry.entry_id)
126+
await hass.async_block_till_done()
127+
128+
# Entity should be removed from registry
129+
assert entity_registry.async_get("sensor.test_ghost_weekly_subscribers") is None
104130

105131

106132
async def test_entities_unavailable_on_update_failure(

0 commit comments

Comments
 (0)