From 9389a7c9bec0a9fba8d630abdaaf8dfeb525a705 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2020 05:19:21 -0500 Subject: [PATCH] Limit zeroconf discovery to name/macaddress when provided (#39877) Co-authored-by: Paulus Schoutsen --- homeassistant/components/axis/manifest.json | 6 +- .../components/brother/manifest.json | 2 +- .../components/doorbird/manifest.json | 2 +- homeassistant/components/shelly/manifest.json | 2 +- .../components/smappee/manifest.json | 3 +- homeassistant/components/zeroconf/__init__.py | 21 +++- homeassistant/generated/zeroconf.py | 117 +++++++++++++---- homeassistant/loader.py | 19 ++- script/hassfest/manifest.py | 13 +- script/hassfest/zeroconf.py | 13 +- tests/components/zeroconf/test_init.py | 118 ++++++++++++++++-- tests/test_loader.py | 46 ++++++- 12 files changed, 306 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 3b08c5ad4d4..ceb926f326e 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -4,7 +4,11 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/axis", "requirements": ["axis==35"], - "zeroconf": ["_axis-video._tcp.local."], + "zeroconf": [ + {"type":"_axis-video._tcp.local.","macaddress":"00408C*"}, + {"type":"_axis-video._tcp.local.","macaddress":"ACCC8E*"}, + {"type":"_axis-video._tcp.local.","macaddress":"B8A44F*"} + ], "after_dependencies": ["mqtt"], "codeowners": ["@Kane610"] } diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index f107c9573da..2e73f9b8450 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/brother", "codeowners": ["@bieniu"], "requirements": ["brother==0.1.17"], - "zeroconf": ["_printer._tcp.local."], + "zeroconf": [{"type": "_printer._tcp.local.", "name":"brother*"}], "config_flow": true, "quality_scale": "platinum" } diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 23495a22bf8..c5805b15eac 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/doorbird", "requirements": ["doorbirdpy==2.1.0"], "dependencies": ["http"], - "zeroconf": ["_axis-video._tcp.local."], + "zeroconf": [{"type":"_axis-video._tcp.local.","macaddress":"1CCAE3*"}], "codeowners": ["@oblogic7", "@bdraco"], "config_flow": true } diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index a71799db168..38ccc9e0f74 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", "requirements": ["aioshelly==0.3.1"], - "zeroconf": ["_http._tcp.local."], + "zeroconf": [{"type": "_http._tcp.local.", "name":"shelly*"}], "codeowners": ["@balloob", "@bieniu"] } diff --git a/homeassistant/components/smappee/manifest.json b/homeassistant/components/smappee/manifest.json index c6cac118b72..ba1005b87d4 100644 --- a/homeassistant/components/smappee/manifest.json +++ b/homeassistant/components/smappee/manifest.json @@ -11,6 +11,7 @@ "@bsmappee" ], "zeroconf": [ - "_ssh._tcp.local." + {"type":"_ssh._tcp.local.", "name":"smappee1*"}, + {"type":"_ssh._tcp.local.", "name":"smappee2*"} ] } diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index f570a30baa6..51da3638a9e 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -1,5 +1,6 @@ """Support for exposing Home Assistant via Zeroconf.""" import asyncio +import fnmatch import ipaddress import logging import socket @@ -268,10 +269,26 @@ def setup(hass, config): # likely bad homekit data return - for domain in zeroconf_types[service_type]: + for entry in zeroconf_types[service_type]: + if len(entry) > 1: + if "macaddress" in entry: + if "properties" not in info: + continue + if "macaddress" not in info["properties"]: + continue + if not fnmatch.fnmatch( + info["properties"]["macaddress"], entry["macaddress"] + ): + continue + if "name" in entry: + if "name" not in info: + continue + if not fnmatch.fnmatch(info["name"], entry["name"]): + continue + hass.add_job( hass.config_entries.flow.async_init( - domain, context={"source": DOMAIN}, data=info + entry["domain"], context={"source": DOMAIN}, data=info ) ) diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 55ec2110c2e..1dfd797306f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -7,75 +7,142 @@ To update, run python3 -m script.hassfest ZEROCONF = { "_Volumio._tcp.local.": [ - "volumio" + { + "domain": "volumio" + } ], "_api._udp.local.": [ - "guardian" + { + "domain": "guardian" + } ], "_axis-video._tcp.local.": [ - "axis", - "doorbird" + { + "domain": "axis", + "macaddress": "00408C*" + }, + { + "domain": "axis", + "macaddress": "ACCC8E*" + }, + { + "domain": "axis", + "macaddress": "B8A44F*" + }, + { + "domain": "doorbird", + "macaddress": "1CCAE3*" + } ], "_bond._tcp.local.": [ - "bond" + { + "domain": "bond" + } ], "_daap._tcp.local.": [ - "forked_daapd" + { + "domain": "forked_daapd" + } ], "_dkapi._tcp.local.": [ - "daikin" + { + "domain": "daikin" + } ], "_elg._tcp.local.": [ - "elgato" + { + "domain": "elgato" + } ], "_esphomelib._tcp.local.": [ - "esphome" + { + "domain": "esphome" + } ], "_googlecast._tcp.local.": [ - "cast" + { + "domain": "cast" + } ], "_hap._tcp.local.": [ - "homekit_controller" + { + "domain": "homekit_controller" + } ], "_homekit._tcp.local.": [ - "homekit" + { + "domain": "homekit" + } ], "_http._tcp.local.": [ - "shelly" + { + "domain": "shelly", + "name": "shelly*" + } ], "_ipp._tcp.local.": [ - "ipp" + { + "domain": "ipp" + } ], "_ipps._tcp.local.": [ - "ipp" + { + "domain": "ipp" + } ], "_miio._udp.local.": [ - "xiaomi_aqara", - "xiaomi_miio" + { + "domain": "xiaomi_aqara" + }, + { + "domain": "xiaomi_miio" + } ], "_nut._tcp.local.": [ - "nut" + { + "domain": "nut" + } ], "_plugwise._tcp.local.": [ - "plugwise" + { + "domain": "plugwise" + } ], "_printer._tcp.local.": [ - "brother" + { + "domain": "brother", + "name": "brother*" + } ], "_spotify-connect._tcp.local.": [ - "spotify" + { + "domain": "spotify" + } ], "_ssh._tcp.local.": [ - "smappee" + { + "domain": "smappee", + "name": "smappee1*" + }, + { + "domain": "smappee", + "name": "smappee2*" + } ], "_viziocast._tcp.local.": [ - "vizio" + { + "domain": "vizio" + } ], "_wled._tcp.local.": [ - "wled" + { + "domain": "wled" + } ], "_xbmc-jsonrpc-h._tcp.local.": [ - "kodi" + { + "domain": "kodi" + } ] } diff --git a/homeassistant/loader.py b/homeassistant/loader.py index c5027710c47..53f793678c0 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -145,18 +145,25 @@ async def async_get_config_flows(hass: "HomeAssistant") -> Set[str]: return flows -async def async_get_zeroconf(hass: "HomeAssistant") -> Dict[str, List]: +async def async_get_zeroconf(hass: "HomeAssistant") -> Dict[str, List[Dict[str, str]]]: """Return cached list of zeroconf types.""" - zeroconf: Dict[str, List] = ZEROCONF.copy() + zeroconf: Dict[str, List[Dict[str, str]]] = ZEROCONF.copy() integrations = await async_get_custom_components(hass) for integration in integrations.values(): if not integration.zeroconf: continue - for typ in integration.zeroconf: - zeroconf.setdefault(typ, []) - if integration.domain not in zeroconf[typ]: - zeroconf[typ].append(integration.domain) + for entry in integration.zeroconf: + data = {"domain": integration.domain} + if isinstance(entry, dict): + typ = entry["type"] + entry_without_type = entry.copy() + del entry_without_type["type"] + data.update(entry_without_type) + else: + typ = entry + + zeroconf.setdefault(typ, []).append(data) return zeroconf diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index cd3895f5f20..b0148b0911a 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -38,7 +38,18 @@ MANIFEST_SCHEMA = vol.Schema( vol.Required("domain"): str, vol.Required("name"): str, vol.Optional("config_flow"): bool, - vol.Optional("zeroconf"): [str], + vol.Optional("zeroconf"): [ + vol.Any( + str, + vol.Schema( + { + vol.Required("type"): str, + vol.Optional("macaddress"): str, + vol.Optional("name"): str, + } + ), + ) + ], vol.Optional("ssdp"): vol.Schema( vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))]) ), diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index d6b39bd0d27..61162b02761 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -37,8 +37,17 @@ def generate_and_validate(integrations: Dict[str, Integration]): if not (service_types or homekit_models): continue - for service_type in service_types: - service_type_dict[service_type].append(domain) + for entry in service_types: + data = {"domain": domain} + if isinstance(entry, dict): + typ = entry["type"] + entry_without_type = entry.copy() + del entry_without_type["type"] + data.update(entry_without_type) + else: + typ = entry + + service_type_dict[typ].append(data) for model in homekit_models: if model in homekit_dict: diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 73629596c35..ae1f6d5fd98 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -79,6 +79,24 @@ def get_homekit_info_mock(model, pairing_status): return mock_homekit_info +def get_zeroconf_info_mock(macaddress): + """Return info for get_service_info for an zeroconf device.""" + + def mock_zc_info(service_type, name): + return ServiceInfo( + service_type, + name, + addresses=[b"\n\x00\x00\x14"], + port=80, + weight=0, + priority=0, + server="name.local.", + properties={b"macaddress": macaddress.encode()}, + ) + + return mock_zc_info + + async def test_setup(hass, mock_zeroconf): """Test configured options for a device are loaded via config entry.""" with patch.object( @@ -94,7 +112,11 @@ async def test_setup(hass, mock_zeroconf): assert len(mock_service_browser.mock_calls) == 1 expected_flow_calls = 0 for matching_components in zc_gen.ZEROCONF.values(): - expected_flow_calls += len(matching_components) + domains = set() + for component in matching_components: + if len(component) == 1: + domains.add(component["domain"]) + expected_flow_calls += len(domains) assert len(mock_config_flow.mock_calls) == expected_flow_calls # Test instance is set. @@ -209,10 +231,77 @@ async def test_service_with_invalid_name(hass, mock_zeroconf, caplog): assert "Failed to get info for device name" in caplog.text +async def test_zeroconf_match(hass, mock_zeroconf): + """Test configured options for a device are loaded via config entry.""" + + def http_only_service_update_mock(zeroconf, services, handlers): + """Call service update handler.""" + handlers[0]( + zeroconf, + "_http._tcp.local.", + "shelly108._http._tcp.local.", + ServiceStateChange.Added, + ) + + with patch.dict( + zc_gen.ZEROCONF, + {"_http._tcp.local.": [{"domain": "shelly", "name": "shelly*"}]}, + clear=True, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow, patch.object( + zeroconf, "HaServiceBrowser", side_effect=http_only_service_update_mock + ) as mock_service_browser: + mock_zeroconf.get_service_info.side_effect = get_zeroconf_info_mock( + "FFAADDCC11DD" + ) + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "shelly" + + +async def test_zeroconf_no_match(hass, mock_zeroconf): + """Test configured options for a device are loaded via config entry.""" + + def http_only_service_update_mock(zeroconf, services, handlers): + """Call service update handler.""" + handlers[0]( + zeroconf, + "_http._tcp.local.", + "somethingelse._http._tcp.local.", + ServiceStateChange.Added, + ) + + with patch.dict( + zc_gen.ZEROCONF, + {"_http._tcp.local.": [{"domain": "shelly", "name": "shelly*"}]}, + clear=True, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow, patch.object( + zeroconf, "HaServiceBrowser", side_effect=http_only_service_update_mock + ) as mock_service_browser: + mock_zeroconf.get_service_info.side_effect = get_zeroconf_info_mock( + "FFAADDCC11DD" + ) + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 0 + + async def test_homekit_match_partial_space(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 + zc_gen.ZEROCONF, + {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]}, + clear=True, ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( @@ -233,7 +322,9 @@ async def test_homekit_match_partial_space(hass, mock_zeroconf): async def test_homekit_match_partial_dash(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 + zc_gen.ZEROCONF, + {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]}, + clear=True, ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( @@ -254,7 +345,9 @@ async def test_homekit_match_partial_dash(hass, mock_zeroconf): async def test_homekit_match_full(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 + zc_gen.ZEROCONF, + {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]}, + clear=True, ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( @@ -267,11 +360,6 @@ async def test_homekit_match_full(hass, mock_zeroconf): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - homekit_mock = get_homekit_info_mock("BSB002", HOMEKIT_STATUS_UNPAIRED) - info = homekit_mock("_hap._tcp.local.", "BSB002._hap._tcp.local.") - import pprint - - pprint.pprint(["homekit", info]) assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "hue" @@ -280,7 +368,9 @@ async def test_homekit_match_full(hass, mock_zeroconf): async def test_homekit_already_paired(hass, mock_zeroconf): """Test that an already paired device is sent to homekit_controller.""" with patch.dict( - zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True + zc_gen.ZEROCONF, + {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]}, + clear=True, ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( @@ -302,7 +392,9 @@ async def test_homekit_already_paired(hass, mock_zeroconf): async def test_homekit_invalid_paring_status(hass, mock_zeroconf): """Test that missing paring data is not sent to homekit_controller.""" with patch.dict( - zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True + zc_gen.ZEROCONF, + {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]}, + clear=True, ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( @@ -323,7 +415,9 @@ async def test_homekit_invalid_paring_status(hass, mock_zeroconf): async def test_homekit_not_paired(hass, mock_zeroconf): """Test that an not paired device is sent to homekit_controller.""" with patch.dict( - zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True + zc_gen.ZEROCONF, + {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]}, + clear=True, ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( diff --git a/tests/test_loader.py b/tests/test_loader.py index 272b0453469..f5ba54ff269 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -218,6 +218,23 @@ def test_integration_properties(hass): assert integration.zeroconf is None assert integration.ssdp is None + integration = loader.Integration( + hass, + "custom_components.hue", + None, + { + "name": "Philips Hue", + "domain": "hue", + "dependencies": ["test-dep"], + "zeroconf": [{"type": "_hue._tcp.local.", "name": "hue*"}], + "requirements": ["test-req==1.0.0"], + }, + ) + assert integration.is_built_in is False + assert integration.homekit is None + assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}] + assert integration.ssdp is None + async def test_integrations_only_once(hass): """Test that we load integrations only once.""" @@ -253,6 +270,25 @@ def _get_test_integration(hass, name, config_flow): ) +def _get_test_integration_with_zeroconf_matcher(hass, name, config_flow): + """Return a generated test integration with a zeroconf matcher.""" + return loader.Integration( + hass, + f"homeassistant.components.{name}", + None, + { + "name": name, + "domain": name, + "config_flow": config_flow, + "dependencies": [], + "requirements": [], + "zeroconf": [{"type": f"_{name}._tcp.local.", "name": f"{name}*"}], + "homekit": {"models": [name]}, + "ssdp": [{"manufacturer": name, "modelName": name}], + }, + ) + + async def test_get_custom_components(hass): """Verify that custom components are cached.""" test_1_integration = _get_test_integration(hass, "test_1", False) @@ -289,7 +325,9 @@ async def test_get_config_flows(hass): async def test_get_zeroconf(hass): """Verify that custom components with zeroconf are found.""" test_1_integration = _get_test_integration(hass, "test_1", True) - test_2_integration = _get_test_integration(hass, "test_2", True) + test_2_integration = _get_test_integration_with_zeroconf_matcher( + hass, "test_2", True + ) with patch("homeassistant.loader.async_get_custom_components") as mock_get: mock_get.return_value = { @@ -297,8 +335,10 @@ async def test_get_zeroconf(hass): "test_2": test_2_integration, } zeroconf = await loader.async_get_zeroconf(hass) - assert zeroconf["_test_1._tcp.local."] == ["test_1"] - assert zeroconf["_test_2._tcp.local."] == ["test_2"] + assert zeroconf["_test_1._tcp.local."] == [{"domain": "test_1"}] + assert zeroconf["_test_2._tcp.local."] == [ + {"domain": "test_2", "name": "test_2*"} + ] async def test_get_homekit(hass):