From a502a8798ff74eb6185473df7f69553fc4663634 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 4 Jun 2022 22:37:08 -0400 Subject: [PATCH] Add config flow to skybell (#70887) --- .coveragerc | 9 +- CODEOWNERS | 2 + homeassistant/components/skybell/__init__.py | 174 ++++++++++-------- .../components/skybell/binary_sensor.py | 89 ++++----- homeassistant/components/skybell/camera.py | 112 ++++------- .../components/skybell/config_flow.py | 76 ++++++++ homeassistant/components/skybell/const.py | 14 ++ .../components/skybell/coordinator.py | 34 ++++ homeassistant/components/skybell/entity.py | 65 +++++++ homeassistant/components/skybell/light.py | 89 ++++----- .../components/skybell/manifest.json | 7 +- homeassistant/components/skybell/sensor.py | 59 ++---- homeassistant/components/skybell/strings.json | 21 +++ homeassistant/components/skybell/switch.py | 64 +++---- .../components/skybell/translations/en.json | 21 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 6 +- requirements_test_all.txt | 3 + tests/components/skybell/__init__.py | 30 +++ tests/components/skybell/test_config_flow.py | 137 ++++++++++++++ 20 files changed, 664 insertions(+), 349 deletions(-) create mode 100644 homeassistant/components/skybell/config_flow.py create mode 100644 homeassistant/components/skybell/const.py create mode 100644 homeassistant/components/skybell/coordinator.py create mode 100644 homeassistant/components/skybell/entity.py create mode 100644 homeassistant/components/skybell/strings.json create mode 100644 homeassistant/components/skybell/translations/en.json create mode 100644 tests/components/skybell/__init__.py create mode 100644 tests/components/skybell/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 204353ffe87..4cbddd14601 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1064,7 +1064,14 @@ omit = homeassistant/components/sisyphus/* homeassistant/components/sky_hub/* homeassistant/components/skybeacon/sensor.py - homeassistant/components/skybell/* + homeassistant/components/skybell/__init__.py + homeassistant/components/skybell/binary_sensor.py + homeassistant/components/skybell/camera.py + homeassistant/components/skybell/coordinator.py + homeassistant/components/skybell/entity.py + homeassistant/components/skybell/light.py + homeassistant/components/skybell/sensor.py + homeassistant/components/skybell/switch.py homeassistant/components/slack/__init__.py homeassistant/components/slack/notify.py homeassistant/components/sia/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index e2e9fc27b5c..59f3671c475 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -933,6 +933,8 @@ build.json @home-assistant/supervisor /tests/components/siren/ @home-assistant/core @raman325 /homeassistant/components/sisyphus/ @jkeljo /homeassistant/components/sky_hub/ @rogerselwyn +/homeassistant/components/skybell/ @tkdrob +/tests/components/skybell/ @tkdrob /homeassistant/components/slack/ @bachya @tkdrob /tests/components/slack/ @bachya @tkdrob /homeassistant/components/sleepiq/ @mfugate1 @kbickar diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 47e22f5b619..00c7a533590 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -1,101 +1,117 @@ """Support for the Skybell HD Doorbell.""" -import logging +from __future__ import annotations -from requests.exceptions import ConnectTimeout, HTTPError -from skybellpy import Skybell +import asyncio +import os + +from aioskybell import Skybell +from aioskybell.exceptions import SkybellAuthenticationException, SkybellException import voluptuous as vol -from homeassistant.components import persistent_notification -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_PASSWORD, - CONF_USERNAME, - __version__, -) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Data provided by Skybell.com" - -NOTIFICATION_ID = "skybell_notification" -NOTIFICATION_TITLE = "Skybell Sensor Setup" - -DOMAIN = "skybell" -DEFAULT_CACHEDB = "./skybell_cache.pickle" -DEFAULT_ENTITY_NAMESPACE = "skybell" - -AGENT_IDENTIFIER = f"HomeAssistant/{__version__}" +from .const import DEFAULT_CACHEDB, DOMAIN +from .coordinator import SkybellDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, + vol.All( + # Deprecated in Home Assistant 2022.6 + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Skybell component.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - try: - cache = hass.config.path(DEFAULT_CACHEDB) - skybell = Skybell( - username=username, - password=password, - get_devices=True, - cache_path=cache, - agent_identifier=AGENT_IDENTIFIER, +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the SkyBell component.""" + hass.data.setdefault(DOMAIN, {}) + + entry_config = {} + if DOMAIN not in config: + return True + for parameter, value in config[DOMAIN].items(): + if parameter == CONF_USERNAME: + entry_config[CONF_EMAIL] = value + else: + entry_config[parameter] = value + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=entry_config, + ) ) - hass.data[DOMAIN] = skybell - except (ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Skybell service: %s", str(ex)) - persistent_notification.create( - hass, - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - return False + # Clean up unused cache file since we are using an account specific name + # Remove with import + def clean_cache(): + """Clean old cache filename.""" + if os.path.exists(hass.config.path(DEFAULT_CACHEDB)): + os.remove(hass.config.path(DEFAULT_CACHEDB)) + + await hass.async_add_executor_job(clean_cache) + return True -class SkybellDevice(Entity): - """A HA implementation for Skybell devices.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Skybell from a config entry.""" + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] - def __init__(self, device): - """Initialize a sensor for Skybell device.""" - self._device = device + api = Skybell( + username=email, + password=password, + get_devices=True, + cache_path=hass.config.path(f"./skybell_{entry.unique_id}.pickle"), + session=async_get_clientsession(hass), + ) + try: + devices = await api.async_initialize() + except SkybellAuthenticationException: + return False + except SkybellException as ex: + raise ConfigEntryNotReady(f"Unable to connect to Skybell service: {ex}") from ex - def update(self): - """Update automation state.""" - self._device.refresh() + device_coordinators: list[SkybellDataUpdateCoordinator] = [ + SkybellDataUpdateCoordinator(hass, device) for device in devices + ] + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in device_coordinators + ] + ) + hass.data[DOMAIN][entry.entry_id] = device_coordinators + hass.config_entries.async_setup_platforms(entry, PLATFORMS) - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - "device_id": self._device.device_id, - "status": self._device.status, - "location": self._device.location, - "wifi_ssid": self._device.wifi_ssid, - "wifi_status": self._device.wifi_status, - "last_check_in": self._device.last_check_in, - "motion_threshold": self._device.motion_threshold, - "video_profile": self._device.video_profile, - } + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index bf8ffcfce9d..dcb5466e479 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -1,9 +1,7 @@ """Binary sensor support for the Skybell HD Doorbell.""" from __future__ import annotations -from datetime import timedelta -from typing import Any - +from aioskybell.helpers import const as CONST import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -12,36 +10,33 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from . import DOMAIN +from .coordinator import SkybellDataUpdateCoordinator +from .entity import SkybellEntity -SCAN_INTERVAL = timedelta(seconds=10) - - -BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { - "button": BinarySensorEntityDescription( - key="device:sensor:button", +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="button", name="Button", device_class=BinarySensorDeviceClass.OCCUPANCY, ), - "motion": BinarySensorEntityDescription( - key="device:sensor:motion", + BinarySensorEntityDescription( + key="motion", name="Motion", device_class=BinarySensorDeviceClass.MOTION, ), -} - +) +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional( - CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE - ): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DOMAIN): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( cv.ensure_list, [vol.In(BINARY_SENSOR_TYPES)] ), @@ -49,53 +44,41 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - binary_sensors = [ - SkybellBinarySensor(device, BINARY_SENSOR_TYPES[sensor_type]) - for device in skybell.get_devices() - for sensor_type in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(binary_sensors, True) + """Set up Skybell switch.""" + async_add_entities( + SkybellBinarySensor(coordinator, sensor) + for sensor in BINARY_SENSOR_TYPES + for coordinator in hass.data[DOMAIN][entry.entry_id] + ) -class SkybellBinarySensor(SkybellDevice, BinarySensorEntity): +class SkybellBinarySensor(SkybellEntity, BinarySensorEntity): """A binary sensor implementation for Skybell devices.""" def __init__( self, - device, + coordinator: SkybellDataUpdateCoordinator, description: BinarySensorEntityDescription, - ): + ) -> None: """Initialize a binary sensor for a Skybell device.""" - super().__init__(device) - self.entity_description = description - self._attr_name = f"{self._device.name} {description.name}" - self._event: dict[Any, Any] = {} + super().__init__(coordinator, description) + self._event: dict[str, str] = {} @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str | int | tuple[str, str]]: """Return the state attributes.""" attrs = super().extra_state_attributes - - attrs["event_date"] = self._event.get("createdAt") - + if event := self._event.get(CONST.CREATED_AT): + attrs["event_date"] = event return attrs - def update(self): - """Get the latest data and updates the state.""" - super().update() - + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" event = self._device.latest(self.entity_description.key) - - self._attr_is_on = bool(event and event.get("id") != self._event.get("id")) - - self._event = event or {} + self._attr_is_on = bool(event.get(CONST.ID) != self._event.get(CONST.ID)) + self._event = event + super()._handle_coordinator_update() diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 96989fad747..f531e67f2d0 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -1,31 +1,31 @@ """Camera support for the Skybell HD Doorbell.""" from __future__ import annotations -from datetime import timedelta -import logging - -import requests import voluptuous as vol -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, + Camera, + CameraEntityDescription, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as SKYBELL_DOMAIN, SkybellDevice - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=90) - -IMAGE_AVATAR = "avatar" -IMAGE_ACTIVITY = "activity" - -CONF_ACTIVITY_NAME = "activity_name" -CONF_AVATAR_NAME = "avatar_name" +from .const import ( + CONF_ACTIVITY_NAME, + CONF_AVATAR_NAME, + DOMAIN, + IMAGE_ACTIVITY, + IMAGE_AVATAR, +) +from .coordinator import SkybellDataUpdateCoordinator +from .entity import SkybellEntity +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=[IMAGE_AVATAR]): vol.All( @@ -36,71 +36,37 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) +CAMERA_TYPES: tuple[CameraEntityDescription, ...] = ( + CameraEntityDescription(key="activity", name="Last Activity"), + CameraEntityDescription(key="avatar", name="Camera"), +) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - cond = config[CONF_MONITORED_CONDITIONS] - names = {} - names[IMAGE_ACTIVITY] = config.get(CONF_ACTIVITY_NAME) - names[IMAGE_AVATAR] = config.get(CONF_AVATAR_NAME) - skybell = hass.data[SKYBELL_DOMAIN] - - sensors = [] - for device in skybell.get_devices(): - for camera_type in cond: - sensors.append(SkybellCamera(device, camera_type, names.get(camera_type))) - - add_entities(sensors, True) + """Set up Skybell switch.""" + async_add_entities( + SkybellCamera(coordinator, description) + for description in CAMERA_TYPES + for coordinator in hass.data[DOMAIN][entry.entry_id] + ) -class SkybellCamera(SkybellDevice, Camera): +class SkybellCamera(SkybellEntity, Camera): """A camera implementation for Skybell devices.""" - def __init__(self, device, camera_type, name=None): + def __init__( + self, + coordinator: SkybellDataUpdateCoordinator, + description: EntityDescription, + ) -> None: """Initialize a camera for a Skybell device.""" - self._type = camera_type - SkybellDevice.__init__(self, device) + super().__init__(coordinator, description) Camera.__init__(self) - if name is not None: - self._name = f"{self._device.name} {name}" - else: - self._name = self._device.name - self._url = None - self._response = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def image_url(self): - """Get the camera image url based on type.""" - if self._type == IMAGE_ACTIVITY: - return self._device.activity_image - return self._device.image - - def camera_image( + async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Get the latest camera image.""" - super().update() - - if self._url != self.image_url: - self._url = self.image_url - - try: - self._response = requests.get(self._url, stream=True, timeout=10) - except requests.HTTPError as err: - _LOGGER.warning("Failed to get camera image: %s", err) - self._response = None - - if not self._response: - return None - - return self._response.content + return self._device.images[self.entity_description.key] diff --git a/homeassistant/components/skybell/config_flow.py b/homeassistant/components/skybell/config_flow.py new file mode 100644 index 00000000000..7b7b43788b3 --- /dev/null +++ b/homeassistant/components/skybell/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for Skybell integration.""" +from __future__ import annotations + +from typing import Any + +from aioskybell import Skybell, exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + + +class SkybellFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Skybell.""" + + async def async_step_import(self, user_input: ConfigType) -> FlowResult: + """Import a config entry from configuration.yaml.""" + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + return await self.async_step_user(user_input) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + email = user_input[CONF_EMAIL].lower() + password = user_input[CONF_PASSWORD] + + self._async_abort_entries_match({CONF_EMAIL: email}) + user_id, error = await self._async_validate_input(email, password) + if error is None: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=email, + data={CONF_EMAIL: email, CONF_PASSWORD: password}, + ) + errors["base"] = error + + user_input = user_input or {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL, default=user_input.get(CONF_EMAIL)): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def _async_validate_input(self, email: str, password: str) -> tuple: + """Validate login credentials.""" + skybell = Skybell( + username=email, + password=password, + disable_cache=True, + session=async_get_clientsession(self.hass), + ) + try: + await skybell.async_initialize() + except exceptions.SkybellAuthenticationException: + return None, "invalid_auth" + except exceptions.SkybellException: + return None, "cannot_connect" + except Exception: # pylint: disable=broad-except + return None, "unknown" + return skybell.user_id, None diff --git a/homeassistant/components/skybell/const.py b/homeassistant/components/skybell/const.py new file mode 100644 index 00000000000..d8f7e4992d5 --- /dev/null +++ b/homeassistant/components/skybell/const.py @@ -0,0 +1,14 @@ +"""Constants for the Skybell HD Doorbell.""" +import logging +from typing import Final + +CONF_ACTIVITY_NAME = "activity_name" +CONF_AVATAR_NAME = "avatar_name" +DEFAULT_CACHEDB = "./skybell_cache.pickle" +DEFAULT_NAME = "SkyBell" +DOMAIN: Final = "skybell" + +IMAGE_AVATAR = "avatar" +IMAGE_ACTIVITY = "activity" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/skybell/coordinator.py b/homeassistant/components/skybell/coordinator.py new file mode 100644 index 00000000000..26545609bd5 --- /dev/null +++ b/homeassistant/components/skybell/coordinator.py @@ -0,0 +1,34 @@ +"""Data update coordinator for the Skybell integration.""" + +from datetime import timedelta + +from aioskybell import SkybellDevice, SkybellException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + + +class SkybellDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for the Skybell integration.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, device: SkybellDevice) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=device.name, + update_interval=timedelta(seconds=30), + ) + self.device = device + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + try: + await self.device.async_update() + except SkybellException as err: + raise UpdateFailed(f"Failed to communicate with device: {err}") from err diff --git a/homeassistant/components/skybell/entity.py b/homeassistant/components/skybell/entity.py new file mode 100644 index 00000000000..cf728cde069 --- /dev/null +++ b/homeassistant/components/skybell/entity.py @@ -0,0 +1,65 @@ +"""Entity representing a Skybell HD Doorbell.""" +from __future__ import annotations + +from aioskybell import SkybellDevice + +from homeassistant.const import ATTR_CONNECTIONS +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import SkybellDataUpdateCoordinator + + +class SkybellEntity(CoordinatorEntity[SkybellDataUpdateCoordinator]): + """An HA implementation for Skybell entity.""" + + _attr_attribution = "Data provided by Skybell.com" + + def __init__( + self, coordinator: SkybellDataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize a SkyBell entity.""" + super().__init__(coordinator) + self.entity_description = description + if description.name != coordinator.device.name: + self._attr_name = f"{self._device.name} {description.name}" + self._attr_unique_id = f"{self._device.device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device.device_id)}, + manufacturer=DEFAULT_NAME, + model=self._device.type, + name=self._device.name, + sw_version=self._device.firmware_ver, + ) + if self._device.mac: + self._attr_device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, self._device.mac) + } + + @property + def _device(self) -> SkybellDevice: + """Return the device.""" + return self.coordinator.device + + @property + def extra_state_attributes(self) -> dict[str, str | int | tuple[str, str]]: + """Return the state attributes.""" + attr: dict[str, str | int | tuple[str, str]] = { + "device_id": self._device.device_id, + "status": self._device.status, + "location": self._device.location, + "motion_threshold": self._device.motion_threshold, + "video_profile": self._device.video_profile, + } + if self._device.owner: + attr["wifi_ssid"] = self._device.wifi_ssid + attr["wifi_status"] = self._device.wifi_status + attr["last_check_in"] = self._device.last_check_in + return attr + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/skybell/light.py b/homeassistant/components/skybell/light.py index 7fbd1519e26..845be44a34b 100644 --- a/homeassistant/components/skybell/light.py +++ b/homeassistant/components/skybell/light.py @@ -1,82 +1,63 @@ """Light/LED support for the Skybell HD Doorbell.""" from __future__ import annotations +from typing import Any + from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_HS_COLOR, + ATTR_RGB_COLOR, ColorMode, LightEntity, + LightEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util -from . import DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from .const import DOMAIN +from .entity import SkybellEntity -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - sensors = [] - for device in skybell.get_devices(): - sensors.append(SkybellLight(device)) - - add_entities(sensors, True) + """Set up Skybell switch.""" + async_add_entities( + SkybellLight( + coordinator, + LightEntityDescription( + key=coordinator.device.name, + name=coordinator.device.name, + ), + ) + for coordinator in hass.data[DOMAIN][entry.entry_id] + ) -def _to_skybell_level(level): - """Convert the given Home Assistant light level (0-255) to Skybell (0-100).""" - return int((level * 100) / 255) +class SkybellLight(SkybellEntity, LightEntity): + """A light implementation for Skybell devices.""" + _attr_supported_color_modes = {ColorMode.BRIGHTNESS, ColorMode.RGB} -def _to_hass_level(level): - """Convert the given Skybell (0-100) light level to Home Assistant (0-255).""" - return int((level * 255) / 100) - - -class SkybellLight(SkybellDevice, LightEntity): - """A binary sensor implementation for Skybell devices.""" - - _attr_color_mode = ColorMode.HS - _attr_supported_color_modes = {ColorMode.HS} - - def __init__(self, device): - """Initialize a light for a Skybell device.""" - super().__init__(device) - self._attr_name = device.name - - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - if ATTR_HS_COLOR in kwargs: - rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) - self._device.led_rgb = rgb - elif ATTR_BRIGHTNESS in kwargs: - self._device.led_intensity = _to_skybell_level(kwargs[ATTR_BRIGHTNESS]) - else: - self._device.led_intensity = _to_skybell_level(255) + if ATTR_RGB_COLOR in kwargs: + rgb = kwargs[ATTR_RGB_COLOR] + await self._device.async_set_setting(ATTR_RGB_COLOR, rgb) + if ATTR_BRIGHTNESS in kwargs: + level = int((kwargs.get(ATTR_BRIGHTNESS, 0) * 100) / 255) + await self._device.async_set_setting(ATTR_BRIGHTNESS, level) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - self._device.led_intensity = 0 + await self._device.async_set_setting(ATTR_BRIGHTNESS, 0) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.led_intensity > 0 @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the light.""" - return _to_hass_level(self._device.led_intensity) - - @property - def hs_color(self): - """Return the color of the light.""" - return color_util.color_RGB_to_hs(*self._device.led_rgb) + return int((self._device.led_intensity * 255) / 100) diff --git a/homeassistant/components/skybell/manifest.json b/homeassistant/components/skybell/manifest.json index ce166179969..335ff2615f8 100644 --- a/homeassistant/components/skybell/manifest.json +++ b/homeassistant/components/skybell/manifest.json @@ -1,9 +1,10 @@ { "domain": "skybell", "name": "SkyBell", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/skybell", - "requirements": ["skybellpy==0.6.3"], - "codeowners": [], + "requirements": ["aioskybell==22.3.0"], + "codeowners": ["@tkdrob"], "iot_class": "cloud_polling", - "loggers": ["skybellpy"] + "loggers": ["aioskybell"] } diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 5922bb05382..c769570b10c 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -1,8 +1,6 @@ """Sensor support for Skybell Doorbells.""" from __future__ import annotations -from datetime import timedelta - import voluptuous as vol from homeassistant.components.sensor import ( @@ -10,15 +8,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice - -SCAN_INTERVAL = timedelta(seconds=30) +from .entity import DOMAIN, SkybellEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -27,14 +23,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( icon="mdi:bell-ring", ), ) -MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] +MONITORED_CONDITIONS = SENSOR_TYPES +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional( - CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE - ): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DOMAIN): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( cv.ensure_list, [vol.In(MONITORED_CONDITIONS)] ), @@ -42,41 +37,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - sensors = [ - SkybellSensor(device, description) - for device in skybell.get_devices() + """Set up Skybell sensor.""" + async_add_entities( + SkybellSensor(coordinator, description) + for coordinator in hass.data[DOMAIN][entry.entry_id] for description in SENSOR_TYPES - if description.key in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(sensors, True) + ) -class SkybellSensor(SkybellDevice, SensorEntity): +class SkybellSensor(SkybellEntity, SensorEntity): """A sensor implementation for Skybell devices.""" - def __init__( - self, - device, - description: SensorEntityDescription, - ): - """Initialize a sensor for a Skybell device.""" - super().__init__(device) - self.entity_description = description - self._attr_name = f"{self._device.name} {description.name}" - - def update(self): - """Get the latest data and updates the state.""" - super().update() - - if self.entity_description.key == "chime_level": - self._attr_native_value = self._device.outdoor_chime_level + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + return self._device.outdoor_chime_level diff --git a/homeassistant/components/skybell/strings.json b/homeassistant/components/skybell/strings.json new file mode 100644 index 00000000000..e48a75c12bd --- /dev/null +++ b/homeassistant/components/skybell/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index 2873ad2c081..d28369e40b0 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -1,6 +1,8 @@ """Switch support for the Skybell HD Doorbell.""" from __future__ import annotations +from typing import Any, cast + import voluptuous as vol from homeassistant.components.switch import ( @@ -8,13 +10,14 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from .const import DOMAIN +from .entity import SkybellEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -26,62 +29,41 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( name="Motion Sensor", ), ) -MONITORED_CONDITIONS: list[str] = [desc.key for desc in SWITCH_TYPES] - +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional( - CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE - ): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DOMAIN): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(MONITORED_CONDITIONS)] + cv.ensure_list, [vol.In(SWITCH_TYPES)] ), } ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - switches = [ - SkybellSwitch(device, description) - for device in skybell.get_devices() + """Set up the SkyBell switch.""" + async_add_entities( + SkybellSwitch(coordinator, description) + for coordinator in hass.data[DOMAIN][entry.entry_id] for description in SWITCH_TYPES - if description.key in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(switches, True) + ) -class SkybellSwitch(SkybellDevice, SwitchEntity): +class SkybellSwitch(SkybellEntity, SwitchEntity): """A switch implementation for Skybell devices.""" - def __init__( - self, - device, - description: SwitchEntityDescription, - ): - """Initialize a light for a Skybell device.""" - super().__init__(device) - self.entity_description = description - self._attr_name = f"{self._device.name} {description.name}" - - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - setattr(self._device, self.entity_description.key, True) + await self._device.async_set_setting(self.entity_description.key, True) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - setattr(self._device, self.entity_description.key, False) + await self._device.async_set_setting(self.entity_description.key, False) @property - def is_on(self): - """Return true if device is on.""" - return getattr(self._device, self.entity_description.key) + def is_on(self) -> bool: + """Return true if entity is on.""" + return cast(bool, getattr(self._device, self.entity_description.key)) diff --git a/homeassistant/components/skybell/translations/en.json b/homeassistant/components/skybell/translations/en.json new file mode 100644 index 00000000000..b84c4ebc999 --- /dev/null +++ b/homeassistant/components/skybell/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid authentication", + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e1e2938c9ff..06da1eae90c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -311,6 +311,7 @@ FLOWS = { "shopping_list", "sia", "simplisafe", + "skybell", "slack", "sleepiq", "slimproto", diff --git a/requirements_all.txt b/requirements_all.txt index 6b8563f0510..e74f72d75dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -240,6 +240,9 @@ aiosenz==1.0.0 # homeassistant.components.shelly aioshelly==2.0.0 +# homeassistant.components.skybell +aioskybell==22.3.0 + # homeassistant.components.slimproto aioslimproto==2.0.1 @@ -2173,9 +2176,6 @@ simplisafe-python==2022.05.2 # homeassistant.components.sisyphus sisyphus-control==3.1.2 -# homeassistant.components.skybell -skybellpy==0.6.3 - # homeassistant.components.slack slackclient==2.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6cfd2352b8b..f68ea51ad97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -209,6 +209,9 @@ aiosenz==1.0.0 # homeassistant.components.shelly aioshelly==2.0.0 +# homeassistant.components.skybell +aioskybell==22.3.0 + # homeassistant.components.slimproto aioslimproto==2.0.1 diff --git a/tests/components/skybell/__init__.py b/tests/components/skybell/__init__.py new file mode 100644 index 00000000000..dd162ed5d80 --- /dev/null +++ b/tests/components/skybell/__init__.py @@ -0,0 +1,30 @@ +"""Tests for the SkyBell integration.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +USERNAME = "user" +PASSWORD = "password" +USER_ID = "123456789012345678901234" + +CONF_CONFIG_FLOW = { + CONF_EMAIL: USERNAME, + CONF_PASSWORD: PASSWORD, +} + + +def _patch_skybell_devices() -> None: + mocked_skybell = AsyncMock() + mocked_skybell.user_id = USER_ID + return patch( + "homeassistant.components.skybell.config_flow.Skybell.async_get_devices", + return_value=[mocked_skybell], + ) + + +def _patch_skybell() -> None: + return patch( + "homeassistant.components.skybell.config_flow.Skybell.async_send_request", + return_value={"id": USER_ID}, + ) diff --git a/tests/components/skybell/test_config_flow.py b/tests/components/skybell/test_config_flow.py new file mode 100644 index 00000000000..0171a522e50 --- /dev/null +++ b/tests/components/skybell/test_config_flow.py @@ -0,0 +1,137 @@ +"""Test SkyBell config flow.""" +from unittest.mock import patch + +from aioskybell import exceptions + +from homeassistant.components.skybell.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import CONF_CONFIG_FLOW, _patch_skybell, _patch_skybell_devices + +from tests.common import MockConfigEntry + + +def _patch_setup_entry() -> None: + return patch( + "homeassistant.components.skybell.async_setup_entry", + return_value=True, + ) + + +def _patch_setup() -> None: + return patch( + "homeassistant.components.skybell.async_setup", + return_value=True, + ) + + +async def test_flow_user(hass: HomeAssistant) -> None: + """Test that the user step works.""" + with _patch_skybell(), _patch_skybell_devices(), _patch_setup_entry(), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_CONFIG_FLOW, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user" + assert result["data"] == CONF_CONFIG_FLOW + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_CONFIG_FLOW, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: + """Test user initialized flow with unreachable server.""" + with _patch_skybell() as skybell_mock: + skybell_mock.side_effect = exceptions.SkybellException(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_invalid_credentials(hass: HomeAssistant) -> None: + """Test that invalid credentials throws an error.""" + with patch("homeassistant.components.skybell.Skybell.async_login") as skybell_mock: + skybell_mock.side_effect = exceptions.SkybellAuthenticationException(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: + """Test user initialized flow with unreachable server.""" + with _patch_skybell_devices() as skybell_mock: + skybell_mock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test import step.""" + with _patch_skybell(), _patch_skybell_devices(), _patch_setup_entry(), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_CONFIG_FLOW, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user" + assert result["data"] == CONF_CONFIG_FLOW + + +async def test_flow_import_already_configured(hass: HomeAssistant) -> None: + """Test import step already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id="123456789012345678901234", data=CONF_CONFIG_FLOW + ) + + entry.add_to_hass(hass) + + with _patch_skybell(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured"