diff --git a/.coveragerc b/.coveragerc index ef1a2adb712..8d2cdd32336 100644 --- a/.coveragerc +++ b/.coveragerc @@ -262,7 +262,9 @@ omit = homeassistant/components/eddystone_temperature/sensor.py homeassistant/components/edimax/switch.py homeassistant/components/egardia/* - homeassistant/components/eight_sleep/* + homeassistant/components/eight_sleep/__init__.py + homeassistant/components/eight_sleep/binary_sensor.py + homeassistant/components/eight_sleep/sensor.py homeassistant/components/eliqonline/sensor.py homeassistant/components/elkm1/__init__.py homeassistant/components/elkm1/alarm_control_panel.py diff --git a/CODEOWNERS b/CODEOWNERS index 9d0ba851339..9a57f3e791a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -273,6 +273,7 @@ build.json @home-assistant/supervisor /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/eight_sleep/ @mezz64 @raman325 +/tests/components/eight_sleep/ @mezz64 @raman325 /homeassistant/components/elgato/ @frenck /tests/components/elgato/ @frenck /homeassistant/components/elkm1/ @gwww @bdraco diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 1bf22defd74..5cd7bec9244 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -1,34 +1,38 @@ """Support for Eight smart mattress covers and mattresses.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from pyeight.eight import EightSleep +from pyeight.exceptions import RequestError from pyeight.user import EightUser import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + ATTR_HW_VERSION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import UNDEFINED, ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import ( - ATTR_HEAT_DURATION, - ATTR_TARGET_HEAT, - DATA_API, - DATA_HEAT, - DATA_USER, - DOMAIN, - NAME_MAP, - SERVICE_HEAT_SET, -) +from .const import DOMAIN, NAME_MAP _LOGGER = logging.getLogger(__name__) @@ -37,17 +41,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] HEAT_SCAN_INTERVAL = timedelta(seconds=60) USER_SCAN_INTERVAL = timedelta(seconds=300) -VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) -VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) - -SERVICE_EIGHT_SCHEMA = vol.Schema( - { - ATTR_ENTITY_ID: cv.entity_ids, - ATTR_TARGET_HEAT: VALID_TARGET_HEAT, - ATTR_HEAT_DURATION: VALID_DURATION, - } -) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -61,6 +54,15 @@ CONFIG_SCHEMA = vol.Schema( ) +@dataclass +class EightSleepConfigEntryData: + """Data used for all entities for a given config entry.""" + + api: EightSleep + heat_coordinator: DataUpdateCoordinator + user_coordinator: DataUpdateCoordinator + + def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str: """Get the device's unique ID.""" unique_id = eight.device_id @@ -71,23 +73,36 @@ def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Eight Sleep component.""" + """Old set up method for the Eight Sleep component.""" + if DOMAIN in config: + _LOGGER.warning( + "Your Eight Sleep configuration has been imported into the UI; " + "please remove it from configuration.yaml as support for it " + "will be removed in a future release" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) + ) - if DOMAIN not in config: - return True + return True - conf = config[DOMAIN] - user = conf[CONF_USERNAME] - password = conf[CONF_PASSWORD] +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Eight Sleep config entry.""" eight = EightSleep( - user, password, hass.config.time_zone, async_get_clientsession(hass) + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + hass.config.time_zone, + async_get_clientsession(hass), ) - hass.data.setdefault(DOMAIN, {}) - # Authenticate, build sensors - success = await eight.start() + try: + success = await eight.start() + except RequestError as err: + raise ConfigEntryNotReady from err if not success: # Authentication failed, cannot continue return False @@ -113,47 +128,60 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # No users, cannot continue return False - hass.data[DOMAIN] = { - DATA_API: eight, - DATA_HEAT: heat_coordinator, - DATA_USER: user_coordinator, + dev_reg = async_get(hass) + assert eight.device_data + device_data = { + ATTR_MANUFACTURER: "Eight Sleep", + ATTR_MODEL: eight.device_data.get("modelString", UNDEFINED), + ATTR_HW_VERSION: eight.device_data.get("sensorInfo", {}).get( + "hwRevision", UNDEFINED + ), + ATTR_SW_VERSION: eight.device_data.get("firmwareVersion", UNDEFINED), } - - for platform in PLATFORMS: - hass.async_create_task( - discovery.async_load_platform(hass, platform, DOMAIN, {}, config) + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, _get_device_unique_id(eight))}, + name=f"{entry.data[CONF_USERNAME]}'s Eight Sleep", + **device_data, + ) + for user in eight.users.values(): + assert user.user_profile + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, _get_device_unique_id(eight, user))}, + name=f"{user.user_profile['firstName']}'s Eight Sleep Side", + via_device=(DOMAIN, _get_device_unique_id(eight)), + **device_data, ) - async def async_service_handler(service: ServiceCall) -> None: - """Handle eight sleep service calls.""" - params = service.data.copy() - - sensor = params.pop(ATTR_ENTITY_ID, None) - target = params.pop(ATTR_TARGET_HEAT, None) - duration = params.pop(ATTR_HEAT_DURATION, 0) - - for sens in sensor: - side = sens.split("_")[1] - user_id = eight.fetch_user_id(side) - assert user_id - usr_obj = eight.users[user_id] - await usr_obj.set_heating_level(target, duration) - - await heat_coordinator.async_request_refresh() - - # Register services - hass.services.async_register( - DOMAIN, SERVICE_HEAT_SET, async_service_handler, schema=SERVICE_EIGHT_SCHEMA + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EightSleepConfigEntryData( + eight, heat_coordinator, user_coordinator ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + 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): + # stop the API before unloading everything + config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] + await config_entry_data.api.stop() + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok + + class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): """The base Eight Sleep entity class.""" def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str | None, @@ -161,6 +189,7 @@ class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): ) -> None: """Initialize the data object.""" super().__init__(coordinator) + self._config_entry = entry self._eight = eight self._user_id = user_id self._sensor = sensor @@ -170,9 +199,25 @@ class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): mapped_name = NAME_MAP.get(sensor, sensor.replace("_", " ").title()) if self._user_obj is not None: - mapped_name = f"{self._user_obj.side.title()} {mapped_name}" + assert self._user_obj.user_profile + name = f"{self._user_obj.user_profile['firstName']}'s {mapped_name}" + self._attr_name = name + else: + self._attr_name = f"Eight Sleep {mapped_name}" + unique_id = f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}" + self._attr_unique_id = unique_id + identifiers = {(DOMAIN, _get_device_unique_id(eight, self._user_obj))} + self._attr_device_info = DeviceInfo(identifiers=identifiers) - self._attr_name = f"Eight {mapped_name}" - self._attr_unique_id = ( - f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}" - ) + async def async_heat_set(self, target: int, duration: int) -> None: + """Handle eight sleep service calls.""" + if self._user_obj is None: + raise HomeAssistantError( + "This entity does not support the heat set service." + ) + + await self._user_obj.set_heating_level(target, duration) + config_entry_data: EightSleepConfigEntryData = self.hass.data[DOMAIN][ + self._config_entry.entry_id + ] + await config_entry_data.heat_coordinator.async_request_refresh() diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py index 94ec423390f..7ad1b882008 100644 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ b/homeassistant/components/eight_sleep/binary_sensor.py @@ -9,37 +9,30 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +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 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import EightSleepBaseEntity -from .const import DATA_API, DATA_HEAT, DOMAIN +from . import EightSleepBaseEntity, EightSleepConfigEntryData +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +BINARY_SENSORS = ["bed_presence"] -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_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 eight sleep binary sensor.""" - if discovery_info is None: - return - - eight: EightSleep = hass.data[DOMAIN][DATA_API] - heat_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][DATA_HEAT] - - entities = [] - for user in eight.users.values(): - entities.append( - EightHeatSensor(heat_coordinator, eight, user.user_id, "bed_presence") - ) - - async_add_entities(entities) + config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] + eight = config_entry_data.api + heat_coordinator = config_entry_data.heat_coordinator + async_add_entities( + EightHeatSensor(entry, heat_coordinator, eight, user.user_id, binary_sensor) + for user in eight.users.values() + for binary_sensor in BINARY_SENSORS + ) class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): @@ -49,13 +42,14 @@ class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str | None, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, user_id, sensor) + super().__init__(entry, coordinator, eight, user_id, sensor) assert self._user_obj _LOGGER.debug( "Presence Sensor: %s, Side: %s, User: %s", diff --git a/homeassistant/components/eight_sleep/config_flow.py b/homeassistant/components/eight_sleep/config_flow.py new file mode 100644 index 00000000000..504fbeb2817 --- /dev/null +++ b/homeassistant/components/eight_sleep/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for Eight Sleep integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyeight.eight import EightSleep +from pyeight.exceptions import RequestError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Eight Sleep.""" + + VERSION = 1 + + async def _validate_data(self, config: dict[str, str]) -> str | None: + """Validate input data and return any error.""" + await self.async_set_unique_id(config[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + + eight = EightSleep( + config[CONF_USERNAME], + config[CONF_PASSWORD], + self.hass.config.time_zone, + client_session=async_get_clientsession(self.hass), + ) + + try: + await eight.fetch_token() + except RequestError as err: + return str(err) + + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + if (err := await self._validate_data(user_input)) is not None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "cannot_connect"}, + description_placeholders={"error": err}, + ) + + return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + + async def async_step_import(self, import_config: dict) -> FlowResult: + """Handle import.""" + if (err := await self._validate_data(import_config)) is not None: + _LOGGER.error("Unable to import configuration.yaml configuration: %s", err) + return self.async_abort( + reason="cannot_connect", description_placeholders={"error": err} + ) + + return self.async_create_entry( + title=import_config[CONF_USERNAME], data=import_config + ) diff --git a/homeassistant/components/eight_sleep/const.py b/homeassistant/components/eight_sleep/const.py index 42a9eea590e..23689066665 100644 --- a/homeassistant/components/eight_sleep/const.py +++ b/homeassistant/components/eight_sleep/const.py @@ -1,7 +1,4 @@ """Eight Sleep constants.""" -DATA_HEAT = "heat" -DATA_USER = "user" -DATA_API = "api" DOMAIN = "eight_sleep" HEAT_ENTITY = "heat" @@ -15,5 +12,5 @@ NAME_MAP = { SERVICE_HEAT_SET = "heat_set" -ATTR_TARGET_HEAT = "target" -ATTR_HEAT_DURATION = "duration" +ATTR_TARGET = "target" +ATTR_DURATION = "duration" diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index e83b2977b77..c1833b222df 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -5,5 +5,6 @@ "requirements": ["pyeight==0.3.0"], "codeowners": ["@mezz64", "@raman325"], "iot_class": "cloud_polling", - "loggers": ["pyeight"] + "loggers": ["pyeight"], + "config_flow": true } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index b2afa496149..b184cd2496f 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -5,16 +5,17 @@ import logging from typing import Any from pyeight.eight import EightSleep +import voluptuous as vol from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers import entity_platform as ep from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import EightSleepBaseEntity -from .const import DATA_API, DATA_HEAT, DATA_USER, DOMAIN +from . import EightSleepBaseEntity, EightSleepConfigEntryData +from .const import ATTR_DURATION, ATTR_TARGET, DOMAIN, SERVICE_HEAT_SET ATTR_ROOM_TEMP = "Room Temperature" ATTR_AVG_ROOM_TEMP = "Average Room Temperature" @@ -53,37 +54,50 @@ EIGHT_USER_SENSORS = [ EIGHT_HEAT_SENSORS = ["bed_state"] EIGHT_ROOM_SENSORS = ["room_temperature"] +VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) +VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +SERVICE_EIGHT_SCHEMA = { + ATTR_TARGET: VALID_TARGET_HEAT, + ATTR_DURATION: VALID_DURATION, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: ep.AddEntitiesCallback ) -> None: """Set up the eight sleep sensors.""" - if discovery_info is None: - return - - eight: EightSleep = hass.data[DOMAIN][DATA_API] - heat_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][DATA_HEAT] - user_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][DATA_USER] + config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] + eight = config_entry_data.api + heat_coordinator = config_entry_data.heat_coordinator + user_coordinator = config_entry_data.user_coordinator all_sensors: list[SensorEntity] = [] for obj in eight.users.values(): - for sensor in EIGHT_USER_SENSORS: - all_sensors.append( - EightUserSensor(user_coordinator, eight, obj.user_id, sensor) - ) - for sensor in EIGHT_HEAT_SENSORS: - all_sensors.append( - EightHeatSensor(heat_coordinator, eight, obj.user_id, sensor) - ) - for sensor in EIGHT_ROOM_SENSORS: - all_sensors.append(EightRoomSensor(user_coordinator, eight, sensor)) + all_sensors.extend( + EightUserSensor(entry, user_coordinator, eight, obj.user_id, sensor) + for sensor in EIGHT_USER_SENSORS + ) + all_sensors.extend( + EightHeatSensor(entry, heat_coordinator, eight, obj.user_id, sensor) + for sensor in EIGHT_HEAT_SENSORS + ) + + all_sensors.extend( + EightRoomSensor(entry, user_coordinator, eight, sensor) + for sensor in EIGHT_ROOM_SENSORS + ) async_add_entities(all_sensors) + platform = ep.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_HEAT_SET, + SERVICE_EIGHT_SCHEMA, + "async_heat_set", + ) + class EightHeatSensor(EightSleepBaseEntity, SensorEntity): """Representation of an eight sleep heat-based sensor.""" @@ -92,13 +106,14 @@ class EightHeatSensor(EightSleepBaseEntity, SensorEntity): def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, user_id, sensor) + super().__init__(entry, coordinator, eight, user_id, sensor) assert self._user_obj _LOGGER.debug( @@ -147,13 +162,14 @@ class EightUserSensor(EightSleepBaseEntity, SensorEntity): def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, user_id, sensor) + super().__init__(entry, coordinator, eight, user_id, sensor) assert self._user_obj if self._sensor == "bed_temperature": @@ -260,12 +276,13 @@ class EightRoomSensor(EightSleepBaseEntity, SensorEntity): def __init__( self, + entry, coordinator: DataUpdateCoordinator, eight: EightSleep, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, None, sensor) + super().__init__(entry, coordinator, eight, None, sensor) @property def native_value(self) -> int | float | None: diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml index de864afc160..39b960a6f7c 100644 --- a/homeassistant/components/eight_sleep/services.yaml +++ b/homeassistant/components/eight_sleep/services.yaml @@ -1,6 +1,10 @@ heat_set: name: Heat set description: Set heating/cooling level for eight sleep. + target: + entity: + integration: eight_sleep + domain: sensor fields: duration: name: Duration @@ -11,14 +15,6 @@ heat_set: min: 0 max: 28800 unit_of_measurement: seconds - entity_id: - name: Entity - description: Entity id of the bed state to adjust. - required: true - selector: - entity: - integration: eight_sleep - domain: sensor target: name: Target description: Target cooling/heating level from -100 to 100. diff --git a/homeassistant/components/eight_sleep/strings.json b/homeassistant/components/eight_sleep/strings.json new file mode 100644 index 00000000000..21accc53a06 --- /dev/null +++ b/homeassistant/components/eight_sleep/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" + } + } +} diff --git a/homeassistant/components/eight_sleep/translations/en.json b/homeassistant/components/eight_sleep/translations/en.json new file mode 100644 index 00000000000..dfd604a6c08 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect: {error}" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1cf8ba743bc..a50fc85a9f0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -87,6 +87,7 @@ FLOWS = { "ecobee", "econet", "efergy", + "eight_sleep", "elgato", "elkm1", "elmax", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e247bf42d82..08185c36295 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -979,6 +979,9 @@ pyeconet==0.1.15 # homeassistant.components.efergy pyefergy==22.1.1 +# homeassistant.components.eight_sleep +pyeight==0.3.0 + # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/eight_sleep/__init__.py b/tests/components/eight_sleep/__init__.py new file mode 100644 index 00000000000..22348f774be --- /dev/null +++ b/tests/components/eight_sleep/__init__.py @@ -0,0 +1 @@ +"""Tests for the Eight Sleep integration.""" diff --git a/tests/components/eight_sleep/conftest.py b/tests/components/eight_sleep/conftest.py new file mode 100644 index 00000000000..753fe1e30d5 --- /dev/null +++ b/tests/components/eight_sleep/conftest.py @@ -0,0 +1,29 @@ +"""Fixtures for Eight Sleep.""" +from unittest.mock import patch + +from pyeight.exceptions import RequestError +import pytest + + +@pytest.fixture(name="bypass", autouse=True) +def bypass_fixture(): + """Bypasses things that slow te tests down or block them from testing the behavior.""" + with patch( + "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", + ), patch( + "homeassistant.components.eight_sleep.config_flow.EightSleep.at_exit", + ), patch( + "homeassistant.components.eight_sleep.async_setup_entry", + return_value=True, + ): + yield + + +@pytest.fixture(name="token_error") +def token_error_fixture(): + """Simulate error when fetching token.""" + with patch( + "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", + side_effect=RequestError, + ): + yield diff --git a/tests/components/eight_sleep/test_config_flow.py b/tests/components/eight_sleep/test_config_flow.py new file mode 100644 index 00000000000..8015fb6c69d --- /dev/null +++ b/tests/components/eight_sleep/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the Eight Sleep config flow.""" +from homeassistant import config_entries +from homeassistant.components.eight_sleep.const import DOMAIN +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + + +async def test_form(hass) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + + +async def test_form_invalid_auth(hass, token_error) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "bad-username", + "password": "bad-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_import(hass) -> None: + """Test import works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + }, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + } + + +async def test_import_invalid_auth(hass, token_error) -> None: + """Test we handle invalid auth on import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "bad-username", + "password": "bad-password", + }, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect"