parent
4236f6e5d4
commit
58499946ed
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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()
|
|
@ -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)
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
|
@ -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}"
|
|
@ -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"
|
||||
}
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
"smarthab",
|
||||
"smartthings",
|
||||
"smarttub",
|
||||
"smhi",
|
||||
"sms",
|
||||
"solaredge",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the smarttub integration."""
|
|
@ -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
|
|
@ -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
|
|
@ -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"}
|
|
@ -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
|
|
@ -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"
|
|
@ -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…
Reference in New Issue