Zeroconf discovery for config entries (#23919)

* Proof of concept

* Follow comments

* Fix line length and bad imports

* Move imports to top

* Exception handling for unicode decoding
Create debug print for new service types
Add empty test files

* First try at a test

* Add type and name to service info
Fix static check

* Add aiozeroconf to test dependencies
This commit is contained in:
Robert Svensson 2019-05-22 00:36:26 +02:00 committed by Paulus Schoutsen
parent e047e4dcff
commit 636077c74d
13 changed files with 199 additions and 22 deletions

View file

@ -146,7 +146,7 @@ class AxisFlowHandler(config_entries.ConfigFlow):
entry.data[CONF_DEVICE][CONF_HOST] = host entry.data[CONF_DEVICE][CONF_HOST] = host
self.hass.config_entries.async_update_entry(entry) self.hass.config_entries.async_update_entry(entry)
async def async_step_discovery(self, discovery_info): async def async_step_zeroconf(self, discovery_info):
"""Prepare configuration for a discovered Axis device. """Prepare configuration for a discovered Axis device.
This flow is triggered by the discovery component. This flow is triggered by the discovery component.

View file

@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/components/axis", "documentation": "https://www.home-assistant.io/components/axis",
"requirements": ["axis==23"], "requirements": ["axis==23"],
"dependencies": [], "dependencies": [],
"zeroconf": ["_axis-video._tcp.local."],
"codeowners": ["@kane610"] "codeowners": ["@kane610"]
} }

View file

@ -24,7 +24,6 @@ DOMAIN = 'discovery'
SCAN_INTERVAL = timedelta(seconds=300) SCAN_INTERVAL = timedelta(seconds=300)
SERVICE_APPLE_TV = 'apple_tv' SERVICE_APPLE_TV = 'apple_tv'
SERVICE_AXIS = 'axis'
SERVICE_DAIKIN = 'daikin' SERVICE_DAIKIN = 'daikin'
SERVICE_DECONZ = 'deconz' SERVICE_DECONZ = 'deconz'
SERVICE_DLNA_DMR = 'dlna_dmr' SERVICE_DLNA_DMR = 'dlna_dmr'
@ -51,7 +50,6 @@ SERVICE_WINK = 'wink'
SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_XIAOMI_GW = 'xiaomi_gw'
CONFIG_ENTRY_HANDLERS = { CONFIG_ENTRY_HANDLERS = {
SERVICE_AXIS: 'axis',
SERVICE_DAIKIN: 'daikin', SERVICE_DAIKIN: 'daikin',
SERVICE_DECONZ: 'deconz', SERVICE_DECONZ: 'deconz',
'esphome': 'esphome', 'esphome': 'esphome',

View file

@ -1,14 +1,25 @@
"""Support for exposing Home Assistant via Zeroconf.""" """Support for exposing Home Assistant via Zeroconf."""
import logging import logging
import ipaddress
import voluptuous as vol import voluptuous as vol
from aiozeroconf import (
ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf)
from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__)
from homeassistant.generated import zeroconf as zeroconf_manifest
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = 'zeroconf' DOMAIN = 'zeroconf'
ATTR_HOST = 'host'
ATTR_PORT = 'port'
ATTR_HOSTNAME = 'hostname'
ATTR_TYPE = 'type'
ATTR_NAME = 'name'
ATTR_PROPERTIES = 'properties'
ZEROCONF_TYPE = '_home-assistant._tcp.local.' ZEROCONF_TYPE = '_home-assistant._tcp.local.'
@ -19,8 +30,6 @@ CONFIG_SCHEMA = vol.Schema({
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up Zeroconf and make Home Assistant discoverable.""" """Set up Zeroconf and make Home Assistant discoverable."""
from aiozeroconf import Zeroconf, ServiceInfo
zeroconf_name = '{}.{}'.format(hass.config.location_name, ZEROCONF_TYPE) zeroconf_name = '{}.{}'.format(hass.config.location_name, ZEROCONF_TYPE)
params = { params = {
@ -37,7 +46,28 @@ async def async_setup(hass, config):
await zeroconf.register_service(info) await zeroconf.register_service(info)
async def stop_zeroconf(event): async def new_service(service_type, name):
"""Signal new service discovered."""
service_info = await 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_manifest.SERVICE_TYPES[service_type]:
hass.async_create_task(
hass.config_entries.flow.async_init(
domain, context={'source': DOMAIN}, data=info
)
)
def service_update(_, service_type, name, state_change):
"""Service state changed."""
if state_change is ServiceStateChange.Added:
hass.async_create_task(new_service(service_type, name))
for service in zeroconf_manifest.SERVICE_TYPES:
ServiceBrowser(zeroconf, service, handlers=[service_update])
async def stop_zeroconf(_):
"""Stop Zeroconf.""" """Stop Zeroconf."""
await zeroconf.unregister_service(info) await zeroconf.unregister_service(info)
await zeroconf.close() await zeroconf.close()
@ -45,3 +75,29 @@ async def async_setup(hass, config):
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf)
return True return True
def info_from_service(service):
"""Return prepared info from mDNS entries."""
properties = {}
for key, value in service.properties.items():
try:
if isinstance(value, bytes):
value = value.decode('utf-8')
properties[key.decode('utf-8')] = value
except UnicodeDecodeError:
_LOGGER.warning("Unicode decode error on %s: %s", key, value)
address = service.address or service.address6
info = {
ATTR_HOST: str(ipaddress.ip_address(address)),
ATTR_PORT: service.port,
ATTR_HOSTNAME: service.server,
ATTR_TYPE: service.type,
ATTR_NAME: service.name,
ATTR_PROPERTIES: properties,
}
return info

View file

@ -0,0 +1,11 @@
"""Automatically generated by hassfest.
To update, run python3 -m hassfest
"""
SERVICE_TYPES = {
"_axis-video._tcp.local.": [
"axis"
]
}

View file

@ -57,6 +57,9 @@ aioswitcher==2019.3.21
# homeassistant.components.unifi # homeassistant.components.unifi
aiounifi==4 aiounifi==4
# homeassistant.components.zeroconf
aiozeroconf==0.1.8
# homeassistant.components.ambiclimate # homeassistant.components.ambiclimate
ambiclimate==0.1.1 ambiclimate==0.1.1

View file

@ -50,6 +50,7 @@ TEST_REQUIREMENTS = (
'aiohue', 'aiohue',
'aiounifi', 'aiounifi',
'aioswitcher', 'aioswitcher',
'aiozeroconf',
'apns2', 'apns2',
'av', 'av',
'axis', 'axis',

View file

@ -3,7 +3,8 @@ import pathlib
import sys import sys
from .model import Integration, Config from .model import Integration, Config
from . import dependencies, manifest, codeowners, services, config_flow from . import (
dependencies, manifest, codeowners, services, config_flow, zeroconf)
PLUGINS = [ PLUGINS = [
manifest, manifest,
@ -11,6 +12,7 @@ PLUGINS = [
codeowners, codeowners,
services, services,
config_flow, config_flow,
zeroconf
] ]

View file

@ -11,6 +11,7 @@ MANIFEST_SCHEMA = vol.Schema({
vol.Required('domain'): str, vol.Required('domain'): str,
vol.Required('name'): str, vol.Required('name'): str,
vol.Optional('config_flow'): bool, vol.Optional('config_flow'): bool,
vol.Optional('zeroconf'): [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

@ -0,0 +1,63 @@
"""Generate zeroconf file."""
import json
from typing import Dict
from .model import Integration, Config
BASE = """
\"\"\"Automatically generated by hassfest.
To update, run python3 -m hassfest
\"\"\"
SERVICE_TYPES = {}
""".strip()
def generate_and_validate(integrations: Dict[str, Integration]):
"""Validate and generate zeroconf data."""
service_type_dict = {}
for domain in sorted(integrations):
integration = integrations[domain]
if not integration.manifest:
continue
service_types = integration.manifest.get('zeroconf')
if not service_types:
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)
return BASE.format(json.dumps(service_type_dict, indent=4))
def validate(integrations: Dict[str, Integration], config: Config):
"""Validate zeroconf file."""
zeroconf_path = config.root / 'homeassistant/generated/zeroconf.py'
config.cache['zeroconf'] = content = generate_and_validate(integrations)
with open(str(zeroconf_path), 'r') as fp:
if fp.read().strip() != content:
config.add_error(
"zeroconf",
"File zeroconf.py is not up to date. "
"Run python3 -m script.hassfest",
fixable=True
)
return
def generate(integrations: Dict[str, Integration], config: Config):
"""Generate zeroconf file."""
zeroconf_path = config.root / 'homeassistant/generated/zeroconf.py'
with open(str(zeroconf_path), 'w') as fp:
fp.write(config.cache['zeroconf'] + '\n')

View file

@ -161,8 +161,8 @@ async def test_flow_create_entry_more_entries(hass):
assert result['data'][config_flow.CONF_NAME] == 'model 2' assert result['data'][config_flow.CONF_NAME] == 'model 2'
async def test_discovery_flow(hass): async def test_zeroconf_flow(hass):
"""Test that discovery for new devices work.""" """Test that zeroconf discovery for new devices work."""
with patch.object(axis, 'get_device', return_value=mock_coro(Mock())): with patch.object(axis, 'get_device', return_value=mock_coro(Mock())):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, config_flow.DOMAIN,
@ -171,15 +171,15 @@ async def test_discovery_flow(hass):
config_flow.CONF_PORT: 80, config_flow.CONF_PORT: 80,
'properties': {'macaddress': '1234'} 'properties': {'macaddress': '1234'}
}, },
context={'source': 'discovery'} context={'source': 'zeroconf'}
) )
assert result['type'] == 'form' assert result['type'] == 'form'
assert result['step_id'] == 'user' assert result['step_id'] == 'user'
async def test_discovery_flow_known_device(hass): async def test_zeroconf_flow_known_device(hass):
"""Test that discovery for known devices work. """Test that zeroconf discovery for known devices work.
This is legacy support from devices registered with configurator. This is legacy support from devices registered with configurator.
""" """
@ -210,14 +210,14 @@ async def test_discovery_flow_known_device(hass):
'hostname': 'name', 'hostname': 'name',
'properties': {'macaddress': '1234ABCD'} 'properties': {'macaddress': '1234ABCD'}
}, },
context={'source': 'discovery'} context={'source': 'zeroconf'}
) )
assert result['type'] == 'create_entry' assert result['type'] == 'create_entry'
async def test_discovery_flow_already_configured(hass): async def test_zeroconf_flow_already_configured(hass):
"""Test that discovery doesn't setup already configured devices.""" """Test that zeroconf doesn't setup already configured devices."""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=axis.DOMAIN, domain=axis.DOMAIN,
data={axis.CONF_DEVICE: {axis.config_flow.CONF_HOST: '1.2.3.4'}, data={axis.CONF_DEVICE: {axis.config_flow.CONF_HOST: '1.2.3.4'},
@ -235,27 +235,27 @@ async def test_discovery_flow_already_configured(hass):
'hostname': 'name', 'hostname': 'name',
'properties': {'macaddress': '1234ABCD'} 'properties': {'macaddress': '1234ABCD'}
}, },
context={'source': 'discovery'} context={'source': 'zeroconf'}
) )
assert result['type'] == 'abort' assert result['type'] == 'abort'
assert result['reason'] == 'already_configured' assert result['reason'] == 'already_configured'
async def test_discovery_flow_ignore_link_local_address(hass): async def test_zeroconf_flow_ignore_link_local_address(hass):
"""Test that discovery doesn't setup devices with link local addresses.""" """Test that zeroconf doesn't setup devices with link local addresses."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, config_flow.DOMAIN,
data={config_flow.CONF_HOST: '169.254.3.4'}, data={config_flow.CONF_HOST: '169.254.3.4'},
context={'source': 'discovery'} context={'source': 'zeroconf'}
) )
assert result['type'] == 'abort' assert result['type'] == 'abort'
assert result['reason'] == 'link_local_address' assert result['reason'] == 'link_local_address'
async def test_discovery_flow_bad_config_file(hass): async def test_zeroconf_flow_bad_config_file(hass):
"""Test that discovery with bad config files abort.""" """Test that zeroconf discovery with bad config files abort."""
with patch('homeassistant.components.axis.config_flow.load_json', with patch('homeassistant.components.axis.config_flow.load_json',
return_value={'1234ABCD': { return_value={'1234ABCD': {
config_flow.CONF_HOST: '2.3.4.5', config_flow.CONF_HOST: '2.3.4.5',
@ -270,7 +270,7 @@ async def test_discovery_flow_bad_config_file(hass):
config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_HOST: '1.2.3.4',
'properties': {'macaddress': '1234ABCD'} 'properties': {'macaddress': '1234ABCD'}
}, },
context={'source': 'discovery'} context={'source': 'zeroconf'}
) )
assert result['type'] == 'abort' assert result['type'] == 'abort'

View file

@ -0,0 +1 @@
"""Tests for the Zeroconf component."""

View file

@ -0,0 +1,40 @@
"""Test Zeroconf component setup process."""
from unittest.mock import patch
from aiozeroconf import ServiceInfo, ServiceStateChange
from homeassistant.setup import async_setup_component
from homeassistant.components import zeroconf
def service_update_mock(zeroconf, service, handlers):
"""Call service update handler."""
handlers[0](
None, service, '{}.{}'.format('name', service),
ServiceStateChange.Added)
async def get_service_info_mock(service_type, name):
"""Return service 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'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:
MockServiceBrowser.side_effect = service_update_mock
mock_get_service_info.side_effect = get_service_info_mock
assert await async_setup_component(
hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
await hass.async_block_till_done()
assert len(MockServiceBrowser.mock_calls) == 1
assert len(mock_config_flow.mock_calls) == 1