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
pull/24033/head
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
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.
This flow is triggered by the discovery component.

View File

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

View File

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

View File

@ -1,14 +1,25 @@
"""Support for exposing Home Assistant via Zeroconf."""
import logging
import ipaddress
import voluptuous as vol
from aiozeroconf import (
ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf)
from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__)
from homeassistant.generated import zeroconf as zeroconf_manifest
_LOGGER = logging.getLogger(__name__)
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.'
@ -19,8 +30,6 @@ CONFIG_SCHEMA = vol.Schema({
async def async_setup(hass, config):
"""Set up Zeroconf and make Home Assistant discoverable."""
from aiozeroconf import Zeroconf, ServiceInfo
zeroconf_name = '{}.{}'.format(hass.config.location_name, ZEROCONF_TYPE)
params = {
@ -37,7 +46,28 @@ async def async_setup(hass, config):
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."""
await zeroconf.unregister_service(info)
await zeroconf.close()
@ -45,3 +75,29 @@ async def async_setup(hass, config):
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf)
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
aiounifi==4
# homeassistant.components.zeroconf
aiozeroconf==0.1.8
# homeassistant.components.ambiclimate
ambiclimate==0.1.1

View File

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

View File

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

View File

@ -11,6 +11,7 @@ MANIFEST_SCHEMA = vol.Schema({
vol.Required('domain'): str,
vol.Required('name'): str,
vol.Optional('config_flow'): bool,
vol.Optional('zeroconf'): [str],
vol.Required('documentation'): str,
vol.Required('requirements'): [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'
async def test_discovery_flow(hass):
"""Test that discovery for new devices work."""
async def test_zeroconf_flow(hass):
"""Test that zeroconf discovery for new devices work."""
with patch.object(axis, 'get_device', return_value=mock_coro(Mock())):
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
@ -171,15 +171,15 @@ async def test_discovery_flow(hass):
config_flow.CONF_PORT: 80,
'properties': {'macaddress': '1234'}
},
context={'source': 'discovery'}
context={'source': 'zeroconf'}
)
assert result['type'] == 'form'
assert result['step_id'] == 'user'
async def test_discovery_flow_known_device(hass):
"""Test that discovery for known devices work.
async def test_zeroconf_flow_known_device(hass):
"""Test that zeroconf discovery for known devices work.
This is legacy support from devices registered with configurator.
"""
@ -210,14 +210,14 @@ async def test_discovery_flow_known_device(hass):
'hostname': 'name',
'properties': {'macaddress': '1234ABCD'}
},
context={'source': 'discovery'}
context={'source': 'zeroconf'}
)
assert result['type'] == 'create_entry'
async def test_discovery_flow_already_configured(hass):
"""Test that discovery doesn't setup already configured devices."""
async def test_zeroconf_flow_already_configured(hass):
"""Test that zeroconf doesn't setup already configured devices."""
entry = MockConfigEntry(
domain=axis.DOMAIN,
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',
'properties': {'macaddress': '1234ABCD'}
},
context={'source': 'discovery'}
context={'source': 'zeroconf'}
)
assert result['type'] == 'abort'
assert result['reason'] == 'already_configured'
async def test_discovery_flow_ignore_link_local_address(hass):
"""Test that discovery doesn't setup devices with link local addresses."""
async def test_zeroconf_flow_ignore_link_local_address(hass):
"""Test that zeroconf doesn't setup devices with link local addresses."""
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
data={config_flow.CONF_HOST: '169.254.3.4'},
context={'source': 'discovery'}
context={'source': 'zeroconf'}
)
assert result['type'] == 'abort'
assert result['reason'] == 'link_local_address'
async def test_discovery_flow_bad_config_file(hass):
"""Test that discovery with bad config files abort."""
async def test_zeroconf_flow_bad_config_file(hass):
"""Test that zeroconf discovery with bad config files abort."""
with patch('homeassistant.components.axis.config_flow.load_json',
return_value={'1234ABCD': {
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',
'properties': {'macaddress': '1234ABCD'}
},
context={'source': 'discovery'}
context={'source': 'zeroconf'}
)
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