mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 14:17:45 +00:00
Add SmartTub integration (#37775)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
4236f6e5d4
commit
58499946ed
@ -421,6 +421,7 @@ homeassistant/components/smappee/* @bsmappee
|
|||||||
homeassistant/components/smart_meter_texas/* @grahamwetzler
|
homeassistant/components/smart_meter_texas/* @grahamwetzler
|
||||||
homeassistant/components/smarthab/* @outadoc
|
homeassistant/components/smarthab/* @outadoc
|
||||||
homeassistant/components/smartthings/* @andrewsayre
|
homeassistant/components/smartthings/* @andrewsayre
|
||||||
|
homeassistant/components/smarttub/* @mdz
|
||||||
homeassistant/components/smarty/* @z0mbieprocess
|
homeassistant/components/smarty/* @z0mbieprocess
|
||||||
homeassistant/components/sms/* @ocalvo
|
homeassistant/components/sms/* @ocalvo
|
||||||
homeassistant/components/smtp/* @fabaff
|
homeassistant/components/smtp/* @fabaff
|
||||||
|
54
homeassistant/components/smarttub/__init__.py
Normal file
54
homeassistant/components/smarttub/__init__.py
Normal 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
|
116
homeassistant/components/smarttub/climate.py
Normal file
116
homeassistant/components/smarttub/climate.py
Normal 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()
|
53
homeassistant/components/smarttub/config_flow.py
Normal file
53
homeassistant/components/smarttub/config_flow.py
Normal 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)
|
14
homeassistant/components/smarttub/const.py
Normal file
14
homeassistant/components/smarttub/const.py
Normal 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
|
110
homeassistant/components/smarttub/controller.py
Normal file
110
homeassistant/components/smarttub/controller.py
Normal 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()
|
64
homeassistant/components/smarttub/entity.py
Normal file
64
homeassistant/components/smarttub/entity.py
Normal 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
|
8
homeassistant/components/smarttub/helpers.py
Normal file
8
homeassistant/components/smarttub/helpers.py
Normal 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}"
|
12
homeassistant/components/smarttub/manifest.json
Normal file
12
homeassistant/components/smarttub/manifest.json
Normal 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"
|
||||||
|
}
|
22
homeassistant/components/smarttub/strings.json
Normal file
22
homeassistant/components/smarttub/strings.json
Normal 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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
homeassistant/components/smarttub/translations/en.json
Normal file
22
homeassistant/components/smarttub/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -197,6 +197,7 @@ FLOWS = [
|
|||||||
"smart_meter_texas",
|
"smart_meter_texas",
|
||||||
"smarthab",
|
"smarthab",
|
||||||
"smartthings",
|
"smartthings",
|
||||||
|
"smarttub",
|
||||||
"smhi",
|
"smhi",
|
||||||
"sms",
|
"sms",
|
||||||
"solaredge",
|
"solaredge",
|
||||||
|
@ -1809,6 +1809,9 @@ python-qbittorrent==0.4.2
|
|||||||
# homeassistant.components.ripple
|
# homeassistant.components.ripple
|
||||||
python-ripple-api==0.0.3
|
python-ripple-api==0.0.3
|
||||||
|
|
||||||
|
# homeassistant.components.smarttub
|
||||||
|
python-smarttub==0.0.6
|
||||||
|
|
||||||
# homeassistant.components.sochain
|
# homeassistant.components.sochain
|
||||||
python-sochain-api==0.0.2
|
python-sochain-api==0.0.2
|
||||||
|
|
||||||
|
@ -934,6 +934,9 @@ python-nest==4.1.0
|
|||||||
# homeassistant.components.ozw
|
# homeassistant.components.ozw
|
||||||
python-openzwave-mqtt[mqtt-client]==1.4.0
|
python-openzwave-mqtt[mqtt-client]==1.4.0
|
||||||
|
|
||||||
|
# homeassistant.components.smarttub
|
||||||
|
python-smarttub==0.0.6
|
||||||
|
|
||||||
# homeassistant.components.songpal
|
# homeassistant.components.songpal
|
||||||
python-songpal==0.12
|
python-songpal==0.12
|
||||||
|
|
||||||
|
1
tests/components/smarttub/__init__.py
Normal file
1
tests/components/smarttub/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the smarttub integration."""
|
86
tests/components/smarttub/conftest.py
Normal file
86
tests/components/smarttub/conftest.py
Normal 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
|
74
tests/components/smarttub/test_climate.py
Normal file
74
tests/components/smarttub/test_climate.py
Normal 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
|
64
tests/components/smarttub/test_config_flow.py
Normal file
64
tests/components/smarttub/test_config_flow.py
Normal 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"}
|
37
tests/components/smarttub/test_controller.py
Normal file
37
tests/components/smarttub/test_controller.py
Normal 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
|
18
tests/components/smarttub/test_entity.py
Normal file
18
tests/components/smarttub/test_entity.py
Normal 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"
|
60
tests/components/smarttub/test_init.py
Normal file
60
tests/components/smarttub/test_init.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user