Add doorsensor + coordinator to nuki (#40933)

* implemented coordinator + doorsensor

* added async_unload_entry

* small fixes + reauth_flow

* update function

* black

* define _data inside __init__

* removed unused property

* await on update & coverage for binary_sensor

* keep reauth seperate from validate

* setting entities unavailable when connection goes down

* add unknown error when entity is not present

* override extra_state_attributes()

* removed unnecessary else

* moved to locks & openers variables

* removed doorsensorState attribute

* changed config entry reload to a task

* wait for reload
This commit is contained in:
Pascal Reeb 2021-04-06 21:20:57 +02:00 committed by GitHub
parent 9f5db2ce3f
commit fb1444c414
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 411 additions and 75 deletions

View File

@ -673,6 +673,7 @@ omit =
homeassistant/components/nsw_fuel_station/sensor.py homeassistant/components/nsw_fuel_station/sensor.py
homeassistant/components/nuki/__init__.py homeassistant/components/nuki/__init__.py
homeassistant/components/nuki/const.py homeassistant/components/nuki/const.py
homeassistant/components/nuki/binary_sensor.py
homeassistant/components/nuki/lock.py homeassistant/components/nuki/lock.py
homeassistant/components/nut/sensor.py homeassistant/components/nut/sensor.py
homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/nx584/alarm_control_panel.py

View File

@ -1,28 +1,53 @@
"""The nuki component.""" """The nuki component."""
import asyncio
from datetime import timedelta 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 import exceptions
from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN 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 DEFAULT_PORT, DOMAIN from .const import (
DATA_BRIDGE,
DATA_COORDINATOR,
DATA_LOCKS,
DATA_OPENERS,
DEFAULT_TIMEOUT,
DOMAIN,
ERROR_STATES,
)
PLATFORMS = ["lock"] _LOGGER = logging.getLogger(__name__)
PLATFORMS = ["binary_sensor", "lock"]
UPDATE_INTERVAL = timedelta(seconds=30) UPDATE_INTERVAL = timedelta(seconds=30)
NUKI_SCHEMA = vol.Schema(
vol.All( def _get_bridge_devices(bridge):
{ return bridge.locks, bridge.openers
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Required(CONF_TOKEN): cv.string, 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): async def async_setup(hass, config):
@ -46,8 +71,98 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, entry): async def async_setup_entry(hass, entry):
"""Set up the Nuki entry.""" """Set up the Nuki entry."""
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.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, LOCK_DOMAIN) 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 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

View File

@ -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

View File

@ -22,6 +22,8 @@ USER_SCHEMA = vol.Schema(
} }
) )
REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_TOKEN): str})
async def validate_input(hass, data): async def validate_input(hass, data):
"""Validate the user input allows us to connect. """Validate the user input allows us to connect.
@ -54,6 +56,7 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self): def __init__(self):
"""Initialize the Nuki config flow.""" """Initialize the Nuki config flow."""
self.discovery_schema = {} self.discovery_schema = {}
self._data = {}
async def async_step_import(self, user_input=None): async def async_step_import(self, user_input=None):
"""Handle a flow initiated by import.""" """Handle a flow initiated by import."""
@ -79,6 +82,50 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_validate() 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): async def async_step_validate(self, user_input=None):
"""Handle init step of a flow.""" """Handle init step of a flow."""
@ -102,7 +149,6 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
data_schema = self.discovery_schema or USER_SCHEMA data_schema = self.discovery_schema or USER_SCHEMA
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors step_id="user", data_schema=data_schema, errors=errors
) )

View File

@ -1,6 +1,19 @@
"""Constants for Nuki.""" """Constants for Nuki."""
DOMAIN = "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 # Defaults
DEFAULT_PORT = 8080 DEFAULT_PORT = 8080
DEFAULT_TIMEOUT = 20 DEFAULT_TIMEOUT = 20
ERROR_STATES = (0, 254, 255)

View File

@ -1,31 +1,28 @@
"""Nuki.io lock platform.""" """Nuki.io lock platform."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import timedelta
import logging import logging
from pynuki import NukiBridge
from requests.exceptions import RequestException
import voluptuous as vol import voluptuous as vol
from homeassistant.components.lock import PLATFORM_SCHEMA, SUPPORT_OPEN, LockEntity from homeassistant.components.lock import PLATFORM_SCHEMA, SUPPORT_OPEN, LockEntity
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN
from homeassistant.helpers import config_validation as cv, entity_platform 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__) _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( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_HOST): cv.string, 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.""" """Set up the Nuki lock platform."""
config = config_entry.data data = hass.data[NUKI_DOMAIN][entry.entry_id]
coordinator = data[DATA_COORDINATOR]
def get_entities(): entities = [NukiLockEntity(coordinator, lock) for lock in data[DATA_LOCKS]]
bridge = NukiBridge( entities.extend(
config[CONF_HOST], [NukiOpenerEntity(coordinator, opener) for opener in data[DATA_OPENERS]]
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)
async_add_entities(entities) async_add_entities(entities)
platform = entity_platform.current_platform.get() platform = entity_platform.current_platform.get()
@ -75,14 +62,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
) )
class NukiDeviceEntity(LockEntity, ABC): class NukiDeviceEntity(NukiEntity, LockEntity, ABC):
"""Representation of a Nuki device.""" """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 @property
def name(self): def name(self):
"""Return the name of the lock.""" """Return the name of the lock."""
@ -115,22 +97,7 @@ class NukiDeviceEntity(LockEntity, ABC):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return self._available return super().available and self._nuki_device.state not in ERROR_STATES
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
@abstractmethod @abstractmethod
def lock(self, **kwargs): def lock(self, **kwargs):

View File

@ -2,7 +2,7 @@
"domain": "nuki", "domain": "nuki",
"name": "Nuki", "name": "Nuki",
"documentation": "https://www.home-assistant.io/integrations/nuki", "documentation": "https://www.home-assistant.io/integrations/nuki",
"requirements": ["pynuki==1.3.8"], "requirements": ["pynuki==1.4.1"],
"codeowners": ["@pschmitt", "@pvizeli", "@pree"], "codeowners": ["@pschmitt", "@pvizeli", "@pree"],
"config_flow": true, "config_flow": true,
"dhcp": [{ "hostname": "nuki_bridge_*" }] "dhcp": [{ "hostname": "nuki_bridge_*" }]

View File

@ -7,12 +7,22 @@
"port": "[%key:common::config_flow::data::port%]", "port": "[%key:common::config_flow::data::port%]",
"token": "[%key:common::config_flow::data::access_token%]" "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": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
} }
} }

View File

@ -1,5 +1,8 @@
{ {
"config": { "config": {
"abort": {
"reauth_successful": "Successfully reauthenticated."
},
"error": { "error": {
"cannot_connect": "Failed to connect", "cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication", "invalid_auth": "Invalid authentication",
@ -12,6 +15,13 @@
"port": "Port", "port": "Port",
"token": "Access Token" "token": "Access Token"
} }
},
"reauth_confirm": {
"title": "Reauthenticate Integration",
"description": "The Nuki integration needs to re-authenticate with your bridge.",
"data": {
"token": "Access Token"
}
} }
} }
} }

View File

@ -1572,7 +1572,7 @@ pynetgear==0.6.1
pynetio==0.1.9.1 pynetio==0.1.9.1
# homeassistant.components.nuki # homeassistant.components.nuki
pynuki==1.3.8 pynuki==1.4.1
# homeassistant.components.nut # homeassistant.components.nut
pynut2==2.1.2 pynut2==2.1.2

View File

@ -850,7 +850,7 @@ pymyq==3.0.4
pymysensors==0.21.0 pymysensors==0.21.0
# homeassistant.components.nuki # homeassistant.components.nuki
pynuki==1.3.8 pynuki==1.4.1
# homeassistant.components.nut # homeassistant.components.nut
pynut2==2.1.2 pynut2==2.1.2

View File

@ -7,6 +7,7 @@ from requests.exceptions import RequestException
from homeassistant import config_entries, data_entry_flow, setup from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS
from homeassistant.components.nuki.const import DOMAIN from homeassistant.components.nuki.const import DOMAIN
from homeassistant.const import CONF_TOKEN
from .mock import HOST, MAC, MOCK_INFO, NAME, setup_nuki_integration 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["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured" 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"}