Fix handling of tracked devices on cleanup in FRITZ!Box Tools (#170574)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Michael
2026-05-18 22:03:43 +02:00
committed by GitHub
co-authored by Martin Hjelmare
parent 1076d65c9c
commit 34254c138f
7 changed files with 192 additions and 93 deletions
+4 -4
View File
@@ -54,6 +54,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
),
)
hass.data.setdefault(FRITZ_DATA_KEY, FritzData())
try:
await avm_wrapper.async_setup(entry.options)
except FRITZ_AUTH_EXCEPTIONS as ex:
@@ -68,13 +70,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo
raise ConfigEntryAuthFailed("Missing UPnP configuration")
await avm_wrapper.async_config_entry_first_refresh()
await avm_wrapper.async_trigger_cleanup()
entry.runtime_data = avm_wrapper
if FRITZ_DATA_KEY not in hass.data:
hass.data[FRITZ_DATA_KEY] = FritzData()
# Load the other platforms like switch
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -89,6 +87,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo
if avm_wrapper.unique_id in fritz_data.tracked:
fritz_data.tracked.pop(avm_wrapper.unique_id)
fritz_data.profile_switches.pop(avm_wrapper.unique_id)
fritz_data.wol_buttons.pop(avm_wrapper.unique_id)
if not bool(fritz_data.tracked):
hass.data.pop(FRITZ_DATA_KEY)
-3
View File
@@ -217,9 +217,6 @@ def _async_wol_buttons_list(
new_wols: list[FritzBoxWOLButton] = []
if avm_wrapper.unique_id not in data_fritz.wol_buttons:
data_fritz.wol_buttons[avm_wrapper.unique_id] = set()
for mac, device in avm_wrapper.devices.items():
if _is_tracked(mac, data_fritz.wol_buttons.values()):
_LOGGER.debug("Skipping wol button creation for device %s", device.hostname)
@@ -187,6 +187,10 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self._options = options
await self.hass.async_add_executor_job(self.setup)
self.hass.data[FRITZ_DATA_KEY].tracked[self.unique_id] = set()
self.hass.data[FRITZ_DATA_KEY].profile_switches[self.unique_id] = set()
self.hass.data[FRITZ_DATA_KEY].wol_buttons[self.unique_id] = set()
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
@@ -715,6 +719,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
) and entry_mac not in device_hosts:
_LOGGER.debug("Removing orphan entity entry %s", entity.entity_id)
entity_reg.async_remove(entity.entity_id)
self._devices.pop(entry_mac, None)
device_reg = dr.async_get(self.hass)
valid_connections = {
@@ -729,6 +734,29 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
device.id, remove_config_entry_id=config_entry.entry_id
)
fritz_data = self.hass.data[FRITZ_DATA_KEY]
tracked = fritz_data.tracked.get(self.unique_id, set())
for mac in tracked.copy():
if mac in device_hosts:
continue
_LOGGER.debug("Removing orphan mac address %s from device trackers", mac)
tracked.remove(mac)
profile_switches = fritz_data.profile_switches.get(self.unique_id, set())
for mac in profile_switches.copy():
if mac in device_hosts:
continue
_LOGGER.debug("Removing orphan mac address %s from profile switches", mac)
profile_switches.remove(mac)
wol_buttons = fritz_data.wol_buttons.get(self.unique_id, set())
for mac in wol_buttons.copy():
if mac in device_hosts:
continue
_LOGGER.debug("Removing orphan mac address %s from WOL buttons", mac)
wol_buttons.remove(mac)
class AvmWrapper(FritzBoxTools):
"""Setup AVM wrapper for API calls."""
@@ -51,9 +51,6 @@ def _async_add_entities(
"""Add new tracker entities from the AVM device."""
new_tracked = []
if avm_wrapper.unique_id not in data_fritz.tracked:
data_fritz.tracked[avm_wrapper.unique_id] = set()
for mac, device in avm_wrapper.devices.items():
if device_filter_out_from_trackers(mac, device, data_fritz.tracked.values()):
continue
-3
View File
@@ -242,9 +242,6 @@ async def _async_profile_entities_list(
if "X_AVM-DE_HostFilter1" not in avm_wrapper.connection.services:
return new_profiles
if avm_wrapper.unique_id not in data_fritz.profile_switches:
data_fritz.profile_switches[avm_wrapper.unique_id] = set()
for mac, device in avm_wrapper.devices.items():
if device_filter_out_from_trackers(
mac, device, data_fritz.profile_switches.values()
+81 -77
View File
@@ -907,84 +907,88 @@ MOCK_NEW_DEVICE_NODE = {
],
}
MOCK_HOST_PRINTER = {
"Index": 1,
"IPAddress": MOCK_IPS["printer"],
"MACAddress": "AA:BB:CC:00:11:22",
"Active": True,
"HostName": "printer",
"InterfaceType": "Ethernet",
"X_AVM-DE_Port": 1,
"X_AVM-DE_Speed": 1000,
"X_AVM-DE_UpdateAvailable": False,
"X_AVM-DE_UpdateSuccessful": "unknown",
"X_AVM-DE_InfoURL": None,
"X_AVM-DE_MACAddressList": None,
"X_AVM-DE_Model": None,
"X_AVM-DE_URL": f"http://{MOCK_IPS['printer']}",
"X_AVM-DE_Guest": False,
"X_AVM-DE_RequestClient": "0",
"X_AVM-DE_VPN": False,
"X_AVM-DE_WANAccess": "granted",
"X_AVM-DE_Disallow": False,
"X_AVM-DE_IsMeshable": "0",
"X_AVM-DE_Priority": "0",
"X_AVM-DE_FriendlyName": "printer",
"X_AVM-DE_FriendlyNameIsWriteable": "1",
}
MOCK_HOST_FRITZBOX = {
"Index": 2,
"IPAddress": MOCK_IPS["fritz.box"],
"MACAddress": MOCK_MESH_MASTER_MAC,
"Active": True,
"HostName": "fritz.box",
"InterfaceType": None,
"X_AVM-DE_Port": 0,
"X_AVM-DE_Speed": 0,
"X_AVM-DE_UpdateAvailable": False,
"X_AVM-DE_UpdateSuccessful": "unknown",
"X_AVM-DE_InfoURL": None,
"X_AVM-DE_MACAddressList": f"{MOCK_MESH_MASTER_MAC},{MOCK_MESH_MASTER_WIFI1_MAC}",
"X_AVM-DE_Model": None,
"X_AVM-DE_URL": f"http://{MOCK_IPS['fritz.box']}",
"X_AVM-DE_Guest": False,
"X_AVM-DE_RequestClient": "0",
"X_AVM-DE_VPN": False,
"X_AVM-DE_WANAccess": "granted",
"X_AVM-DE_Disallow": False,
"X_AVM-DE_IsMeshable": "1",
"X_AVM-DE_Priority": "0",
"X_AVM-DE_FriendlyName": "fritz.box",
"X_AVM-DE_FriendlyNameIsWriteable": "0",
}
MOCK_HOST_SERVER = {
"Index": 3,
"IPAddress": MOCK_IPS["server"],
"MACAddress": "AA:BB:CC:33:44:55",
"Active": True,
"HostName": "server",
"InterfaceType": "Ethernet",
"X_AVM-DE_Port": 1,
"X_AVM-DE_Speed": 1000,
"X_AVM-DE_UpdateAvailable": False,
"X_AVM-DE_UpdateSuccessful": "unknown",
"X_AVM-DE_InfoURL": None,
"X_AVM-DE_MACAddressList": None,
"X_AVM-DE_Model": None,
"X_AVM-DE_URL": f"http://{MOCK_IPS['server']}",
"X_AVM-DE_Guest": False,
"X_AVM-DE_RequestClient": "0",
"X_AVM-DE_VPN": False,
"X_AVM-DE_WANAccess": "granted",
"X_AVM-DE_Disallow": False,
"X_AVM-DE_IsMeshable": "0",
"X_AVM-DE_Priority": "0",
"X_AVM-DE_FriendlyName": "server",
"X_AVM-DE_FriendlyNameIsWriteable": "1",
}
MOCK_HOST_ATTRIBUTES_DATA = [
{
"Index": 1,
"IPAddress": MOCK_IPS["printer"],
"MACAddress": "AA:BB:CC:00:11:22",
"Active": True,
"HostName": "printer",
"InterfaceType": "Ethernet",
"X_AVM-DE_Port": 1,
"X_AVM-DE_Speed": 1000,
"X_AVM-DE_UpdateAvailable": False,
"X_AVM-DE_UpdateSuccessful": "unknown",
"X_AVM-DE_InfoURL": None,
"X_AVM-DE_MACAddressList": None,
"X_AVM-DE_Model": None,
"X_AVM-DE_URL": f"http://{MOCK_IPS['printer']}",
"X_AVM-DE_Guest": False,
"X_AVM-DE_RequestClient": "0",
"X_AVM-DE_VPN": False,
"X_AVM-DE_WANAccess": "granted",
"X_AVM-DE_Disallow": False,
"X_AVM-DE_IsMeshable": "0",
"X_AVM-DE_Priority": "0",
"X_AVM-DE_FriendlyName": "printer",
"X_AVM-DE_FriendlyNameIsWriteable": "1",
},
{
"Index": 2,
"IPAddress": MOCK_IPS["fritz.box"],
"MACAddress": MOCK_MESH_MASTER_MAC,
"Active": True,
"HostName": "fritz.box",
"InterfaceType": None,
"X_AVM-DE_Port": 0,
"X_AVM-DE_Speed": 0,
"X_AVM-DE_UpdateAvailable": False,
"X_AVM-DE_UpdateSuccessful": "unknown",
"X_AVM-DE_InfoURL": None,
"X_AVM-DE_MACAddressList": (
f"{MOCK_MESH_MASTER_MAC},{MOCK_MESH_MASTER_WIFI1_MAC}"
),
"X_AVM-DE_Model": None,
"X_AVM-DE_URL": f"http://{MOCK_IPS['fritz.box']}",
"X_AVM-DE_Guest": False,
"X_AVM-DE_RequestClient": "0",
"X_AVM-DE_VPN": False,
"X_AVM-DE_WANAccess": "granted",
"X_AVM-DE_Disallow": False,
"X_AVM-DE_IsMeshable": "1",
"X_AVM-DE_Priority": "0",
"X_AVM-DE_FriendlyName": "fritz.box",
"X_AVM-DE_FriendlyNameIsWriteable": "0",
},
{
"Index": 3,
"IPAddress": MOCK_IPS["server"],
"MACAddress": "AA:BB:CC:33:44:55",
"Active": True,
"HostName": "server",
"InterfaceType": "Ethernet",
"X_AVM-DE_Port": 1,
"X_AVM-DE_Speed": 1000,
"X_AVM-DE_UpdateAvailable": False,
"X_AVM-DE_UpdateSuccessful": "unknown",
"X_AVM-DE_InfoURL": None,
"X_AVM-DE_MACAddressList": None,
"X_AVM-DE_Model": None,
"X_AVM-DE_URL": f"http://{MOCK_IPS['server']}",
"X_AVM-DE_Guest": False,
"X_AVM-DE_RequestClient": "0",
"X_AVM-DE_VPN": False,
"X_AVM-DE_WANAccess": "granted",
"X_AVM-DE_Disallow": False,
"X_AVM-DE_IsMeshable": "0",
"X_AVM-DE_Priority": "0",
"X_AVM-DE_FriendlyName": "server",
"X_AVM-DE_FriendlyNameIsWriteable": "1",
},
MOCK_HOST_PRINTER,
MOCK_HOST_FRITZBOX,
MOCK_HOST_SERVER,
]
MOCK_CALL_DEFLECTION_DATA = {
+79 -3
View File
@@ -5,6 +5,7 @@ from copy import deepcopy
from typing import cast
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory
from fritzconnection.core.exceptions import (
FritzActionError,
FritzConnectionException,
@@ -18,12 +19,15 @@ from homeassistant.components.fritz.const import (
DEFAULT_CONF_FEATURE_DEVICE_TRACKING,
DEFAULT_SSL,
DOMAIN,
SCAN_INTERVAL,
)
from homeassistant.components.fritz.coordinator import (
FRITZ_DATA_KEY,
AvmWrapper,
ClassSetupMissing,
FritzBoxTools,
FritzConnectionCached,
FritzData,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
@@ -35,12 +39,18 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import FritzConnectionMock, FritzServiceMock
from .const import MOCK_SERIAL_NUMBER, MOCK_STATUS_DEVICE_INFO_DATA, MOCK_USER_DATA
from .const import (
MOCK_HOST_FRITZBOX,
MOCK_HOST_PRINTER,
MOCK_SERIAL_NUMBER,
MOCK_STATUS_DEVICE_INFO_DATA,
MOCK_USER_DATA,
)
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture(name="mock_config_entry")
@@ -82,6 +92,8 @@ async def fixture_fritz_tools(
port=mock_config_entry.data["port"],
)
hass.data.setdefault(FRITZ_DATA_KEY, FritzData())
await coordinator.async_setup()
return coordinator
@@ -567,6 +579,70 @@ async def test_avmwrapper_passthrough_methods(
assert await wrapper.async_wake_on_lan("AA:BB:CC:DD:EE:FF") == {}
async def test_async_trigger_cleanup(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
fc_class_mock,
fh_class_mock,
fs_class_mock,
) -> None:
"""Test the cleanup of orphan devices."""
fh_class_mock.get_hosts_attributes.return_value = [
MOCK_HOST_PRINTER,
MOCK_HOST_FRITZBOX,
]
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.state is ConfigEntryState.LOADED
# Verify the printer is registered as tracked device
assert device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:00:11:22")}
)
assert entity_registry.async_get("device_tracker.printer")
assert entity_registry.async_get("switch.printer_internet_access")
# remove printer from host list
fh_class_mock.get_hosts_attributes.return_value = [MOCK_HOST_FRITZBOX]
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify the printer was removed from tracked devices
assert (
device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:00:11:22")}
)
is None
)
assert entity_registry.async_get("device_tracker.printer") is None
assert entity_registry.async_get("switch.printer_internet_access") is None
# add printer again
fh_class_mock.get_hosts_attributes.return_value = [
MOCK_HOST_PRINTER,
MOCK_HOST_FRITZBOX,
]
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify the printer is registered again as tracked device
assert device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:00:11:22")}
)
assert entity_registry.async_get("device_tracker.printer")
assert entity_registry.async_get("switch.printer_internet_access")
async def test_async_trigger_cleanup_preserves_fritz_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,