diff --git a/.coveragerc b/.coveragerc index 30ea684740d..c692dfbba5e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -621,6 +621,8 @@ omit = homeassistant/components/notify_events/notify.py homeassistant/components/nsw_fuel_station/sensor.py homeassistant/components/nuimo_controller/* + homeassistant/components/nuki/__init__.py + homeassistant/components/nuki/const.py homeassistant/components/nuki/lock.py homeassistant/components/nut/sensor.py homeassistant/components/nx584/alarm_control_panel.py diff --git a/CODEOWNERS b/CODEOWNERS index b8175614fb5..73d42f4efcf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -310,7 +310,7 @@ homeassistant/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte homeassistant/components/nuheat/* @bdraco -homeassistant/components/nuki/* @pschmitt @pvizeli +homeassistant/components/nuki/* @pschmitt @pvizeli @pree homeassistant/components/numato/* @clssn homeassistant/components/number/* @home-assistant/core @Shulyaka homeassistant/components/nut/* @bdraco diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index c8b19082585..627cf20b16b 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1,3 +1,63 @@ """The nuki component.""" -DOMAIN = "nuki" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +NUKI_PLATFORMS = ["lock"] +UPDATE_INTERVAL = timedelta(seconds=30) + +NUKI_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_TOKEN): cv.string, + }, + ) +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema(NUKI_SCHEMA)}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Nuki component.""" + hass.data.setdefault(DOMAIN, {}) + _LOGGER.debug("Config: %s", config) + + for platform in NUKI_PLATFORMS: + confs = config.get(platform) + if confs is None: + continue + + for conf in confs: + _LOGGER.debug("Conf: %s", conf) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up the Nuki entry.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, LOCK_DOMAIN) + ) + + return True diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py new file mode 100644 index 00000000000..9af74cb4423 --- /dev/null +++ b/homeassistant/components/nuki/config_flow.py @@ -0,0 +1,97 @@ +"""Config flow to configure the Nuki integration.""" +import logging + +from pynuki import NukiBridge +from pynuki.bridge import InvalidCredentialsException +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN + +from .const import ( # pylint: disable=unused-import + DEFAULT_PORT, + DEFAULT_TIMEOUT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + vol.Required(CONF_TOKEN): str, + } +) + + +async def validate_input(hass, data): + """Validate the user input allows us to connect. + + Data has the keys from USER_SCHEMA with values provided by the user. + """ + + try: + bridge = await hass.async_add_executor_job( + NukiBridge, + data[CONF_HOST], + data[CONF_TOKEN], + data[CONF_PORT], + True, + DEFAULT_TIMEOUT, + ) + + info = bridge.info() + except InvalidCredentialsException as err: + raise InvalidAuth from err + except RequestException as err: + raise CannotConnect from err + + return info + + +class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Nuki config flow.""" + + async def async_step_import(self, user_input=None): + """Handle a flow initiated by import.""" + return await self.async_step_validate(user_input) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + return await self.async_step_validate(user_input) + + async def async_step_validate(self, user_input): + """Handle init step of a flow.""" + + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(info["ids"]["hardwareId"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=info["ids"]["hardwareId"], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/nuki/const.py b/homeassistant/components/nuki/const.py new file mode 100644 index 00000000000..07ef49ebd88 --- /dev/null +++ b/homeassistant/components/nuki/const.py @@ -0,0 +1,6 @@ +"""Constants for Nuki.""" +DOMAIN = "nuki" + +# Defaults +DEFAULT_PORT = 8080 +DEFAULT_TIMEOUT = 20 diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index d0b55514a63..fe024405908 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -11,10 +11,9 @@ from homeassistant.components.lock import PLATFORM_SCHEMA, SUPPORT_OPEN, LockEnt from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.helpers import config_validation as cv, entity_platform -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_PORT, DEFAULT_TIMEOUT -DEFAULT_PORT = 8080 -DEFAULT_TIMEOUT = 20 +_LOGGER = logging.getLogger(__name__) ATTR_BATTERY_CRITICAL = "battery_critical" ATTR_NUKI_ID = "nuki_id" @@ -38,6 +37,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Nuki lock platform.""" + _LOGGER.warning( + "Loading Nuki by lock platform configuration is deprecated and will be removed in the future" + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Nuki lock platform.""" + config = config_entry.data + _LOGGER.debug("Config: %s", config) def get_entities(): bridge = NukiBridge( diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 09cf112d41c..9385821845a 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -1,7 +1,8 @@ { - "domain": "nuki", - "name": "Nuki", - "documentation": "https://www.home-assistant.io/integrations/nuki", - "requirements": ["pynuki==1.3.8"], - "codeowners": ["@pschmitt", "@pvizeli"] -} + "domain": "nuki", + "name": "Nuki", + "documentation": "https://www.home-assistant.io/integrations/nuki", + "requirements": ["pynuki==1.3.8"], + "codeowners": ["@pschmitt", "@pvizeli", "@pree"], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json new file mode 100644 index 00000000000..9e1e4f5e5ab --- /dev/null +++ b/homeassistant/components/nuki/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/en.json b/homeassistant/components/nuki/translations/en.json new file mode 100644 index 00000000000..70ae9c6a1fe --- /dev/null +++ b/homeassistant/components/nuki/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Could not login with provided token", + "unknown": "Unknown error" + }, + "step": { + "user": { + "data": { + "token": "Access Token", + "host": "Host", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 77a1dc91dd7..4c12ff30e49 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -141,6 +141,7 @@ FLOWS = [ "nightscout", "notion", "nuheat", + "nuki", "nut", "nws", "nzbget", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80341520116..d40bb96da45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -804,6 +804,9 @@ pymonoprice==0.3 # homeassistant.components.myq pymyq==2.0.14 +# homeassistant.components.nuki +pynuki==1.3.8 + # homeassistant.components.nut pynut2==2.1.2 diff --git a/tests/components/nuki/__init__.py b/tests/components/nuki/__init__.py new file mode 100644 index 00000000000..a774935b9db --- /dev/null +++ b/tests/components/nuki/__init__.py @@ -0,0 +1 @@ +"""The tests for nuki integration.""" diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py new file mode 100644 index 00000000000..45168e42c9d --- /dev/null +++ b/tests/components/nuki/test_config_flow.py @@ -0,0 +1,93 @@ +"""Test the nuki config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.nuki.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.nuki.const import DOMAIN + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_info = {"ids": {"hardwareId": "0001"}} + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + return_value=mock_info, + ), patch( + "homeassistant.components.nuki.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.nuki.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "0001" + assert result2["data"] == { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 8080, + "token": "test-token", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}