Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve device-delete related functions #585

Merged
merged 45 commits into from
Mar 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
4d7a1d5
Clean-up
bouwew Feb 18, 2024
0897c98
Simplify cleanup_device_registry()
bouwew Feb 18, 2024
78ddbcd
Move device_registry cleaning to __init__.py
bouwew Feb 18, 2024
06cb136
Simplify/improve coordinator.py
bouwew Feb 18, 2024
8bc0cc8
Add reloading to coordinator.py
bouwew Feb 18, 2024
99e2217
Link to plugwise v0.38.0a0, bump to v0.48.0a0 test-version
bouwew Feb 18, 2024
02dcfa0
Improve device-removal testcase
bouwew Feb 18, 2024
6fc2ced
Improve logger-message
bouwew Feb 18, 2024
f208290
Make sure the device_list has minimum 2 items
bouwew Feb 18, 2024
98fb4d7
Check length of device-list before starting the removal
bouwew Feb 18, 2024
988f5b2
Add async_remove_config_entry_device() function
bouwew Feb 20, 2024
15a9258
Add test_device_remove_device()
bouwew Feb 20, 2024
0081dd7
Add import
bouwew Feb 20, 2024
66bceda
Fix added test, fix method
bouwew Feb 27, 2024
a3b04c0
Make assert
bouwew Feb 27, 2024
3c65d52
Add pragma-no cover
bouwew Feb 27, 2024
3c032fa
Revert back to plugwise v0.37.1
bouwew Mar 5, 2024
9b4e08a
Don't reload for now
bouwew Mar 5, 2024
9c49e0a
Shorten logger message
bouwew Mar 5, 2024
11581fc
Correct code
bouwew Mar 6, 2024
c9f10fe
Improve code
bouwew Mar 6, 2024
a09633f
More robust code
bouwew Mar 7, 2024
393239e
Fix
bouwew Mar 7, 2024
04be4bd
Fix 2
bouwew Mar 7, 2024
f4c95a0
Debug
bouwew Mar 7, 2024
e4eff1a
More debug
bouwew Mar 7, 2024
4ec17cf
Fix logic
bouwew Mar 7, 2024
44cf789
Fix logic 2
bouwew Mar 7, 2024
4d3cbb6
Improve further
bouwew Mar 7, 2024
f620866
Improve again further
bouwew Mar 7, 2024
07227c7
Final code improvement, also improve test-case code
bouwew Mar 8, 2024
9dc3c07
Improve test-case names
bouwew Mar 8, 2024
544c26e
Debug
bouwew Mar 9, 2024
e77927d
Try
bouwew Mar 9, 2024
8199dae
Add debugging
bouwew Mar 9, 2024
90bad0a
Add guarding for empty identifiers
bouwew Mar 9, 2024
b5f7731
Use gateway_id to determine via_device
bouwew Mar 9, 2024
0029b64
Fix
bouwew Mar 9, 2024
2bc29c5
Simplify, the via_device is always the first device
bouwew Mar 9, 2024
4a3e15c
Add/improve comments
bouwew Mar 9, 2024
2a3466f
Update CHANGELOG
bouwew Mar 10, 2024
6906282
Bump to v0.48.0 release-version
bouwew Mar 10, 2024
a19eaa3
Use dr.async_entries_for_config_entry()
bouwew Mar 10, 2024
58ab578
Fix
bouwew Mar 10, 2024
5f1c4f8
Move cleanup_device_registry() into util.py
bouwew Mar 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

## Versions from 0.40 and up

## Ongoing
## v0.48.0

- Add a delete-button per device, enables the user to manually delete a removed Plugwise device
- Add automatic deletion of removed Plugwise devices after a HA restart
- Replace outdated test-fixture, update related test-case.

## v0.47.4
Expand Down
19 changes: 15 additions & 4 deletions custom_components/plugwise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
UNDO_UPDATE_LISTENER,
)
from .coordinator import PlugwiseDataUpdateCoordinator
from .util import cleanup_device_registry


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Expand All @@ -35,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
LOGGER.debug("DUC cooldown interval: %s", cooldown)

coordinator = PlugwiseDataUpdateCoordinator(
hass, entry, cooldown
hass, cooldown
) # pw-beta - cooldown, update_interval as extra
await coordinator.async_config_entry_first_refresh()
# Migrate a changed sensor unique_id
Expand All @@ -48,6 +49,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
UNDO_UPDATE_LISTENER: undo_listener, # pw-beta
}

# Clean-up removed devices
cleanup_device_registry(hass, coordinator.data, entry)

device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
Expand Down Expand Up @@ -85,20 +89,28 @@ async def delete_notification(

return True


async def _update_listener(
hass: HomeAssistant, entry: ConfigEntry
) -> None: # pragma: no cover # pw-beta
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove no longer present Plugwise device from config/device_registry."""
coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
return not any(
identifier
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN and (identifier[1] in coordinator.data.devices)
)

@callback
def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None:
Expand All @@ -118,7 +130,6 @@ def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None
# No migration needed
return None


def migrate_sensor_entities(
hass: HomeAssistant,
coordinator: PlugwiseDataUpdateCoordinator,
Expand Down
62 changes: 7 additions & 55 deletions custom_components/plugwise/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,65 +20,23 @@
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_USERNAME, DOMAIN, LOGGER


def remove_stale_devices(
data: PlugwiseData,
device_registry: dr.DeviceRegistry,
via_id: str,
) -> None:
"""Process the Plugwise devices present in the device_registry connected to a specific Gateway."""
device_list = list(data.devices.keys())
for dev_id, device_entry in list(device_registry.devices.items()):
if device_entry.via_device_id == via_id:
for item in device_entry.identifiers:
if item[0] == DOMAIN and item[1] in device_list:
continue

device_registry.async_remove_device(dev_id)
LOGGER.debug(
"Removed device %s %s %s from device_registry",
DOMAIN,
device_entry.model,
dev_id,
)


def cleanup_device_registry(
hass: HomeAssistant,
data: PlugwiseData,
) -> None:
"""Remove deleted devices from device-registry."""
device_registry = dr.async_get(hass)
via_id_list: list[list[str]] = []
# Collect the required data of the Plugwise Gateway's
for device_entry in list(device_registry.devices.values()):
if device_entry.manufacturer == "Plugwise" and device_entry.model == "Gateway":
for item in device_entry.identifiers:
via_id_list.append([item[1], device_entry.id])

for via_id in via_id_list:
if via_id[0] != data.gateway["gateway_id"]:
continue # pragma: no cover

remove_stale_devices(data, device_registry, via_id[1])


class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]):
"""Class to manage fetching Plugwise data from single endpoint."""

_connected: bool = False

config_entry: ConfigEntry

def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
cooldown: float,
update_interval: timedelta = timedelta(seconds=60),
) -> None: # pw-beta cooldown
Expand All @@ -100,17 +58,14 @@ def __init__(
)

self.api = Smile(
host=entry.data[CONF_HOST],
username=entry.data.get(CONF_USERNAME, DEFAULT_USERNAME),
password=entry.data[CONF_PASSWORD],
port=entry.data.get(CONF_PORT, DEFAULT_PORT),
host=self.config_entry.data[CONF_HOST],
username=self.config_entry.data.get(CONF_USERNAME, DEFAULT_USERNAME),
password=self.config_entry.data[CONF_PASSWORD],
port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT),
timeout=30,
websession=async_get_clientsession(hass, verify_ssl=False),
)
self.hass = hass
self._entry = entry
self._unavailable_logged = False
self.current_unique_ids: set[tuple[str, str]] = {("dummy", "dummy_id")}
self.update_interval = update_interval

async def _connect(self) -> None:
Expand All @@ -121,7 +76,7 @@ async def _connect(self) -> None:
self.update_interval = DEFAULT_SCAN_INTERVAL.get(
self.api.smile_type, timedelta(seconds=60)
) # pw-beta options scan-interval
if (custom_time := self._entry.options.get(CONF_SCAN_INTERVAL)) is not None:
if (custom_time := self.config_entry.options.get(CONF_SCAN_INTERVAL)) is not None:
self.update_interval = timedelta(
seconds=int(custom_time)
) # pragma: no cover # pw-beta options
Expand Down Expand Up @@ -158,7 +113,4 @@ async def _async_update_data(self) -> PlugwiseData:
self._unavailable_logged = True
raise UpdateFailed("Failed to connect") from err

# Clean-up removed devices
cleanup_device_registry(self.hass, data)

return data
2 changes: 1 addition & 1 deletion custom_components/plugwise/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"requirements": ["plugwise==0.37.1"],
"version": "0.47.4"
"version": "0.48.0"
}
41 changes: 41 additions & 0 deletions custom_components/plugwise/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Concatenate, ParamSpec, TypeVar

from plugwise import PlugwiseData
from plugwise.exceptions import PlugwiseException

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr

from .const import DOMAIN, LOGGER
from .entity import PlugwiseEntity

_PlugwiseEntityT = TypeVar("_PlugwiseEntityT", bound=PlugwiseEntity)
Expand Down Expand Up @@ -37,3 +42,39 @@ async def handler(
await self.coordinator.async_request_refresh()

return handler

def cleanup_device_registry(
hass: HomeAssistant,
data: PlugwiseData,
entry: ConfigEntry,
) -> None:
"""Remove deleted devices from device-registry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, entry.entry_id
)
# via_device cannot be None, this will result in the deletion
# of other Plugwise Gateways when present!
via_device: str = ""
for device_entry in device_entries:
if not device_entry.identifiers:
continue # pragma: no cover

item = list(list(device_entry.identifiers)[0])
if item[0] != DOMAIN:
continue # pragma: no cover

# First find the Plugwise via_device, this is always the first device
if item[1] == data.gateway["gateway_id"]:
via_device = device_entry.id
elif ( # then remove the connected orphaned device(s)
device_entry.via_device_id == via_device
and item[1] not in list(data.devices.keys())
):
device_registry.async_remove_device(device_entry.id)
LOGGER.debug(
"Removed %s device %s %s from device_registry",
DOMAIN,
device_entry.model,
item[1],
)
68 changes: 62 additions & 6 deletions tests/components/plugwise/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow

from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import MockHAClientWebSocket, WebSocketGenerator

LOGGER = logging.getLogger(__package__)

Expand Down Expand Up @@ -189,7 +191,7 @@ async def test_migrate_unique_id_relay(
)


async def test_device_removal(
async def test_device_registry_cleanup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smile_adam_2: MagicMock,
Expand All @@ -209,17 +211,71 @@ async def test_device_removal(
# Replace a Tom/Floor
data.devices.pop("1772a4ea304041adb83f357b751341ff")
data.devices.update(TOM)
device_list = list(data.devices.keys())
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data), patch(
HA_PLUGWISE_SMILE, side_effect=device_list
):
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
async_fire_time_changed(hass, utcnow() + timedelta(minutes=1))
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()

devices = dr.async_entries_for_config_entry(dev_reg, mock_config_entry.entry_id)
dev_reg = dr.async_get(hass)
item_list = []
for device_entry in list(dev_reg.devices.values()):
for item in device_entry.identifiers:
item_list.append(item[1])
assert "01234567890abcdefghijklmnopqrstu" in item_list
assert "1772a4ea304041adb83f357b751341ff" not in item_list


async def test_remove_config_entry_device(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
mock_config_entry: MockConfigEntry,
mock_smile_adam_2: MagicMock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test we can only remove a device that no longer exists."""
assert await async_setup_component(hass, "config", {})

mock_config_entry.add_to_hass(hass)

assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

device_entry = device_registry.async_get_device(
identifiers={
(
DOMAIN,
"1772a4ea304041adb83f357b751341ff",
)
},
)
assert (
await remove_device(
await hass_ws_client(hass), device_entry.id, mock_config_entry.entry_id
)
is False
)
old_device_entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, "01234567890abcdefghijklmnopqrstu")},
)
assert (
await remove_device(
await hass_ws_client(hass), old_device_entry.id, mock_config_entry.entry_id
)
is True
)

async def remove_device(
ws_client: MockHAClientWebSocket, device_id: str, config_entry_id: str
) -> bool:
"""Remove config entry from a device."""
await ws_client.send_json(
{
"id": 5,
"type": "config/device_registry/remove_config_entry",
"config_entry_id": config_entry_id,
"device_id": device_id,
}
)
response = await ws_client.receive_json()
return response["success"]
Loading