Add manifest support for homekit discovery (#24225)

* Add manifest support for homekit discovery

* Add a space after model check

* Update comment
This commit is contained in:
Paulus Schoutsen 2019-05-31 11:58:48 -07:00 committed by GitHub
parent 18286dbf4b
commit 3c1cdecb88
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 169 additions and 32 deletions

View file

@ -7,6 +7,11 @@
"aiolifx==0.6.7", "aiolifx==0.6.7",
"aiolifx_effects==0.2.2" "aiolifx_effects==0.2.2"
], ],
"homekit": {
"models": [
"LIFX"
]
},
"dependencies": [], "dependencies": [],
"codeowners": [ "codeowners": [
"@amelchio" "@amelchio"

View file

@ -10,7 +10,7 @@ import voluptuous as vol
from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf
from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) 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__) _LOGGER = logging.getLogger(__name__)
@ -24,6 +24,7 @@ ATTR_NAME = 'name'
ATTR_PROPERTIES = 'properties' ATTR_PROPERTIES = 'properties'
ZEROCONF_TYPE = '_home-assistant._tcp.local.' ZEROCONF_TYPE = '_home-assistant._tcp.local.'
HOMEKIT_TYPE = '_hap._tcp.local.'
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({}), DOMAIN: vol.Schema({}),
@ -50,21 +51,30 @@ def setup(hass, config):
def service_update(zeroconf, service_type, name, state_change): def service_update(zeroconf, service_type, name, state_change):
"""Service state changed.""" """Service state changed."""
if state_change is ServiceStateChange.Added: if state_change != ServiceStateChange.Added:
service_info = zeroconf.get_service_info(service_type, name) return
info = info_from_service(service_info)
_LOGGER.debug("Discovered new device %s %s", name, info)
for domain in ZEROCONF[service_type]: service_info = zeroconf.get_service_info(service_type, name)
hass.add_job( info = info_from_service(service_info)
hass.config_entries.flow.async_init( _LOGGER.debug("Discovered new device %s %s", name, info)
domain, context={'source': DOMAIN}, data=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: for service in ZEROCONF:
ServiceBrowser(zeroconf, service, handlers=[service_update]) ServiceBrowser(zeroconf, service, handlers=[service_update])
if HOMEKIT_TYPE not in ZEROCONF:
ServiceBrowser(zeroconf, HOMEKIT_TYPE, handlers=[service_update])
def stop_zeroconf(_): def stop_zeroconf(_):
"""Stop Zeroconf.""" """Stop Zeroconf."""
zeroconf.unregister_service(info) zeroconf.unregister_service(info)
@ -75,6 +85,36 @@ def setup(hass, config):
return True 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): def info_from_service(service):
"""Return prepared info from mDNS entries.""" """Return prepared info from mDNS entries."""
properties = {} properties = {}

View file

@ -18,3 +18,7 @@ ZEROCONF = {
"homekit_controller" "homekit_controller"
] ]
} }
HOMEKIT = {
"LIFX ": "lifx"
}

View file

@ -83,6 +83,7 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow):
async_step_zeroconf = async_step_discovery async_step_zeroconf = async_step_discovery
async_step_ssdp = async_step_discovery async_step_ssdp = async_step_discovery
async_step_homekit = async_step_discovery
async def async_step_import(self, _): async def async_step_import(self, _):
"""Handle a flow initialized by import.""" """Handle a flow initialized by import."""

View file

@ -17,6 +17,9 @@ MANIFEST_SCHEMA = vol.Schema({
vol.Optional('manufacturer'): [str], vol.Optional('manufacturer'): [str],
vol.Optional('device_type'): [str], vol.Optional('device_type'): [str],
}), }),
vol.Optional('homekit'): vol.Schema({
vol.Optional('models'): [str],
}),
vol.Required('documentation'): str, vol.Required('documentation'): str,
vol.Required('requirements'): [str], vol.Required('requirements'): [str],
vol.Required('dependencies'): [str], vol.Required('dependencies'): [str],

View file

@ -1,5 +1,5 @@
"""Generate zeroconf file.""" """Generate zeroconf file."""
from collections import OrderedDict from collections import OrderedDict, defaultdict
import json import json
from typing import Dict from typing import Dict
@ -13,12 +13,15 @@ To update, run python3 -m script.hassfest
ZEROCONF = {} ZEROCONF = {}
HOMEKIT = {}
""".strip() """.strip()
def generate_and_validate(integrations: Dict[str, Integration]): def generate_and_validate(integrations: Dict[str, Integration]):
"""Validate and generate zeroconf data.""" """Validate and generate zeroconf data."""
service_type_dict = {} service_type_dict = defaultdict(list)
homekit_dict = {}
for domain in sorted(integrations): for domain in sorted(integrations):
integration = integrations[domain] integration = integrations[domain]
@ -26,17 +29,30 @@ def generate_and_validate(integrations: Dict[str, Integration]):
if not integration.manifest: if not integration.manifest:
continue 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 continue
try: try:
with open(str(integration.path / "config_flow.py")) as fp: 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( integration.add_error(
'zeroconf', 'Config flow has no async_step_zeroconf') 'zeroconf', 'Config flow has no async_step_zeroconf')
continue 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: except FileNotFoundError:
integration.add_error( integration.add_error(
'zeroconf', 'zeroconf',
@ -45,16 +61,50 @@ def generate_and_validate(integrations: Dict[str, Integration]):
continue continue
for service_type in service_types: 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) service_type_dict[service_type].append(domain)
data = OrderedDict((key, service_type_dict[key]) for model in homekit_models:
for key in sorted(service_type_dict)) # 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): def validate(integrations: Dict[str, Integration], config: Config):

View file

@ -1,6 +1,7 @@
"""Test Zeroconf component setup process.""" """Test Zeroconf component setup process."""
from unittest.mock import patch from unittest.mock import patch
import pytest
from zeroconf import ServiceInfo, ServiceStateChange from zeroconf import ServiceInfo, ServiceStateChange
from homeassistant.generated import zeroconf as zc_gen from homeassistant.generated import zeroconf as zc_gen
@ -8,6 +9,13 @@ from homeassistant.setup import async_setup_component
from homeassistant.components import zeroconf 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): def service_update_mock(zeroconf, service, handlers):
"""Call service update handler.""" """Call service update handler."""
handlers[0]( handlers[0](
@ -23,18 +31,44 @@ def get_service_info_mock(service_type, name):
properties={b'macaddress': b'ABCDEF012345'}) 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.""" """Test configured options for a device are loaded via config entry."""
with patch.object(hass.config_entries, 'flow') as mock_config_flow, \ with patch.object(
patch.object(zeroconf, 'ServiceBrowser') as MockServiceBrowser, \ hass.config_entries, 'flow'
patch.object(zeroconf.Zeroconf, 'get_service_info') as \ ) as mock_config_flow, patch.object(
mock_get_service_info: zeroconf, 'ServiceBrowser', side_effect=service_update_mock
) as mock_service_browser:
MockServiceBrowser.side_effect = service_update_mock mock_zeroconf.get_service_info.side_effect = get_service_info_mock
mock_get_service_info.side_effect = get_service_info_mock
assert await async_setup_component( assert await async_setup_component(
hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) 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 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'