Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow to configure the Synology DSM integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 from ipaddress import ip_address as ip
7 import logging
8 from typing import Any, cast
9 from urllib.parse import urlparse
10 
11 from synology_dsm import SynologyDSM
12 from synology_dsm.exceptions import (
13  SynologyDSMException,
14  SynologyDSMLogin2SAFailedException,
15  SynologyDSMLogin2SARequiredException,
16  SynologyDSMLoginInvalidException,
17  SynologyDSMRequestException,
18 )
19 import voluptuous as vol
20 
21 from homeassistant.components import ssdp, zeroconf
22 from homeassistant.config_entries import (
23  ConfigEntry,
24  ConfigFlow,
25  ConfigFlowResult,
26  OptionsFlow,
27 )
28 from homeassistant.const import (
29  CONF_DISKS,
30  CONF_HOST,
31  CONF_MAC,
32  CONF_NAME,
33  CONF_PASSWORD,
34  CONF_PORT,
35  CONF_SCAN_INTERVAL,
36  CONF_SSL,
37  CONF_USERNAME,
38  CONF_VERIFY_SSL,
39 )
40 from homeassistant.core import callback
41 from homeassistant.exceptions import HomeAssistantError
42 from homeassistant.helpers.aiohttp_client import async_get_clientsession
44 from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType
45 from homeassistant.util.network import is_ip_address as is_ip
46 
47 from .const import (
48  CONF_DEVICE_TOKEN,
49  CONF_SNAPSHOT_QUALITY,
50  CONF_VOLUMES,
51  DEFAULT_PORT,
52  DEFAULT_PORT_SSL,
53  DEFAULT_SCAN_INTERVAL,
54  DEFAULT_SNAPSHOT_QUALITY,
55  DEFAULT_TIMEOUT,
56  DEFAULT_USE_SSL,
57  DEFAULT_VERIFY_SSL,
58  DOMAIN,
59 )
60 
61 _LOGGER = logging.getLogger(__name__)
62 
63 CONF_OTP_CODE = "otp_code"
64 
65 HTTP_SUFFIX = "._http._tcp.local."
66 
67 
68 def _discovery_schema_with_defaults(discovery_info: DiscoveryInfoType) -> vol.Schema:
69  return vol.Schema(_ordered_shared_schema(discovery_info))
70 
71 
72 def _reauth_schema() -> vol.Schema:
73  return vol.Schema(
74  {
75  vol.Required(CONF_USERNAME): str,
76  vol.Required(CONF_PASSWORD): str,
77  }
78  )
79 
80 
81 def _user_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema:
82  user_schema: VolDictType = {
83  vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
84  }
85  user_schema.update(_ordered_shared_schema(user_input))
86 
87  return vol.Schema(user_schema)
88 
89 
90 def _ordered_shared_schema(schema_input: dict[str, Any]) -> VolDictType:
91  return {
92  vol.Required(CONF_USERNAME, default=schema_input.get(CONF_USERNAME, "")): str,
93  vol.Required(CONF_PASSWORD, default=schema_input.get(CONF_PASSWORD, "")): str,
94  vol.Optional(CONF_PORT, default=schema_input.get(CONF_PORT, "")): str,
95  vol.Optional(
96  CONF_SSL, default=schema_input.get(CONF_SSL, DEFAULT_USE_SSL)
97  ): bool,
98  vol.Optional(
99  CONF_VERIFY_SSL,
100  default=schema_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
101  ): bool,
102  }
103 
104 
105 def format_synology_mac(mac: str) -> str:
106  """Format a mac address to the format used by Synology DSM."""
107  return mac.replace(":", "").replace("-", "").upper()
108 
109 
110 class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
111  """Handle a config flow."""
112 
113  VERSION = 1
114 
115  @staticmethod
116  @callback
118  config_entry: ConfigEntry,
119  ) -> SynologyDSMOptionsFlowHandler:
120  """Get the options flow for this handler."""
122 
123  def __init__(self) -> None:
124  """Initialize the synology_dsm config flow."""
125  self.saved_user_inputsaved_user_input: dict[str, Any] = {}
126  self.discovered_confdiscovered_conf: dict[str, Any] = {}
127  self.reauth_confreauth_conf: Mapping[str, Any] = {}
128  self.reauth_reason: str | None = None
129 
131  self,
132  step_id: str,
133  user_input: dict[str, Any] | None = None,
134  errors: dict[str, str] | None = None,
135  ) -> ConfigFlowResult:
136  """Show the setup form to the user."""
137  if not user_input:
138  user_input = {}
139 
140  description_placeholders = {}
141  data_schema = None
142 
143  if step_id == "link":
144  user_input.update(self.discovered_confdiscovered_conf)
145  data_schema = _discovery_schema_with_defaults(user_input)
146  description_placeholders = self.discovered_confdiscovered_conf
147  elif step_id == "reauth_confirm":
148  data_schema = _reauth_schema()
149  elif step_id == "user":
150  data_schema = _user_schema_with_defaults(user_input)
151 
152  return self.async_show_formasync_show_formasync_show_form(
153  step_id=step_id,
154  data_schema=data_schema,
155  errors=errors or {},
156  description_placeholders=description_placeholders,
157  )
158 
160  self, user_input: dict[str, Any], step_id: str
161  ) -> ConfigFlowResult:
162  """Process user input and create new or update existing config entry."""
163  host = user_input[CONF_HOST]
164  port = user_input.get(CONF_PORT)
165  username = user_input[CONF_USERNAME]
166  password = user_input[CONF_PASSWORD]
167  use_ssl = user_input.get(CONF_SSL, DEFAULT_USE_SSL)
168  verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
169  otp_code = user_input.get(CONF_OTP_CODE)
170  friendly_name = user_input.get(CONF_NAME)
171 
172  if not port:
173  if use_ssl is True:
174  port = DEFAULT_PORT_SSL
175  else:
176  port = DEFAULT_PORT
177 
178  session = async_get_clientsession(self.hass, verify_ssl)
179  api = SynologyDSM(
180  session, host, port, username, password, use_ssl, timeout=DEFAULT_TIMEOUT
181  )
182 
183  errors = {}
184  try:
185  serial = await _login_and_fetch_syno_info(api, otp_code)
186  except SynologyDSMLogin2SARequiredException:
187  return await self.async_step_2saasync_step_2sa(user_input)
188  except SynologyDSMLogin2SAFailedException:
189  errors[CONF_OTP_CODE] = "otp_failed"
190  user_input[CONF_OTP_CODE] = None
191  return await self.async_step_2saasync_step_2sa(user_input, errors)
192  except SynologyDSMLoginInvalidException as ex:
193  _LOGGER.error(ex)
194  errors[CONF_USERNAME] = "invalid_auth"
195  except SynologyDSMRequestException as ex:
196  _LOGGER.error(ex)
197  errors[CONF_HOST] = "cannot_connect"
198  except SynologyDSMException as ex:
199  _LOGGER.error(ex)
200  errors["base"] = "unknown"
201  except InvalidData:
202  errors["base"] = "missing_data"
203 
204  if errors:
205  return self._show_form_show_form(step_id, user_input, errors)
206 
207  # unique_id should be serial for services purpose
208  existing_entry = await self.async_set_unique_idasync_set_unique_id(serial, raise_on_progress=False)
209 
210  config_data = {
211  CONF_HOST: host,
212  CONF_PORT: port,
213  CONF_SSL: use_ssl,
214  CONF_VERIFY_SSL: verify_ssl,
215  CONF_USERNAME: username,
216  CONF_PASSWORD: password,
217  CONF_MAC: api.network.macs,
218  }
219  if otp_code:
220  config_data[CONF_DEVICE_TOKEN] = api.device_token
221  if user_input.get(CONF_DISKS):
222  config_data[CONF_DISKS] = user_input[CONF_DISKS]
223  if user_input.get(CONF_VOLUMES):
224  config_data[CONF_VOLUMES] = user_input[CONF_VOLUMES]
225 
226  if existing_entry:
227  reason = (
228  "reauth_successful" if self.reauth_confreauth_conf else "reconfigure_successful"
229  )
230  return self.async_update_reload_and_abortasync_update_reload_and_abort(
231  existing_entry, data=config_data, reason=reason
232  )
233 
234  return self.async_create_entryasync_create_entryasync_create_entry(title=friendly_name or host, data=config_data)
235 
236  async def async_step_user(
237  self, user_input: dict[str, Any] | None = None
238  ) -> ConfigFlowResult:
239  """Handle a flow initiated by the user."""
240  step = "user"
241  if not user_input:
242  return self._show_form_show_form(step)
243  return await self.async_validate_input_create_entryasync_validate_input_create_entry(user_input, step_id=step)
244 
246  self, discovery_info: zeroconf.ZeroconfServiceInfo
247  ) -> ConfigFlowResult:
248  """Handle a discovered synology_dsm via zeroconf."""
249  discovered_macs = [
251  for mac in discovery_info.properties.get("mac_address", "").split("|")
252  if mac
253  ]
254  if not discovered_macs:
255  return self.async_abortasync_abortasync_abort(reason="no_mac_address")
256  host = discovery_info.host
257  friendly_name = discovery_info.name.removesuffix(HTTP_SUFFIX)
258  return await self._async_from_discovery_async_from_discovery(host, friendly_name, discovered_macs)
259 
260  async def async_step_ssdp(
261  self, discovery_info: ssdp.SsdpServiceInfo
262  ) -> ConfigFlowResult:
263  """Handle a discovered synology_dsm via ssdp."""
264  parsed_url = urlparse(discovery_info.ssdp_location)
265  upnp_friendly_name: str = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
266  friendly_name = upnp_friendly_name.split("(", 1)[0].strip()
267  mac_address = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]
268  discovered_macs = [format_synology_mac(mac_address)]
269  # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets.
270  # The serial of the NAS is actually its MAC address.
271  host = cast(str, parsed_url.hostname)
272  return await self._async_from_discovery_async_from_discovery(host, friendly_name, discovered_macs)
273 
275  self, host: str, friendly_name: str, discovered_macs: list[str]
276  ) -> ConfigFlowResult:
277  """Handle a discovered synology_dsm via zeroconf or ssdp."""
278  existing_entry = None
279  for discovered_mac in discovered_macs:
280  await self.async_set_unique_idasync_set_unique_id(discovered_mac)
281  if existing_entry := self._async_get_existing_entry_async_get_existing_entry(discovered_mac):
282  break
283  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
284 
285  if (
286  existing_entry
287  and is_ip(existing_entry.data[CONF_HOST])
288  and is_ip(host)
289  and existing_entry.data[CONF_HOST] != host
290  and ip(existing_entry.data[CONF_HOST]).version == ip(host).version
291  ):
292  _LOGGER.debug(
293  "Update host from '%s' to '%s' for NAS '%s' via discovery",
294  existing_entry.data[CONF_HOST],
295  host,
296  existing_entry.unique_id,
297  )
298  self.hass.config_entries.async_update_entry(
299  existing_entry,
300  data={**existing_entry.data, CONF_HOST: host},
301  )
302  return self.async_abortasync_abortasync_abort(reason="reconfigure_successful")
303 
304  if existing_entry:
305  return self.async_abortasync_abortasync_abort(reason="already_configured")
306 
307  self.discovered_confdiscovered_conf = {
308  CONF_NAME: friendly_name,
309  CONF_HOST: host,
310  }
311  self.context["title_placeholders"] = self.discovered_confdiscovered_conf
312  return await self.async_step_linkasync_step_link()
313 
314  async def async_step_link(
315  self, user_input: dict[str, Any] | None = None
316  ) -> ConfigFlowResult:
317  """Link a config entry from discovery."""
318  step = "link"
319  if not user_input:
320  return self._show_form_show_form(step)
321  user_input = {**self.discovered_confdiscovered_conf, **user_input}
322  return await self.async_validate_input_create_entryasync_validate_input_create_entry(user_input, step_id=step)
323 
324  async def async_step_reauth(
325  self, entry_data: Mapping[str, Any]
326  ) -> ConfigFlowResult:
327  """Perform reauth upon an API authentication error."""
328  self.reauth_confreauth_conf = entry_data
329  placeholders = {
330  **self.context["title_placeholders"],
331  CONF_HOST: entry_data[CONF_HOST],
332  }
333  self.context["title_placeholders"] = placeholders
334 
335  return await self.async_step_reauth_confirmasync_step_reauth_confirm()
336 
338  self, user_input: dict[str, Any] | None = None
339  ) -> ConfigFlowResult:
340  """Perform reauth confirm upon an API authentication error."""
341  step = "reauth_confirm"
342  if not user_input:
343  return self._show_form_show_form(step)
344  user_input = {**self.reauth_confreauth_conf, **user_input}
345  return await self.async_validate_input_create_entryasync_validate_input_create_entry(user_input, step_id=step)
346 
347  async def async_step_2sa(
348  self, user_input: dict[str, Any], errors: dict[str, str] | None = None
349  ) -> ConfigFlowResult:
350  """Enter 2SA code to anthenticate."""
351  if not self.saved_user_inputsaved_user_input:
352  self.saved_user_inputsaved_user_input = user_input
353 
354  if not user_input.get(CONF_OTP_CODE):
355  return self.async_show_formasync_show_formasync_show_form(
356  step_id="2sa",
357  data_schema=vol.Schema({vol.Required(CONF_OTP_CODE): str}),
358  errors=errors or {},
359  )
360 
361  user_input = {**self.saved_user_inputsaved_user_input, **user_input}
362  self.saved_user_inputsaved_user_input = {}
363 
364  return await self.async_step_userasync_step_userasync_step_user(user_input)
365 
366  def _async_get_existing_entry(self, discovered_mac: str) -> ConfigEntry | None:
367  """See if we already have a configured NAS with this MAC address."""
368  for entry in self._async_current_entries_async_current_entries():
369  if discovered_mac in [
370  format_synology_mac(mac) for mac in entry.data.get(CONF_MAC, [])
371  ]:
372  return entry
373  return None
374 
375 
377  """Handle a option flow."""
378 
379  async def async_step_init(
380  self, user_input: dict[str, Any] | None = None
381  ) -> ConfigFlowResult:
382  """Handle options flow."""
383  if user_input is not None:
384  return self.async_create_entryasync_create_entry(title="", data=user_input)
385 
386  data_schema = vol.Schema(
387  {
388  vol.Required(
389  CONF_SCAN_INTERVAL,
390  default=self.config_entryconfig_entryconfig_entry.options.get(
391  CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
392  ),
393  ): cv.positive_int,
394  vol.Required(
395  CONF_SNAPSHOT_QUALITY,
396  default=self.config_entryconfig_entryconfig_entry.options.get(
397  CONF_SNAPSHOT_QUALITY, DEFAULT_SNAPSHOT_QUALITY
398  ),
399  ): vol.All(vol.Coerce(int), vol.Range(min=0, max=2)),
400  }
401  )
402  return self.async_show_formasync_show_form(step_id="init", data_schema=data_schema)
403 
404 
405 async def _login_and_fetch_syno_info(api: SynologyDSM, otp_code: str | None) -> str:
406  """Login to the NAS and fetch basic data."""
407  # These do i/o
408  await api.login(otp_code)
409  await api.utilisation.update()
410  await api.storage.update()
411  await api.network.update()
412 
413  if (
414  not api.information.serial
415  or api.utilisation.cpu_user_load is None
416  or not api.storage.volumes_ids
417  or not api.network.macs
418  ):
419  raise InvalidData
420 
421  return api.information.serial
422 
423 
425  """Error to indicate we get invalid data from the nas."""
SynologyDSMOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:119
ConfigFlowResult _async_from_discovery(self, str host, str friendly_name, list[str] discovered_macs)
Definition: config_flow.py:276
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:247
ConfigFlowResult async_validate_input_create_entry(self, dict[str, Any] user_input, str step_id)
Definition: config_flow.py:161
ConfigFlowResult _show_form(self, str step_id, dict[str, Any]|None user_input=None, dict[str, str]|None errors=None)
Definition: config_flow.py:135
ConfigFlowResult async_step_link(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:316
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:326
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:238
ConfigEntry|None _async_get_existing_entry(self, str discovered_mac)
Definition: config_flow.py:366
ConfigFlowResult async_step_ssdp(self, ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:262
ConfigFlowResult async_step_reauth_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:339
ConfigFlowResult async_step_2sa(self, dict[str, Any] user_input, dict[str, str]|None errors=None)
Definition: config_flow.py:349
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:381
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
ConfigEntry|None async_set_unique_id(self, str|None unique_id=None, *bool raise_on_progress=True)
ConfigFlowResult async_create_entry(self, *str title, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None, Mapping[str, Any]|None options=None)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=None)
ConfigFlowResult async_update_reload_and_abort(self, ConfigEntry entry, *str|None|UndefinedType unique_id=UNDEFINED, str|UndefinedType title=UNDEFINED, Mapping[str, Any]|UndefinedType data=UNDEFINED, Mapping[str, Any]|UndefinedType data_updates=UNDEFINED, Mapping[str, Any]|UndefinedType options=UNDEFINED, str|UndefinedType reason=UNDEFINED, bool reload_even_if_entry_is_unchanged=True)
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
ConfigFlowResult async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
None config_entry(self, ConfigEntry value)
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
vol.Schema _discovery_schema_with_defaults(DiscoveryInfoType discovery_info)
Definition: config_flow.py:68
str _login_and_fetch_syno_info(SynologyDSM api, str|None otp_code)
Definition: config_flow.py:405
VolDictType _ordered_shared_schema(dict[str, Any] schema_input)
Definition: config_flow.py:90
vol.Schema _user_schema_with_defaults(dict[str, Any] user_input)
Definition: config_flow.py:81
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)