Add support for migrated Hue bridge (#151411)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Marcel van der Veldt
2025-09-05 12:09:37 +02:00
committed by Franck Nijhof
parent cdf7d8df16
commit 7fc8da6769
2 changed files with 290 additions and 5 deletions

View File

@@ -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."""

View File

@@ -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"