From 7fc8da6769c2adf08b0cb7f4d60777e9ee52fe4b Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 5 Sep 2025 12:09:37 +0200 Subject: [PATCH] Add support for migrated Hue bridge (#151411) Co-authored-by: Joostlek --- homeassistant/components/hue/config_flow.py | 80 +++++++- tests/components/hue/test_config_flow.py | 215 +++++++++++++++++++- 2 files changed, 290 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index bec443526138..3328b5ab6594 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -9,7 +9,9 @@ from typing import Any import aiohttp from aiohue import LinkButtonNotPressed, create_app_key from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp +from aiohue.errors import AiohueException from aiohue.util import normalize_bridge_id +from aiohue.v2 import HueBridgeV2 import slugify as unicode_slug import voluptuous as vol @@ -40,6 +42,9 @@ HUE_MANUFACTURERURL = ("http://www.philips.com", "http://www.philips-hue.com") HUE_IGNORED_BRIDGE_NAMES = ["Home Assistant Bridge", "Espalexa"] HUE_MANUAL_BRIDGE_ID = "manual" +BSB002_MODEL_ID = "BSB002" +BSB003_MODEL_ID = "BSB003" + class HueFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Hue config flow.""" @@ -74,7 +79,14 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): """Return a DiscoveredHueBridge object.""" try: bridge = await discover_bridge( - host, websession=aiohttp_client.async_get_clientsession(self.hass) + host, + websession=aiohttp_client.async_get_clientsession( + # NOTE: we disable SSL verification for now due to the fact that the (BSB003) + # Hue bridge uses a certificate from a on-bridge root authority. + # We need to specifically handle this case in a follow-up update. + self.hass, + verify_ssl=False, + ), ) except aiohttp.ClientError as err: LOGGER.warning( @@ -110,7 +122,9 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): try: async with asyncio.timeout(5): bridges = await discover_nupnp( - websession=aiohttp_client.async_get_clientsession(self.hass) + websession=aiohttp_client.async_get_clientsession( + self.hass, verify_ssl=False + ) ) except TimeoutError: bridges = [] @@ -178,7 +192,9 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): app_key = await create_app_key( bridge.host, f"home-assistant#{device_name}", - websession=aiohttp_client.async_get_clientsession(self.hass), + websession=aiohttp_client.async_get_clientsession( + self.hass, verify_ssl=False + ), ) except LinkButtonNotPressed: errors["base"] = "register_failed" @@ -228,7 +244,6 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured( updates={CONF_HOST: discovery_info.host}, reload_on_update=True ) - # we need to query the other capabilities too bridge = await self._get_bridge( discovery_info.host, discovery_info.properties["bridgeid"] @@ -236,6 +251,14 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): if bridge is None: return self.async_abort(reason="cannot_connect") self.bridge = bridge + if ( + bridge.supports_v2 + and discovery_info.properties.get("modelid") == BSB003_MODEL_ID + ): + # try to handle migration of BSB002 --> BSB003 + if await self._check_migrated_bridge(bridge): + return self.async_abort(reason="migrated_bridge") + return await self.async_step_link() async def async_step_homekit( @@ -272,6 +295,55 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): self.bridge = bridge return await self.async_step_link() + async def _check_migrated_bridge(self, bridge: DiscoveredHueBridge) -> bool: + """Check if the discovered bridge is a migrated bridge.""" + # Try to handle migration of BSB002 --> BSB003. + # Once we detect a BSB003 bridge on the network which has not yet been + # configured in HA (otherwise we would have had a unique id match), + # we check if we have any existing (BSB002) entries and if we can connect to the + # new bridge with our previously stored api key. + # If that succeeds, we migrate the entry to the new bridge. + for conf_entry in self.hass.config_entries.async_entries( + DOMAIN, include_ignore=False, include_disabled=False + ): + if conf_entry.data[CONF_API_VERSION] != 2: + continue + if conf_entry.data[CONF_HOST] == bridge.host: + continue + # found an existing (BSB002) bridge entry, + # check if we can connect to the new BSB003 bridge using the old credentials + api = HueBridgeV2(bridge.host, conf_entry.data[CONF_API_KEY]) + try: + await api.fetch_full_state() + except (AiohueException, aiohttp.ClientError): + continue + old_bridge_id = conf_entry.unique_id + assert old_bridge_id is not None + # found a matching entry, migrate it + self.hass.config_entries.async_update_entry( + conf_entry, + data={ + **conf_entry.data, + CONF_HOST: bridge.host, + }, + unique_id=bridge.id, + ) + # also update the bridge device + dev_reg = dr.async_get(self.hass) + if bridge_device := dev_reg.async_get_device( + identifiers={(DOMAIN, old_bridge_id)} + ): + dev_reg.async_update_device( + bridge_device.id, + # overwrite identifiers with new bridge id + new_identifiers={(DOMAIN, bridge.id)}, + # overwrite mac addresses with empty set to drop the old (incorrect) addresses + # this will be auto corrected once the integration is loaded + new_connections=set(), + ) + return True + return False + class HueV1OptionsFlowHandler(OptionsFlow): """Handle Hue options for V1 implementation.""" diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index e4bdda422d1c..bc63343f9bef 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -4,7 +4,7 @@ from ipaddress import ip_address from unittest.mock import Mock, patch from aiohue.discovery import URL_NUPNP -from aiohue.errors import LinkButtonNotPressed +from aiohue.errors import AiohueException, LinkButtonNotPressed import pytest import voluptuous as vol @@ -732,3 +732,216 @@ async def test_bridge_connection_failed( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_bsb003_bridge_discovery( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(const.DOMAIN, "bsb002_00000")}, + connections={(dr.CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF")}, + ) + create_mock_api_discovery( + aioclient_mock, + [("192.168.1.217", "bsb002_00000"), ("192.168.1.218", "bsb003_00000")], + ) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.218", supports_v2=True + ) + + with ( + patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ), + patch( + "homeassistant.components.hue.config_flow.HueBridgeV2", + autospec=True, + ) as mock_bridge, + ): + mock_bridge.return_value.fetch_full_state.return_value = {} + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.218"), + ip_addresses=[ip_address("192.168.1.218")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migrated_bridge" + + migrated_device = device_registry.async_get(device.id) + + assert migrated_device is not None + assert len(migrated_device.identifiers) == 1 + assert list(migrated_device.identifiers)[0] == (const.DOMAIN, "bsb003_00000") + # The tests don't add new connection, but that will happen + # outside of the config flow + assert len(migrated_device.connections) == 0 + assert entry.data["host"] == "192.168.1.218" + + +async def test_bsb003_bridge_discovery_old_version( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 1, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.218", supports_v2=True + ) + + with patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.218"), + ip_addresses=[ip_address("192.168.1.218")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" + + +async def test_bsb003_bridge_discovery_same_host( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + create_mock_api_discovery( + aioclient_mock, + [("192.168.1.217", "bsb003_00000")], + ) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.217", supports_v2=True + ) + + with ( + patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ), + patch( + "homeassistant.components.hue.config_flow.HueBridgeV2", + autospec=True, + ), + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" + + +@pytest.mark.parametrize("exception", [AiohueException, ClientError]) +async def test_bsb003_bridge_discovery_cannot_connect( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, + exception: Exception, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + create_mock_api_discovery( + aioclient_mock, + [("192.168.1.217", "bsb003_00000")], + ) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.217", supports_v2=True + ) + + with ( + patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ), + patch( + "homeassistant.components.hue.config_flow.HueBridgeV2", + autospec=True, + ) as mock_bridge, + ): + mock_bridge.return_value.fetch_full_state.side_effect = exception + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link"