diff --git a/.coveragerc b/.coveragerc index db940ed642b450..065e400489199d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -658,6 +658,7 @@ omit = homeassistant/components/nsw_fuel_station/sensor.py homeassistant/components/nuki/__init__.py homeassistant/components/nuki/const.py + homeassistant/components/nuki/binary_sensor.py homeassistant/components/nuki/lock.py homeassistant/components/nut/sensor.py homeassistant/components/nx584/alarm_control_panel.py diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 6aa945a52bf53a..a96cda070772bb 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1,28 +1,53 @@ """The nuki component.""" +import asyncio from datetime import timedelta +import logging -import voluptuous as vol +import async_timeout +from pynuki import NukiBridge +from pynuki.bridge import InvalidCredentialsException +from requests.exceptions import RequestException -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant import exceptions +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + DATA_BRIDGE, + DATA_COORDINATOR, + DATA_LOCKS, + DATA_OPENERS, + DEFAULT_TIMEOUT, + DOMAIN, + ERROR_STATES, +) -from .const import DEFAULT_PORT, DOMAIN +_LOGGER = logging.getLogger(__name__) -PLATFORMS = ["lock"] +PLATFORMS = ["binary_sensor", "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, - }, - ) -) + +def _get_bridge_devices(bridge): + return bridge.locks, bridge.openers + + +def _update_devices(devices): + for device in devices: + for level in (False, True): + try: + device.update(level) + except RequestException: + continue + + if device.state not in ERROR_STATES: + break async def async_setup(hass, config): @@ -46,8 +71,98 @@ async def async_setup(hass, config): 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) + + hass.data.setdefault(DOMAIN, {}) + + try: + bridge = await hass.async_add_executor_job( + NukiBridge, + entry.data[CONF_HOST], + entry.data[CONF_TOKEN], + entry.data[CONF_PORT], + True, + DEFAULT_TIMEOUT, + ) + + locks, openers = await hass.async_add_executor_job(_get_bridge_devices, bridge) + except InvalidCredentialsException: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=entry.data + ) + ) + return False + except RequestException as err: + raise exceptions.ConfigEntryNotReady from err + + async def async_update_data(): + """Fetch data from Nuki bridge.""" + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(10): + await hass.async_add_executor_job(_update_devices, locks + openers) + except InvalidCredentialsException as err: + raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="nuki devices", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=UPDATE_INTERVAL, ) + hass.data[DOMAIN][entry.entry_id] = { + DATA_COORDINATOR: coordinator, + DATA_BRIDGE: bridge, + DATA_LOCKS: locks, + DATA_OPENERS: openers, + } + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload the Nuki entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class NukiEntity(CoordinatorEntity): + """An entity using CoordinatorEntity. + + The CoordinatorEntity class provides: + should_poll + async_update + async_added_to_hass + available + + """ + + def __init__(self, coordinator, nuki_device): + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self._nuki_device = nuki_device diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py new file mode 100644 index 00000000000000..37641dbf15a98f --- /dev/null +++ b/homeassistant/components/nuki/binary_sensor.py @@ -0,0 +1,73 @@ +"""Doorsensor Support for the Nuki Lock.""" + +import logging + +from pynuki import STATE_DOORSENSOR_OPENED + +from homeassistant.components.binary_sensor import DEVICE_CLASS_DOOR, BinarySensorEntity + +from . import NukiEntity +from .const import ATTR_NUKI_ID, DATA_COORDINATOR, DATA_LOCKS, DOMAIN as NUKI_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Nuki lock binary sensor.""" + data = hass.data[NUKI_DOMAIN][entry.entry_id] + coordinator = data[DATA_COORDINATOR] + + entities = [] + + for lock in data[DATA_LOCKS]: + if lock.is_door_sensor_activated: + entities.extend([NukiDoorsensorEntity(coordinator, lock)]) + + async_add_entities(entities) + + +class NukiDoorsensorEntity(NukiEntity, BinarySensorEntity): + """Representation of a Nuki Lock Doorsensor.""" + + @property + def name(self): + """Return the name of the lock.""" + return self._nuki_device.name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._nuki_device.nuki_id}_doorsensor" + + @property + def extra_state_attributes(self): + """Return the device specific state attributes.""" + data = { + ATTR_NUKI_ID: self._nuki_device.nuki_id, + } + return data + + @property + def available(self): + """Return true if door sensor is present and activated.""" + return super().available and self._nuki_device.is_door_sensor_activated + + @property + def door_sensor_state(self): + """Return the state of the door sensor.""" + return self._nuki_device.door_sensor_state + + @property + def door_sensor_state_name(self): + """Return the state name of the door sensor.""" + return self._nuki_device.door_sensor_state_name + + @property + def is_on(self): + """Return true if the door is open.""" + return self.door_sensor_state == STATE_DOORSENSOR_OPENED + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_DOOR diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index f065f1c27ef7f2..d4f67b16b4c325 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -26,6 +26,8 @@ } ) +REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_TOKEN): str}) + async def validate_input(hass, data): """Validate the user input allows us to connect. @@ -58,6 +60,7 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the Nuki config flow.""" self.discovery_schema = {} + self._data = {} async def async_step_import(self, user_input=None): """Handle a flow initiated by import.""" @@ -83,6 +86,50 @@ async def async_step_dhcp(self, discovery_info: dict): return await self.async_step_validate() + async def async_step_reauth(self, data): + """Perform reauth upon an API authentication error.""" + self._data = data + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that inform the user that reauth is required.""" + errors = {} + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", data_schema=REAUTH_SCHEMA + ) + + conf = { + CONF_HOST: self._data[CONF_HOST], + CONF_PORT: self._data[CONF_PORT], + CONF_TOKEN: user_input[CONF_TOKEN], + } + + try: + info = await validate_input(self.hass, conf) + 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 not errors: + existing_entry = await self.async_set_unique_id(info["ids"]["hardwareId"]) + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=conf) + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors + ) + async def async_step_validate(self, user_input=None): """Handle init step of a flow.""" @@ -106,7 +153,6 @@ async def async_step_validate(self, user_input=None): ) data_schema = self.discovery_schema or USER_SCHEMA - return self.async_show_form( step_id="user", data_schema=data_schema, errors=errors ) diff --git a/homeassistant/components/nuki/const.py b/homeassistant/components/nuki/const.py index 07ef49ebd88723..da12a3a074ddb5 100644 --- a/homeassistant/components/nuki/const.py +++ b/homeassistant/components/nuki/const.py @@ -1,6 +1,19 @@ """Constants for Nuki.""" DOMAIN = "nuki" +# Attributes +ATTR_BATTERY_CRITICAL = "battery_critical" +ATTR_NUKI_ID = "nuki_id" +ATTR_UNLATCH = "unlatch" + +# Data +DATA_BRIDGE = "nuki_bridge_data" +DATA_LOCKS = "nuki_locks_data" +DATA_OPENERS = "nuki_openers_data" +DATA_COORDINATOR = "nuki_coordinator" + # Defaults DEFAULT_PORT = 8080 DEFAULT_TIMEOUT = 20 + +ERROR_STATES = (0, 254, 255) diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 360153d14feabb..bd5d58ed42ae1e 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -1,31 +1,28 @@ """Nuki.io lock platform.""" from abc import ABC, abstractmethod -from datetime import timedelta import logging -from pynuki import NukiBridge -from requests.exceptions import RequestException import voluptuous as vol from homeassistant.components.lock import PLATFORM_SCHEMA, SUPPORT_OPEN, LockEntity from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.helpers import config_validation as cv, entity_platform -from .const import DEFAULT_PORT, DEFAULT_TIMEOUT +from . import NukiEntity +from .const import ( + ATTR_BATTERY_CRITICAL, + ATTR_NUKI_ID, + ATTR_UNLATCH, + DATA_COORDINATOR, + DATA_LOCKS, + DATA_OPENERS, + DEFAULT_PORT, + DOMAIN as NUKI_DOMAIN, + ERROR_STATES, +) _LOGGER = logging.getLogger(__name__) -ATTR_BATTERY_CRITICAL = "battery_critical" -ATTR_NUKI_ID = "nuki_id" -ATTR_UNLATCH = "unlatch" - -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=5) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) - -NUKI_DATA = "nuki" - -ERROR_STATES = (0, 254, 255) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -42,25 +39,15 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass, entry, async_add_entities): """Set up the Nuki lock platform.""" - config = config_entry.data - - def get_entities(): - bridge = NukiBridge( - config[CONF_HOST], - config[CONF_TOKEN], - config[CONF_PORT], - True, - DEFAULT_TIMEOUT, - ) - - entities = [NukiLockEntity(lock) for lock in bridge.locks] - entities.extend([NukiOpenerEntity(opener) for opener in bridge.openers]) - return entities - - entities = await hass.async_add_executor_job(get_entities) + data = hass.data[NUKI_DOMAIN][entry.entry_id] + coordinator = data[DATA_COORDINATOR] + entities = [NukiLockEntity(coordinator, lock) for lock in data[DATA_LOCKS]] + entities.extend( + [NukiOpenerEntity(coordinator, opener) for opener in data[DATA_OPENERS]] + ) async_add_entities(entities) platform = entity_platform.current_platform.get() @@ -75,14 +62,9 @@ def get_entities(): ) -class NukiDeviceEntity(LockEntity, ABC): +class NukiDeviceEntity(NukiEntity, LockEntity, ABC): """Representation of a Nuki device.""" - def __init__(self, nuki_device): - """Initialize the lock.""" - self._nuki_device = nuki_device - self._available = nuki_device.state not in ERROR_STATES - @property def name(self): """Return the name of the lock.""" @@ -115,22 +97,7 @@ def supported_features(self): @property def available(self) -> bool: """Return True if entity is available.""" - return self._available - - def update(self): - """Update the nuki lock properties.""" - for level in (False, True): - try: - self._nuki_device.update(aggressive=level) - except RequestException: - _LOGGER.warning("Network issues detect with %s", self.name) - self._available = False - continue - - # If in error state, we force an update and repoll data - self._available = self._nuki_device.state not in ERROR_STATES - if self._available: - break + return super().available and self._nuki_device.state not in ERROR_STATES @abstractmethod def lock(self, **kwargs): diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 7fb9a134c4c55a..8500a3c90aa069 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -2,7 +2,7 @@ "domain": "nuki", "name": "Nuki", "documentation": "https://www.home-assistant.io/integrations/nuki", - "requirements": ["pynuki==1.3.8"], + "requirements": ["pynuki==1.4.1"], "codeowners": ["@pschmitt", "@pvizeli", "@pree"], "config_flow": true, "dhcp": [{ "hostname": "nuki_bridge_*" }] diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index 9e1e4f5e5ab248..3f6de25122a872 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -7,12 +7,22 @@ "port": "[%key:common::config_flow::data::port%]", "token": "[%key:common::config_flow::data::access_token%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Nuki integration needs to re-authenticate with your bridge.", + "data": { + "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%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/en.json b/homeassistant/components/nuki/translations/en.json index 135e8de2b2f6d8..3d53b85920ca23 100644 --- a/homeassistant/components/nuki/translations/en.json +++ b/homeassistant/components/nuki/translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "reauth_successful": "Successfully reauthenticated." + }, "error": { "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", @@ -12,6 +15,13 @@ "port": "Port", "token": "Access Token" } + }, + "reauth_confirm": { + "title": "Reauthenticate Integration", + "description": "The Nuki integration needs to re-authenticate with your bridge.", + "data": { + "token": "Access Token" + } } } } diff --git a/requirements_all.txt b/requirements_all.txt index 607dc94f5af68a..3ef739e8074029 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1567,7 +1567,7 @@ pynetgear==0.6.1 pynetio==0.1.9.1 # homeassistant.components.nuki -pynuki==1.3.8 +pynuki==1.4.1 # homeassistant.components.nut pynut2==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e53a94f0cd90b5..b0e5f9207bde2b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -827,7 +827,7 @@ pymyq==3.0.4 pymysensors==0.21.0 # homeassistant.components.nuki -pynuki==1.3.8 +pynuki==1.4.1 # homeassistant.components.nut pynut2==2.1.2 diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index 4933ea52b77a30..4039eef598402c 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.components.nuki.const import DOMAIN +from homeassistant.const import CONF_TOKEN from .mock import HOST, MAC, MOCK_INFO, NAME, setup_nuki_integration @@ -227,3 +228,103 @@ async def test_dhcp_flow_already_configured(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_success(hass): + """Test starting a reauthentication flow.""" + entry = await setup_nuki_integration(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + return_value=MOCK_INFO, + ), patch("homeassistant.components.nuki.async_setup", return_value=True), patch( + "homeassistant.components.nuki.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: "new-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data[CONF_TOKEN] == "new-token" + + +async def test_reauth_invalid_auth(hass): + """Test starting a reauthentication flow with invalid auth.""" + entry = await setup_nuki_integration(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + side_effect=InvalidCredentialsException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: "new-token"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth_confirm" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_cannot_connect(hass): + """Test starting a reauthentication flow with cannot connect.""" + entry = await setup_nuki_integration(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + side_effect=RequestException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: "new-token"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth_confirm" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_unknown_exception(hass): + """Test starting a reauthentication flow with an unknown exception.""" + entry = await setup_nuki_integration(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.nuki.config_flow.NukiBridge.info", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: "new-token"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth_confirm" + assert result2["errors"] == {"base": "unknown"}