Align zeroconf matching with ZeroconfServiceInfo (#62133)

This commit is contained in:
J. Nick Koston 2021-12-19 02:09:21 -06:00 committed by GitHub
parent a63fa53275
commit 615872a5d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 195 additions and 63 deletions

View File

@ -7,7 +7,7 @@
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_touch-able._tcp.local.",
{"type":"_airplay._tcp.local.","model":"appletv*"}
{"type":"_airplay._tcp.local.","properties":{"model":"appletv*"}}
],
"codeowners": ["@postlund"],
"iot_class": "local_push"

View File

@ -26,15 +26,15 @@
"zeroconf": [
{
"type": "_axis-video._tcp.local.",
"macaddress": "00408C*"
"properties": {"macaddress": "00408c*"}
},
{
"type": "_axis-video._tcp.local.",
"macaddress": "ACCC8E*"
"properties": {"macaddress": "accc8e*"}
},
{
"type": "_axis-video._tcp.local.",
"macaddress": "B8A44F*"
"properties": {"macaddress": "b8a44f*"}
}
],
"after_dependencies": ["mqtt"],

View File

@ -7,7 +7,7 @@
"zeroconf": [
{
"type": "_axis-video._tcp.local.",
"macaddress": "1CCAE3*"
"properties": {"macaddress": "1ccae3*"}
}
],
"codeowners": ["@oblogic7", "@bdraco"],

View File

@ -11,10 +11,10 @@
},
{
"type": "_http._tcp.local.",
"manufacturer": "nettigo"
"properties": {"manufacturer": "nettigo"}
}
],
"config_flow": true,
"quality_scale": "platinum",
"iot_class": "local_polling"
}
}

View File

@ -14,7 +14,7 @@
}
],
"zeroconf": [
{"type":"_airplay._tcp.local.","manufacturer":"samsung*"}
{"type":"_airplay._tcp.local.","properties":{"manufacturer":"samsung*"}}
],
"dhcp": [
{

View File

@ -48,17 +48,9 @@ HOMEKIT_TYPES = [
"_hap._udp.local.",
]
# Keys we support matching against in properties that are always matched in
# upper case. ex: ZeroconfServiceInfo.properties["macaddress"]
UPPER_MATCH_PROPS = {"macaddress"}
# Keys we support matching against in properties that are always matched in
# lower case. ex: ZeroconfServiceInfo.properties["model"]
LOWER_MATCH_PROPS = {"manufacturer", "model"}
# Top level keys we support matching against in properties that are always matched in
# lower case. ex: ZeroconfServiceInfo.name
LOWER_MATCH_ATTRS = {"name"}
# Everything we support matching
ALL_MATCHERS = UPPER_MATCH_PROPS | LOWER_MATCH_PROPS | LOWER_MATCH_ATTRS
CONF_DEFAULT_INTERFACE = "default_interface"
CONF_IPV6 = "ipv6"
@ -75,6 +67,8 @@ MAX_PROPERTY_VALUE_LEN = 230
# Dns label max length
MAX_NAME_LEN = 63
ATTR_PROPERTIES: Final = "properties"
# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES]
ATTR_PROPERTIES_ID: Final = "id"
@ -321,15 +315,28 @@ async def _async_register_hass_zc_service(
await aio_zc.async_register_service(info, allow_name_change=True)
def _match_against_data(matcher: dict[str, str], match_data: dict[str, str]) -> bool:
def _match_against_data(
matcher: dict[str, str | dict[str, str]], match_data: dict[str, str]
) -> bool:
"""Check a matcher to ensure all values in match_data match."""
for key in LOWER_MATCH_ATTRS:
if key not in matcher:
continue
if key not in match_data:
return False
match_val = matcher[key]
assert isinstance(match_val, str)
if not fnmatch.fnmatch(match_data[key], match_val):
return False
return True
def _match_against_props(matcher: dict[str, str], props: dict[str, str]) -> bool:
"""Check a matcher to ensure all values in props."""
return not any(
key
for key in ALL_MATCHERS
if key in matcher
and (
key not in match_data or not fnmatch.fnmatch(match_data[key], matcher[key])
)
for key in matcher
if key not in props or not fnmatch.fnmatch(props[key].lower(), matcher[key])
)
@ -340,7 +347,7 @@ class ZeroconfDiscovery:
self,
hass: HomeAssistant,
zeroconf: HaZeroconf,
zeroconf_types: dict[str, list[dict[str, str]]],
zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]],
homekit_models: dict[str, str],
ipv6: bool,
) -> None:
@ -436,22 +443,24 @@ class ZeroconfDiscovery:
for key in LOWER_MATCH_ATTRS:
attr_value: str = getattr(info, key)
match_data[key] = attr_value.lower()
for key in UPPER_MATCH_PROPS:
if key in props:
match_data[key] = props[key].upper()
for key in LOWER_MATCH_PROPS:
if key in props:
match_data[key] = props[key].lower()
# Not all homekit types are currently used for discovery
# so not all service type exist in zeroconf_types
for matcher in self.zeroconf_types.get(service_type, []):
if len(matcher) > 1 and not _match_against_data(matcher, match_data):
continue
if len(matcher) > 1:
if not _match_against_data(matcher, match_data):
continue
if ATTR_PROPERTIES in matcher:
matcher_props = matcher[ATTR_PROPERTIES]
assert isinstance(matcher_props, dict)
if not _match_against_props(matcher_props, props):
continue
matcher_domain = matcher["domain"]
assert isinstance(matcher_domain, str)
discovery_flow.async_create_flow(
self.hass,
matcher["domain"],
matcher_domain,
{"source": config_entries.SOURCE_ZEROCONF},
info,
)

View File

@ -14,11 +14,15 @@ ZEROCONF = {
"_airplay._tcp.local.": [
{
"domain": "apple_tv",
"model": "appletv*"
"properties": {
"model": "appletv*"
}
},
{
"domain": "samsungtv",
"manufacturer": "samsung*"
"properties": {
"manufacturer": "samsung*"
}
}
],
"_api._udp.local.": [
@ -29,19 +33,27 @@ ZEROCONF = {
"_axis-video._tcp.local.": [
{
"domain": "axis",
"macaddress": "00408C*"
"properties": {
"macaddress": "00408c*"
}
},
{
"domain": "axis",
"macaddress": "ACCC8E*"
"properties": {
"macaddress": "accc8e*"
}
},
{
"domain": "axis",
"macaddress": "B8A44F*"
"properties": {
"macaddress": "b8a44f*"
}
},
{
"domain": "doorbird",
"macaddress": "1CCAE3*"
"properties": {
"macaddress": "1ccae3*"
}
}
],
"_bond._tcp.local.": [
@ -123,7 +135,9 @@ ZEROCONF = {
},
{
"domain": "nam",
"manufacturer": "nettigo"
"properties": {
"manufacturer": "nettigo"
}
},
{
"domain": "rachio",

View File

@ -58,6 +58,8 @@ _UNDEF = object() # Internal; not helpers.typing.UNDEFINED due to circular depe
MAX_LOAD_CONCURRENTLY = 4
MOVED_ZEROCONF_PROPS = ("macaddress", "model", "manufacturer")
class Manifest(TypedDict, total=False):
"""
@ -182,21 +184,42 @@ async def async_get_config_flows(hass: HomeAssistant) -> set[str]:
return flows
async def async_get_zeroconf(hass: HomeAssistant) -> dict[str, list[dict[str, str]]]:
def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> dict[str, Any]:
"""Handle backwards compat with zeroconf matchers."""
entry_without_type: dict[str, Any] = entry.copy()
del entry_without_type["type"]
# These properties keys used to be at the top level, we relocate
# them for backwards compat
for moved_prop in MOVED_ZEROCONF_PROPS:
if value := entry_without_type.pop(moved_prop, None):
_LOGGER.warning(
'Matching the zeroconf property "%s" at top-level is deprecated and should be moved into a properties dict; Check the developer documentation',
moved_prop,
)
if "properties" not in entry_without_type:
prop_dict: dict[str, str] = {}
entry_without_type["properties"] = prop_dict
else:
prop_dict = entry_without_type["properties"]
prop_dict[moved_prop] = value.lower()
return entry_without_type
async def async_get_zeroconf(
hass: HomeAssistant,
) -> dict[str, list[dict[str, str | dict[str, str]]]]:
"""Return cached list of zeroconf types."""
zeroconf: dict[str, list[dict[str, str]]] = ZEROCONF.copy()
zeroconf: dict[str, list[dict[str, str | dict[str, str]]]] = ZEROCONF.copy() # type: ignore[assignment]
integrations = await async_get_custom_components(hass)
for integration in integrations.values():
if not integration.zeroconf:
continue
for entry in integration.zeroconf:
data = {"domain": integration.domain}
data: dict[str, str | dict[str, str]] = {"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)
data.update(async_process_zeroconf_match_dict(entry))
else:
typ = entry

View File

@ -12,6 +12,8 @@ from awesomeversion import (
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.helpers import config_validation as cv
from .model import Config, Integration
DOCUMENTATION_URL_SCHEMA = "https"
@ -180,16 +182,26 @@ MANIFEST_SCHEMA = vol.Schema(
vol.Optional("zeroconf"): [
vol.Any(
str,
vol.Schema(
{
vol.Required("type"): str,
vol.Optional("macaddress"): vol.All(
str, verify_uppercase, verify_wildcard
),
vol.Optional("manufacturer"): vol.All(str, verify_lowercase),
vol.Optional("model"): vol.All(str, verify_lowercase),
vol.Optional("name"): vol.All(str, verify_lowercase),
}
vol.All(
cv.deprecated("macaddress"),
cv.deprecated("model"),
cv.deprecated("manufacturer"),
vol.Schema(
{
vol.Required("type"): str,
vol.Optional("macaddress"): vol.All(
str, verify_uppercase, verify_wildcard
),
vol.Optional("manufacturer"): vol.All(
str, verify_lowercase
),
vol.Optional("model"): vol.All(str, verify_lowercase),
vol.Optional("name"): vol.All(str, verify_lowercase),
vol.Optional("properties"): vol.Schema(
{str: verify_lowercase}
),
}
),
),
)
],

View File

@ -4,6 +4,8 @@ from __future__ import annotations
from collections import OrderedDict, defaultdict
import json
from homeassistant.loader import async_process_zeroconf_match_dict
from .model import Config, Integration
BASE = """
@ -42,9 +44,7 @@ def generate_and_validate(integrations: dict[str, Integration]):
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)
data.update(async_process_zeroconf_match_dict(entry))
else:
typ = entry

View File

@ -295,7 +295,11 @@ async def test_zeroconf_match_macaddress(hass, mock_async_zeroconf):
zc_gen.ZEROCONF,
{
"_http._tcp.local.": [
{"domain": "shelly", "name": "shelly*", "macaddress": "FFAADD*"}
{
"domain": "shelly",
"name": "shelly*",
"properties": {"macaddress": "ffaadd*"},
}
]
},
clear=True,
@ -330,7 +334,11 @@ async def test_zeroconf_match_manufacturer(hass, mock_async_zeroconf):
with patch.dict(
zc_gen.ZEROCONF,
{"_airplay._tcp.local.": [{"domain": "samsungtv", "manufacturer": "samsung*"}]},
{
"_airplay._tcp.local.": [
{"domain": "samsungtv", "properties": {"manufacturer": "samsung*"}}
]
},
clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
@ -363,7 +371,11 @@ async def test_zeroconf_match_model(hass, mock_async_zeroconf):
with patch.dict(
zc_gen.ZEROCONF,
{"_airplay._tcp.local.": [{"domain": "appletv", "model": "appletv*"}]},
{
"_airplay._tcp.local.": [
{"domain": "appletv", "properties": {"model": "appletv*"}}
]
},
clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
@ -396,7 +408,11 @@ async def test_zeroconf_match_manufacturer_not_present(hass, mock_async_zeroconf
with patch.dict(
zc_gen.ZEROCONF,
{"_airplay._tcp.local.": [{"domain": "samsungtv", "manufacturer": "samsung*"}]},
{
"_airplay._tcp.local.": [
{"domain": "samsungtv", "properties": {"manufacturer": "samsung*"}}
]
},
clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
@ -460,7 +476,11 @@ async def test_zeroconf_no_match_manufacturer(hass, mock_async_zeroconf):
with patch.dict(
zc_gen.ZEROCONF,
{"_airplay._tcp.local.": [{"domain": "samsungtv", "manufacturer": "samsung*"}]},
{
"_airplay._tcp.local.": [
{"domain": "samsungtv", "properties": {"manufacturer": "samsung*"}}
]
},
clear=True,
), patch.object(
hass.config_entries.flow, "async_init"

View File

@ -329,6 +329,33 @@ def _get_test_integration_with_zeroconf_matcher(hass, name, config_flow):
)
def _get_test_integration_with_legacy_zeroconf_matcher(hass, name, config_flow):
"""Return a generated test integration with a legacy 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.",
"macaddress": "AABBCC*",
"manufacturer": "legacy*",
"model": "legacy*",
"name": f"{name}*",
}
],
"homekit": {"models": [name]},
"ssdp": [{"manufacturer": name, "modelName": name}],
},
)
def _get_test_integration_with_dhcp_matcher(hass, name, config_flow):
"""Return a generated test integration with a dhcp matcher."""
return loader.Integration(
@ -435,6 +462,33 @@ async def test_get_zeroconf(hass):
]
async def test_get_zeroconf_back_compat(hass):
"""Verify that custom components with zeroconf are found and legacy matchers are converted."""
test_1_integration = _get_test_integration(hass, "test_1", True)
test_2_integration = _get_test_integration_with_legacy_zeroconf_matcher(
hass, "test_2", True
)
with patch("homeassistant.loader.async_get_custom_components") as mock_get:
mock_get.return_value = {
"test_1": test_1_integration,
"test_2": test_2_integration,
}
zeroconf = await loader.async_get_zeroconf(hass)
assert zeroconf["_test_1._tcp.local."] == [{"domain": "test_1"}]
assert zeroconf["_test_2._tcp.local."] == [
{
"domain": "test_2",
"name": "test_2*",
"properties": {
"macaddress": "aabbcc*",
"model": "legacy*",
"manufacturer": "legacy*",
},
}
]
async def test_get_dhcp(hass):
"""Verify that custom components with dhcp are found."""
test_1_integration = _get_test_integration_with_dhcp_matcher(hass, "test_1", True)