mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Offer local control option when there are multiple zeroconf homekit matches (#62649)
This commit is contained in:
parent
411fcad798
commit
ee375ff42d
@ -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] = {}
|
||||||
|
@ -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."
|
||||||
|
Loading…
x
Reference in New Issue
Block a user