diff --git a/CODEOWNERS b/CODEOWNERS index d1bda212d2d..8a2ea23ded9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -421,6 +421,7 @@ homeassistant/components/smappee/* @bsmappee homeassistant/components/smart_meter_texas/* @grahamwetzler homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre +homeassistant/components/smarttub/* @mdz homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/sms/* @ocalvo homeassistant/components/smtp/* @fabaff diff --git a/homeassistant/components/smarttub/__init__.py b/homeassistant/components/smarttub/__init__.py new file mode 100644 index 00000000000..85298a75f65 --- /dev/null +++ b/homeassistant/components/smarttub/__init__.py @@ -0,0 +1,54 @@ +"""SmartTub integration.""" +import asyncio +import logging + +from .const import DOMAIN, SMARTTUB_CONTROLLER +from .controller import SmartTubController + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate"] + + +async def async_setup(hass, _config): + """Set up smarttub component.""" + + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a smarttub config entry.""" + + controller = SmartTubController(hass) + hass.data[DOMAIN][entry.entry_id] = { + SMARTTUB_CONTROLLER: controller, + } + + if not await controller.async_setup_entry(entry): + return False + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Remove a smarttub config entry.""" + if not all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ): + return False + + hass.data[DOMAIN].pop(entry.entry_id) + + return True diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py new file mode 100644 index 00000000000..5f16bea8cf7 --- /dev/null +++ b/homeassistant/components/smarttub/climate.py @@ -0,0 +1,116 @@ +"""Platform for climate integration.""" +import logging + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_HEAT, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.util.temperature import convert as convert_temperature + +from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SMARTTUB_CONTROLLER +from .entity import SmartTubEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up climate entity for the thermostat in the tub.""" + + controller = hass.data[DOMAIN][entry.entry_id][SMARTTUB_CONTROLLER] + + entities = [ + SmartTubThermostat(controller.coordinator, spa) for spa in controller.spas + ] + + async_add_entities(entities) + + +class SmartTubThermostat(SmartTubEntity, ClimateEntity): + """The target water temperature for the spa.""" + + def __init__(self, coordinator, spa): + """Initialize the entity.""" + super().__init__(coordinator, spa, "thermostat") + + @property + def unique_id(self) -> str: + """Return a unique id for the entity.""" + return f"{self.spa.id}-{self._entity_type}" + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def hvac_action(self): + """Return the current running hvac operation.""" + heater_status = self.get_spa_status("heater") + if heater_status == "ON": + return CURRENT_HVAC_HEAT + if heater_status == "OFF": + return CURRENT_HVAC_IDLE + return None + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes.""" + return [HVAC_MODE_HEAT] + + @property + def hvac_mode(self): + """Return the current hvac mode. + + SmartTub devices don't seem to have the option of disabling the heater, + so this is always HVAC_MODE_HEAT. + """ + return HVAC_MODE_HEAT + + async def async_set_hvac_mode(self, hvac_mode: str): + """Set new target hvac mode. + + As with hvac_mode, we don't really have an option here. + """ + if hvac_mode == HVAC_MODE_HEAT: + return + raise NotImplementedError(hvac_mode) + + @property + def min_temp(self): + """Return the minimum temperature.""" + min_temp = DEFAULT_MIN_TEMP + return convert_temperature(min_temp, TEMP_CELSIUS, self.temperature_unit) + + @property + def max_temp(self): + """Return the maximum temperature.""" + max_temp = DEFAULT_MAX_TEMP + return convert_temperature(max_temp, TEMP_CELSIUS, self.temperature_unit) + + @property + def supported_features(self): + """Return the set of supported features. + + Only target temperature is supported. + """ + return SUPPORT_TARGET_TEMPERATURE + + @property + def current_temperature(self): + """Return the current water temperature.""" + return self.get_spa_status("water.temperature") + + @property + def target_temperature(self): + """Return the target water temperature.""" + return self.get_spa_status("setTemperature") + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + await self.spa.set_temperature(temperature) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/smarttub/config_flow.py b/homeassistant/components/smarttub/config_flow.py new file mode 100644 index 00000000000..8f3ed17f93a --- /dev/null +++ b/homeassistant/components/smarttub/config_flow.py @@ -0,0 +1,53 @@ +"""Config flow to configure the SmartTub integration.""" +import logging + +from smarttub import LoginFailed +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import DOMAIN # pylint: disable=unused-import +from .controller import SmartTubController + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} +) + + +_LOGGER = logging.getLogger(__name__) + + +class SmartTubConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """SmartTub configuration flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + controller = SmartTubController(self.hass) + try: + account = await controller.login( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except LoginFailed: + errors["base"] = "invalid_auth" + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + existing_entry = await self.async_set_unique_id(account.id) + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=user_input) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry(title=user_input[CONF_EMAIL], data=user_input) diff --git a/homeassistant/components/smarttub/const.py b/homeassistant/components/smarttub/const.py new file mode 100644 index 00000000000..99e7f21f86d --- /dev/null +++ b/homeassistant/components/smarttub/const.py @@ -0,0 +1,14 @@ +"""smarttub constants.""" + +DOMAIN = "smarttub" + +EVENT_SMARTTUB = "smarttub" + +SMARTTUB_CONTROLLER = "smarttub_controller" + +SCAN_INTERVAL = 60 + +POLLING_TIMEOUT = 10 + +DEFAULT_MIN_TEMP = 18.5 +DEFAULT_MAX_TEMP = 40 diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py new file mode 100644 index 00000000000..31fe71d14b6 --- /dev/null +++ b/homeassistant/components/smarttub/controller.py @@ -0,0 +1,110 @@ +"""Interface to the SmartTub API.""" + +import asyncio +from datetime import timedelta +import logging + +from aiohttp import client_exceptions +import async_timeout +from smarttub import APIError, LoginFailed, SmartTub +from smarttub.api import Account + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, POLLING_TIMEOUT, SCAN_INTERVAL +from .helpers import get_spa_name + +_LOGGER = logging.getLogger(__name__) + + +class SmartTubController: + """Interface between Home Assistant and the SmartTub API.""" + + def __init__(self, hass): + """Initialize an interface to SmartTub.""" + self._hass = hass + self._account = None + self.spas = set() + self._spa_devices = {} + + self.coordinator = None + + async def async_setup_entry(self, entry): + """Perform initial setup. + + Authenticate, query static state, set up polling, and otherwise make + ready for normal operations . + """ + + try: + self._account = await self.login( + entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD] + ) + except LoginFailed: + # credentials were changed or invalidated, we need new ones + + return False + except ( + asyncio.TimeoutError, + client_exceptions.ClientOSError, + client_exceptions.ServerDisconnectedError, + client_exceptions.ContentTypeError, + ) as err: + raise ConfigEntryNotReady from err + + self.spas = await self._account.get_spas() + + self.coordinator = DataUpdateCoordinator( + self._hass, + _LOGGER, + name=DOMAIN, + update_method=self.async_update_data, + update_interval=timedelta(seconds=SCAN_INTERVAL), + ) + + await self.coordinator.async_refresh() + + await self.async_register_devices(entry) + + return True + + async def async_update_data(self): + """Query the API and return the new state.""" + + data = {} + try: + async with async_timeout.timeout(POLLING_TIMEOUT): + for spa in self.spas: + data[spa.id] = {"status": await spa.get_status()} + except APIError as err: + raise UpdateFailed(err) from err + + return data + + async def async_register_devices(self, entry): + """Register devices with the device registry for all spas.""" + device_registry = await dr.async_get_registry(self._hass) + for spa in self.spas: + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, spa.id)}, + manufacturer=spa.brand, + name=get_spa_name(spa), + model=spa.model, + ) + self._spa_devices[spa.id] = device + + async def login(self, email, password) -> Account: + """Retrieve the account corresponding to the specified email and password. + + Returns None if the credentials are invalid. + """ + + api = SmartTub(async_get_clientsession(self._hass)) + + await api.login(email, password) + return await api.get_account() diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py new file mode 100644 index 00000000000..95d89971cb7 --- /dev/null +++ b/homeassistant/components/smarttub/entity.py @@ -0,0 +1,64 @@ +"""SmartTub integration.""" +import logging + +import smarttub + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .helpers import get_spa_name + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate"] + + +class SmartTubEntity(CoordinatorEntity): + """Base class for SmartTub entities.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, spa: smarttub.Spa, entity_type + ): + """Initialize the entity. + + Given a spa id and a short name for the entity, we provide basic device + info, name, unique id, etc. for all derived entities. + """ + + super().__init__(coordinator) + self.spa = spa + self._entity_type = entity_type + + @property + def device_info(self) -> str: + """Return device info.""" + return { + "identifiers": {(DOMAIN, self.spa.id)}, + "manufacturer": self.spa.brand, + "model": self.spa.model, + } + + @property + def name(self) -> str: + """Return the name of the entity.""" + spa_name = get_spa_name(self.spa) + return f"{spa_name} {self._entity_type}" + + def get_spa_status(self, path): + """Retrieve a value from the data returned by Spa.get_status(). + + Nested keys can be specified by a dotted path, e.g. + status['foo']['bar'] is 'foo.bar'. + """ + + status = self.coordinator.data[self.spa.id].get("status") + if status is None: + return None + + for key in path.split("."): + status = status[key] + + return status diff --git a/homeassistant/components/smarttub/helpers.py b/homeassistant/components/smarttub/helpers.py new file mode 100644 index 00000000000..a6f2d09c38f --- /dev/null +++ b/homeassistant/components/smarttub/helpers.py @@ -0,0 +1,8 @@ +"""Helper functions for SmartTub integration.""" + +import smarttub + + +def get_spa_name(spa: smarttub.Spa) -> str: + """Return the name of the specified spa.""" + return f"{spa.brand} {spa.model}" diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json new file mode 100644 index 00000000000..9735a3753b4 --- /dev/null +++ b/homeassistant/components/smarttub/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "smarttub", + "name": "SmartTub", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/smarttub", + "dependencies": [], + "codeowners": ["@mdz"], + "requirements": [ + "python-smarttub==0.0.6" + ], + "quality_scale": "platinum" +} diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json new file mode 100644 index 00000000000..0d52673a469 --- /dev/null +++ b/homeassistant/components/smarttub/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Login", + "description": "Enter your SmartTub email address and password to login", + "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%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/smarttub/translations/en.json b/homeassistant/components/smarttub/translations/en.json new file mode 100644 index 00000000000..4cf93091887 --- /dev/null +++ b/homeassistant/components/smarttub/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "description": "Enter your SmartTub email address and password to login", + "title": "Login" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6ff72cf5572..400f2f2352b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -197,6 +197,7 @@ FLOWS = [ "smart_meter_texas", "smarthab", "smartthings", + "smarttub", "smhi", "sms", "solaredge", diff --git a/requirements_all.txt b/requirements_all.txt index 24bb508c713..de6c34f4feb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1809,6 +1809,9 @@ python-qbittorrent==0.4.2 # homeassistant.components.ripple python-ripple-api==0.0.3 +# homeassistant.components.smarttub +python-smarttub==0.0.6 + # homeassistant.components.sochain python-sochain-api==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e22ab3fa0e2..4f15becca1a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -934,6 +934,9 @@ python-nest==4.1.0 # homeassistant.components.ozw python-openzwave-mqtt[mqtt-client]==1.4.0 +# homeassistant.components.smarttub +python-smarttub==0.0.6 + # homeassistant.components.songpal python-songpal==0.12 diff --git a/tests/components/smarttub/__init__.py b/tests/components/smarttub/__init__.py new file mode 100644 index 00000000000..afbf271eb63 --- /dev/null +++ b/tests/components/smarttub/__init__.py @@ -0,0 +1 @@ +"""Tests for the smarttub integration.""" diff --git a/tests/components/smarttub/conftest.py b/tests/components/smarttub/conftest.py new file mode 100644 index 00000000000..fec74a2b30a --- /dev/null +++ b/tests/components/smarttub/conftest.py @@ -0,0 +1,86 @@ +"""Common fixtures for smarttub tests.""" + +from unittest.mock import create_autospec, patch + +import pytest +import smarttub + +from homeassistant.components.smarttub.const import DOMAIN +from homeassistant.components.smarttub.controller import SmartTubController +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_data(): + """Provide configuration data for tests.""" + return {CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"} + + +@pytest.fixture +def config_entry(config_data): + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=config_data, + options={}, + ) + + +@pytest.fixture(name="spa") +def mock_spa(): + """Mock a SmartTub.Spa.""" + + mock_spa = create_autospec(smarttub.Spa, instance=True) + mock_spa.id = "mockspa1" + mock_spa.brand = "mockbrand1" + mock_spa.model = "mockmodel1" + mock_spa.get_status.return_value = { + "setTemperature": 39, + "water": {"temperature": 38}, + "heater": "ON", + } + return mock_spa + + +@pytest.fixture(name="account") +def mock_account(spa): + """Mock a SmartTub.Account.""" + + mock_account = create_autospec(smarttub.Account, instance=True) + mock_account.id = "mockaccount1" + mock_account.get_spas.return_value = [spa] + return mock_account + + +@pytest.fixture(name="smarttub_api") +def mock_api(account, spa): + """Mock the SmartTub API.""" + + with patch( + "homeassistant.components.smarttub.controller.SmartTub", + autospec=True, + ) as api_class_mock: + api_mock = api_class_mock.return_value + api_mock.get_account.return_value = account + yield api_mock + + +@pytest.fixture +async def controller(smarttub_api, hass, config_entry): + """Instantiate controller for testing.""" + + controller = SmartTubController(hass) + assert len(controller.spas) == 0 + assert await controller.async_setup_entry(config_entry) + + assert len(controller.spas) > 0 + + return controller + + +@pytest.fixture +async def coordinator(controller): + """Provide convenient access to the coordinator via the controller.""" + return controller.coordinator diff --git a/tests/components/smarttub/test_climate.py b/tests/components/smarttub/test_climate.py new file mode 100644 index 00000000000..f0e6ced4abd --- /dev/null +++ b/tests/components/smarttub/test_climate.py @@ -0,0 +1,74 @@ +"""Test the SmartTub climate platform.""" + +from homeassistant.components.climate.const import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + DOMAIN as CLIMATE_DOMAIN, + HVAC_MODE_HEAT, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.components.smarttub.const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, +) + + +async def test_thermostat(coordinator, spa, hass, config_entry): + """Test the thermostat entity.""" + + spa.get_status.return_value = { + "heater": "ON", + "water": { + "temperature": 38, + }, + "setTemperature": 39, + } + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = f"climate.{spa.brand}_{spa.model}_thermostat" + state = hass.states.get(entity_id) + assert state + + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT + + spa.get_status.return_value["heater"] = "OFF" + await hass.helpers.entity_component.async_update_entity(entity_id) + state = hass.states.get(entity_id) + + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE + + assert set(state.attributes[ATTR_HVAC_MODES]) == {HVAC_MODE_HEAT} + assert state.state == HVAC_MODE_HEAT + assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_TARGET_TEMPERATURE + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 38 + assert state.attributes[ATTR_TEMPERATURE] == 39 + assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP + assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 37}, + blocking=True, + ) + spa.set_temperature.assert_called_with(37) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVAC_MODE_HEAT}, + blocking=True, + ) + # does nothing diff --git a/tests/components/smarttub/test_config_flow.py b/tests/components/smarttub/test_config_flow.py new file mode 100644 index 00000000000..a57eb43eef7 --- /dev/null +++ b/tests/components/smarttub/test_config_flow.py @@ -0,0 +1,64 @@ +"""Test the smarttub config flow.""" +from unittest.mock import patch + +from smarttub import LoginFailed + +from homeassistant import config_entries +from homeassistant.components.smarttub.const import DOMAIN + + +async def test_form(hass, smarttub_api): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.smarttub.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.smarttub.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-email" + assert result2["data"] == { + "email": "test-email", + "password": "test-password", + } + await hass.async_block_till_done() + mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"email": "test-email2", "password": "test-password2"} + ) + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + +async def test_form_invalid_auth(hass, smarttub_api): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + smarttub_api.login.side_effect = LoginFailed + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/smarttub/test_controller.py b/tests/components/smarttub/test_controller.py new file mode 100644 index 00000000000..e59ad86c09e --- /dev/null +++ b/tests/components/smarttub/test_controller.py @@ -0,0 +1,37 @@ +"""Test the SmartTub controller.""" + +import pytest +import smarttub + +from homeassistant.components.smarttub.controller import SmartTubController +from homeassistant.helpers.update_coordinator import UpdateFailed + + +async def test_invalid_credentials(hass, controller, smarttub_api, config_entry): + """Check that we return False if the configured credentials are invalid. + + This should mean that the user changed their SmartTub password. + """ + + smarttub_api.login.side_effect = smarttub.LoginFailed + controller = SmartTubController(hass) + ret = await controller.async_setup_entry(config_entry) + assert ret is False + + +async def test_update(controller, spa): + """Test data updates from API.""" + data = await controller.async_update_data() + assert data[spa.id] == {"status": spa.get_status.return_value} + + spa.get_status.side_effect = smarttub.APIError + with pytest.raises(UpdateFailed): + data = await controller.async_update_data() + + +async def test_login(controller, smarttub_api, account): + """Test SmartTubController.login.""" + smarttub_api.get_account.return_value.id = "account-id1" + account = await controller.login("test-email1", "test-password1") + smarttub_api.login.assert_called() + assert account == account diff --git a/tests/components/smarttub/test_entity.py b/tests/components/smarttub/test_entity.py new file mode 100644 index 00000000000..4a19b265090 --- /dev/null +++ b/tests/components/smarttub/test_entity.py @@ -0,0 +1,18 @@ +"""Test SmartTubEntity.""" + +from homeassistant.components.smarttub.entity import SmartTubEntity + + +async def test_entity(coordinator, spa): + """Test SmartTubEntity.""" + + entity = SmartTubEntity(coordinator, spa, "entity1") + + assert entity.device_info + assert entity.name + + coordinator.data[spa.id] = {} + assert entity.get_spa_status("foo") is None + coordinator.data[spa.id]["status"] = {"foo": "foo1", "bar": {"baz": "barbaz1"}} + assert entity.get_spa_status("foo") == "foo1" + assert entity.get_spa_status("bar.baz") == "barbaz1" diff --git a/tests/components/smarttub/test_init.py b/tests/components/smarttub/test_init.py new file mode 100644 index 00000000000..aa22780e9b8 --- /dev/null +++ b/tests/components/smarttub/test_init.py @@ -0,0 +1,60 @@ +"""Test smarttub setup process.""" + +import asyncio +from unittest.mock import patch + +import pytest +from smarttub import LoginFailed + +from homeassistant.components import smarttub +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.setup import async_setup_component + + +async def test_setup_with_no_config(hass): + """Test that we do not discover anything.""" + assert await async_setup_component(hass, smarttub.DOMAIN, {}) is True + + # No flows started + assert len(hass.config_entries.flow.async_progress()) == 0 + + assert smarttub.const.SMARTTUB_CONTROLLER not in hass.data[smarttub.DOMAIN] + + +async def test_setup_entry_not_ready(hass, config_entry, smarttub_api): + """Test setup when the entry is not ready.""" + assert await async_setup_component(hass, smarttub.DOMAIN, {}) is True + smarttub_api.login.side_effect = asyncio.TimeoutError + + with pytest.raises(ConfigEntryNotReady): + await smarttub.async_setup_entry(hass, config_entry) + + +async def test_setup_auth_failed(hass, config_entry, smarttub_api): + """Test setup when the credentials are invalid.""" + assert await async_setup_component(hass, smarttub.DOMAIN, {}) is True + smarttub_api.login.side_effect = LoginFailed + + assert await smarttub.async_setup_entry(hass, config_entry) is False + + +async def test_config_passed_to_config_entry(hass, config_entry, config_data): + """Test that configured options are loaded via config entry.""" + config_entry.add_to_hass(hass) + ret = await async_setup_component(hass, smarttub.DOMAIN, config_data) + assert ret is True + + +async def test_unload_entry(hass, config_entry, smarttub_api): + """Test being able to unload an entry.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, smarttub.DOMAIN, {}) is True + + assert await smarttub.async_unload_entry(hass, config_entry) + + # test failure of platform unload + assert await async_setup_component(hass, smarttub.DOMAIN, {}) is True + with patch.object(hass.config_entries, "async_forward_entry_unload") as mock: + mock.return_value = False + assert await smarttub.async_unload_entry(hass, config_entry) is False