diff --git a/.coveragerc b/.coveragerc index 18a4ddfbcf8..2fcdebffccc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index 48a3b5ed02b..6804400f6a4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py new file mode 100644 index 00000000000..0922218aa5c --- /dev/null +++ b/homeassistant/components/balboa/__init__.py @@ -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) + ) diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py new file mode 100644 index 00000000000..567c65d6388 --- /dev/null +++ b/homeassistant/components/balboa/climate.py @@ -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]) diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py new file mode 100644 index 00000000000..1c91376d76e --- /dev/null +++ b/homeassistant/components/balboa/config_flow.py @@ -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, + } + ), + ) diff --git a/homeassistant/components/balboa/const.py b/homeassistant/components/balboa/const.py new file mode 100644 index 00000000000..121c6be3bfa --- /dev/null +++ b/homeassistant/components/balboa/const.py @@ -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_{}" diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py new file mode 100644 index 00000000000..016beadac5c --- /dev/null +++ b/homeassistant/components/balboa/entity.py @@ -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 diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json new file mode 100644 index 00000000000..aa52bee230d --- /dev/null +++ b/homeassistant/components/balboa/manifest.json @@ -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" +} diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json new file mode 100644 index 00000000000..68bd4ddef7b --- /dev/null +++ b/homeassistant/components/balboa/strings.json @@ -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" + } + } + } + } +} diff --git a/homeassistant/components/balboa/translations/en.json b/homeassistant/components/balboa/translations/en.json new file mode 100644 index 00000000000..15d8aa2b47e --- /dev/null +++ b/homeassistant/components/balboa/translations/en.json @@ -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" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6c199b47e32..0c0ea7964e0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -35,6 +35,7 @@ FLOWS = [ "awair", "axis", "azure_devops", + "balboa", "blebox", "blink", "bmw_connected_drive", diff --git a/requirements_all.txt b/requirements_all.txt index f63190bbd6e..3cfcf26657c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b7c224ec34..45a20c67ff2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/balboa/__init__.py b/tests/components/balboa/__init__.py new file mode 100644 index 00000000000..13c8b6240a7 --- /dev/null +++ b/tests/components/balboa/__init__.py @@ -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 diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py new file mode 100644 index 00000000000..53eb0307beb --- /dev/null +++ b/tests/components/balboa/test_climate.py @@ -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 diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py new file mode 100644 index 00000000000..fc12289d90a --- /dev/null +++ b/tests/components/balboa/test_config_flow.py @@ -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} diff --git a/tests/components/balboa/test_init.py b/tests/components/balboa/test_init.py new file mode 100644 index 00000000000..ac0dea3b007 --- /dev/null +++ b/tests/components/balboa/test_init.py @@ -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