Skip to content

Commit e2351ec

Browse files
authored
Fix orphaned devices not being removed during integration startup (home-assistant#155900)
1 parent d75e549 commit e2351ec

File tree

2 files changed

+79
-38
lines changed

2 files changed

+79
-38
lines changed

homeassistant/components/libre_hardware_monitor/coordinator.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
from datetime import timedelta
66
import logging
7-
from types import MappingProxyType
87

98
from librehardwaremonitor_api import (
109
LibreHardwareMonitorClient,
@@ -55,15 +54,11 @@ def __init__(
5554
device_entries: list[DeviceEntry] = dr.async_entries_for_config_entry(
5655
registry=dr.async_get(self.hass), config_entry_id=config_entry.entry_id
5756
)
58-
self._previous_devices: MappingProxyType[DeviceId, DeviceName] = (
59-
MappingProxyType(
60-
{
61-
DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
62-
for device in device_entries
63-
if device.identifiers and device.name
64-
}
65-
)
66-
)
57+
self._previous_devices: dict[DeviceId, DeviceName] = {
58+
DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
59+
for device in device_entries
60+
if device.identifiers and device.name
61+
}
6762

6863
async def _async_update_data(self) -> LibreHardwareMonitorData:
6964
try:
@@ -75,7 +70,9 @@ async def _async_update_data(self) -> LibreHardwareMonitorData:
7570
except LibreHardwareMonitorNoDevicesError as err:
7671
raise UpdateFailed("No sensor data available, will retry") from err
7772

78-
await self._async_handle_changes_in_devices(lhm_data.main_device_ids_and_names)
73+
await self._async_handle_changes_in_devices(
74+
dict(lhm_data.main_device_ids_and_names)
75+
)
7976

8077
return lhm_data
8178

@@ -92,18 +89,21 @@ async def _async_refresh(
9289
)
9390

9491
async def _async_handle_changes_in_devices(
95-
self, detected_devices: MappingProxyType[DeviceId, DeviceName]
92+
self, detected_devices: dict[DeviceId, DeviceName]
9693
) -> None:
9794
"""Handle device changes by deleting devices from / adding devices to Home Assistant."""
95+
detected_devices = {
96+
DeviceId(f"{self.config_entry.entry_id}_{detected_id}"): device_name
97+
for detected_id, device_name in detected_devices.items()
98+
}
99+
98100
previous_device_ids = set(self._previous_devices.keys())
99101
detected_device_ids = set(detected_devices.keys())
100102

101-
if previous_device_ids == detected_device_ids:
102-
return
103+
_LOGGER.debug("Previous device_ids: %s", previous_device_ids)
104+
_LOGGER.debug("Detected device_ids: %s", detected_device_ids)
103105

104-
if self.data is None:
105-
# initial update during integration startup
106-
self._previous_devices = detected_devices # type: ignore[unreachable]
106+
if previous_device_ids == detected_device_ids:
107107
return
108108

109109
if orphaned_devices := previous_device_ids - detected_device_ids:
@@ -114,13 +114,21 @@ async def _async_handle_changes_in_devices(
114114
device_registry = dr.async_get(self.hass)
115115
for device_id in orphaned_devices:
116116
if device := device_registry.async_get_device(
117-
identifiers={(DOMAIN, f"{self.config_entry.entry_id}_{device_id}")}
117+
identifiers={(DOMAIN, device_id)}
118118
):
119+
_LOGGER.debug(
120+
"Removing device: %s", self._previous_devices[device_id]
121+
)
119122
device_registry.async_update_device(
120123
device_id=device.id,
121124
remove_config_entry_id=self.config_entry.entry_id,
122125
)
123126

127+
if self.data is None:
128+
# initial update during integration startup
129+
self._previous_devices = detected_devices # type: ignore[unreachable]
130+
return
131+
124132
if new_devices := detected_device_ids - previous_device_ids:
125133
_LOGGER.warning(
126134
"New Device(s) detected, reload integration to add them to Home Assistant: %s",

tests/components/libre_hardware_monitor/test_sensor.py

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from datetime import timedelta
55
import logging
66
from types import MappingProxyType
7-
from unittest.mock import AsyncMock, patch
7+
from unittest.mock import AsyncMock
88

99
from freezegun.api import FrozenDateTimeFactory
1010
from librehardwaremonitor_api import (
@@ -26,6 +26,7 @@
2626
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
2727
from homeassistant.core import HomeAssistant
2828
from homeassistant.helpers import device_registry as dr, entity_registry as er
29+
from homeassistant.helpers.device_registry import DeviceEntry
2930

3031
from . import init_integration
3132

@@ -152,42 +153,74 @@ async def test_sensor_state_is_unknown_when_no_sensor_data_is_provided(
152153
assert state.state == STATE_UNKNOWN
153154

154155

155-
async def test_orphaned_devices_are_removed(
156+
async def test_orphaned_devices_are_removed_if_not_present_after_update(
156157
hass: HomeAssistant,
157158
mock_lhm_client: AsyncMock,
158159
mock_config_entry: MockConfigEntry,
159160
freezer: FrozenDateTimeFactory,
161+
device_registry: dr.DeviceRegistry,
160162
) -> None:
161-
"""Test that devices in HA that do not receive updates are removed."""
163+
"""Test that devices in HA that are not found in LHM's data after sensor update are removed."""
164+
orphaned_device = await _mock_orphaned_device(
165+
device_registry, hass, mock_config_entry, mock_lhm_client
166+
)
167+
168+
freezer.tick(timedelta(DEFAULT_SCAN_INTERVAL))
169+
async_fire_time_changed(hass)
170+
await hass.async_block_till_done()
171+
172+
assert device_registry.async_get(orphaned_device.id) is None
173+
174+
175+
async def test_orphaned_devices_are_removed_if_not_present_during_startup(
176+
hass: HomeAssistant,
177+
mock_lhm_client: AsyncMock,
178+
mock_config_entry: MockConfigEntry,
179+
device_registry: dr.DeviceRegistry,
180+
) -> None:
181+
"""Test that devices in HA that are not found in LHM's data during integration startup are removed."""
182+
orphaned_device = await _mock_orphaned_device(
183+
device_registry, hass, mock_config_entry, mock_lhm_client
184+
)
185+
186+
hass.config_entries.async_schedule_reload(mock_config_entry.entry_id)
187+
188+
assert device_registry.async_get(orphaned_device.id) is None
189+
190+
191+
async def _mock_orphaned_device(
192+
device_registry: dr.DeviceRegistry,
193+
hass: HomeAssistant,
194+
mock_config_entry: MockConfigEntry,
195+
mock_lhm_client: AsyncMock,
196+
) -> DeviceEntry:
162197
await init_integration(hass, mock_config_entry)
163198

199+
removed_device = "lpc-nct6687d-0"
200+
previous_data = mock_lhm_client.get_data.return_value
201+
164202
mock_lhm_client.get_data.return_value = LibreHardwareMonitorData(
165203
main_device_ids_and_names=MappingProxyType(
166204
{
167-
DeviceId("amdcpu-0"): DeviceName("AMD Ryzen 7 7800X3D"),
168-
DeviceId("gpu-nvidia-0"): DeviceName("NVIDIA GeForce RTX 4080 SUPER"),
205+
device_id: name
206+
for (device_id, name) in previous_data.main_device_ids_and_names.items()
207+
if device_id != removed_device
208+
}
209+
),
210+
sensor_data=MappingProxyType(
211+
{
212+
sensor_id: data
213+
for (sensor_id, data) in previous_data.sensor_data.items()
214+
if not sensor_id.startswith(removed_device)
169215
}
170216
),
171-
sensor_data=mock_lhm_client.get_data.return_value.sensor_data,
172217
)
173218

174-
device_registry = dr.async_get(hass)
175-
orphaned_device = device_registry.async_get_or_create(
219+
return device_registry.async_get_or_create(
176220
config_entry_id=mock_config_entry.entry_id,
177-
identifiers={(DOMAIN, f"{mock_config_entry.entry_id}_lpc-nct6687d-0")},
221+
identifiers={(DOMAIN, f"{mock_config_entry.entry_id}_{removed_device}")},
178222
)
179223

180-
with patch.object(
181-
device_registry,
182-
"async_remove_device",
183-
wraps=device_registry.async_update_device,
184-
) as mock_remove:
185-
freezer.tick(timedelta(DEFAULT_SCAN_INTERVAL))
186-
async_fire_time_changed(hass)
187-
await hass.async_block_till_done()
188-
189-
mock_remove.assert_called_once_with(orphaned_device.id)
190-
191224

192225
async def test_integration_does_not_log_new_devices_on_first_refresh(
193226
hass: HomeAssistant,

0 commit comments

Comments
 (0)