diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 0c175de20c7..fc2051e4925 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -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. diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 507f63c12b5..27c108b334c 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/components/axis", "requirements": ["axis==23"], "dependencies": [], + "zeroconf": ["_axis-video._tcp.local."], "codeowners": ["@kane610"] } diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 99879b60e66..246a66eb9a1 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -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', diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index e745cb53f6b..161321d1e88 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -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 diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py new file mode 100644 index 00000000000..08c520b3816 --- /dev/null +++ b/homeassistant/generated/zeroconf.py @@ -0,0 +1,11 @@ +"""Automatically generated by hassfest. + +To update, run python3 -m hassfest +""" + + +SERVICE_TYPES = { + "_axis-video._tcp.local.": [ + "axis" + ] +} diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 826544c4e8d..f03364c7b0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 057f5c9fd24..108d0bcab07 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -50,6 +50,7 @@ TEST_REQUIREMENTS = ( 'aiohue', 'aiounifi', 'aioswitcher', + 'aiozeroconf', 'apns2', 'av', 'axis', diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 9e7797201ea..6a6b19aada7 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -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 ] diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 789b5fc0b41..cfb2fdc006a 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -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], diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py new file mode 100644 index 00000000000..468d2741dbd --- /dev/null +++ b/script/hassfest/zeroconf.py @@ -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') diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 1a83e9be8b5..ebd2062ee0f 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -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' diff --git a/tests/components/zeroconf/__init__.py b/tests/components/zeroconf/__init__.py new file mode 100644 index 00000000000..d702ef482d6 --- /dev/null +++ b/tests/components/zeroconf/__init__.py @@ -0,0 +1 @@ +"""Tests for the Zeroconf component.""" diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py new file mode 100644 index 00000000000..b3257f57714 --- /dev/null +++ b/tests/components/zeroconf/test_init.py @@ -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