From 5c5073dd3ae42e844a5711ec93de8e9c43338209 Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:00:42 -0700 Subject: [PATCH] Type the lookin UDP manager with a HassKey (#181098) --- homeassistant/components/lookin/__init__.py | 18 +++++------ tests/components/lookin/test_init.py | 35 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index a7296fee15c8..461ed4ee5826 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -1,5 +1,4 @@ """The lookin integration.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Callable, Coroutine @@ -23,6 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.hass_dict import HassKey from .const import ( DOMAIN, @@ -37,8 +37,6 @@ from .models import LookinConfigEntry, LookinData LOGGER = logging.getLogger(__name__) -UDP_MANAGER = "udp_manager" - def _async_climate_updater( lookin_protocol: LookInHttpProtocol, @@ -90,9 +88,13 @@ class LookinUDPManager: self._subscriptions = None +# One UDP listener serves every lookin device, so the manager is shared between +# config entries rather than owned by any one of them. +UDP_MANAGER: HassKey[LookinUDPManager] = HassKey(DOMAIN) + + async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bool: """Set up lookin from a config entry.""" - domain_data = hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] lookin_protocol = LookInHttpProtocol( api_uri=f"http://{host}", session=async_get_clientsession(hass) @@ -159,10 +161,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bo meteo.update_from_value(event.value) meteo_coordinator.async_set_updated_data(meteo) - if UDP_MANAGER not in domain_data: - manager = domain_data[UDP_MANAGER] = LookinUDPManager() - else: - manager = domain_data[UDP_MANAGER] + if (manager := hass.data.get(UDP_MANAGER)) is None: + manager = hass.data[UDP_MANAGER] = LookinUDPManager() lookin_udp_subs = await manager.async_get_subscriptions() @@ -200,7 +200,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> b unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not hass.config_entries.async_loaded_entries(DOMAIN): - manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER] + manager = hass.data[UDP_MANAGER] await manager.async_stop() return unload_ok diff --git a/tests/components/lookin/test_init.py b/tests/components/lookin/test_init.py index 7d3dd7bd6abb..3c613a15f466 100644 --- a/tests/components/lookin/test_init.py +++ b/tests/components/lookin/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.components.lookin import UDP_MANAGER from homeassistant.components.lookin.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST @@ -81,3 +82,37 @@ async def test_controlled_device_links_to_lookin_device( ) assert controlled_device is not None assert controlled_device.via_device_id == lookin_device.id + + +async def test_udp_manager_outlives_the_config_entry(hass: HomeAssistant) -> None: + """Test the shared UDP manager is reused rather than rebuilt per entry.""" + device = _mocked_device() + remote = _mocked_remote() + protocol = _mocked_protocol(device, remote) + + subscriptions = MagicMock() + subscriptions.subscribe_event = MagicMock(return_value=MagicMock()) + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=DEVICE_ID + ) + entry.add_to_hass(hass) + + with ( + patch(f"{MODULE}.LookInHttpProtocol", return_value=protocol), + patch(f"{MODULE}.LookinUDPSubscriptions", return_value=subscriptions), + patch(f"{MODULE}.start_lookin_udp", AsyncMock(return_value=MagicMock())), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + manager = hass.data[UDP_MANAGER] + assert manager is not None + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + # The manager is keyed globally, not on the entry, so a reload reuses it + # instead of leaving a second listener behind. + assert hass.data[UDP_MANAGER] is manager