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:
parent
18286dbf4b
commit
3c1cdecb88
7 changed files with 169 additions and 32 deletions
|
@ -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"
|
||||||
|
|
|
@ -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 = {}
|
||||||
|
|
|
@ -18,3 +18,7 @@ ZEROCONF = {
|
||||||
"homekit_controller"
|
"homekit_controller"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HOMEKIT = {
|
||||||
|
"LIFX ": "lifx"
|
||||||
|
}
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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],
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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'
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue