Limit zeroconf discovery to name/macaddress when provided (#39877)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
pull/39938/head
J. Nick Koston 2020-09-11 05:19:21 -05:00 committed by GitHub
parent 487a74ba5d
commit 9389a7c9be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 306 additions and 56 deletions

View File

@ -4,7 +4,11 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/axis", "documentation": "https://www.home-assistant.io/integrations/axis",
"requirements": ["axis==35"], "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"], "after_dependencies": ["mqtt"],
"codeowners": ["@Kane610"] "codeowners": ["@Kane610"]
} }

View File

@ -4,7 +4,7 @@
"documentation": "https://www.home-assistant.io/integrations/brother", "documentation": "https://www.home-assistant.io/integrations/brother",
"codeowners": ["@bieniu"], "codeowners": ["@bieniu"],
"requirements": ["brother==0.1.17"], "requirements": ["brother==0.1.17"],
"zeroconf": ["_printer._tcp.local."], "zeroconf": [{"type": "_printer._tcp.local.", "name":"brother*"}],
"config_flow": true, "config_flow": true,
"quality_scale": "platinum" "quality_scale": "platinum"
} }

View File

@ -4,7 +4,7 @@
"documentation": "https://www.home-assistant.io/integrations/doorbird", "documentation": "https://www.home-assistant.io/integrations/doorbird",
"requirements": ["doorbirdpy==2.1.0"], "requirements": ["doorbirdpy==2.1.0"],
"dependencies": ["http"], "dependencies": ["http"],
"zeroconf": ["_axis-video._tcp.local."], "zeroconf": [{"type":"_axis-video._tcp.local.","macaddress":"1CCAE3*"}],
"codeowners": ["@oblogic7", "@bdraco"], "codeowners": ["@oblogic7", "@bdraco"],
"config_flow": true "config_flow": true
} }

View File

@ -4,6 +4,6 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/shelly", "documentation": "https://www.home-assistant.io/integrations/shelly",
"requirements": ["aioshelly==0.3.1"], "requirements": ["aioshelly==0.3.1"],
"zeroconf": ["_http._tcp.local."], "zeroconf": [{"type": "_http._tcp.local.", "name":"shelly*"}],
"codeowners": ["@balloob", "@bieniu"] "codeowners": ["@balloob", "@bieniu"]
} }

View File

@ -11,6 +11,7 @@
"@bsmappee" "@bsmappee"
], ],
"zeroconf": [ "zeroconf": [
"_ssh._tcp.local." {"type":"_ssh._tcp.local.", "name":"smappee1*"},
{"type":"_ssh._tcp.local.", "name":"smappee2*"}
] ]
} }

View File

@ -1,5 +1,6 @@
"""Support for exposing Home Assistant via Zeroconf.""" """Support for exposing Home Assistant via Zeroconf."""
import asyncio import asyncio
import fnmatch
import ipaddress import ipaddress
import logging import logging
import socket import socket
@ -268,10 +269,26 @@ def setup(hass, config):
# likely bad homekit data # likely bad homekit data
return 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.add_job(
hass.config_entries.flow.async_init( hass.config_entries.flow.async_init(
domain, context={"source": DOMAIN}, data=info entry["domain"], context={"source": DOMAIN}, data=info
) )
) )

View File

@ -7,75 +7,142 @@ To update, run python3 -m script.hassfest
ZEROCONF = { ZEROCONF = {
"_Volumio._tcp.local.": [ "_Volumio._tcp.local.": [
"volumio" {
"domain": "volumio"
}
], ],
"_api._udp.local.": [ "_api._udp.local.": [
"guardian" {
"domain": "guardian"
}
], ],
"_axis-video._tcp.local.": [ "_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._tcp.local.": [
"bond" {
"domain": "bond"
}
], ],
"_daap._tcp.local.": [ "_daap._tcp.local.": [
"forked_daapd" {
"domain": "forked_daapd"
}
], ],
"_dkapi._tcp.local.": [ "_dkapi._tcp.local.": [
"daikin" {
"domain": "daikin"
}
], ],
"_elg._tcp.local.": [ "_elg._tcp.local.": [
"elgato" {
"domain": "elgato"
}
], ],
"_esphomelib._tcp.local.": [ "_esphomelib._tcp.local.": [
"esphome" {
"domain": "esphome"
}
], ],
"_googlecast._tcp.local.": [ "_googlecast._tcp.local.": [
"cast" {
"domain": "cast"
}
], ],
"_hap._tcp.local.": [ "_hap._tcp.local.": [
"homekit_controller" {
"domain": "homekit_controller"
}
], ],
"_homekit._tcp.local.": [ "_homekit._tcp.local.": [
"homekit" {
"domain": "homekit"
}
], ],
"_http._tcp.local.": [ "_http._tcp.local.": [
"shelly" {
"domain": "shelly",
"name": "shelly*"
}
], ],
"_ipp._tcp.local.": [ "_ipp._tcp.local.": [
"ipp" {
"domain": "ipp"
}
], ],
"_ipps._tcp.local.": [ "_ipps._tcp.local.": [
"ipp" {
"domain": "ipp"
}
], ],
"_miio._udp.local.": [ "_miio._udp.local.": [
"xiaomi_aqara", {
"xiaomi_miio" "domain": "xiaomi_aqara"
},
{
"domain": "xiaomi_miio"
}
], ],
"_nut._tcp.local.": [ "_nut._tcp.local.": [
"nut" {
"domain": "nut"
}
], ],
"_plugwise._tcp.local.": [ "_plugwise._tcp.local.": [
"plugwise" {
"domain": "plugwise"
}
], ],
"_printer._tcp.local.": [ "_printer._tcp.local.": [
"brother" {
"domain": "brother",
"name": "brother*"
}
], ],
"_spotify-connect._tcp.local.": [ "_spotify-connect._tcp.local.": [
"spotify" {
"domain": "spotify"
}
], ],
"_ssh._tcp.local.": [ "_ssh._tcp.local.": [
"smappee" {
"domain": "smappee",
"name": "smappee1*"
},
{
"domain": "smappee",
"name": "smappee2*"
}
], ],
"_viziocast._tcp.local.": [ "_viziocast._tcp.local.": [
"vizio" {
"domain": "vizio"
}
], ],
"_wled._tcp.local.": [ "_wled._tcp.local.": [
"wled" {
"domain": "wled"
}
], ],
"_xbmc-jsonrpc-h._tcp.local.": [ "_xbmc-jsonrpc-h._tcp.local.": [
"kodi" {
"domain": "kodi"
}
] ]
} }

View File

@ -145,18 +145,25 @@ async def async_get_config_flows(hass: "HomeAssistant") -> Set[str]:
return flows 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.""" """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) integrations = await async_get_custom_components(hass)
for integration in integrations.values(): for integration in integrations.values():
if not integration.zeroconf: if not integration.zeroconf:
continue continue
for typ in integration.zeroconf: for entry in integration.zeroconf:
zeroconf.setdefault(typ, []) data = {"domain": integration.domain}
if integration.domain not in zeroconf[typ]: if isinstance(entry, dict):
zeroconf[typ].append(integration.domain) 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 return zeroconf

View File

@ -38,7 +38,18 @@ 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.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.Optional("ssdp"): vol.Schema(
vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))]) vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))])
), ),

View File

@ -37,8 +37,17 @@ def generate_and_validate(integrations: Dict[str, Integration]):
if not (service_types or homekit_models): if not (service_types or homekit_models):
continue continue
for service_type in service_types: for entry in service_types:
service_type_dict[service_type].append(domain) 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: for model in homekit_models:
if model in homekit_dict: if model in homekit_dict:

View File

@ -79,6 +79,24 @@ def get_homekit_info_mock(model, pairing_status):
return mock_homekit_info 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): async def test_setup(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry.""" """Test configured options for a device are loaded via config entry."""
with patch.object( with patch.object(
@ -94,7 +112,11 @@ async def test_setup(hass, mock_zeroconf):
assert len(mock_service_browser.mock_calls) == 1 assert len(mock_service_browser.mock_calls) == 1
expected_flow_calls = 0 expected_flow_calls = 0
for matching_components in zc_gen.ZEROCONF.values(): 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 assert len(mock_config_flow.mock_calls) == expected_flow_calls
# Test instance is set. # 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 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): async def test_homekit_match_partial_space(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry.""" """Test configured options for a device are loaded via config entry."""
with patch.dict( 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( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object( ) 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): async def test_homekit_match_partial_dash(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry.""" """Test configured options for a device are loaded via config entry."""
with patch.dict( 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( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object( ) 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): async def test_homekit_match_full(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry.""" """Test configured options for a device are loaded via config entry."""
with patch.dict( 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( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object( ) 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) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() 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_service_browser.mock_calls) == 1
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "hue" 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): async def test_homekit_already_paired(hass, mock_zeroconf):
"""Test that an already paired device is sent to homekit_controller.""" """Test that an already paired device is sent to homekit_controller."""
with patch.dict( 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( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object( ) 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): async def test_homekit_invalid_paring_status(hass, mock_zeroconf):
"""Test that missing paring data is not sent to homekit_controller.""" """Test that missing paring data is not sent to homekit_controller."""
with patch.dict( 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( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object( ) 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): async def test_homekit_not_paired(hass, mock_zeroconf):
"""Test that an not paired device is sent to homekit_controller.""" """Test that an not paired device is sent to homekit_controller."""
with patch.dict( 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( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object( ) as mock_config_flow, patch.object(

View File

@ -218,6 +218,23 @@ def test_integration_properties(hass):
assert integration.zeroconf is None assert integration.zeroconf is None
assert integration.ssdp 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): async def test_integrations_only_once(hass):
"""Test that we load integrations only once.""" """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): async def test_get_custom_components(hass):
"""Verify that custom components are cached.""" """Verify that custom components are cached."""
test_1_integration = _get_test_integration(hass, "test_1", False) 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): async def test_get_zeroconf(hass):
"""Verify that custom components with zeroconf are found.""" """Verify that custom components with zeroconf are found."""
test_1_integration = _get_test_integration(hass, "test_1", True) 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: with patch("homeassistant.loader.async_get_custom_components") as mock_get:
mock_get.return_value = { mock_get.return_value = {
@ -297,8 +335,10 @@ async def test_get_zeroconf(hass):
"test_2": test_2_integration, "test_2": test_2_integration,
} }
zeroconf = await loader.async_get_zeroconf(hass) zeroconf = await loader.async_get_zeroconf(hass)
assert zeroconf["_test_1._tcp.local."] == ["test_1"] assert zeroconf["_test_1._tcp.local."] == [{"domain": "test_1"}]
assert zeroconf["_test_2._tcp.local."] == ["test_2"] assert zeroconf["_test_2._tcp.local."] == [
{"domain": "test_2", "name": "test_2*"}
]
async def test_get_homekit(hass): async def test_get_homekit(hass):