Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add doorsensor + coordinator to nuki #40933

Merged
merged 17 commits into from
Apr 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 132 additions & 17 deletions homeassistant/components/nuki/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
},
)
)

pree marked this conversation as resolved.
Show resolved Hide resolved
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):
Expand All @@ -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)
pree marked this conversation as resolved.
Show resolved Hide resolved
)

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
73 changes: 73 additions & 0 deletions homeassistant/components/nuki/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -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]:
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
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
48 changes: 47 additions & 1 deletion homeassistant/components/nuki/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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."""
Expand All @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we handle the other case and show an error that no matching entry was found?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the docs it's not handling the other case, if I should implement this, should it abort the flow or just print an error?

Copy link
Member

@MartinHjelmare MartinHjelmare Mar 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example in the docs creates a new entry if there's no existing entry. Here we just show the form again without any error message. How will the user know what to do?

I suggest showing an error message that the reauthentication failed with the user provided details.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, I've misread that in the docs. I've added the "unknown" error since that is the only one from the common strings that would match here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add a custom message if we have better info than unknown.

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."""

Expand All @@ -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
)
Expand Down
13 changes: 13 additions & 0 deletions homeassistant/components/nuki/const.py
Original file line number Diff line number Diff line change
@@ -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)
Loading