mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Align zeroconf matching with ZeroconfServiceInfo (#62133)
This commit is contained in:
parent
a63fa53275
commit
615872a5d1
@ -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"
|
||||
|
@ -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"],
|
||||
|
@ -7,7 +7,7 @@
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_axis-video._tcp.local.",
|
||||
"macaddress": "1CCAE3*"
|
||||
"properties": {"macaddress": "1ccae3*"}
|
||||
}
|
||||
],
|
||||
"codeowners": ["@oblogic7", "@bdraco"],
|
||||
|
@ -11,10 +11,10 @@
|
||||
},
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
"manufacturer": "nettigo"
|
||||
"properties": {"manufacturer": "nettigo"}
|
||||
}
|
||||
],
|
||||
"config_flow": true,
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,7 @@
|
||||
}
|
||||
],
|
||||
"zeroconf": [
|
||||
{"type":"_airplay._tcp.local.","manufacturer":"samsung*"}
|
||||
{"type":"_airplay._tcp.local.","properties":{"manufacturer":"samsung*"}}
|
||||
],
|
||||
"dhcp": [
|
||||
{
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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}
|
||||
),
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user