Skip to content

Commit 7fc8da6

Browse files
marcelveldtjoostlek
authored andcommitted
Add support for migrated Hue bridge (home-assistant#151411)
Co-authored-by: Joostlek <[email protected]>
1 parent cdf7d8d commit 7fc8da6

File tree

2 files changed

+290
-5
lines changed

2 files changed

+290
-5
lines changed

homeassistant/components/hue/config_flow.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
import aiohttp
1010
from aiohue import LinkButtonNotPressed, create_app_key
1111
from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp
12+
from aiohue.errors import AiohueException
1213
from aiohue.util import normalize_bridge_id
14+
from aiohue.v2 import HueBridgeV2
1315
import slugify as unicode_slug
1416
import voluptuous as vol
1517

@@ -40,6 +42,9 @@
4042
HUE_IGNORED_BRIDGE_NAMES = ["Home Assistant Bridge", "Espalexa"]
4143
HUE_MANUAL_BRIDGE_ID = "manual"
4244

45+
BSB002_MODEL_ID = "BSB002"
46+
BSB003_MODEL_ID = "BSB003"
47+
4348

4449
class HueFlowHandler(ConfigFlow, domain=DOMAIN):
4550
"""Handle a Hue config flow."""
@@ -74,7 +79,14 @@ async def _get_bridge(
7479
"""Return a DiscoveredHueBridge object."""
7580
try:
7681
bridge = await discover_bridge(
77-
host, websession=aiohttp_client.async_get_clientsession(self.hass)
82+
host,
83+
websession=aiohttp_client.async_get_clientsession(
84+
# NOTE: we disable SSL verification for now due to the fact that the (BSB003)
85+
# Hue bridge uses a certificate from a on-bridge root authority.
86+
# We need to specifically handle this case in a follow-up update.
87+
self.hass,
88+
verify_ssl=False,
89+
),
7890
)
7991
except aiohttp.ClientError as err:
8092
LOGGER.warning(
@@ -110,7 +122,9 @@ async def async_step_init(
110122
try:
111123
async with asyncio.timeout(5):
112124
bridges = await discover_nupnp(
113-
websession=aiohttp_client.async_get_clientsession(self.hass)
125+
websession=aiohttp_client.async_get_clientsession(
126+
self.hass, verify_ssl=False
127+
)
114128
)
115129
except TimeoutError:
116130
bridges = []
@@ -178,7 +192,9 @@ async def async_step_link(
178192
app_key = await create_app_key(
179193
bridge.host,
180194
f"home-assistant#{device_name}",
181-
websession=aiohttp_client.async_get_clientsession(self.hass),
195+
websession=aiohttp_client.async_get_clientsession(
196+
self.hass, verify_ssl=False
197+
),
182198
)
183199
except LinkButtonNotPressed:
184200
errors["base"] = "register_failed"
@@ -228,14 +244,21 @@ async def async_step_zeroconf(
228244
self._abort_if_unique_id_configured(
229245
updates={CONF_HOST: discovery_info.host}, reload_on_update=True
230246
)
231-
232247
# we need to query the other capabilities too
233248
bridge = await self._get_bridge(
234249
discovery_info.host, discovery_info.properties["bridgeid"]
235250
)
236251
if bridge is None:
237252
return self.async_abort(reason="cannot_connect")
238253
self.bridge = bridge
254+
if (
255+
bridge.supports_v2
256+
and discovery_info.properties.get("modelid") == BSB003_MODEL_ID
257+
):
258+
# try to handle migration of BSB002 --> BSB003
259+
if await self._check_migrated_bridge(bridge):
260+
return self.async_abort(reason="migrated_bridge")
261+
239262
return await self.async_step_link()
240263

241264
async def async_step_homekit(
@@ -272,6 +295,55 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu
272295
self.bridge = bridge
273296
return await self.async_step_link()
274297

298+
async def _check_migrated_bridge(self, bridge: DiscoveredHueBridge) -> bool:
299+
"""Check if the discovered bridge is a migrated bridge."""
300+
# Try to handle migration of BSB002 --> BSB003.
301+
# Once we detect a BSB003 bridge on the network which has not yet been
302+
# configured in HA (otherwise we would have had a unique id match),
303+
# we check if we have any existing (BSB002) entries and if we can connect to the
304+
# new bridge with our previously stored api key.
305+
# If that succeeds, we migrate the entry to the new bridge.
306+
for conf_entry in self.hass.config_entries.async_entries(
307+
DOMAIN, include_ignore=False, include_disabled=False
308+
):
309+
if conf_entry.data[CONF_API_VERSION] != 2:
310+
continue
311+
if conf_entry.data[CONF_HOST] == bridge.host:
312+
continue
313+
# found an existing (BSB002) bridge entry,
314+
# check if we can connect to the new BSB003 bridge using the old credentials
315+
api = HueBridgeV2(bridge.host, conf_entry.data[CONF_API_KEY])
316+
try:
317+
await api.fetch_full_state()
318+
except (AiohueException, aiohttp.ClientError):
319+
continue
320+
old_bridge_id = conf_entry.unique_id
321+
assert old_bridge_id is not None
322+
# found a matching entry, migrate it
323+
self.hass.config_entries.async_update_entry(
324+
conf_entry,
325+
data={
326+
**conf_entry.data,
327+
CONF_HOST: bridge.host,
328+
},
329+
unique_id=bridge.id,
330+
)
331+
# also update the bridge device
332+
dev_reg = dr.async_get(self.hass)
333+
if bridge_device := dev_reg.async_get_device(
334+
identifiers={(DOMAIN, old_bridge_id)}
335+
):
336+
dev_reg.async_update_device(
337+
bridge_device.id,
338+
# overwrite identifiers with new bridge id
339+
new_identifiers={(DOMAIN, bridge.id)},
340+
# overwrite mac addresses with empty set to drop the old (incorrect) addresses
341+
# this will be auto corrected once the integration is loaded
342+
new_connections=set(),
343+
)
344+
return True
345+
return False
346+
275347

276348
class HueV1OptionsFlowHandler(OptionsFlow):
277349
"""Handle Hue options for V1 implementation."""

tests/components/hue/test_config_flow.py

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from unittest.mock import Mock, patch
55

66
from aiohue.discovery import URL_NUPNP
7-
from aiohue.errors import LinkButtonNotPressed
7+
from aiohue.errors import AiohueException, LinkButtonNotPressed
88
import pytest
99
import voluptuous as vol
1010

@@ -732,3 +732,216 @@ async def test_bridge_connection_failed(
732732
)
733733
assert result["type"] is FlowResultType.ABORT
734734
assert result["reason"] == "cannot_connect"
735+
736+
737+
async def test_bsb003_bridge_discovery(
738+
hass: HomeAssistant,
739+
aioclient_mock: AiohttpClientMocker,
740+
device_registry: dr.DeviceRegistry,
741+
) -> None:
742+
"""Test a bridge being discovered."""
743+
entry = MockConfigEntry(
744+
domain=const.DOMAIN,
745+
data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"},
746+
unique_id="bsb002_00000",
747+
)
748+
entry.add_to_hass(hass)
749+
device = device_registry.async_get_or_create(
750+
config_entry_id=entry.entry_id,
751+
identifiers={(const.DOMAIN, "bsb002_00000")},
752+
connections={(dr.CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF")},
753+
)
754+
create_mock_api_discovery(
755+
aioclient_mock,
756+
[("192.168.1.217", "bsb002_00000"), ("192.168.1.218", "bsb003_00000")],
757+
)
758+
disc_bridge = get_discovered_bridge(
759+
bridge_id="bsb003_00000", host="192.168.1.218", supports_v2=True
760+
)
761+
762+
with (
763+
patch(
764+
"homeassistant.components.hue.config_flow.discover_bridge",
765+
return_value=disc_bridge,
766+
),
767+
patch(
768+
"homeassistant.components.hue.config_flow.HueBridgeV2",
769+
autospec=True,
770+
) as mock_bridge,
771+
):
772+
mock_bridge.return_value.fetch_full_state.return_value = {}
773+
result = await hass.config_entries.flow.async_init(
774+
const.DOMAIN,
775+
context={"source": config_entries.SOURCE_ZEROCONF},
776+
data=ZeroconfServiceInfo(
777+
ip_address=ip_address("192.168.1.218"),
778+
ip_addresses=[ip_address("192.168.1.218")],
779+
port=443,
780+
hostname="Philips-hue.local",
781+
type="_hue._tcp.local.",
782+
name="Philips Hue - ABCABC._hue._tcp.local.",
783+
properties={
784+
"bridgeid": "bsb003_00000",
785+
"modelid": "BSB003",
786+
},
787+
),
788+
)
789+
790+
assert result["type"] is FlowResultType.ABORT
791+
assert result["reason"] == "migrated_bridge"
792+
793+
migrated_device = device_registry.async_get(device.id)
794+
795+
assert migrated_device is not None
796+
assert len(migrated_device.identifiers) == 1
797+
assert list(migrated_device.identifiers)[0] == (const.DOMAIN, "bsb003_00000")
798+
# The tests don't add new connection, but that will happen
799+
# outside of the config flow
800+
assert len(migrated_device.connections) == 0
801+
assert entry.data["host"] == "192.168.1.218"
802+
803+
804+
async def test_bsb003_bridge_discovery_old_version(
805+
hass: HomeAssistant,
806+
aioclient_mock: AiohttpClientMocker,
807+
device_registry: dr.DeviceRegistry,
808+
) -> None:
809+
"""Test a bridge being discovered."""
810+
entry = MockConfigEntry(
811+
domain=const.DOMAIN,
812+
data={"host": "192.168.1.217", "api_version": 1, "api_key": "abc"},
813+
unique_id="bsb002_00000",
814+
)
815+
entry.add_to_hass(hass)
816+
disc_bridge = get_discovered_bridge(
817+
bridge_id="bsb003_00000", host="192.168.1.218", supports_v2=True
818+
)
819+
820+
with patch(
821+
"homeassistant.components.hue.config_flow.discover_bridge",
822+
return_value=disc_bridge,
823+
):
824+
result = await hass.config_entries.flow.async_init(
825+
const.DOMAIN,
826+
context={"source": config_entries.SOURCE_ZEROCONF},
827+
data=ZeroconfServiceInfo(
828+
ip_address=ip_address("192.168.1.218"),
829+
ip_addresses=[ip_address("192.168.1.218")],
830+
port=443,
831+
hostname="Philips-hue.local",
832+
type="_hue._tcp.local.",
833+
name="Philips Hue - ABCABC._hue._tcp.local.",
834+
properties={
835+
"bridgeid": "bsb003_00000",
836+
"modelid": "BSB003",
837+
},
838+
),
839+
)
840+
841+
assert result["type"] is FlowResultType.FORM
842+
assert result["step_id"] == "link"
843+
844+
845+
async def test_bsb003_bridge_discovery_same_host(
846+
hass: HomeAssistant,
847+
aioclient_mock: AiohttpClientMocker,
848+
device_registry: dr.DeviceRegistry,
849+
) -> None:
850+
"""Test a bridge being discovered."""
851+
entry = MockConfigEntry(
852+
domain=const.DOMAIN,
853+
data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"},
854+
unique_id="bsb002_00000",
855+
)
856+
entry.add_to_hass(hass)
857+
create_mock_api_discovery(
858+
aioclient_mock,
859+
[("192.168.1.217", "bsb003_00000")],
860+
)
861+
disc_bridge = get_discovered_bridge(
862+
bridge_id="bsb003_00000", host="192.168.1.217", supports_v2=True
863+
)
864+
865+
with (
866+
patch(
867+
"homeassistant.components.hue.config_flow.discover_bridge",
868+
return_value=disc_bridge,
869+
),
870+
patch(
871+
"homeassistant.components.hue.config_flow.HueBridgeV2",
872+
autospec=True,
873+
),
874+
):
875+
result = await hass.config_entries.flow.async_init(
876+
const.DOMAIN,
877+
context={"source": config_entries.SOURCE_ZEROCONF},
878+
data=ZeroconfServiceInfo(
879+
ip_address=ip_address("192.168.1.217"),
880+
ip_addresses=[ip_address("192.168.1.217")],
881+
port=443,
882+
hostname="Philips-hue.local",
883+
type="_hue._tcp.local.",
884+
name="Philips Hue - ABCABC._hue._tcp.local.",
885+
properties={
886+
"bridgeid": "bsb003_00000",
887+
"modelid": "BSB003",
888+
},
889+
),
890+
)
891+
892+
assert result["type"] is FlowResultType.FORM
893+
assert result["step_id"] == "link"
894+
895+
896+
@pytest.mark.parametrize("exception", [AiohueException, ClientError])
897+
async def test_bsb003_bridge_discovery_cannot_connect(
898+
hass: HomeAssistant,
899+
aioclient_mock: AiohttpClientMocker,
900+
device_registry: dr.DeviceRegistry,
901+
exception: Exception,
902+
) -> None:
903+
"""Test a bridge being discovered."""
904+
entry = MockConfigEntry(
905+
domain=const.DOMAIN,
906+
data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"},
907+
unique_id="bsb002_00000",
908+
)
909+
entry.add_to_hass(hass)
910+
create_mock_api_discovery(
911+
aioclient_mock,
912+
[("192.168.1.217", "bsb003_00000")],
913+
)
914+
disc_bridge = get_discovered_bridge(
915+
bridge_id="bsb003_00000", host="192.168.1.217", supports_v2=True
916+
)
917+
918+
with (
919+
patch(
920+
"homeassistant.components.hue.config_flow.discover_bridge",
921+
return_value=disc_bridge,
922+
),
923+
patch(
924+
"homeassistant.components.hue.config_flow.HueBridgeV2",
925+
autospec=True,
926+
) as mock_bridge,
927+
):
928+
mock_bridge.return_value.fetch_full_state.side_effect = exception
929+
result = await hass.config_entries.flow.async_init(
930+
const.DOMAIN,
931+
context={"source": config_entries.SOURCE_ZEROCONF},
932+
data=ZeroconfServiceInfo(
933+
ip_address=ip_address("192.168.1.217"),
934+
ip_addresses=[ip_address("192.168.1.217")],
935+
port=443,
936+
hostname="Philips-hue.local",
937+
type="_hue._tcp.local.",
938+
name="Philips Hue - ABCABC._hue._tcp.local.",
939+
properties={
940+
"bridgeid": "bsb003_00000",
941+
"modelid": "BSB003",
942+
},
943+
),
944+
)
945+
946+
assert result["type"] is FlowResultType.FORM
947+
assert result["step_id"] == "link"

0 commit comments

Comments
 (0)