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 dependenciespull/24033/head
parent
e047e4dcff
commit
636077c74d
|
@ -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.
|
||||
|
|
|
@ -5,5 +5,6 @@
|
|||
"documentation": "https://www.home-assistant.io/components/axis",
|
||||
"requirements": ["axis==23"],
|
||||
"dependencies": [],
|
||||
"zeroconf": ["_axis-video._tcp.local."],
|
||||
"codeowners": ["@kane610"]
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
"""Automatically generated by hassfest.
|
||||
|
||||
To update, run python3 -m hassfest
|
||||
"""
|
||||
|
||||
|
||||
SERVICE_TYPES = {
|
||||
"_axis-video._tcp.local.": [
|
||||
"axis"
|
||||
]
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ TEST_REQUIREMENTS = (
|
|||
'aiohue',
|
||||
'aiounifi',
|
||||
'aioswitcher',
|
||||
'aiozeroconf',
|
||||
'apns2',
|
||||
'av',
|
||||
'axis',
|
||||
|
|
|
@ -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
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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')
|
|
@ -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'
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the Zeroconf component."""
|
|
@ -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
|
Loading…
Reference in New Issue