Add Balboa Spa integration (#59234)

This commit is contained in:
Tim Rightnour 2021-11-25 11:04:06 -07:00 committed by GitHub
parent 78305ac6ae
commit 03d1efab46
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1182 additions and 0 deletions

View File

@ -90,6 +90,8 @@ omit =
homeassistant/components/azure_devops/sensor.py
homeassistant/components/azure_service_bus/*
homeassistant/components/baidu/tts.py
homeassistant/components/balboa/__init__.py
homeassistant/components/balboa/entity.py
homeassistant/components/beewi_smartclim/sensor.py
homeassistant/components/bbb_gpio/*
homeassistant/components/bbox/device_tracker.py

View File

@ -67,6 +67,7 @@ homeassistant/components/axis/* @Kane610
homeassistant/components/azure_devops/* @timmo001
homeassistant/components/azure_event_hub/* @eavanvalkenburg
homeassistant/components/azure_service_bus/* @hfurubotten
homeassistant/components/balboa/* @garbled1
homeassistant/components/beewi_smartclim/* @alemuro
homeassistant/components/bitcoin/* @fabaff
homeassistant/components/bizkaibus/* @UgaitzEtxebarria

View File

@ -0,0 +1,102 @@
"""The Balboa Spa Client integration."""
import asyncio
from datetime import timedelta
import time
from pybalboa import BalboaSpaWifi
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
import homeassistant.util.dt as dt_util
from .const import (
_LOGGER,
CONF_SYNC_TIME,
DEFAULT_SYNC_TIME,
DOMAIN,
PLATFORMS,
SIGNAL_UPDATE,
)
SYNC_TIME_INTERVAL = timedelta(days=1)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Balboa Spa from a config entry."""
host = entry.data[CONF_HOST]
_LOGGER.debug("Attempting to connect to %s", host)
spa = BalboaSpaWifi(host)
connected = await spa.connect()
if not connected:
_LOGGER.error("Failed to connect to spa at %s", host)
raise ConfigEntryNotReady
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = spa
# send config requests, and then listen until we are configured.
await spa.send_mod_ident_req()
await spa.send_panel_req(0, 1)
async def _async_balboa_update_cb():
"""Primary update callback called from pybalboa."""
_LOGGER.debug("Primary update callback triggered")
async_dispatcher_send(hass, SIGNAL_UPDATE.format(entry.entry_id))
# set the callback so we know we have new data
spa.new_data_cb = _async_balboa_update_cb
_LOGGER.debug("Starting listener and monitor tasks")
hass.loop.create_task(spa.listen())
await spa.spa_configured()
asyncio.create_task(spa.check_connection_status())
# At this point we have a configured spa.
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
# call update_listener on startup and for options change as well.
await async_setup_time_sync(hass, entry)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.debug("Disconnecting from spa")
spa = hass.data[DOMAIN][entry.entry_id]
await spa.disconnect()
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_setup_time_sync(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Set up the time sync."""
if not entry.options.get(CONF_SYNC_TIME, DEFAULT_SYNC_TIME):
return
_LOGGER.debug("Setting up daily time sync")
spa = hass.data[DOMAIN][entry.entry_id]
async def sync_time():
_LOGGER.debug("Syncing time with Home Assistant")
await spa.set_time(time.strptime(str(dt_util.now()), "%Y-%m-%d %H:%M:%S.%f%z"))
await sync_time()
entry.async_on_unload(
async_track_time_interval(hass, sync_time, SYNC_TIME_INTERVAL)
)

View File

@ -0,0 +1,161 @@
"""Support for Balboa Spa Wifi adaptor."""
from __future__ import annotations
import asyncio
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
FAN_OFF,
HVAC_MODE_AUTO,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
SUPPORT_FAN_MODE,
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_HALVES,
PRECISION_WHOLE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.exceptions import HomeAssistantError
from .const import CLIMATE, CLIMATE_SUPPORTED_FANSTATES, CLIMATE_SUPPORTED_MODES, DOMAIN
from .entity import BalboaEntity
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the spa climate device."""
async_add_entities(
[
BalboaSpaClimate(
hass,
entry,
hass.data[DOMAIN][entry.entry_id],
CLIMATE,
)
],
)
class BalboaSpaClimate(BalboaEntity, ClimateEntity):
"""Representation of a Balboa Spa Climate device."""
_attr_icon = "mdi:hot-tub"
_attr_fan_modes = CLIMATE_SUPPORTED_FANSTATES
_attr_hvac_modes = CLIMATE_SUPPORTED_MODES
def __init__(self, hass, entry, client, devtype, num=None):
"""Initialize the climate entity."""
super().__init__(hass, entry, client, devtype, num)
self._balboa_to_ha_blower_map = {
self._client.BLOWER_OFF: FAN_OFF,
self._client.BLOWER_LOW: FAN_LOW,
self._client.BLOWER_MEDIUM: FAN_MEDIUM,
self._client.BLOWER_HIGH: FAN_HIGH,
}
self._ha_to_balboa_blower_map = {
value: key for key, value in self._balboa_to_ha_blower_map.items()
}
self._balboa_to_ha_heatmode_map = {
self._client.HEATMODE_READY: HVAC_MODE_HEAT,
self._client.HEATMODE_RNR: HVAC_MODE_AUTO,
self._client.HEATMODE_REST: HVAC_MODE_OFF,
}
self._ha_heatmode_to_balboa_map = {
value: key for key, value in self._balboa_to_ha_heatmode_map.items()
}
scale = self._client.get_tempscale()
self._attr_preset_modes = self._client.get_heatmode_stringlist()
self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
if self._client.have_blower():
self._attr_supported_features |= SUPPORT_FAN_MODE
self._attr_min_temp = self._client.tmin[self._client.TEMPRANGE_LOW][scale]
self._attr_max_temp = self._client.tmax[self._client.TEMPRANGE_HIGH][scale]
self._attr_temperature_unit = TEMP_FAHRENHEIT
self._attr_precision = PRECISION_WHOLE
if self._client.get_tempscale() == self._client.TSCALE_C:
self._attr_temperature_unit = TEMP_CELSIUS
self._attr_precision = PRECISION_HALVES
@property
def hvac_mode(self) -> str:
"""Return the current HVAC mode."""
mode = self._client.get_heatmode()
return self._balboa_to_ha_heatmode_map[mode]
@property
def hvac_action(self) -> str:
"""Return the current operation mode."""
state = self._client.get_heatstate()
if state >= self._client.ON:
return CURRENT_HVAC_HEAT
return CURRENT_HVAC_IDLE
@property
def fan_mode(self) -> str:
"""Return the current fan mode."""
fanmode = self._client.get_blower()
return self._balboa_to_ha_blower_map.get(fanmode, FAN_OFF)
@property
def current_temperature(self):
"""Return the current temperature."""
return self._client.get_curtemp()
@property
def target_temperature(self):
"""Return the target temperature we try to reach."""
return self._client.get_settemp()
@property
def preset_mode(self):
"""Return current preset mode."""
return self._client.get_heatmode(True)
async def async_set_temperature(self, **kwargs):
"""Set a new target temperature."""
scale = self._client.get_tempscale()
newtemp = kwargs[ATTR_TEMPERATURE]
if newtemp > self._client.tmax[self._client.TEMPRANGE_LOW][scale]:
await self._client.change_temprange(self._client.TEMPRANGE_HIGH)
await asyncio.sleep(1)
if newtemp < self._client.tmin[self._client.TEMPRANGE_HIGH][scale]:
await self._client.change_temprange(self._client.TEMPRANGE_LOW)
await asyncio.sleep(1)
await self._client.send_temp_change(newtemp)
async def async_set_preset_mode(self, preset_mode) -> None:
"""Set new preset mode."""
modelist = self._client.get_heatmode_stringlist()
self._async_validate_mode_or_raise(preset_mode)
if preset_mode not in modelist:
raise HomeAssistantError(f"{preset_mode} is not a valid preset mode")
await self._client.change_heatmode(modelist.index(preset_mode))
async def async_set_fan_mode(self, fan_mode):
"""Set new fan mode."""
await self._client.change_blower(self._ha_to_balboa_blower_map[fan_mode])
def _async_validate_mode_or_raise(self, mode):
"""Check that the mode can be set."""
if mode == self._client.HEATMODE_RNR:
raise HomeAssistantError(f"{mode} can only be reported but not set")
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target hvac mode.
OFF = Rest
AUTO = Ready in Rest (can't be set, only reported)
HEAT = Ready
"""
mode = self._ha_heatmode_to_balboa_map[hvac_mode]
self._async_validate_mode_or_raise(mode)
await self._client.change_heatmode(self._ha_heatmode_to_balboa_map[hvac_mode])

View File

@ -0,0 +1,99 @@
"""Config flow for Balboa Spa Client integration."""
from pybalboa import BalboaSpaWifi
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_HOST
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
from .const import _LOGGER, CONF_SYNC_TIME, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect."""
_LOGGER.debug("Attempting to connect to %s", data[CONF_HOST])
spa = BalboaSpaWifi(data[CONF_HOST])
connected = await spa.connect()
_LOGGER.debug("Got connected = %d", connected)
if not connected:
raise CannotConnect
# send config requests, and then listen until we are configured.
await spa.send_mod_ident_req()
await spa.send_panel_req(0, 1)
hass.loop.create_task(spa.listen())
await spa.spa_configured()
macaddr = format_mac(spa.get_macaddr())
model = spa.get_model_name()
await spa.disconnect()
return {"title": model, "formatted_mac": macaddr}
class BalboaSpaClientFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Balboa Spa Client config flow."""
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return BalboaSpaClientOptionsFlowHandler(config_entry)
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(info["formatted_mac"])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class BalboaSpaClientOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Balboa Spa Client options."""
def __init__(self, config_entry):
"""Initialize Balboa Spa Client options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Manage Balboa Spa Client options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_SYNC_TIME,
default=self.config_entry.options.get(CONF_SYNC_TIME, False),
): bool,
}
),
)

View File

@ -0,0 +1,35 @@
"""Constants for the Balboa Spa Client integration."""
import logging
from homeassistant.components.climate.const import (
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
FAN_OFF,
HVAC_MODE_AUTO,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
)
from homeassistant.components.fan import SPEED_HIGH, SPEED_LOW, SPEED_OFF
_LOGGER = logging.getLogger(__name__)
DOMAIN = "balboa"
CLIMATE_SUPPORTED_FANSTATES = [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH]
CLIMATE_SUPPORTED_MODES = [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF]
CONF_SYNC_TIME = "sync_time"
DEFAULT_SYNC_TIME = False
FAN_SUPPORTED_SPEEDS = [SPEED_OFF, SPEED_LOW, SPEED_HIGH]
PLATFORMS = ["climate"]
AUX = "Aux"
CIRC_PUMP = "Circ Pump"
CLIMATE = "Climate"
FILTER = "Filter"
LIGHT = "Light"
MISTER = "Mister"
PUMP = "Pump"
TEMP_RANGE = "Temp Range"
SIGNAL_UPDATE = "balboa_update_{}"

View File

@ -0,0 +1,57 @@
"""Base class for Balboa Spa Client integration."""
import time
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import SIGNAL_UPDATE
class BalboaEntity(Entity):
"""Abstract class for all Balboa platforms.
Once you connect to the spa's port, it continuously sends data (at a rate
of about 5 per second!). The API updates the internal states of things
from this stream, and all we have to do is read the values out of the
accessors.
"""
_attr_should_poll = False
def __init__(self, hass, entry, client, devtype, num=None):
"""Initialize the spa entity."""
self._client = client
self._device_name = self._client.get_model_name()
self._type = devtype
self._num = num
self._entry = entry
self._attr_unique_id = f'{self._device_name}-{self._type}{self._num or ""}-{self._client.get_macaddr().replace(":","")[-6:]}'
self._attr_name = f'{self._device_name}: {self._type}{self._num or ""}'
self._attr_device_info = DeviceInfo(
name=self._device_name,
manufacturer="Balboa Water Group",
model=self._client.get_model_name(),
sw_version=self._client.get_ssid(),
connections={(CONNECTION_NETWORK_MAC, self._client.get_macaddr())},
)
async def async_added_to_hass(self) -> None:
"""Set up a listener for the entity."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_UPDATE.format(self._entry.entry_id),
self.async_write_ha_state,
)
)
@property
def assumed_state(self) -> bool:
"""Return whether the state is based on actual reading from device."""
return (self._client.lastupd + 5 * 60) < time.time()
@property
def available(self) -> bool:
"""Return whether the entity is available or not."""
return self._client.connected

View File

@ -0,0 +1,13 @@
{
"domain": "balboa",
"name": "Balboa Spa Client",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/balboa",
"requirements": [
"pybalboa==0.13"
],
"codeowners": [
"@garbled1"
],
"iot_class": "local_push"
}

View File

@ -0,0 +1,28 @@
{
"config": {
"step": {
"user": {
"title": "Connect to the Balboa Wi-Fi device",
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {
"step": {
"init": {
"data": {
"sync_time": "Keep your Balboa Spa Client's time synchronized with Home Assistant"
}
}
}
}
}

View File

@ -0,0 +1,28 @@
{
"config": {
"step": {
"user": {
"title": "Connect to the Balboa Wi-Fi device",
"data": {
"host": "Host"
}
}
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured"
}
},
"options": {
"step": {
"init": {
"data": {
"sync_time": "Keep your Balboa Spa Client's time synchronized with Home Assistant"
}
}
}
}
}

View File

@ -35,6 +35,7 @@ FLOWS = [
"awair",
"axis",
"azure_devops",
"balboa",
"blebox",
"blink",
"bmw_connected_drive",

View File

@ -1371,6 +1371,9 @@ pyatome==0.1.1
# homeassistant.components.apple_tv
pyatv==0.8.2
# homeassistant.components.balboa
pybalboa==0.13
# homeassistant.components.bbox
pybbox==0.0.5-alpha

View File

@ -837,6 +837,9 @@ pyatmo==6.2.0
# homeassistant.components.apple_tv
pyatv==0.8.2
# homeassistant.components.balboa
pybalboa==0.13
# homeassistant.components.blackbird
pyblackbird==0.5

View File

@ -0,0 +1,167 @@
"""Test the Balboa Spa Client integration."""
import asyncio
from unittest.mock import patch
from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
BALBOA_DEFAULT_PORT = 4257
TEST_HOST = "balboatest.localdomain"
async def init_integration(hass: HomeAssistant) -> MockConfigEntry:
"""Mock integration setup."""
config_entry = MockConfigEntry(
domain=BALBOA_DOMAIN,
data={
CONF_HOST: TEST_HOST,
},
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.balboa.BalboaSpaWifi",
new=BalboaMock,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry
async def init_integration_mocked(hass: HomeAssistant) -> MockConfigEntry:
"""Mock integration setup."""
config_entry = MockConfigEntry(
domain=BALBOA_DOMAIN,
data={
CONF_HOST: TEST_HOST,
},
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.connect",
new=BalboaMock.connect,
), patch(
"homeassistant.components.balboa.BalboaSpaWifi.listen_until_configured",
new=BalboaMock.listen_until_configured,
), patch(
"homeassistant.components.balboa.BalboaSpaWifi.listen",
new=BalboaMock.listen,
), patch(
"homeassistant.components.balboa.BalboaSpaWifi.check_connection_status",
new=BalboaMock.check_connection_status,
), patch(
"homeassistant.components.balboa.BalboaSpaWifi.send_panel_req",
new=BalboaMock.send_panel_req,
), patch(
"homeassistant.components.balboa.BalboaSpaWifi.send_mod_ident_req",
new=BalboaMock.send_mod_ident_req,
), patch(
"homeassistant.components.balboa.BalboaSpaWifi.spa_configured",
new=BalboaMock.spa_configured,
), patch(
"homeassistant.components.balboa.BalboaSpaWifi.get_model_name",
new=BalboaMock.get_model_name,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry
class BalboaMock:
"""Mock pybalboa library."""
def __init__(self, hostname, port=BALBOA_DEFAULT_PORT):
"""Mock init."""
self.host = hostname
self.port = port
self.connected = False
self.new_data_cb = None
self.lastupd = 0
self.connected = False
self.fake_action = False
async def connect(self):
"""Connect to the spa."""
self.connected = True
return True
async def broken_connect(self):
"""Connect to the spa."""
self.connected = False
return False
async def disconnect(self):
"""Stop talking to the spa."""
self.connected = False
async def send_panel_req(self, arg_ba, arg_bb):
"""Send a panel request, 2 bytes of data."""
self.fake_action = False
return
async def send_mod_ident_req(self):
"""Ask for the module identification."""
self.fake_action = False
return
@staticmethod
def get_macaddr():
"""Return the macaddr of the spa wifi."""
return "ef:ef:ef:c0:ff:ee"
def get_model_name(self):
"""Return the model name."""
self.fake_action = False
return "FakeSpa"
@staticmethod
def get_ssid():
"""Return the software version."""
return "V0.0"
@staticmethod
async def set_time(new_time, timescale=None):
"""Set time on spa to new_time with optional timescale."""
return
async def listen(self):
"""Listen to the spa babble forever."""
while True:
if not self.connected:
# sleep and hope the checker fixes us
await asyncio.sleep(5)
continue
# fake it
await asyncio.sleep(5)
async def check_connection_status(self):
"""Set this up to periodically check the spa connection and fix."""
self.fake_action = False
while True:
# fake it
await asyncio.sleep(15)
async def spa_configured(self):
"""Check if the spa has been configured."""
self.fake_action = False
return
async def int_new_data_cb(self):
"""Call false internal data callback."""
if self.new_data_cb is None:
return
await self.new_data_cb() # pylint: disable=not-callable
async def listen_until_configured(self, maxiter=20):
"""Listen to the spa babble until we are configured."""
if not self.connected:
return False
return True

View File

@ -0,0 +1,272 @@
"""Tests of the climate entity of the balboa integration."""
from unittest.mock import patch
import pytest
from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN, SIGNAL_UPDATE
from homeassistant.components.climate.const import (
ATTR_FAN_MODE,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODES,
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
FAN_OFF,
HVAC_MODE_AUTO,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
SUPPORT_FAN_MODE,
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.const import ATTR_TEMPERATURE, TEMP_FAHRENHEIT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.setup import async_setup_component
from . import init_integration_mocked
from tests.components.climate import common
FAN_SETTINGS = [
FAN_OFF,
FAN_LOW,
FAN_MEDIUM,
FAN_HIGH,
]
HVAC_SETTINGS = [
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
]
ENTITY_CLIMATE = "climate.fakespa_climate"
async def test_spa_defaults(hass: HomeAssistant):
"""Test supported features flags."""
await _setup_climate_test(hass)
state = hass.states.get(ENTITY_CLIMATE)
assert (
state.attributes["supported_features"]
== SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
)
assert state.state == HVAC_MODE_HEAT
assert state.attributes[ATTR_MIN_TEMP] == 10.0
assert state.attributes[ATTR_MAX_TEMP] == 40.0
assert state.attributes[ATTR_PRESET_MODE] == "Ready"
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
async def test_spa_defaults_fake_tscale(hass: HomeAssistant):
"""Test supported features flags."""
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.get_tempscale", return_value=1
):
await _setup_climate_test(hass)
state = hass.states.get(ENTITY_CLIMATE)
assert (
state.attributes["supported_features"]
== SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
)
assert state.state == HVAC_MODE_HEAT
assert state.attributes[ATTR_MIN_TEMP] == 10.0
assert state.attributes[ATTR_MAX_TEMP] == 40.0
assert state.attributes[ATTR_PRESET_MODE] == "Ready"
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
async def test_spa_with_blower(hass: HomeAssistant):
"""Test supported features flags."""
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.have_blower", return_value=True
):
config_entry = await _setup_climate_test(hass)
# force a refresh
async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id))
await hass.async_block_till_done()
state = hass.states.get(ENTITY_CLIMATE)
assert (
state.attributes["supported_features"]
== SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_FAN_MODE
)
for fan_state in range(4):
# set blower
state = await _patch_blower(hass, config_entry, fan_state)
assert state.attributes[ATTR_FAN_MODE] == FAN_SETTINGS[fan_state]
# test the nonsense checks
for fan_state in (None, 70):
state = await _patch_blower(hass, config_entry, fan_state)
assert state.attributes[ATTR_FAN_MODE] == FAN_OFF
async def test_spa_temperature(hass: HomeAssistant):
"""Test spa temperature settings."""
config_entry = await _setup_climate_test(hass)
# flip the spa into F
# set temp to a valid number
state = await _patch_spa_settemp(hass, config_entry, 0, 100.0)
assert state.attributes.get(ATTR_TEMPERATURE) == 38.0
async def test_spa_temperature_unit(hass: HomeAssistant):
"""Test temperature unit conversions."""
with patch.object(hass.config.units, "temperature_unit", TEMP_FAHRENHEIT):
config_entry = await _setup_climate_test(hass)
state = await _patch_spa_settemp(hass, config_entry, 0, 15.4)
assert state.attributes.get(ATTR_TEMPERATURE) == 15.0
async def test_spa_hvac_modes(hass: HomeAssistant):
"""Test hvac modes."""
config_entry = await _setup_climate_test(hass)
# try out the different heat modes
for heat_mode in range(2):
state = await _patch_spa_heatmode(hass, config_entry, heat_mode)
modes = state.attributes.get(ATTR_HVAC_MODES)
assert [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] == modes
assert state.state == HVAC_SETTINGS[heat_mode]
with pytest.raises(HomeAssistantError):
await _patch_spa_heatmode(hass, config_entry, 2)
async def test_spa_hvac_action(hass: HomeAssistant):
"""Test setting of the HVAC action."""
config_entry = await _setup_climate_test(hass)
# try out the different heat states
state = await _patch_spa_heatstate(hass, config_entry, 1)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT
state = await _patch_spa_heatstate(hass, config_entry, 0)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
async def test_spa_preset_modes(hass: HomeAssistant):
"""Test the various preset modes."""
config_entry = await _setup_climate_test(hass)
state = hass.states.get(ENTITY_CLIMATE)
modes = state.attributes.get(ATTR_PRESET_MODES)
assert ["Ready", "Rest", "Ready in Rest"] == modes
# Put it in Ready and Rest
modelist = ["Ready", "Rest"]
for mode in modelist:
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.get_heatmode",
return_value=modelist.index(mode),
):
await common.async_set_preset_mode(hass, mode, ENTITY_CLIMATE)
async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id))
await hass.async_block_till_done()
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes[ATTR_PRESET_MODE] == modelist.index(mode)
# put it in RNR and test assertion
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.get_heatmode",
return_value=2,
), pytest.raises(HomeAssistantError):
await common.async_set_preset_mode(hass, 2, ENTITY_CLIMATE)
# Helpers
async def _patch_blower(hass, config_entry, fan_state):
"""Patch the blower state."""
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.get_blower",
return_value=fan_state,
):
if fan_state is not None and fan_state <= len(FAN_SETTINGS):
await common.async_set_fan_mode(hass, FAN_SETTINGS[fan_state])
async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id))
await hass.async_block_till_done()
return hass.states.get(ENTITY_CLIMATE)
async def _patch_spa_settemp(hass, config_entry, tscale, settemp):
"""Patch the settemp."""
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.get_tempscale",
return_value=tscale,
), patch(
"homeassistant.components.balboa.BalboaSpaWifi.get_settemp",
return_value=settemp,
):
await common.async_set_temperature(
hass, temperature=settemp, entity_id=ENTITY_CLIMATE
)
async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id))
await hass.async_block_till_done()
return hass.states.get(ENTITY_CLIMATE)
async def _patch_spa_heatmode(hass, config_entry, heat_mode):
"""Patch the heatmode."""
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.get_heatmode",
return_value=heat_mode,
):
await common.async_set_hvac_mode(hass, HVAC_SETTINGS[heat_mode], ENTITY_CLIMATE)
async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id))
await hass.async_block_till_done()
return hass.states.get(ENTITY_CLIMATE)
async def _patch_spa_heatstate(hass, config_entry, heat_state):
"""Patch the heatmode."""
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.get_heatstate",
return_value=heat_state,
):
await common.async_set_hvac_mode(
hass, HVAC_SETTINGS[heat_state], ENTITY_CLIMATE
)
async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id))
await hass.async_block_till_done()
return hass.states.get(ENTITY_CLIMATE)
async def _setup_climate_test(hass):
"""Prepare the test."""
config_entry = await init_integration_mocked(hass)
await async_setup_component(hass, BALBOA_DOMAIN, config_entry)
await hass.async_block_till_done()
return config_entry

View File

@ -0,0 +1,167 @@
"""Test the Balboa Spa Client config flow."""
from unittest.mock import patch
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from . import BalboaMock
from tests.common import MockConfigEntry
TEST_DATA = {
CONF_HOST: "1.1.1.1",
}
TEST_ID = "FakeBalboa"
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect",
new=BalboaMock.connect,
), patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.disconnect",
new=BalboaMock.disconnect,
), patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.listen",
new=BalboaMock.listen,
), patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.send_mod_ident_req",
new=BalboaMock.send_mod_ident_req,
), patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.send_panel_req",
new=BalboaMock.send_panel_req,
), patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.spa_configured",
new=BalboaMock.spa_configured,
), patch(
"homeassistant.components.balboa.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_DATA,
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["data"] == TEST_DATA
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect",
new=BalboaMock.broken_connect,
), patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.disconnect",
new=BalboaMock.disconnect,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_DATA,
)
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_unknown_error(hass: HomeAssistant) -> None:
"""Test we handle unknown error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_DATA,
)
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "unknown"}
async def test_already_configured(hass: HomeAssistant) -> None:
"""Test when provided credentials are already configured."""
MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
with patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect",
new=BalboaMock.connect,
), patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi.disconnect",
new=BalboaMock.disconnect,
), patch(
"homeassistant.components.balboa.async_setup_entry",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_DATA,
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_ABORT
assert result2["reason"] == "already_configured"
async def test_options_flow(hass):
"""Test specifying non default settings using options flow."""
config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID)
config_entry.add_to_hass(hass)
# Rather than mocking out 15 or so functions, we just need to mock
# the entire library, otherwise it will get stuck in a listener and
# the various loops in pybalboa.
with patch(
"homeassistant.components.balboa.config_flow.BalboaSpaWifi",
new=BalboaMock,
), patch(
"homeassistant.components.balboa.BalboaSpaWifi",
new=BalboaMock,
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_SYNC_TIME: True},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {CONF_SYNC_TIME: True}

View File

@ -0,0 +1,43 @@
"""Tests of the initialization of the balboa integration."""
from unittest.mock import patch
from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from . import TEST_HOST, BalboaMock, init_integration
from tests.common import MockConfigEntry
async def test_setup_entry(hass: HomeAssistant):
"""Validate that setup entry also configure the client."""
config_entry = await init_integration(hass)
assert config_entry.state == ConfigEntryState.LOADED
await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state == ConfigEntryState.NOT_LOADED
async def test_setup_entry_fails(hass):
"""Validate that setup entry also configure the client."""
config_entry = MockConfigEntry(
domain=BALBOA_DOMAIN,
data={
CONF_HOST: TEST_HOST,
},
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.balboa.BalboaSpaWifi.connect",
new=BalboaMock.broken_connect,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.SETUP_RETRY