From 3eeccc1a653e3cec731c584e9bf013ae8a78f0c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 11:58:48 -0700 Subject: [PATCH] Add manifest support for homekit discovery (#24225) * Add manifest support for homekit discovery * Add a space after model check * Update comment --- homeassistant/components/lifx/manifest.json | 5 ++ homeassistant/components/zeroconf/__init__.py | 60 ++++++++++++--- homeassistant/generated/zeroconf.py | 4 + homeassistant/helpers/config_entry_flow.py | 1 + script/hassfest/manifest.py | 3 + script/hassfest/zeroconf.py | 74 ++++++++++++++++--- tests/components/zeroconf/test_init.py | 54 +++++++++++--- 7 files changed, 169 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index ca9b578432b..fd74d9831fc 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -7,6 +7,11 @@ "aiolifx==0.6.7", "aiolifx_effects==0.2.2" ], + "homekit": { + "models": [ + "LIFX" + ] + }, "dependencies": [], "codeowners": [ "@amelchio" diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index fe757b8ae85..2f93020b4d5 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) -from homeassistant.generated.zeroconf import ZEROCONF +from homeassistant.generated.zeroconf import ZEROCONF, HOMEKIT _LOGGER = logging.getLogger(__name__) @@ -24,6 +24,7 @@ ATTR_NAME = 'name' ATTR_PROPERTIES = 'properties' ZEROCONF_TYPE = '_home-assistant._tcp.local.' +HOMEKIT_TYPE = '_hap._tcp.local.' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({}), @@ -50,21 +51,30 @@ def setup(hass, config): def service_update(zeroconf, service_type, name, state_change): """Service state changed.""" - if state_change is ServiceStateChange.Added: - service_info = zeroconf.get_service_info(service_type, name) - info = info_from_service(service_info) - _LOGGER.debug("Discovered new device %s %s", name, info) + if state_change != ServiceStateChange.Added: + return - for domain in ZEROCONF[service_type]: - hass.add_job( - hass.config_entries.flow.async_init( - domain, context={'source': DOMAIN}, data=info - ) + service_info = zeroconf.get_service_info(service_type, name) + info = info_from_service(service_info) + _LOGGER.debug("Discovered new device %s %s", name, info) + + # If we can handle it as a HomeKit discovery, we do that here. + if service_type == HOMEKIT_TYPE and handle_homekit(hass, info): + return + + for domain in ZEROCONF[service_type]: + hass.add_job( + hass.config_entries.flow.async_init( + domain, context={'source': DOMAIN}, data=info ) + ) for service in ZEROCONF: ServiceBrowser(zeroconf, service, handlers=[service_update]) + if HOMEKIT_TYPE not in ZEROCONF: + ServiceBrowser(zeroconf, HOMEKIT_TYPE, handlers=[service_update]) + def stop_zeroconf(_): """Stop Zeroconf.""" zeroconf.unregister_service(info) @@ -75,6 +85,36 @@ def setup(hass, config): return True +def handle_homekit(hass, info) -> bool: + """Handle a HomeKit discovery. + + Return if discovery was forwarded. + """ + model = None + props = info.get('properties', {}) + + for key in props: + if key.lower() == 'md': + model = props[key] + break + + if model is None: + return False + + for test_model in HOMEKIT: + if not model.startswith(test_model): + continue + + hass.add_job( + hass.config_entries.flow.async_init( + HOMEKIT[test_model], context={'source': 'homekit'}, data=info + ) + ) + return True + + return False + + def info_from_service(service): """Return prepared info from mDNS entries.""" properties = {} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 05b0a0247b9..024bb89dc99 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -18,3 +18,7 @@ ZEROCONF = { "homekit_controller" ] } + +HOMEKIT = { + "LIFX ": "lifx" +} diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 7c087a1ee64..c3e5195131b 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -83,6 +83,7 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): async_step_zeroconf = async_step_discovery async_step_ssdp = async_step_discovery + async_step_homekit = async_step_discovery async def async_step_import(self, _): """Handle a flow initialized by import.""" diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 31181ed76bd..3e25ab31712 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -17,6 +17,9 @@ MANIFEST_SCHEMA = vol.Schema({ vol.Optional('manufacturer'): [str], vol.Optional('device_type'): [str], }), + vol.Optional('homekit'): vol.Schema({ + vol.Optional('models'): [str], + }), vol.Required('documentation'): str, vol.Required('requirements'): [str], vol.Required('dependencies'): [str], diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 1ed9575c95f..25e8da99b55 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -1,5 +1,5 @@ """Generate zeroconf file.""" -from collections import OrderedDict +from collections import OrderedDict, defaultdict import json from typing import Dict @@ -13,12 +13,15 @@ To update, run python3 -m hassfest ZEROCONF = {} + +HOMEKIT = {} """.strip() def generate_and_validate(integrations: Dict[str, Integration]): """Validate and generate zeroconf data.""" - service_type_dict = {} + service_type_dict = defaultdict(list) + homekit_dict = {} for domain in sorted(integrations): integration = integrations[domain] @@ -26,17 +29,30 @@ def generate_and_validate(integrations: Dict[str, Integration]): if not integration.manifest: continue - service_types = integration.manifest.get('zeroconf') + service_types = integration.manifest.get('zeroconf', []) + homekit = integration.manifest.get('homekit', {}) + homekit_models = homekit.get('models', []) - if not service_types: + if not service_types and not homekit_models: continue try: with open(str(integration.path / "config_flow.py")) as fp: - if ' async_step_zeroconf(' not in fp.read(): + content = fp.read() + uses_discovery_flow = 'register_discovery_flow' in content + + if (service_types and not uses_discovery_flow and + ' async_step_zeroconf(' not in content): integration.add_error( 'zeroconf', 'Config flow has no async_step_zeroconf') continue + + if (homekit_models and not uses_discovery_flow and + ' async_step_homekit(' not in content): + integration.add_error( + 'zeroconf', 'Config flow has no async_step_homekit') + continue + except FileNotFoundError: integration.add_error( 'zeroconf', @@ -45,16 +61,50 @@ def generate_and_validate(integrations: Dict[str, Integration]): continue for service_type in service_types: - - if service_type not in service_type_dict: - service_type_dict[service_type] = [] - service_type_dict[service_type].append(domain) - data = OrderedDict((key, service_type_dict[key]) - for key in sorted(service_type_dict)) + for model in homekit_models: + # We add a space, as we want to test for it to be model + space. + model += " " - return BASE.format(json.dumps(data, indent=4)) + if model in homekit_dict: + integration.add_error( + 'zeroconf', + 'Integrations {} and {} have overlapping HomeKit ' + 'models'.format(domain, homekit_dict[model])) + break + + homekit_dict[model] = domain + + # HomeKit models are matched on starting string, make sure none overlap. + warned = set() + for key in homekit_dict: + if key in warned: + continue + + # n^2 yoooo + for key_2 in homekit_dict: + if key == key_2 or key_2 in warned: + continue + + if key.startswith(key_2) or key_2.startswith(key): + integration.add_error( + 'zeroconf', + 'Integrations {} and {} have overlapping HomeKit ' + 'models'.format(homekit_dict[key], homekit_dict[key_2])) + warned.add(key) + warned.add(key_2) + break + + zeroconf = OrderedDict((key, service_type_dict[key]) + for key in sorted(service_type_dict)) + homekit = OrderedDict((key, homekit_dict[key]) + for key in sorted(homekit_dict)) + + return BASE.format( + json.dumps(zeroconf, indent=4), + json.dumps(homekit, indent=4), + ) def validate(integrations: Dict[str, Integration], config: Config): diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index e7d7756fe7c..27c1dc75749 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,6 +1,7 @@ """Test Zeroconf component setup process.""" from unittest.mock import patch +import pytest from zeroconf import ServiceInfo, ServiceStateChange from homeassistant.generated import zeroconf as zc_gen @@ -8,6 +9,13 @@ from homeassistant.setup import async_setup_component from homeassistant.components import zeroconf +@pytest.fixture +def mock_zeroconf(): + """Mock zeroconf.""" + with patch('homeassistant.components.zeroconf.Zeroconf') as mock_zc: + yield mock_zc.return_value + + def service_update_mock(zeroconf, service, handlers): """Call service update handler.""" handlers[0]( @@ -23,18 +31,44 @@ def get_service_info_mock(service_type, name): properties={b'macaddress': b'ABCDEF012345'}) -async def test_setup(hass): +def get_homekit_info_mock(service_type, name): + """Return homekit info for get_service_info.""" + return ServiceInfo( + service_type, name, address=b'\n\x00\x00\x14', port=80, weight=0, + priority=0, server='name.local.', + properties={b'md': b'LIFX Bulb'}) + + +async def test_setup(hass, mock_zeroconf): """Test configured options for a device are loaded via config entry.""" - with patch.object(hass.config_entries, 'flow') as mock_config_flow, \ - patch.object(zeroconf, 'ServiceBrowser') as MockServiceBrowser, \ - patch.object(zeroconf.Zeroconf, 'get_service_info') as \ - mock_get_service_info: - - MockServiceBrowser.side_effect = service_update_mock - mock_get_service_info.side_effect = get_service_info_mock - + with patch.object( + hass.config_entries, 'flow' + ) as mock_config_flow, patch.object( + zeroconf, 'ServiceBrowser', side_effect=service_update_mock + ) as mock_service_browser: + mock_zeroconf.get_service_info.side_effect = get_service_info_mock assert await async_setup_component( hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) - assert len(MockServiceBrowser.mock_calls) == len(zc_gen.ZEROCONF) + assert len(mock_service_browser.mock_calls) == len(zc_gen.ZEROCONF) assert len(mock_config_flow.mock_calls) == len(zc_gen.ZEROCONF) * 2 + + +async def test_homekit(hass, mock_zeroconf): + """Test configured options for a device are loaded via config entry.""" + with patch.dict( + zc_gen.ZEROCONF, { + zeroconf.HOMEKIT_TYPE: ["homekit_controller"] + }, clear=True + ), patch.object( + hass.config_entries, 'flow' + ) as mock_config_flow, patch.object( + zeroconf, 'ServiceBrowser', side_effect=service_update_mock + ) as mock_service_browser: + mock_zeroconf.get_service_info.side_effect = get_homekit_info_mock + assert await async_setup_component( + hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + + 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] == 'lifx'