From cfeb8eb06a13e0a01289e383416e8ce6484f8be9 Mon Sep 17 00:00:00 2001 From: Khole Date: Mon, 15 Mar 2021 11:27:10 +0000 Subject: [PATCH] Add Hive config flow (#47300) * Add Hive UI * Fix tests and review updates * Slimmed down config_flow * Fix tests * Updated Services.yaml with extra ui attributes * cleanup config flow * Update config entry * Remove ATTR_AVAILABLE * Fix Re-Auth Test * Added more tests. * Update tests --- .coveragerc | 8 +- homeassistant/components/hive/__init__.py | 181 +++--- .../components/hive/binary_sensor.py | 25 +- homeassistant/components/hive/climate.py | 61 +- homeassistant/components/hive/config_flow.py | 171 ++++++ homeassistant/components/hive/const.py | 20 + homeassistant/components/hive/light.py | 29 +- homeassistant/components/hive/manifest.json | 3 +- homeassistant/components/hive/sensor.py | 32 +- homeassistant/components/hive/services.yaml | 54 +- homeassistant/components/hive/strings.json | 53 ++ homeassistant/components/hive/switch.py | 32 +- .../components/hive/translations/en.json | 53 ++ homeassistant/components/hive/water_heater.py | 62 +- homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/hive/test_config_flow.py | 576 ++++++++++++++++++ 18 files changed, 1165 insertions(+), 201 deletions(-) create mode 100644 homeassistant/components/hive/config_flow.py create mode 100644 homeassistant/components/hive/const.py create mode 100644 homeassistant/components/hive/strings.json create mode 100644 homeassistant/components/hive/translations/en.json create mode 100644 tests/components/hive/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index db940ed642b..72d6e1e9293 100644 --- a/.coveragerc +++ b/.coveragerc @@ -386,7 +386,13 @@ omit = homeassistant/components/hikvisioncam/switch.py homeassistant/components/hisense_aehw4a1/* homeassistant/components/hitron_coda/device_tracker.py - homeassistant/components/hive/* + homeassistant/components/hive/__init__.py + homeassistant/components/hive/climate.py + homeassistant/components/hive/binary_sensor.py + homeassistant/components/hive/light.py + homeassistant/components/hive/sensor.py + homeassistant/components/hive/switch.py + homeassistant/components/hive/water_heater.py homeassistant/components/hlk_sw16/__init__.py homeassistant/components/hlk_sw16/switch.py homeassistant/components/home_connect/* diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 331ab37224f..040ef7b4674 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -1,43 +1,26 @@ """Support for the Hive devices and services.""" +import asyncio from functools import wraps import logging -from pyhiveapi import Hive +from aiohttp.web_exceptions import HTTPException +from apyhiveapi import Hive +from apyhiveapi.helper.hive_exceptions import HiveReauthRequired import voluptuous as vol -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_TEMPERATURE, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import Entity -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS -ATTR_AVAILABLE = "available" -DOMAIN = "hive" -DATA_HIVE = "data_hive" -SERVICES = ["Heating", "HotWater", "TRV"] -SERVICE_BOOST_HOT_WATER = "boost_hot_water" -SERVICE_BOOST_HEATING = "boost_heating" -ATTR_TIME_PERIOD = "time_period" -ATTR_MODE = "on_off" -DEVICETYPES = { - "binary_sensor": "device_list_binary_sensor", - "climate": "device_list_climate", - "water_heater": "device_list_water_heater", - "light": "device_list_light", - "switch": "device_list_plug", - "sensor": "device_list_sensor", -} +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { @@ -52,101 +35,88 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -BOOST_HEATING_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_TIME_PERIOD): vol.All( - cv.time_period, cv.positive_timedelta, lambda td: td.total_seconds() // 60 - ), - vol.Optional(ATTR_TEMPERATURE, default="25.0"): vol.Coerce(float), - } -) - -BOOST_HOT_WATER_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Optional(ATTR_TIME_PERIOD, default="00:30:00"): vol.All( - cv.time_period, cv.positive_timedelta, lambda td: td.total_seconds() // 60 - ), - vol.Required(ATTR_MODE): cv.string, - } -) - async def async_setup(hass, config): - """Set up the Hive Component.""" + """Hive configuration setup.""" + hass.data[DOMAIN] = {} - async def heating_boost(service): - """Handle the service call.""" + if DOMAIN not in config: + return True - entity_lookup = hass.data[DOMAIN]["entity_lookup"] - hive_id = entity_lookup.get(service.data[ATTR_ENTITY_ID]) - if not hive_id: - # log or raise error - _LOGGER.error("Cannot boost entity id entered") - return + conf = config[DOMAIN] - minutes = service.data[ATTR_TIME_PERIOD] - temperature = service.data[ATTR_TEMPERATURE] + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_USERNAME: conf[CONF_USERNAME], + CONF_PASSWORD: conf[CONF_PASSWORD], + }, + ) + ) + return True - hive.heating.turn_boost_on(hive_id, minutes, temperature) - async def hot_water_boost(service): - """Handle the service call.""" - entity_lookup = hass.data[DOMAIN]["entity_lookup"] - hive_id = entity_lookup.get(service.data[ATTR_ENTITY_ID]) - if not hive_id: - # log or raise error - _LOGGER.error("Cannot boost entity id entered") - return - minutes = service.data[ATTR_TIME_PERIOD] - mode = service.data[ATTR_MODE] +async def async_setup_entry(hass, entry): + """Set up Hive from a config entry.""" - if mode == "on": - hive.hotwater.turn_boost_on(hive_id, minutes) - elif mode == "off": - hive.hotwater.turn_boost_off(hive_id) + websession = aiohttp_client.async_get_clientsession(hass) + hive = Hive(websession) + hive_config = dict(entry.data) - hive = Hive() + hive_config["options"] = {} + hive_config["options"].update( + {CONF_SCAN_INTERVAL: dict(entry.options).get(CONF_SCAN_INTERVAL, 120)} + ) + hass.data[DOMAIN][entry.entry_id] = hive - config = {} - config["username"] = config[DOMAIN][CONF_USERNAME] - config["password"] = config[DOMAIN][CONF_PASSWORD] - config["update_interval"] = config[DOMAIN][CONF_SCAN_INTERVAL] - - devices = await hive.session.startSession(config) - - if devices is None: - _LOGGER.error("Hive API initialization failed") + try: + devices = await hive.session.startSession(hive_config) + except HTTPException as error: + _LOGGER.error("Could not connect to the internet: %s", error) + raise ConfigEntryNotReady() from error + except HiveReauthRequired: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + ) return False - hass.data[DOMAIN][DATA_HIVE] = hive - hass.data[DOMAIN]["entity_lookup"] = {} - - for ha_type in DEVICETYPES: - devicelist = devices.get(DEVICETYPES[ha_type]) - if devicelist: + for ha_type, hive_type in PLATFORM_LOOKUP.items(): + device_list = devices.get(hive_type) + if device_list: hass.async_create_task( - async_load_platform(hass, ha_type, DOMAIN, devicelist, config) + hass.config_entries.async_forward_entry_setup(entry, ha_type) ) - if ha_type == "climate": - hass.services.async_register( - DOMAIN, - SERVICE_BOOST_HEATING, - heating_boost, - schema=BOOST_HEATING_SCHEMA, - ) - if ha_type == "water_heater": - hass.services.async_register( - DOMAIN, - SERVICE_BOOST_HOT_WATER, - hot_water_boost, - schema=BOOST_HOT_WATER_SCHEMA, - ) return True +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + def refresh_system(func): """Force update all entities after state change.""" @@ -173,6 +143,3 @@ class HiveEntity(Entity): self.async_on_remove( async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) ) - if self.device["hiveType"] in SERVICES: - entity_lookup = self.hass.data[DOMAIN]["entity_lookup"] - entity_lookup[self.entity_id] = self.device["hiveID"] diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index eed08c45b3a..d5f1ca53afd 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -10,7 +10,8 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) -from . import ATTR_AVAILABLE, ATTR_MODE, DATA_HIVE, DOMAIN, HiveEntity +from . import HiveEntity +from .const import ATTR_MODE, DOMAIN DEVICETYPE = { "contactsensor": DEVICE_CLASS_OPENING, @@ -24,13 +25,11 @@ PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Hive Binary Sensor.""" - if discovery_info is None: - return +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN].get(DATA_HIVE) - devices = hive.devices.get("binary_sensor") + hive = hass.data[DOMAIN][entry.entry_id] + devices = hive.session.deviceList.get("binary_sensor") entities = [] if devices: for dev in devices: @@ -49,7 +48,14 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): @property def device_info(self): """Return device information.""" - return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + return { + "identifiers": {(DOMAIN, self.device["device_id"])}, + "name": self.device["device_name"], + "model": self.device["deviceData"]["model"], + "manufacturer": self.device["deviceData"]["manufacturer"], + "sw_version": self.device["deviceData"]["version"], + "via_device": (DOMAIN, self.device["parentDevice"]), + } @property def device_class(self): @@ -72,7 +78,6 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): def extra_state_attributes(self): """Show Device Attributes.""" return { - ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE), ATTR_MODE: self.attributes.get(ATTR_MODE), } @@ -84,5 +89,5 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) - self.device = await self.hive.sensor.get_sensor(self.device) + self.device = await self.hive.sensor.getSensor(self.device) self.attributes = self.device.get("attributes", {}) diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index c0b33dbb3ae..31b4bd273ad 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -1,6 +1,8 @@ """Support for the Hive climate devices.""" from datetime import timedelta +import voluptuous as vol + from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( CURRENT_HVAC_HEAT, @@ -15,8 +17,10 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.helpers import config_validation as cv, entity_platform -from . import ATTR_AVAILABLE, DATA_HIVE, DOMAIN, HiveEntity, refresh_system +from . import HiveEntity, refresh_system +from .const import ATTR_TIME_PERIOD, DOMAIN, SERVICE_BOOST_HEATING HIVE_TO_HASS_STATE = { "SCHEDULE": HVAC_MODE_AUTO, @@ -45,19 +49,32 @@ PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Hive thermostat.""" - if discovery_info is None: - return +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN].get(DATA_HIVE) - devices = hive.devices.get("climate") + hive = hass.data[DOMAIN][entry.entry_id] + devices = hive.session.deviceList.get("climate") entities = [] if devices: for dev in devices: entities.append(HiveClimateEntity(hive, dev)) async_add_entities(entities, True) + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SERVICE_BOOST_HEATING, + { + vol.Required(ATTR_TIME_PERIOD): vol.All( + cv.time_period, + cv.positive_timedelta, + lambda td: td.total_seconds() // 60, + ), + vol.Optional(ATTR_TEMPERATURE, default="25.0"): vol.Coerce(float), + }, + "async_heating_boost", + ) + class HiveClimateEntity(HiveEntity, ClimateEntity): """Hive Climate Device.""" @@ -76,7 +93,14 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): @property def device_info(self): """Return device information.""" - return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + return { + "identifiers": {(DOMAIN, self.device["device_id"])}, + "name": self.device["device_name"], + "model": self.device["deviceData"]["model"], + "manufacturer": self.device["deviceData"]["manufacturer"], + "sw_version": self.device["deviceData"]["version"], + "via_device": (DOMAIN, self.device["parentDevice"]), + } @property def supported_features(self): @@ -93,11 +117,6 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): """Return if the device is available.""" return self.device["deviceData"]["online"] - @property - def extra_state_attributes(self): - """Show Device Attributes.""" - return {ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE)} - @property def hvac_modes(self): """Return the list of available hvac operation modes. @@ -160,27 +179,31 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" new_mode = HASS_TO_HIVE_STATE[hvac_mode] - await self.hive.heating.set_mode(self.device, new_mode) + await self.hive.heating.setMode(self.device, new_mode) @refresh_system async def async_set_temperature(self, **kwargs): """Set new target temperature.""" new_temperature = kwargs.get(ATTR_TEMPERATURE) if new_temperature is not None: - await self.hive.heating.set_target_temperature(self.device, new_temperature) + await self.hive.heating.setTargetTemperature(self.device, new_temperature) @refresh_system async def async_set_preset_mode(self, preset_mode): """Set new preset mode.""" if preset_mode == PRESET_NONE and self.preset_mode == PRESET_BOOST: - await self.hive.heating.turn_boost_off(self.device) + await self.hive.heating.turnBoostOff(self.device) elif preset_mode == PRESET_BOOST: curtemp = round(self.current_temperature * 2) / 2 temperature = curtemp + 0.5 - await self.hive.heating.turn_boost_on(self.device, 30, temperature) + await self.hive.heating.turnBoostOn(self.device, 30, temperature) + + @refresh_system + async def async_heating_boost(self, time_period, temperature): + """Handle boost heating service call.""" + await self.hive.heating.turnBoostOn(self.device, time_period, temperature) async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) - self.device = await self.hive.heating.get_heating(self.device) - self.attributes.update(self.device.get("attributes", {})) + self.device = await self.hive.heating.getHeating(self.device) diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py new file mode 100644 index 00000000000..ff58e8d96df --- /dev/null +++ b/homeassistant/components/hive/config_flow.py @@ -0,0 +1,171 @@ +"""Config Flow for Hive.""" + +from apyhiveapi import Auth +from apyhiveapi.helper.hive_exceptions import ( + HiveApiError, + HiveInvalid2FACode, + HiveInvalidPassword, + HiveInvalidUsername, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.core import callback + +from .const import ( # pylint:disable=unused-import + CONF_CODE, + CONFIG_ENTRY_VERSION, + DOMAIN, +) + + +class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Hive config flow.""" + + VERSION = CONFIG_ENTRY_VERSION + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the config flow.""" + self.hive_auth = None + self.data = {} + self.tokens = {} + self.entry = None + + async def async_step_user(self, user_input=None): + """Prompt user input. Create or edit entry.""" + errors = {} + # Login to Hive with user data. + if user_input is not None: + self.data.update(user_input) + self.hive_auth = Auth( + username=self.data[CONF_USERNAME], password=self.data[CONF_PASSWORD] + ) + + # Get user from existing entry and abort if already setup + self.entry = await self.async_set_unique_id(self.data[CONF_USERNAME]) + if self.context["source"] != config_entries.SOURCE_REAUTH: + self._abort_if_unique_id_configured() + + # Login to the Hive. + try: + self.tokens = await self.hive_auth.login() + except HiveInvalidUsername: + errors["base"] = "invalid_username" + except HiveInvalidPassword: + errors["base"] = "invalid_password" + except HiveApiError: + errors["base"] = "no_internet_available" + + if self.tokens.get("ChallengeName") == "SMS_MFA": + # Complete SMS 2FA. + return await self.async_step_2fa() + + if not errors: + # Complete the entry setup. + try: + return await self.async_setup_hive_entry() + except UnknownHiveError: + errors["base"] = "unknown" + + # Show User Input form. + schema = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_2fa(self, user_input=None): + """Handle 2fa step.""" + errors = {} + + if user_input and user_input["2fa"] == "0000": + self.tokens = await self.hive_auth.login() + elif user_input: + try: + self.tokens = await self.hive_auth.sms_2fa( + user_input["2fa"], self.tokens + ) + except HiveInvalid2FACode: + errors["base"] = "invalid_code" + except HiveApiError: + errors["base"] = "no_internet_available" + + if not errors: + try: + return await self.async_setup_hive_entry() + except UnknownHiveError: + errors["base"] = "unknown" + + schema = vol.Schema({vol.Required(CONF_CODE): str}) + return self.async_show_form(step_id="2fa", data_schema=schema, errors=errors) + + async def async_setup_hive_entry(self): + """Finish setup and create the config entry.""" + + if "AuthenticationResult" not in self.tokens: + raise UnknownHiveError + + # Setup the config entry + self.data["tokens"] = self.tokens + if self.context["source"] == config_entries.SOURCE_REAUTH: + self.hass.config_entries.async_update_entry( + self.entry, title=self.data["username"], data=self.data + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry(title=self.data["username"], data=self.data) + + async def async_step_reauth(self, user_input=None): + """Re Authenticate a user.""" + data = { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + return await self.async_step_user(data) + + async def async_step_import(self, user_input=None): + """Import user.""" + return await self.async_step_user(user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Hive options callback.""" + return HiveOptionsFlowHandler(config_entry) + + +class HiveOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options for Hive.""" + + def __init__(self, config_entry): + """Initialize Hive options flow.""" + self.hive = None + self.config_entry = config_entry + self.interval = config_entry.options.get(CONF_SCAN_INTERVAL, 120) + + async def async_step_init(self, user_input=None): + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self.hive = self.hass.data["hive"][self.config_entry.entry_id] + errors = {} + if user_input is not None: + new_interval = user_input.get(CONF_SCAN_INTERVAL) + await self.hive.updateInterval(new_interval) + return self.async_create_entry(title="", data=user_input) + + schema = vol.Schema( + { + vol.Optional(CONF_SCAN_INTERVAL, default=self.interval): vol.All( + vol.Coerce(int), vol.Range(min=30) + ) + } + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + +class UnknownHiveError(Exception): + """Catch unknown hive error.""" diff --git a/homeassistant/components/hive/const.py b/homeassistant/components/hive/const.py new file mode 100644 index 00000000000..ea416fbfe32 --- /dev/null +++ b/homeassistant/components/hive/const.py @@ -0,0 +1,20 @@ +"""Constants for Hive.""" +ATTR_MODE = "mode" +ATTR_TIME_PERIOD = "time_period" +ATTR_ONOFF = "on_off" +CONF_CODE = "2fa" +CONFIG_ENTRY_VERSION = 1 +DEFAULT_NAME = "Hive" +DOMAIN = "hive" +PLATFORMS = ["binary_sensor", "climate", "light", "sensor", "switch", "water_heater"] +PLATFORM_LOOKUP = { + "binary_sensor": "binary_sensor", + "climate": "climate", + "light": "light", + "sensor": "sensor", + "switch": "switch", + "water_heater": "water_heater", +} +SERVICE_BOOST_HOT_WATER = "boost_hot_water" +SERVICE_BOOST_HEATING = "boost_heating" +WATER_HEATER_MODES = ["on", "off"] diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 12779ef9d2e..46e8c5b5790 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -12,19 +12,18 @@ from homeassistant.components.light import ( ) import homeassistant.util.color as color_util -from . import ATTR_AVAILABLE, ATTR_MODE, DATA_HIVE, DOMAIN, HiveEntity, refresh_system +from . import HiveEntity, refresh_system +from .const import ATTR_MODE, DOMAIN PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Hive Light.""" - if discovery_info is None: - return +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN].get(DATA_HIVE) - devices = hive.devices.get("light") + hive = hass.data[DOMAIN][entry.entry_id] + devices = hive.session.deviceList.get("light") entities = [] if devices: for dev in devices: @@ -43,7 +42,14 @@ class HiveDeviceLight(HiveEntity, LightEntity): @property def device_info(self): """Return device information.""" - return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + return { + "identifiers": {(DOMAIN, self.device["device_id"])}, + "name": self.device["device_name"], + "model": self.device["deviceData"]["model"], + "manufacturer": self.device["deviceData"]["manufacturer"], + "sw_version": self.device["deviceData"]["version"], + "via_device": (DOMAIN, self.device["parentDevice"]), + } @property def name(self): @@ -59,7 +65,6 @@ class HiveDeviceLight(HiveEntity, LightEntity): def extra_state_attributes(self): """Show Device Attributes.""" return { - ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE), ATTR_MODE: self.attributes.get(ATTR_MODE), } @@ -117,14 +122,14 @@ class HiveDeviceLight(HiveEntity, LightEntity): saturation = int(get_new_color[1]) new_color = (hue, saturation, 100) - await self.hive.light.turn_on( + await self.hive.light.turnOn( self.device, new_brightness, new_color_temp, new_color ) @refresh_system async def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" - await self.hive.light.turn_off(self.device) + await self.hive.light.turnOff(self.device) @property def supported_features(self): @@ -142,5 +147,5 @@ class HiveDeviceLight(HiveEntity, LightEntity): async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) - self.device = await self.hive.light.get_light(self.device) + self.device = await self.hive.light.getLight(self.device) self.attributes.update(self.device.get("attributes", {})) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 27f235949bf..f8f40401599 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -1,9 +1,10 @@ { "domain": "hive", "name": "Hive", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hive", "requirements": [ - "pyhiveapi==0.3.4.4" + "pyhiveapi==0.3.9" ], "codeowners": [ "@Rendili", diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index fe413a35b2f..53cc643250c 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -5,7 +5,8 @@ from datetime import timedelta from homeassistant.components.sensor import DEVICE_CLASS_BATTERY from homeassistant.helpers.entity import Entity -from . import ATTR_AVAILABLE, DATA_HIVE, DOMAIN, HiveEntity +from . import HiveEntity +from .const import DOMAIN PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) @@ -14,18 +15,15 @@ DEVICETYPE = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Hive Sensor.""" - if discovery_info is None: - return +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN].get(DATA_HIVE) - devices = hive.devices.get("sensor") + hive = hass.data[DOMAIN][entry.entry_id] + devices = hive.session.deviceList.get("sensor") entities = [] if devices: for dev in devices: - if dev["hiveType"] in DEVICETYPE: - entities.append(HiveSensorEntity(hive, dev)) + entities.append(HiveSensorEntity(hive, dev)) async_add_entities(entities, True) @@ -40,7 +38,14 @@ class HiveSensorEntity(HiveEntity, Entity): @property def device_info(self): """Return device information.""" - return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + return { + "identifiers": {(DOMAIN, self.device["device_id"])}, + "name": self.device["device_name"], + "model": self.device["deviceData"]["model"], + "manufacturer": self.device["deviceData"]["manufacturer"], + "sw_version": self.device["deviceData"]["version"], + "via_device": (DOMAIN, self.device["parentDevice"]), + } @property def available(self): @@ -67,12 +72,7 @@ class HiveSensorEntity(HiveEntity, Entity): """Return the state of the sensor.""" return self.device["status"]["state"] - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE)} - async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) - self.device = await self.hive.sensor.get_sensor(self.device) + self.device = await self.hive.sensor.getSensor(self.device) diff --git a/homeassistant/components/hive/services.yaml b/homeassistant/components/hive/services.yaml index f09baea7655..f029af7b0b5 100644 --- a/homeassistant/components/hive/services.yaml +++ b/homeassistant/components/hive/services.yaml @@ -1,24 +1,62 @@ boost_heating: + name: Boost Heating description: Set the boost mode ON defining the period of time and the desired target temperature for the boost. fields: entity_id: - description: Enter the entity_id for the device required to set the boost mode. - example: "climate.heating" + name: Entity ID + description: Select entity_id to boost. + required: true + example: climate.heating + selector: + entity: + integration: hive + domain: climate time_period: + name: Time Period description: Set the time period for the boost. - example: "01:30:00" + required: true + example: 01:30:00 + selector: + time: temperature: + name: Temperature description: Set the target temperature for the boost period. - example: "20.5" + required: true + example: 20.5 + selector: + number: + min: 7 + max: 35 + step: 0.5 + unit_of_measurement: degrees + mode: slider boost_hot_water: - description: "Set the boost mode ON or OFF defining the period of time for the boost." + name: Boost Hotwater + description: Set the boost mode ON or OFF defining the period of time for the boost. fields: entity_id: - description: Enter the entity_id for the device reuired to set the boost mode. - example: "water_heater.hot_water" + name: Entity ID + description: Select entity_id to boost. + required: true + example: water_heater.hot_water + selector: + entity: + integration: hive + domain: water_heater time_period: + name: Time Period description: Set the time period for the boost. - example: "01:30:00" + required: true + example: 01:30:00 + selector: + time: on_off: + name: Mode description: Set the boost function on or off. + required: true example: "on" + selector: + select: + options: + - "on" + - "off" diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json new file mode 100644 index 00000000000..0a7a587b2db --- /dev/null +++ b/homeassistant/components/hive/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "title": "Hive Login", + "description": "Enter your Hive login information and configuration.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "scan_interval": "Scan Interval (seconds)" + } + }, + "2fa": { + "title": "Hive Two-factor Authentication.", + "description": "Enter your Hive authentication code. \n \n Please enter code 0000 to request another code.", + "data": { + "2fa": "Two-factor code" + } + }, + "reauth": { + "title": "Hive Login", + "description": "Re-enter your Hive login information.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", + "invalid_password": "Failed to sign into Hive. Incorrect password please try again.", + "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", + "no_internet_available": "An internet connection is required to connect to Hive.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "unknown_entry": "Unable to find existing entry.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "options": { + "step": { + "user": { + "title": "Options for Hive", + "description": "Update the scan interval to poll for data more often.", + "data": { + "scan_interval": "Scan Interval (seconds)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index 821f48dbf97..acc2040db00 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -3,19 +3,18 @@ from datetime import timedelta from homeassistant.components.switch import SwitchEntity -from . import ATTR_AVAILABLE, ATTR_MODE, DATA_HIVE, DOMAIN, HiveEntity, refresh_system +from . import HiveEntity, refresh_system +from .const import ATTR_MODE, DOMAIN PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Hive Switch.""" - if discovery_info is None: - return +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN].get(DATA_HIVE) - devices = hive.devices.get("switch") + hive = hass.data[DOMAIN][entry.entry_id] + devices = hive.session.deviceList.get("switch") entities = [] if devices: for dev in devices: @@ -34,7 +33,15 @@ class HiveDevicePlug(HiveEntity, SwitchEntity): @property def device_info(self): """Return device information.""" - return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + if self.device["hiveType"] == "activeplug": + return { + "identifiers": {(DOMAIN, self.device["device_id"])}, + "name": self.device["device_name"], + "model": self.device["deviceData"]["model"], + "manufacturer": self.device["deviceData"]["manufacturer"], + "sw_version": self.device["deviceData"]["version"], + "via_device": (DOMAIN, self.device["parentDevice"]), + } @property def name(self): @@ -50,7 +57,6 @@ class HiveDevicePlug(HiveEntity, SwitchEntity): def extra_state_attributes(self): """Show Device Attributes.""" return { - ATTR_AVAILABLE: self.attributes.get(ATTR_AVAILABLE), ATTR_MODE: self.attributes.get(ATTR_MODE), } @@ -67,16 +73,14 @@ class HiveDevicePlug(HiveEntity, SwitchEntity): @refresh_system async def async_turn_on(self, **kwargs): """Turn the switch on.""" - if self.device["hiveType"] == "activeplug": - await self.hive.switch.turn_on(self.device) + await self.hive.switch.turnOn(self.device) @refresh_system async def async_turn_off(self, **kwargs): """Turn the device off.""" - if self.device["hiveType"] == "activeplug": - await self.hive.switch.turn_off(self.device) + await self.hive.switch.turnOff(self.device) async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) - self.device = await self.hive.switch.get_plug(self.device) + self.device = await self.hive.switch.getPlug(self.device) diff --git a/homeassistant/components/hive/translations/en.json b/homeassistant/components/hive/translations/en.json new file mode 100644 index 00000000000..1d491d64ebf --- /dev/null +++ b/homeassistant/components/hive/translations/en.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "title": "Hive Login", + "description": "Enter your Hive login information and configuration.", + "data": { + "username": "Username", + "password": "Password", + "scan_interval": "Scan Interval (seconds)" + } + }, + "2fa": { + "title": "Hive Two-factor Authentication.", + "description": "Enter your Hive authentication code. \n \n Please enter code 0000 to request another code.", + "data": { + "2fa": "Two-factor code" + } + }, + "reauth": { + "title": "Hive Login", + "description": "Re-enter your Hive login information.", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", + "invalid_password": "Failed to sign into Hive. Incorrect password please try again.", + "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", + "no_internet_available": "An internet connection is required to connect to Hive.", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Account is already configured", + "unknown_entry": "Unable to find existing entry.", + "reauth_successful": "Re-authentication was successful" + } + }, + "options": { + "step": { + "user": { + "title": "Options for Hive", + "description": "Update the scan interval to poll for data more often.", + "data": { + "scan_interval": "Scan Interval (seconds)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index 56e98a690b8..5d8eb590ea7 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -2,6 +2,8 @@ from datetime import timedelta +import voluptuous as vol + from homeassistant.components.water_heater import ( STATE_ECO, STATE_OFF, @@ -10,8 +12,16 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, ) from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers import config_validation as cv, entity_platform -from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system +from . import HiveEntity, refresh_system +from .const import ( + ATTR_ONOFF, + ATTR_TIME_PERIOD, + DOMAIN, + SERVICE_BOOST_HOT_WATER, + WATER_HEATER_MODES, +) SUPPORT_FLAGS_HEATER = SUPPORT_OPERATION_MODE HOTWATER_NAME = "Hot Water" @@ -32,19 +42,32 @@ HASS_TO_HIVE_STATE = { SUPPORT_WATER_HEATER = [STATE_ECO, STATE_ON, STATE_OFF] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Hive Hotwater.""" - if discovery_info is None: - return +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN].get(DATA_HIVE) - devices = hive.devices.get("water_heater") + hive = hass.data[DOMAIN][entry.entry_id] + devices = hive.session.deviceList.get("water_heater") entities = [] if devices: for dev in devices: entities.append(HiveWaterHeater(hive, dev)) async_add_entities(entities, True) + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SERVICE_BOOST_HOT_WATER, + { + vol.Optional(ATTR_TIME_PERIOD, default="00:30:00"): vol.All( + cv.time_period, + cv.positive_timedelta, + lambda td: td.total_seconds() // 60, + ), + vol.Required(ATTR_ONOFF): vol.In(WATER_HEATER_MODES), + }, + "async_hot_water_boost", + ) + class HiveWaterHeater(HiveEntity, WaterHeaterEntity): """Hive Water Heater Device.""" @@ -57,7 +80,14 @@ class HiveWaterHeater(HiveEntity, WaterHeaterEntity): @property def device_info(self): """Return device information.""" - return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + return { + "identifiers": {(DOMAIN, self.device["device_id"])}, + "name": self.device["device_name"], + "model": self.device["deviceData"]["model"], + "manufacturer": self.device["deviceData"]["manufacturer"], + "sw_version": self.device["deviceData"]["version"], + "via_device": (DOMAIN, self.device["parentDevice"]), + } @property def supported_features(self): @@ -92,20 +122,28 @@ class HiveWaterHeater(HiveEntity, WaterHeaterEntity): @refresh_system async def async_turn_on(self, **kwargs): """Turn on hotwater.""" - await self.hive.hotwater.set_mode(self.device, "MANUAL") + await self.hive.hotwater.setMode(self.device, "MANUAL") @refresh_system async def async_turn_off(self, **kwargs): """Turn on hotwater.""" - await self.hive.hotwater.set_mode(self.device, "OFF") + await self.hive.hotwater.setMode(self.device, "OFF") @refresh_system async def async_set_operation_mode(self, operation_mode): """Set operation mode.""" new_mode = HASS_TO_HIVE_STATE[operation_mode] - await self.hive.hotwater.set_mode(self.device, new_mode) + await self.hive.hotwater.setMode(self.device, new_mode) + + @refresh_system + async def async_hot_water_boost(self, time_period, on_off): + """Handle the service call.""" + if on_off == "on": + await self.hive.hotwater.turnBoostOn(self.device, time_period) + elif on_off == "off": + await self.hive.hotwater.turnBoostOff(self.device) async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) - self.device = await self.hive.hotwater.get_hotwater(self.device) + self.device = await self.hive.hotwater.getHotwater(self.device) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a0a846c0d44..057ebe74865 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -92,6 +92,7 @@ FLOWS = [ "harmony", "heos", "hisense_aehw4a1", + "hive", "hlk_sw16", "home_connect", "homekit", diff --git a/requirements_all.txt b/requirements_all.txt index 03fd077d9be..a749acabf99 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1428,7 +1428,7 @@ pyheos==0.7.2 pyhik==0.2.8 # homeassistant.components.hive -pyhiveapi==0.3.4.4 +pyhiveapi==0.3.9 # homeassistant.components.homematic pyhomematic==0.1.72 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ff9b14e6d7..82425db675b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -747,6 +747,9 @@ pyhaversion==3.4.2 # homeassistant.components.heos pyheos==0.7.2 +# homeassistant.components.hive +pyhiveapi==0.3.9 + # homeassistant.components.homematic pyhomematic==0.1.72 diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py new file mode 100644 index 00000000000..dae69eebd96 --- /dev/null +++ b/tests/components/hive/test_config_flow.py @@ -0,0 +1,576 @@ +"""Test the Hive config flow.""" +from unittest.mock import patch + +from apyhiveapi.helper import hive_exceptions + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.hive.const import CONF_CODE, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME + +from tests.common import MockConfigEntry + +USERNAME = "username@home-assistant.com" +UPDATED_USERNAME = "updated_username@home-assistant.com" +PASSWORD = "test-password" +UPDATED_PASSWORD = "updated-password" +INCORRECT_PASSWORD = "incoreect-password" +SCAN_INTERVAL = 120 +UPDATED_SCAN_INTERVAL = 60 +MFA_CODE = "1234" +MFA_RESEND_CODE = "0000" +MFA_INVALID_CODE = "HIVE" + + +async def test_import_flow(hass): + """Check import flow.""" + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ), patch( + "homeassistant.components.hive.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.hive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + "tokens": { + "AuthenticationResult": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + "ChallengeName": "SUCCESS", + }, + } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow(hass): + """Test the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ), patch( + "homeassistant.components.hive.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.hive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == USERNAME + assert result2["data"] == { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + "tokens": { + "AuthenticationResult": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + "ChallengeName": "SUCCESS", + }, + } + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_user_flow_2fa(hass): + """Test user flow with 2FA.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SMS_MFA", + }, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == CONF_CODE + assert result2["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ), patch( + "homeassistant.components.hive.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.hive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {CONF_CODE: MFA_CODE} + ) + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == USERNAME + assert result3["data"] == { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + "tokens": { + "AuthenticationResult": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + "ChallengeName": "SUCCESS", + }, + } + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_flow(hass): + """Test the reauth flow.""" + + await setup.async_setup_component(hass, "persistent_notification", {}) + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=USERNAME, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: INCORRECT_PASSWORD, + "tokens": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + }, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + side_effect=hive_exceptions.HiveInvalidPassword(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_password"} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: UPDATED_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get("username") == USERNAME + assert mock_config.data.get("password") == UPDATED_PASSWORD + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_option_flow(hass): + """Test config flow options.""" + + entry = MockConfigEntry( + domain=DOMAIN, + title=USERNAME, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + entry.entry_id, + data=None, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: UPDATED_SCAN_INTERVAL} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_SCAN_INTERVAL] == UPDATED_SCAN_INTERVAL + + +async def test_user_flow_2fa_send_new_code(hass): + """Resend a 2FA code if it didn't arrive.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SMS_MFA", + }, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == CONF_CODE + assert result2["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SMS_MFA", + }, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {CONF_CODE: MFA_RESEND_CODE} + ) + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["step_id"] == CONF_CODE + assert result3["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ), patch( + "homeassistant.components.hive.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.hive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], {CONF_CODE: MFA_CODE} + ) + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result4["title"] == USERNAME + assert result4["data"] == { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + "tokens": { + "AuthenticationResult": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + "ChallengeName": "SUCCESS", + }, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_abort_if_existing_entry(hass): + """Check flow abort when an entry already exist.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=USERNAME, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + options={CONF_SCAN_INTERVAL: SCAN_INTERVAL}, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_invalid_username(hass): + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + side_effect=hive_exceptions.HiveInvalidUsername(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_username"} + + +async def test_user_flow_invalid_password(hass): + """Test user flow with invalid password.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + side_effect=hive_exceptions.HiveInvalidPassword(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_password"} + + +async def test_user_flow_no_internet_connection(hass): + """Test user flow with no internet connection.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + side_effect=hive_exceptions.HiveApiError(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "no_internet_available"} + + +async def test_user_flow_2fa_no_internet_connection(hass): + """Test user flow with no internet connection.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SMS_MFA", + }, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == CONF_CODE + assert result2["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + side_effect=hive_exceptions.HiveApiError(), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_CODE: MFA_CODE}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["step_id"] == CONF_CODE + assert result3["errors"] == {"base": "no_internet_available"} + + +async def test_user_flow_2fa_invalid_code(hass): + """Test user flow with 2FA.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SMS_MFA", + }, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == CONF_CODE + assert result2["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + side_effect=hive_exceptions.HiveInvalid2FACode(), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_CODE: MFA_INVALID_CODE}, + ) + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["step_id"] == CONF_CODE + assert result3["errors"] == {"base": "invalid_code"} + + +async def test_user_flow_unknown_error(hass): + """Test user flow when unknown error occurs.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={"ChallengeName": "FAILED", "InvalidAuthenticationResult": {}}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_user_flow_2fa_unknown_error(hass): + """Test 2fa flow when unknown error occurs.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SMS_MFA", + }, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == CONF_CODE + + with patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + return_value={"ChallengeName": "FAILED", "InvalidAuthenticationResult": {}}, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_CODE: MFA_CODE}, + ) + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["errors"] == {"base": "unknown"}