Offer local control option when there are multiple zeroconf homekit matches (#62649)

This commit is contained in:
J. Nick Koston 2022-01-04 05:51:38 -10:00 committed by GitHub
parent 411fcad798
commit ee375ff42d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 76 additions and 19 deletions

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
import fnmatch import fnmatch
@ -32,7 +33,13 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.frame import report from homeassistant.helpers.frame import report
from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass from homeassistant.loader import (
Integration,
async_get_homekit,
async_get_integration,
async_get_zeroconf,
bind_hass,
)
from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf
from .usage import install_multiple_zeroconf_catcher from .usage import install_multiple_zeroconf_catcher
@ -72,7 +79,6 @@ ATTR_PROPERTIES: Final = "properties"
# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES]
ATTR_PROPERTIES_ID: Final = "id" ATTR_PROPERTIES_ID: Final = "id"
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
DOMAIN: vol.All( DOMAIN: vol.All(
@ -340,6 +346,17 @@ def _match_against_props(matcher: dict[str, str], props: dict[str, str]) -> bool
) )
def is_homekit_paired(props: dict[str, Any]) -> bool:
"""Check properties to see if a device is homekit paired."""
if HOMEKIT_PAIRED_STATUS_FLAG not in props:
return False
with contextlib.suppress(ValueError):
# 0 means paired and not discoverable by iOS clients)
return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0
# If we cannot tell, we assume its not paired
return False
class ZeroconfDiscovery: class ZeroconfDiscovery:
"""Discovery via zeroconf.""" """Discovery via zeroconf."""
@ -417,8 +434,9 @@ class ZeroconfDiscovery:
props: dict[str, str] = info.properties props: dict[str, str] = info.properties
# If we can handle it as a HomeKit discovery, we do that here. # If we can handle it as a HomeKit discovery, we do that here.
if service_type in HOMEKIT_TYPES: if service_type in HOMEKIT_TYPES and (
if domain := async_get_homekit_discovery_domain(self.homekit_models, props): domain := async_get_homekit_discovery_domain(self.homekit_models, props)
):
discovery_flow.async_create_flow( discovery_flow.async_create_flow(
self.hass, domain, {"source": config_entries.SOURCE_HOMEKIT}, info self.hass, domain, {"source": config_entries.SOURCE_HOMEKIT}, info
) )
@ -429,14 +447,19 @@ class ZeroconfDiscovery:
# We only send updates to homekit_controller # We only send updates to homekit_controller
# if the device is already paired in order to avoid # if the device is already paired in order to avoid
# offering a second discovery for the same device # offering a second discovery for the same device
if domain and HOMEKIT_PAIRED_STATUS_FLAG in props: if not is_homekit_paired(props):
try: integration: Integration = await async_get_integration(
# 0 means paired and not discoverable by iOS clients) self.hass, domain
if int(props[HOMEKIT_PAIRED_STATUS_FLAG]): )
return # Since we prefer local control, if the integration that is being discovered
except ValueError: # is cloud AND the homekit device is UNPAIRED we still want to discovery it.
# HomeKit pairing status unknown #
# likely bad homekit data # As soon as the device becomes paired, the config flow will be dismissed
# in the event the user does not want to pair with Home Assistant.
#
if not integration.iot_class or not integration.iot_class.startswith(
"cloud"
):
return return
match_data: dict[str, str] = {} match_data: dict[str, str] = {}

View File

@ -541,7 +541,7 @@ async def test_homekit_match_partial_dash(hass, mock_async_zeroconf):
), ),
) as mock_service_browser, patch( ) as mock_service_browser, patch(
"homeassistant.components.zeroconf.AsyncServiceInfo", "homeassistant.components.zeroconf.AsyncServiceInfo",
side_effect=get_homekit_info_mock("Rachio-fa46ba", HOMEKIT_STATUS_UNPAIRED), side_effect=get_homekit_info_mock("Smart Bridge-001", HOMEKIT_STATUS_UNPAIRED),
): ):
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
@ -549,7 +549,7 @@ async def test_homekit_match_partial_dash(hass, mock_async_zeroconf):
assert len(mock_service_browser.mock_calls) == 1 assert len(mock_service_browser.mock_calls) == 1
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "rachio" assert mock_config_flow.mock_calls[0][1][0] == "lutron_caseta"
async def test_homekit_match_partial_fnmatch(hass, mock_async_zeroconf): async def test_homekit_match_partial_fnmatch(hass, mock_async_zeroconf):
@ -650,7 +650,7 @@ async def test_homekit_invalid_paring_status(hass, mock_async_zeroconf):
), ),
) as mock_service_browser, patch( ) as mock_service_browser, patch(
"homeassistant.components.zeroconf.AsyncServiceInfo", "homeassistant.components.zeroconf.AsyncServiceInfo",
side_effect=get_homekit_info_mock("tado", b"invalid"), side_effect=get_homekit_info_mock("Smart Bridge", b"invalid"),
): ):
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
@ -658,7 +658,7 @@ async def test_homekit_invalid_paring_status(hass, mock_async_zeroconf):
assert len(mock_service_browser.mock_calls) == 1 assert len(mock_service_browser.mock_calls) == 1
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "tado" assert mock_config_flow.mock_calls[0][1][0] == "lutron_caseta"
async def test_homekit_not_paired(hass, mock_async_zeroconf): async def test_homekit_not_paired(hass, mock_async_zeroconf):
@ -686,6 +686,40 @@ async def test_homekit_not_paired(hass, mock_async_zeroconf):
assert mock_config_flow.mock_calls[0][1][0] == "homekit_controller" assert mock_config_flow.mock_calls[0][1][0] == "homekit_controller"
async def test_homekit_controller_still_discovered_unpaired_for_cloud(
hass, mock_async_zeroconf
):
"""Test discovery is still passed to homekit controller when unpaired and discovered by cloud integration.
Since we prefer local control, if the integration that is being discovered
is cloud AND the homekit device is unpaired we still want to discovery it
"""
with patch.dict(
zc_gen.ZEROCONF,
{"_hap._udp.local.": [{"domain": "homekit_controller"}]},
clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object(
zeroconf,
"HaAsyncServiceBrowser",
side_effect=lambda *args, **kwargs: service_update_mock(
*args, **kwargs, limit_service="_hap._udp.local."
),
) as mock_service_browser, patch(
"homeassistant.components.zeroconf.AsyncServiceInfo",
side_effect=get_homekit_info_mock("Rachio-xyz", HOMEKIT_STATUS_UNPAIRED),
):
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert len(mock_service_browser.mock_calls) == 1
assert len(mock_config_flow.mock_calls) == 2
assert mock_config_flow.mock_calls[0][1][0] == "rachio"
assert mock_config_flow.mock_calls[1][1][0] == "homekit_controller"
async def test_info_from_service_non_utf8(hass): async def test_info_from_service_non_utf8(hass):
"""Test info_from_service handles non UTF-8 property keys and values correctly.""" """Test info_from_service handles non UTF-8 property keys and values correctly."""
service_type = "_test._tcp.local." service_type = "_test._tcp.local."