Skip to content

Commit

Permalink
Add manifest support for homekit discovery (#24225)
Browse files Browse the repository at this point in the history
* Add manifest support for homekit discovery

* Add a space after model check

* Update comment
  • Loading branch information
balloob committed May 31, 2019
1 parent 18286db commit 3c1cdec
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 31 deletions.
5 changes: 5 additions & 0 deletions homeassistant/components/lifx/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
"aiolifx==0.6.7",
"aiolifx_effects==0.2.2"
],
"homekit": {
"models": [
"LIFX"
]
},
"dependencies": [],
"codeowners": [
"@amelchio"
Expand Down
62 changes: 51 additions & 11 deletions homeassistant/components/zeroconf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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__)

Expand All @@ -24,6 +24,7 @@
ATTR_PROPERTIES = 'properties'

ZEROCONF_TYPE = '_home-assistant._tcp.local.'
HOMEKIT_TYPE = '_hap._tcp.local.'

CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({}),
Expand All @@ -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)

for domain in ZEROCONF[service_type]:
hass.add_job(
hass.config_entries.flow.async_init(
domain, context={'source': DOMAIN}, data=info
)
if state_change != ServiceStateChange.Added:
return

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)
Expand All @@ -75,6 +85,36 @@ def stop_zeroconf(_):
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 = {}
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/generated/zeroconf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@
"homekit_controller"
]
}

HOMEKIT = {
"LIFX ": "lifx"
}
1 change: 1 addition & 0 deletions homeassistant/helpers/config_entry_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ async def async_step_discovery(self, discovery_info):

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."""
Expand Down
3 changes: 3 additions & 0 deletions script/hassfest/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
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],
Expand Down
72 changes: 61 additions & 11 deletions script/hassfest/zeroconf.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Generate zeroconf file."""
from collections import OrderedDict
from collections import OrderedDict, defaultdict
import json
from typing import Dict

Expand All @@ -13,30 +13,46 @@
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]

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',
Expand All @@ -45,16 +61,50 @@ def generate_and_validate(integrations: Dict[str, Integration]):
continue

for service_type in service_types:
service_type_dict[service_type].append(domain)

if service_type not in service_type_dict:
service_type_dict[service_type] = []
for model in homekit_models:
# We add a space, as we want to test for it to be model + space.
model += " "

service_type_dict[service_type].append(domain)
if model in homekit_dict:
integration.add_error(
'zeroconf',
'Integrations {} and {} have overlapping HomeKit '
'models'.format(domain, homekit_dict[model]))
break

data = OrderedDict((key, service_type_dict[key])
for key in sorted(service_type_dict))
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

return BASE.format(json.dumps(data, indent=4))
# 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):
Expand Down
52 changes: 43 additions & 9 deletions tests/components/zeroconf/test_init.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
"""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
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](
Expand All @@ -23,18 +31,44 @@ def get_service_info_mock(service_type, name):
properties={b'macaddress': b'ABCDEF012345'})


async def test_setup(hass):
"""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:
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'})

MockServiceBrowser.side_effect = service_update_mock
mock_get_service_info.side_effect = get_service_info_mock

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', 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'

0 comments on commit 3c1cdec

Please sign in to comment.