From a62b8a4f5b02a8bf9d5822168820577bb00e8661 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Jan 2023 11:12:18 -1000 Subject: [PATCH] Add zeroconf discovery to Synology DSM (#86062) --- .../components/synology_dsm/config_flow.py | 66 +++++++++++----- .../components/synology_dsm/manifest.json | 3 + .../components/synology_dsm/strings.json | 1 + .../synology_dsm/translations/en.json | 1 + homeassistant/generated/zeroconf.py | 6 ++ .../synology_dsm/test_config_flow.py | 76 ++++++++++++++++++- 6 files changed, 132 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 90bf43aa611..9342849b2fe 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from ipaddress import ip_address import logging -from typing import Any +from typing import Any, cast from urllib.parse import urlparse from synology_dsm import SynologyDSM @@ -18,7 +18,7 @@ from synology_dsm.exceptions import ( import voluptuous as vol from homeassistant import exceptions -from homeassistant.components import ssdp +from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( CONF_DISKS, @@ -57,6 +57,8 @@ _LOGGER = logging.getLogger(__name__) CONF_OTP_CODE = "otp_code" +HTTP_SUFFIX = "._http._tcp.local." + def _discovery_schema_with_defaults(discovery_info: DiscoveryInfoType) -> vol.Schema: return vol.Schema(_ordered_shared_schema(discovery_info)) @@ -105,6 +107,11 @@ def _is_valid_ip(text: str) -> bool: return True +def format_synology_mac(mac: str) -> str: + """Format a mac address to the format used by Synology DSM.""" + return mac.replace(":", "").replace("-", "").upper() + + class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -239,21 +246,42 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return self._show_form(step) return await self.async_validate_input_create_entry(user_input, step_id=step) - async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: - """Handle a discovered synology_dsm.""" - parsed_url = urlparse(discovery_info.ssdp_location) - friendly_name = ( - discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME].split("(", 1)[0].strip() - ) + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle a discovered synology_dsm via zeroconf.""" + discovered_macs = [ + format_synology_mac(mac) + for mac in discovery_info.properties.get("mac_address", "").split("|") + if mac + ] + if not discovered_macs: + return self.async_abort(reason="no_mac_address") + host = discovery_info.host + friendly_name = discovery_info.name.removesuffix(HTTP_SUFFIX) + return await self._async_from_discovery(host, friendly_name, discovered_macs) - discovered_mac = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL].upper() + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: + """Handle a discovered synology_dsm via ssdp.""" + parsed_url = urlparse(discovery_info.ssdp_location) + upnp_friendly_name: str = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = upnp_friendly_name.split("(", 1)[0].strip() + mac_address = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + discovered_macs = [format_synology_mac(mac_address)] # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. + host = cast(str, parsed_url.hostname) + return await self._async_from_discovery(host, friendly_name, discovered_macs) - await self.async_set_unique_id(discovered_mac) - existing_entry = self._async_get_existing_entry(discovered_mac) - - if not existing_entry: + async def _async_from_discovery( + self, host: str, friendly_name: str, discovered_macs: list[str] + ) -> FlowResult: + """Handle a discovered synology_dsm via zeroconf or ssdp.""" + existing_entry = None + for discovered_mac in discovered_macs: + await self.async_set_unique_id(discovered_mac) + if existing_entry := self._async_get_existing_entry(discovered_mac): + break self._abort_if_unique_id_configured() fqdn_with_ssl_verification = ( @@ -264,18 +292,18 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if ( existing_entry - and existing_entry.data[CONF_HOST] != parsed_url.hostname + and existing_entry.data[CONF_HOST] != host and not fqdn_with_ssl_verification ): _LOGGER.info( - "Update host from '%s' to '%s' for NAS '%s' via SSDP discovery", + "Update host from '%s' to '%s' for NAS '%s' via discovery", existing_entry.data[CONF_HOST], - parsed_url.hostname, + host, existing_entry.unique_id, ) self.hass.config_entries.async_update_entry( existing_entry, - data={**existing_entry.data, CONF_HOST: parsed_url.hostname}, + data={**existing_entry.data, CONF_HOST: host}, ) return self.async_abort(reason="reconfigure_successful") @@ -284,7 +312,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.discovered_conf = { CONF_NAME: friendly_name, - CONF_HOST: parsed_url.hostname, + CONF_HOST: host, } self.context["title_placeholders"] = self.discovered_conf return await self.async_step_link() @@ -339,7 +367,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): """See if we already have a configured NAS with this MAC address.""" for entry in self._async_current_entries(): if discovered_mac in [ - mac.replace("-", "") for mac in entry.data.get(CONF_MAC, []) + format_synology_mac(mac) for mac in entry.data.get(CONF_MAC, []) ]: return entry return None diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 60add26674d..a3e9ca3c149 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -11,6 +11,9 @@ "deviceType": "urn:schemas-upnp-org:device:Basic:1" } ], + "zeroconf": [ + { "type": "_http._tcp.local.", "properties": { "vendor": "synology*" } } + ], "iot_class": "local_polling", "loggers": ["synology_dsm"] } diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 09574f82f9e..f571b9c5326 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -44,6 +44,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "no_mac_address": "The MAC address is missing from the zeroconf record", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "Re-configuration was successful" diff --git a/homeassistant/components/synology_dsm/translations/en.json b/homeassistant/components/synology_dsm/translations/en.json index 72eec8ff461..08d13e36d1c 100644 --- a/homeassistant/components/synology_dsm/translations/en.json +++ b/homeassistant/components/synology_dsm/translations/en.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Device is already configured", + "no_mac_address": "The MAC address is missing from the zeroconf record", "reauth_successful": "Re-authentication was successful", "reconfigure_successful": "Re-configuration was successful" }, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e7c8bdaf1df..6032ce4bf7d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -282,6 +282,12 @@ ZEROCONF = { "domain": "shelly", "name": "shelly*", }, + { + "domain": "synology_dsm", + "properties": { + "vendor": "synology*", + }, + }, ], "_hue._tcp.local.": [ { diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index ab70b3f548c..402dcd2f602 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -11,7 +11,7 @@ from synology_dsm.exceptions import ( ) from homeassistant import data_entry_flow -from homeassistant.components import ssdp +from homeassistant.components import ssdp, zeroconf from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( CONF_SNAPSHOT_QUALITY, @@ -25,7 +25,12 @@ from homeassistant.components.synology_dsm.const import ( DEFAULT_VERIFY_SSL, DOMAIN, ) -from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_SSDP, + SOURCE_USER, + SOURCE_ZEROCONF, +) from homeassistant.const import ( CONF_DISKS, CONF_HOST, @@ -629,3 +634,70 @@ async def test_options_flow(hass: HomeAssistant, service: MagicMock): assert config_entry.options[CONF_SCAN_INTERVAL] == 2 assert config_entry.options[CONF_TIMEOUT] == 30 assert config_entry.options[CONF_SNAPSHOT_QUALITY] == 0 + + +async def test_discovered_via_zeroconf(hass: HomeAssistant, service: MagicMock): + """Test we can setup from zeroconf.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.5", + addresses=["192.168.1.5"], + port=5000, + hostname="mydsm.local.", + type="_http._tcp.local.", + name="mydsm._http._tcp.local.", + properties={ + "mac_address": "00:11:32:XX:XX:99|00:11:22:33:44:55", # MAC address, but SSDP does not have `-` + }, + ), + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "link" + assert result["errors"] == {} + + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM", + return_value=service, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == "mydsm" + assert result["data"][CONF_HOST] == "192.168.1.5" + assert result["data"][CONF_PORT] == 5001 + assert result["data"][CONF_SSL] == DEFAULT_USE_SSL + assert result["data"][CONF_VERIFY_SSL] == DEFAULT_VERIFY_SSL + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_MAC] == MACS + assert result["data"].get("device_token") is None + assert result["data"].get(CONF_DISKS) is None + assert result["data"].get(CONF_VOLUMES) is None + + +async def test_discovered_via_zeroconf_missing_mac( + hass: HomeAssistant, service: MagicMock +): + """Test we abort if the mac address is missing.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.5", + addresses=["192.168.1.5"], + port=5000, + hostname="mydsm.local.", + type="_http._tcp.local.", + name="mydsm._http._tcp.local.", + properties={}, + ), + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "no_mac_address"