Add SmartTub integration (#37775)

Co-authored-by: J. Nick Koston <nick@koston.org>
pull/46686/head
Matt Zimmerman 2021-02-16 21:37:56 -08:00 committed by GitHub
parent 4236f6e5d4
commit 58499946ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 823 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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}"

View File

@ -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"
}

View File

@ -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%]"
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -197,6 +197,7 @@ FLOWS = [
"smart_meter_texas",
"smarthab",
"smartthings",
"smarttub",
"smhi",
"sms",
"solaredge",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the smarttub integration."""

View File

@ -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

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

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

View File

@ -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