diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index a9a97b1660d..02fabc02565 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -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" diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 52e0c99044b..59e72341150 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -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"], diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 5dd9ecbd0db..b379dab7e98 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -7,7 +7,7 @@ "zeroconf": [ { "type": "_axis-video._tcp.local.", - "macaddress": "1CCAE3*" + "properties": {"macaddress": "1ccae3*"} } ], "codeowners": ["@oblogic7", "@bdraco"], diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 1aab1cf4613..68d5fb50746 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -11,10 +11,10 @@ }, { "type": "_http._tcp.local.", - "manufacturer": "nettigo" + "properties": {"manufacturer": "nettigo"} } ], "config_flow": true, "quality_scale": "platinum", "iot_class": "local_polling" -} \ No newline at end of file +} diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 36481b43756..9123a68b716 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -14,7 +14,7 @@ } ], "zeroconf": [ - {"type":"_airplay._tcp.local.","manufacturer":"samsung*"} + {"type":"_airplay._tcp.local.","properties":{"manufacturer":"samsung*"}} ], "dhcp": [ { diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index deeb42367cd..98d371d2d2f 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -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, ) diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index c5c4e0c9a01..af828c077c5 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -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", diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 84d9cd2a72f..af3f90ab356 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -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 diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index ecc00142e30..f2185c86fc8 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -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} + ), + } + ), ), ) ], diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 4ce4896952e..446a6f32aeb 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -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 diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 16aca70d7fc..0482f38415b 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -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" diff --git a/tests/test_loader.py b/tests/test_loader.py index 2bffee75d1e..9c6dcba770a 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -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)