diff --git a/.coveragerc b/.coveragerc index 42c05d72f6d34..01c582aa353e7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -104,7 +104,6 @@ omit = homeassistant/components/baf/sensor.py homeassistant/components/baf/switch.py homeassistant/components/baidu/tts.py - homeassistant/components/balboa/__init__.py homeassistant/components/bbox/device_tracker.py homeassistant/components/bbox/sensor.py homeassistant/components/beewi_smartclim/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index dafd89c2f563a..c5c5400c65568 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -135,8 +135,8 @@ build.json @home-assistant/supervisor /tests/components/backup/ @home-assistant/core /homeassistant/components/baf/ @bdraco @jfroy /tests/components/baf/ @bdraco @jfroy -/homeassistant/components/balboa/ @garbled1 -/tests/components/balboa/ @garbled1 +/homeassistant/components/balboa/ @garbled1 @natekspencer +/tests/components/balboa/ @garbled1 @natekspencer /homeassistant/components/bayesian/ @HarvsG /tests/components/bayesian/ @HarvsG /homeassistant/components/beewi_smartclim/ @alemuro diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 6be1d7411374f..eadf18f05da45 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -1,29 +1,27 @@ """The Balboa Spa Client integration.""" -import asyncio +from __future__ import annotations + from datetime import datetime, timedelta -import time +import logging -from pybalboa import BalboaSpaWifi +from pybalboa import SpaClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform 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, -) +from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE] + KEEP_ALIVE_INTERVAL = timedelta(minutes=1) -SYNC_TIME_INTERVAL = timedelta(days=1) +SYNC_TIME_INTERVAL = timedelta(hours=1) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -31,48 +29,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = entry.data[CONF_HOST] _LOGGER.debug("Attempting to connect to %s", host) - spa = BalboaSpaWifi(host) - connected = await spa.connect() - if not connected: + spa = SpaClient(host) + if not await spa.connect(): _LOGGER.error("Failed to connect to spa at %s", host) - raise ConfigEntryNotReady + raise ConfigEntryNotReady("Unable to connect") + if not await spa.async_configuration_loaded(): + _LOGGER.error("Failed to get spa info at %s", host) + raise ConfigEntryNotReady("Unable to configure") hass.data.setdefault(DOMAIN, {})[entry.entry_id] = spa - async def _async_balboa_update_cb() -> None: - """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") - monitoring_tasks = [asyncio.create_task(spa.listen())] - await spa.spa_configured() - monitoring_tasks.append(asyncio.create_task(spa.check_connection_status())) - - def stop_monitoring() -> None: - """Stop monitoring the spa connection.""" - _LOGGER.debug("Canceling listener and monitor tasks") - for task in monitoring_tasks: - task.cancel() - - entry.async_on_unload(stop_monitoring) - - # At this point we have a configured spa. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def keep_alive(now: datetime) -> None: - """Keep alive task.""" - _LOGGER.debug("Keep alive") - await spa.send_mod_ident_req() - - entry.async_on_unload( - async_track_time_interval(hass, keep_alive, KEEP_ALIVE_INTERVAL) - ) - - # 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)) @@ -82,7 +50,7 @@ async def keep_alive(now: datetime) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.debug("Disconnecting from spa") - spa: BalboaSpaWifi = hass.data[DOMAIN][entry.entry_id] + spa: SpaClient = hass.data[DOMAIN][entry.entry_id] if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) @@ -103,11 +71,13 @@ async def async_setup_time_sync(hass: HomeAssistant, entry: ConfigEntry) -> None return _LOGGER.debug("Setting up daily time sync") - spa: BalboaSpaWifi = hass.data[DOMAIN][entry.entry_id] + spa: SpaClient = hass.data[DOMAIN][entry.entry_id] async def sync_time(now: datetime) -> None: - _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")) + now = dt_util.as_local(now) + if (now.hour, now.minute) != (spa.time_hour, spa.time_minute): + _LOGGER.debug("Syncing time with Home Assistant") + await spa.set_time(now.hour, now.minute) await sync_time(dt_util.utcnow()) entry.async_on_unload( diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index f9c436b81bbbf..11a0cae0a01b7 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -1,68 +1,98 @@ """Support for Balboa Spa binary sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + +from pybalboa import SpaClient + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CIRC_PUMP, DOMAIN, FILTER +from .const import DOMAIN from .entity import BalboaEntity -FILTER_STATES = [ - [False, False], # self.FILTER_OFF - [True, False], # self.FILTER_1 - [False, True], # self.FILTER_2 - [True, True], # self.FILTER_1_2 -] - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the spa's binary sensors.""" - spa = hass.data[DOMAIN][entry.entry_id] - entities: list[BalboaSpaBinarySensor] = [ - BalboaSpaFilter(entry, spa, FILTER, index) for index in range(1, 3) + spa: SpaClient = hass.data[DOMAIN][entry.entry_id] + entities = [ + BalboaBinarySensorEntity(spa, description) + for description in BINARY_SENSOR_DESCRIPTIONS ] - if spa.have_circ_pump(): - entities.append(BalboaSpaCircPump(entry, spa, CIRC_PUMP)) - + if spa.circulation_pump is not None: + entities.append(BalboaBinarySensorEntity(spa, CIRCULATION_PUMP_DESCRIPTION)) async_add_entities(entities) -class BalboaSpaBinarySensor(BalboaEntity, BinarySensorEntity): - """Representation of a Balboa Spa binary sensor entity.""" - - _attr_device_class = BinarySensorDeviceClass.MOVING - +@dataclass +class BalboaBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + is_on_fn: Callable[[SpaClient], bool] + on_off_icons: tuple[str, str] + + +@dataclass +class BalboaBinarySensorEntityDescription( + BinarySensorEntityDescription, BalboaBinarySensorEntityDescriptionMixin +): + """A class that describes Balboa binary sensor entities.""" + + +FILTER_CYCLE_ICONS = ("mdi:sync", "mdi:sync-off") +BINARY_SENSOR_DESCRIPTIONS = ( + BalboaBinarySensorEntityDescription( + key="filter_cycle_1", + name="Filter1", + device_class=BinarySensorDeviceClass.RUNNING, + is_on_fn=lambda spa: spa.filter_cycle_1_running, + on_off_icons=FILTER_CYCLE_ICONS, + ), + BalboaBinarySensorEntityDescription( + key="filter_cycle_2", + name="Filter2", + device_class=BinarySensorDeviceClass.RUNNING, + is_on_fn=lambda spa: spa.filter_cycle_2_running, + on_off_icons=FILTER_CYCLE_ICONS, + ), +) +CIRCULATION_PUMP_DESCRIPTION = BalboaBinarySensorEntityDescription( + key="circulation_pump", + name="Circ Pump", + device_class=BinarySensorDeviceClass.RUNNING, + is_on_fn=lambda spa: (pump := spa.circulation_pump) is not None and pump.state > 0, + on_off_icons=("mdi:pump", "mdi:pump-off"), +) -class BalboaSpaCircPump(BalboaSpaBinarySensor): - """Representation of a Balboa Spa circulation pump.""" - @property - def is_on(self) -> bool: - """Return true if the filter is on.""" - return self._client.get_circ_pump() - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:water-pump" if self.is_on else "mdi:water-pump-off" +class BalboaBinarySensorEntity(BalboaEntity, BinarySensorEntity): + """Representation of a Balboa Spa binary sensor entity.""" + entity_description: BalboaBinarySensorEntityDescription -class BalboaSpaFilter(BalboaSpaBinarySensor): - """Representation of a Balboa Spa Filter.""" + def __init__( + self, spa: SpaClient, description: BalboaBinarySensorEntityDescription + ) -> None: + """Initialize a Balboa binary sensor entity.""" + super().__init__(spa, description.name) + self.entity_description = description @property def is_on(self) -> bool: - """Return true if the filter is on.""" - return FILTER_STATES[self._client.get_filtermode()][self._num - 1] + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self._client) @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:sync" if self.is_on else "mdi:sync-off" + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + icons = self.entity_description.on_off_icons + return icons[0] if self.is_on else icons[1] diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index c04f6367cfd7e..06e8d26550282 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -1,14 +1,13 @@ """Support for Balboa Spa Wifi adaptor.""" from __future__ import annotations -import asyncio +from enum import IntEnum from typing import Any +from pybalboa import SpaClient, SpaControl +from pybalboa.enums import HeatMode, HeatState, TemperatureUnit + from homeassistant.components.climate import ( - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, - FAN_OFF, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -24,139 +23,122 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CLIMATE, CLIMATE_SUPPORTED_FANSTATES, CLIMATE_SUPPORTED_MODES, DOMAIN +from .const import DOMAIN from .entity import BalboaEntity -SET_TEMPERATURE_WAIT = 1 +HEAT_HVAC_MODE_MAP: dict[IntEnum, HVACMode] = { + HeatMode.READY: HVACMode.HEAT, + HeatMode.REST: HVACMode.OFF, + HeatMode.READY_IN_REST: HVACMode.AUTO, +} +HVAC_HEAT_MODE_MAP = {value: key for key, value in HEAT_HVAC_MODE_MAP.items()} +HEAT_STATE_HVAC_ACTION_MAP = { + HeatState.OFF: HVACAction.OFF, + HeatState.HEATING: HVACAction.HEATING, + HeatState.HEAT_WAITING: HVACAction.IDLE, +} +TEMPERATURE_UNIT_MAP = { + TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS, + TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, +} async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the spa climate device.""" - async_add_entities( - [ - BalboaSpaClimate( - entry, - hass.data[DOMAIN][entry.entry_id], - CLIMATE, - ) - ], - ) + """Set up the spa climate entity.""" + async_add_entities([BalboaClimateEntity(hass.data[DOMAIN][entry.entry_id])]) -class BalboaSpaClimate(BalboaEntity, ClimateEntity): - """Representation of a Balboa Spa Climate device.""" +class BalboaClimateEntity(BalboaEntity, ClimateEntity): + """Representation of a Balboa spa climate entity.""" _attr_icon = "mdi:hot-tub" - _attr_fan_modes = CLIMATE_SUPPORTED_FANSTATES - _attr_hvac_modes = CLIMATE_SUPPORTED_MODES + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_translation_key = DOMAIN - def __init__(self, entry, client, devtype, num=None): + def __init__(self, client: SpaClient) -> None: """Initialize the climate entity.""" - super().__init__(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: HVACMode.HEAT, - self._client.HEATMODE_RNR: HVACMode.AUTO, - self._client.HEATMODE_REST: HVACMode.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 = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - ) - if self._client.have_blower(): + super().__init__(client, "Climate") + self._attr_preset_modes = [opt.name.lower() for opt in client.heat_mode.options] + + self._blower: SpaControl | None = None + if client.blowers and (blower := client.blowers[0]) is not None: + self._blower = blower self._attr_supported_features |= ClimateEntityFeature.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 = UnitOfTemperature.FAHRENHEIT - self._attr_precision = PRECISION_WHOLE - if self._client.get_tempscale() == self._client.TSCALE_C: - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_precision = PRECISION_HALVES + self._fan_mode_map = {opt.name.lower(): opt for opt in blower.options} + self._attr_fan_modes = list(self._fan_mode_map) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode | None: """Return the current HVAC mode.""" - mode = self._client.get_heatmode() - return self._balboa_to_ha_heatmode_map[mode] + return HEAT_HVAC_MODE_MAP.get(self._client.heat_mode.state) @property def hvac_action(self) -> str: """Return the current operation mode.""" - if self._client.get_heatstate() >= self._client.ON: - return HVACAction.HEATING - return HVACAction.IDLE + return HEAT_STATE_HVAC_ACTION_MAP[self._client.heat_state] @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) + def fan_mode(self) -> str | None: + """Return the fan setting.""" + if (blower := self._blower) is not None: + return blower.state.name.lower() + return None @property - def current_temperature(self): + def precision(self) -> float: + """Return the precision of the system.""" + if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS: + return PRECISION_HALVES + return PRECISION_WHOLE + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMPERATURE_UNIT_MAP[self._client.temperature_unit] + + @property + def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._client.get_curtemp() + return self._client.temperature @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the target temperature we try to reach.""" - return self._client.get_settemp() + return self._client.target_temperature + + @property + def min_temp(self) -> float: + """Return the minimum temperature supported by the spa.""" + return self._client.temperature_minimum @property - def preset_mode(self): + def max_temp(self) -> float: + """Return the minimum temperature supported by the spa.""" + return self._client.temperature_maximum + + @property + def preset_mode(self) -> str: """Return current preset mode.""" - return self._client.get_heatmode(True) + return self._client.heat_mode.state.name.lower() async def async_set_temperature(self, **kwargs: Any) -> None: """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(SET_TEMPERATURE_WAIT) - if newtemp < self._client.tmin[self._client.TEMPRANGE_HIGH][scale]: - await self._client.change_temprange(self._client.TEMPRANGE_LOW) - await asyncio.sleep(SET_TEMPERATURE_WAIT) - await self._client.send_temp_change(newtemp) + await self._client.set_temperature(kwargs[ATTR_TEMPERATURE]) async def async_set_preset_mode(self, preset_mode: str) -> 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 ValueError(f"{preset_mode} is not a valid preset mode") - await self._client.change_heatmode(modelist.index(preset_mode)) + await self._client.heat_mode.set_state(HeatMode[preset_mode.upper()]) async def async_set_fan_mode(self, fan_mode: str) -> None: """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 ValueError(f"{mode} can only be reported but not set") + if (blower := self._blower) is not None: + await blower.set_state(self._fan_mode_map[fan_mode]) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """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]) + """Set new target hvac mode.""" + await self._client.heat_mode.set_state(HVAC_HEAT_MODE_MAP[hvac_mode]) diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py index e9f94795ea545..73f19f0e32785 100644 --- a/homeassistant/components/balboa/config_flow.py +++ b/homeassistant/components/balboa/config_flow.py @@ -1,13 +1,15 @@ """Config flow for Balboa Spa Client integration.""" from __future__ import annotations -import asyncio +import logging from typing import Any -from pybalboa import BalboaSpaWifi +from pybalboa import SpaClient +from pybalboa.exceptions import SpaConnectionError import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import exceptions +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -17,7 +19,9 @@ SchemaOptionsFlowHandler, ) -from .const import _LOGGER, CONF_SYNC_TIME, DOMAIN +from .const import CONF_SYNC_TIME, DOMAIN + +_LOGGER = logging.getLogger(__name__) DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) @@ -34,33 +38,28 @@ async def validate_input(data: dict[str, Any]) -> dict[str, str]: """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 - - task = asyncio.create_task(spa.listen()) - await spa.spa_configured() + try: + async with SpaClient(data[CONF_HOST]) as spa: + if not await spa.async_configuration_loaded(): + raise CannotConnect + mac = format_mac(spa.mac_address) + model = spa.model + except SpaConnectionError as err: + raise CannotConnect from err - mac_addr = format_mac(spa.get_macaddr()) - model = spa.get_model_name() - task.cancel() - await spa.disconnect() + return {"title": model, "formatted_mac": mac} - return {"title": model, "formatted_mac": mac_addr} - -class BalboaSpaClientFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Balboa Spa Client config flow.""" VERSION = 1 + _host: str | None + @staticmethod @callback - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> SchemaOptionsFlowHandler: + def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/balboa/const.py b/homeassistant/components/balboa/const.py index dcd9c05ac919b..23189a4d7e9dd 100644 --- a/homeassistant/components/balboa/const.py +++ b/homeassistant/components/balboa/const.py @@ -1,34 +1,4 @@ """Constants for the Balboa Spa Client integration.""" -from __future__ import annotations - -import logging - -from homeassistant.components.climate import ( - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, - FAN_OFF, - HVACMode, -) -from homeassistant.const import Platform - -_LOGGER = logging.getLogger(__name__) - DOMAIN = "balboa" - -CLIMATE_SUPPORTED_FANSTATES = [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH] -CLIMATE_SUPPORTED_MODES = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] CONF_SYNC_TIME = "sync_time" DEFAULT_SYNC_TIME = False -PLATFORMS = [Platform.BINARY_SENSOR, Platform.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 index 44f0635024337..e50c35db47777 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -1,57 +1,45 @@ -"""Base class for Balboa Spa Client integration.""" -import time +"""Balboa entities.""" +from __future__ import annotations + +from pybalboa import EVENT_UPDATE, SpaClient 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 - +from .const import DOMAIN -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. - """ +class BalboaBaseEntity(Entity): + """Balboa base entity.""" - _attr_should_poll = False + def __init__(self, client: SpaClient, name: str | None = None) -> None: + """Initialize the control.""" + mac = client.mac_address + model = client.model - def __init__(self, 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_should_poll = False + self._attr_unique_id = f'{model}-{name}-{mac.replace(":","")[-6:]}' + self._attr_name = name + self._attr_has_entity_name = True self._attr_device_info = DeviceInfo( - name=self._device_name, + identifiers={(DOMAIN, mac)}, + name=model, 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, - ) + model=model, + sw_version=client.software_version, + connections={(CONNECTION_NETWORK_MAC, mac)}, ) + self._client = client @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() + return not self._client.available - @property - def available(self) -> bool: - """Return whether the entity is available or not.""" - return self._client.connected + +class BalboaEntity(BalboaBaseEntity): + """Balboa entity.""" + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self.async_on_remove(self._client.on(EVENT_UPDATE, self.async_write_ha_state)) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index b5c4636e55149..1eb31c6577086 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -3,8 +3,8 @@ "name": "Balboa Spa Client", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/balboa", - "requirements": ["pybalboa==0.13"], - "codeowners": ["@garbled1"], + "requirements": ["pybalboa==1.0.0"], + "codeowners": ["@garbled1", "@natekspencer"], "iot_class": "local_push", "loggers": ["pybalboa"] } diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 68bd4ddef7b58..214ccf8fbe1d5 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -20,7 +20,22 @@ "step": { "init": { "data": { - "sync_time": "Keep your Balboa Spa Client's time synchronized with Home Assistant" + "sync_time": "Keep your Balboa spa's time synchronized with Home Assistant" + } + } + } + }, + "entity": { + "climate": { + "balboa": { + "state_attributes": { + "preset_mode": { + "state": { + "ready": "Ready", + "rest": "Rest", + "ready_in_rest": "Ready-in-rest" + } + } } } } diff --git a/homeassistant/components/balboa/translations/en.json b/homeassistant/components/balboa/translations/en.json index bad5167fc5ec7..7a9018f598da2 100644 --- a/homeassistant/components/balboa/translations/en.json +++ b/homeassistant/components/balboa/translations/en.json @@ -16,11 +16,26 @@ } } }, + "entity": { + "climate": { + "balboa": { + "state_attributes": { + "preset_mode": { + "state": { + "ready": "Ready", + "ready_in_rest": "Ready-in-rest", + "rest": "Rest" + } + } + } + } + } + }, "options": { "step": { "init": { "data": { - "sync_time": "Keep your Balboa Spa Client's time synchronized with Home Assistant" + "sync_time": "Keep your Balboa spa's time synchronized with Home Assistant" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 57e7637b2d8d7..43b8de46d5e13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1516,7 +1516,7 @@ pyatv==0.10.3 pyaussiebb==0.0.15 # homeassistant.components.balboa -pybalboa==0.13 +pybalboa==1.0.0 # homeassistant.components.bbox pybbox==0.0.5-alpha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7392de2ad06e..ca30c886311d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ pyatv==0.10.3 pyaussiebb==0.0.15 # homeassistant.components.balboa -pybalboa==0.13 +pybalboa==1.0.0 # homeassistant.components.blackbird pyblackbird==0.5 diff --git a/tests/components/balboa/__init__.py b/tests/components/balboa/__init__.py index 7cae68f220328..a8641db01a128 100644 --- a/tests/components/balboa/__init__.py +++ b/tests/components/balboa/__init__.py @@ -1,25 +1,21 @@ """Test the Balboa Spa Client integration.""" -from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN +from __future__ import annotations + +from homeassistant.components.balboa import CONF_SYNC_TIME, 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, - }, + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: TEST_HOST}, options={CONF_SYNC_TIME: True} ) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - - return config_entry + return entry diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index fbc7faf4d30f6..04447d0b3ccc2 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -1,94 +1,61 @@ """Provide common fixtures.""" from __future__ import annotations -from collections.abc import Generator -import time -from unittest.mock import MagicMock, patch +from collections.abc import Callable, Generator +from unittest.mock import AsyncMock, MagicMock, patch -from pybalboa.balboa import text_heatmode +from pybalboa.enums import HeatMode import pytest +from homeassistant.core import HomeAssistant -@pytest.fixture(name="client") -def client_fixture() -> Generator[None, MagicMock, None]: - """Mock balboa.""" - with patch( - "homeassistant.components.balboa.BalboaSpaWifi", autospec=True - ) as mock_balboa: - # common attributes - client = mock_balboa.return_value - client.connected = True - client.lastupd = time.time() - client.new_data_cb = None - client.connect.return_value = True - client.get_macaddr.return_value = "ef:ef:ef:c0:ff:ee" - client.get_model_name.return_value = "FakeSpa" - client.get_ssid.return_value = "V0.0" - - # constants should preferably be moved in the library - # to be class attributes or further refactored - client.TSCALE_C = 1 - client.TSCALE_F = 0 - client.HEATMODE_READY = 0 - client.HEATMODE_REST = 1 - client.HEATMODE_RNR = 2 - client.TIMESCALE_12H = 0 - client.TIMESCALE_24H = 1 - client.PUMP_OFF = 0 - client.PUMP_LOW = 1 - client.PUMP_HIGH = 2 - client.TEMPRANGE_LOW = 0 - client.TEMPRANGE_HIGH = 1 - client.tmin = [ - [50.0, 10.0], - [80.0, 26.0], - ] - client.tmax = [ - [80.0, 26.0], - [104.0, 40.0], - ] - client.BLOWER_OFF = 0 - client.BLOWER_LOW = 1 - client.BLOWER_MEDIUM = 2 - client.BLOWER_HIGH = 3 - client.FILTER_OFF = 0 - client.FILTER_1 = 1 - client.FILTER_2 = 2 - client.FILTER_1_2 = 3 - client.OFF = 0 - client.ON = 1 - client.HEATSTATE_IDLE = 0 - client.HEATSTATE_HEATING = 1 - client.HEATSTATE_HEAT_WAITING = 2 - client.VOLTAGE_240 = 240 - client.VOLTAGE_UNKNOWN = 0 - client.HEATERTYPE_STANDARD = "Standard" - client.HEATERTYPE_UNKNOWN = "Unknown" +from . import init_integration - # Climate attributes - client.heatmode = 0 - client.get_heatmode_stringlist.return_value = text_heatmode - client.get_tempscale.return_value = client.TSCALE_F - client.have_blower.return_value = False +from tests.common import MockConfigEntry - # Climate methods - client.get_heatstate.return_value = 0 - client.get_blower.return_value = 0 - client.get_curtemp.return_value = 20.0 - client.get_settemp.return_value = 20.0 - def get_heatmode(text=False): - """Ask for the current heatmode.""" - if text: - return text_heatmode[client.heatmode] - return client.heatmode +@pytest.fixture(name="integration") +async def integration_fixture(hass: HomeAssistant) -> MockConfigEntry: + """Set up the balboa integration.""" + return await init_integration(hass) - client.get_heatmode.side_effect = get_heatmode - yield client +@pytest.fixture(name="client") +def client_fixture() -> Generator[MagicMock, None, None]: + """Mock balboa spa client.""" + with patch( + "homeassistant.components.balboa.SpaClient", autospec=True + ) as mock_balboa: + client = mock_balboa.return_value + callback: list[Callable] = [] + + def on(_, _callback: Callable): # pylint: disable=invalid-name + callback.append(_callback) + return lambda: None + + def emit(_): + for _cb in callback: + _cb() + + client.on.side_effect = on + client.emit.side_effect = emit + + client.model = "FakeSpa" + client.mac_address = "ef:ef:ef:c0:ff:ee" + client.software_version = "M0 V0.0" + + client.blowers = [] + client.circulation_pump.state = 0 + client.filter_cycle_1_running = False + client.filter_cycle_2_running = False + client.temperature_unit = 1 + client.temperature = 10 + client.temperature_minimum = 10 + client.temperature_maximum = 40 + client.target_temperature = 40 + client.heat_mode.state = HeatMode.READY + client.heat_mode.set_state = AsyncMock() + client.heat_mode.options = list(HeatMode)[:2] + client.heat_state = 2 -@pytest.fixture(autouse=True) -def set_temperature_wait(): - """Mock set temperature wait time.""" - with patch("homeassistant.components.balboa.climate.SET_TEMPERATURE_WAIT", new=0): - yield + yield client diff --git a/tests/components/balboa/test_binary_sensor.py b/tests/components/balboa/test_binary_sensor.py index 4f080f29ab3f5..e97887b154a34 100644 --- a/tests/components/balboa/test_binary_sensor.py +++ b/tests/components/balboa/test_binary_sensor.py @@ -1,56 +1,46 @@ """Tests of the climate entity of the balboa integration.""" +from __future__ import annotations + from unittest.mock import MagicMock from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from . import init_integration +from tests.common import MockConfigEntry ENTITY_BINARY_SENSOR = "binary_sensor.fakespa_" -FILTER_MAP = [ - [STATE_OFF, STATE_OFF], - [STATE_ON, STATE_OFF], - [STATE_OFF, STATE_ON], - [STATE_ON, STATE_ON], -] - -async def test_filters(hass: HomeAssistant, client: MagicMock) -> None: +async def test_filters( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """Test spa filters.""" + for num in (1, 2): + sensor = f"{ENTITY_BINARY_SENSOR}filter{num}" - config_entry = await init_integration(hass) + state = hass.states.get(sensor) + assert state.state == STATE_OFF - for filter_mode in range(4): - for spa_filter in range(1, 3): - state = await _patch_filter( - hass, config_entry, filter_mode, spa_filter, client - ) - assert state.state == FILTER_MAP[filter_mode][spa_filter - 1] + setattr(client, f"filter_cycle_{num}_running", True) + client.emit("") + await hass.async_block_till_done() + state = hass.states.get(sensor) + assert state.state == STATE_ON -async def test_circ_pump(hass: HomeAssistant, client: MagicMock) -> None: + +async def test_circ_pump( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """Test spa circ pump.""" - client.have_circ_pump.return_value = (True,) - config_entry = await init_integration(hass) + sensor = f"{ENTITY_BINARY_SENSOR}circ_pump" - state = await _patch_circ_pump(hass, config_entry, True, client) - assert state.state == STATE_ON - state = await _patch_circ_pump(hass, config_entry, False, client) + state = hass.states.get(sensor) assert state.state == STATE_OFF - -async def _patch_circ_pump(hass, config_entry, pump_state, client): - """Patch the circ pump state.""" - client.get_circ_pump.return_value = pump_state - await client.new_data_cb() + client.circulation_pump.state = 1 + client.emit("") await hass.async_block_till_done() - return hass.states.get(f"{ENTITY_BINARY_SENSOR}circ_pump") - -async def _patch_filter(hass, config_entry, filter_mode, num, client): - """Patch the filter state.""" - client.get_filtermode.return_value = filter_mode - await client.new_data_cb() - await hass.async_block_till_done() - return hass.states.get(f"{ENTITY_BINARY_SENSOR}filter{num}") + state = hass.states.get(sensor) + assert state.state == STATE_ON diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 50ca33c5209ce..4967bcdfa38f7 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -3,10 +3,13 @@ from unittest.mock import MagicMock, patch +from pybalboa import SpaControl +from pybalboa.enums import HeatMode, OffLowMediumHighState import pytest from homeassistant.components.climate import ( ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HVAC_ACTION, ATTR_HVAC_MODES, ATTR_MAX_TEMP, @@ -22,19 +25,13 @@ HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from . import init_integration +from tests.common import MockConfigEntry from tests.components.climate import common -FAN_SETTINGS = [ - FAN_OFF, - FAN_LOW, - FAN_MEDIUM, - FAN_HIGH, -] - HVAC_SETTINGS = [ HVACMode.HEAT, HVACMode.OFF, @@ -44,10 +41,10 @@ ENTITY_CLIMATE = "climate.fakespa_climate" -async def test_spa_defaults(hass: HomeAssistant, client: MagicMock) -> None: +async def test_spa_defaults( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """Test supported features flags.""" - await init_integration(hass) - state = hass.states.get(ENTITY_CLIMATE) assert state @@ -58,15 +55,15 @@ async def test_spa_defaults(hass: HomeAssistant, client: MagicMock) -> None: assert state.state == HVACMode.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_PRESET_MODE] == "ready" assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE -async def test_spa_defaults_fake_tscale(hass: HomeAssistant, client: MagicMock) -> None: +async def test_spa_defaults_fake_tscale( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """Test supported features flags.""" - client.get_tempscale.return_value = 1 - - await init_integration(hass) + client.temperature_unit = 1 state = hass.states.get(ENTITY_CLIMATE) @@ -78,172 +75,161 @@ async def test_spa_defaults_fake_tscale(hass: HomeAssistant, client: MagicMock) assert state.state == HVACMode.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_PRESET_MODE] == "ready" assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE -async def test_spa_with_blower(hass: HomeAssistant, client: MagicMock) -> None: - """Test supported features flags.""" - client.have_blower.return_value = True - - config_entry = await init_integration(hass) - - # force a refresh - await client.new_data_cb() - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_CLIMATE) - - assert state - assert ( - state.attributes["supported_features"] - == ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.FAN_MODE - ) - - for fan_state in range(4): - # set blower - state = await _patch_blower(hass, config_entry, fan_state, client) - assert state - assert state.attributes[ATTR_FAN_MODE] == FAN_SETTINGS[fan_state] - - # test the nonsense checks - for fan_state in (None, 70): # type: ignore[assignment] - state = await _patch_blower(hass, config_entry, fan_state, client) - assert state - assert state.attributes[ATTR_FAN_MODE] == FAN_OFF - - -async def test_spa_temperature(hass: HomeAssistant, client: MagicMock) -> None: +async def test_spa_temperature( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """Test spa temperature settings.""" - - config_entry = await init_integration(hass) - # flip the spa into F # set temp to a valid number - state = await _patch_spa_settemp(hass, config_entry, 0, 100.0, client) - assert state + state = await _patch_spa_settemp(hass, client, 0, 100) assert state.attributes.get(ATTR_TEMPERATURE) == 38.0 -async def test_spa_temperature_unit(hass: HomeAssistant, client: MagicMock) -> None: +async def test_spa_temperature_unit( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """Test temperature unit conversions.""" - with patch.object( hass.config.units, "temperature_unit", UnitOfTemperature.FAHRENHEIT ): - config_entry = await init_integration(hass) - - state = await _patch_spa_settemp(hass, config_entry, 0, 15.4, client) - assert state + state = await _patch_spa_settemp(hass, client, 0, 15.4) assert state.attributes.get(ATTR_TEMPERATURE) == 15.0 -async def test_spa_hvac_modes(hass: HomeAssistant, client: MagicMock) -> None: +async def test_spa_hvac_modes( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """Test hvac modes.""" - - config_entry = await init_integration(hass) - # try out the different heat modes - for heat_mode in range(2): - state = await _patch_spa_heatmode(hass, config_entry, heat_mode, client) - assert state + for heat_mode in list(HeatMode)[:2]: + state = await _patch_spa_heatmode(hass, client, heat_mode) modes = state.attributes.get(ATTR_HVAC_MODES) - assert [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] == modes + assert modes == [HVACMode.HEAT, HVACMode.OFF] assert state.state == HVAC_SETTINGS[heat_mode] - with pytest.raises(ValueError): - await _patch_spa_heatmode(hass, config_entry, 2, client) - -async def test_spa_hvac_action(hass: HomeAssistant, client: MagicMock) -> None: +async def test_spa_hvac_action( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """Test setting of the HVAC action.""" - - config_entry = await init_integration(hass) - # try out the different heat states - state = await _patch_spa_heatstate(hass, config_entry, 1, client) - assert state + state = await _patch_spa_heatstate(hass, client, 0) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + + state = await _patch_spa_heatstate(hass, client, 1) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - state = await _patch_spa_heatstate(hass, config_entry, 0, client) + state = await _patch_spa_heatstate(hass, client, 2) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE -async def test_spa_preset_modes(hass: HomeAssistant, client: MagicMock) -> None: +async def test_spa_preset_modes( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """Test the various preset modes.""" - - await init_integration(hass) - state = hass.states.get(ENTITY_CLIMATE) assert state modes = state.attributes.get(ATTR_PRESET_MODES) - assert ["Ready", "Rest", "Ready in Rest"] == modes + assert modes == ["ready", "rest"] # Put it in Ready and Rest - modelist = ["Ready", "Rest"] + modelist = ["ready", "rest"] for mode in modelist: - client.heatmode = modelist.index(mode) + client.heat_mode.state = HeatMode[mode.upper()] await common.async_set_preset_mode(hass, mode, ENTITY_CLIMATE) - await client.new_data_cb() - await hass.async_block_till_done() - state = hass.states.get(ENTITY_CLIMATE) + state = await _client_update(hass, client) assert state assert state.attributes[ATTR_PRESET_MODE] == mode + with pytest.raises(KeyError): + await common.async_set_preset_mode(hass, 2, ENTITY_CLIMATE) + # put it in RNR and test assertion - client.heatmode = 2 + client.heat_mode.state = HeatMode.READY_IN_REST + state = await _client_update(hass, client) + assert state + assert state.attributes[ATTR_PRESET_MODE] == "ready_in_rest" - with pytest.raises(ValueError): - await common.async_set_preset_mode(hass, 2, ENTITY_CLIMATE) +async def test_spa_with_blower(hass: HomeAssistant, client: MagicMock) -> None: + """Test supported features flags.""" + blower = MagicMock(SpaControl) + blower.state = OffLowMediumHighState.OFF + blower.options = list(OffLowMediumHighState) + client.blowers = [blower] + + await init_integration(hass) + + state = hass.states.get(ENTITY_CLIMATE) + + assert state + assert ( + state.attributes["supported_features"] + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + assert state.state == HVACMode.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] == HVACAction.IDLE + assert state.attributes[ATTR_FAN_MODES] == ["off", "low", "medium", "high"] + assert state.attributes[ATTR_FAN_MODE] == FAN_OFF + + for fan_mode in (FAN_LOW, FAN_MEDIUM, FAN_HIGH): + client.blowers[0].set_state.reset_mock() + state = await _patch_blower(hass, client, fan_mode) + assert state.attributes[ATTR_FAN_MODE] == fan_mode + client.blowers[0].set_state.assert_called_once() -# Helpers -async def _patch_blower(hass, config_entry, fan_state, client): - """Patch the blower state.""" - client.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]) - await client.new_data_cb() +# Helpers +async def _client_update(hass: HomeAssistant, client: MagicMock) -> State: + """Update the client.""" + client.emit("") await hass.async_block_till_done() + assert (state := hass.states.get(ENTITY_CLIMATE)) is not None + return state - return hass.states.get(ENTITY_CLIMATE) +async def _patch_blower(hass: HomeAssistant, client: MagicMock, fan_mode: str) -> State: + """Patch the blower state.""" + client.blowers[0].state = OffLowMediumHighState[fan_mode.upper()] + await common.async_set_fan_mode(hass, fan_mode) + return await _client_update(hass, client) -async def _patch_spa_settemp(hass, config_entry, tscale, settemp, client): - """Patch the settemp.""" - client.get_tempscale.return_value = tscale - client.get_settemp.return_value = settemp +async def _patch_spa_settemp( + hass: HomeAssistant, client: MagicMock, tscale: int, settemp: float +) -> State: + """Patch the settemp.""" + client.temperature_unit = tscale + client.target_temperature = settemp await common.async_set_temperature( hass, temperature=settemp, entity_id=ENTITY_CLIMATE ) - await client.new_data_cb() - await hass.async_block_till_done() + return await _client_update(hass, client) - return hass.states.get(ENTITY_CLIMATE) - -async def _patch_spa_heatmode(hass, config_entry, heat_mode, client): +async def _patch_spa_heatmode( + hass: HomeAssistant, client: MagicMock, heat_mode: int +) -> State: """Patch the heatmode.""" - client.heatmode = heat_mode - + client.heat_mode.state = heat_mode await common.async_set_hvac_mode(hass, HVAC_SETTINGS[heat_mode], ENTITY_CLIMATE) - await client.new_data_cb() - await hass.async_block_till_done() - - return hass.states.get(ENTITY_CLIMATE) + return await _client_update(hass, client) -async def _patch_spa_heatstate(hass, config_entry, heat_state, client): +async def _patch_spa_heatstate( + hass: HomeAssistant, client: MagicMock, heat_state: int +) -> State: """Patch the heatmode.""" - client.get_heatstate.return_value = heat_state - + client.heat_state = heat_state await common.async_set_hvac_mode(hass, HVAC_SETTINGS[heat_state], ENTITY_CLIMATE) - await client.new_data_cb() - await hass.async_block_till_done() - - return hass.states.get(ENTITY_CLIMATE) + return await _client_update(hass, client) diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py index d6be9feb72729..44ead926e46c5 100644 --- a/tests/components/balboa/test_config_flow.py +++ b/tests/components/balboa/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Balboa Spa Client config flow.""" from unittest.mock import MagicMock, patch +from pybalboa.exceptions import SpaConnectionError + 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 @@ -25,7 +27,7 @@ async def test_form(hass: HomeAssistant, client: MagicMock) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi", + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", return_value=client, ), patch( "homeassistant.components.balboa.async_setup_entry", @@ -49,17 +51,35 @@ async def test_form_cannot_connect(hass: HomeAssistant, client: MagicMock) -> No ) with patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi", + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", return_value=client, + side_effect=SpaConnectionError(), ): - client.connect.return_value = False - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_DATA ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_spa_not_configured(hass: HomeAssistant, client: MagicMock) -> None: + """Test we handle spa not configured error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + ): + client.async_configuration_loaded.return_value = False + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_DATA + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} async def test_unknown_error(hass: HomeAssistant, client: MagicMock) -> None: @@ -69,10 +89,10 @@ async def test_unknown_error(hass: HomeAssistant, client: MagicMock) -> None: ) with patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi", + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", return_value=client, + side_effect=Exception("Boom"), ): - client.connect.side_effect = Exception("Boom") result2 = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, @@ -94,7 +114,7 @@ async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> Non assert result["step_id"] == SOURCE_USER with patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi", + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", return_value=client, ), patch( "homeassistant.components.balboa.async_setup_entry", diff --git a/tests/components/balboa/test_init.py b/tests/components/balboa/test_init.py index a0b6e6a78bae1..867339c56ef8d 100644 --- a/tests/components/balboa/test_init.py +++ b/tests/components/balboa/test_init.py @@ -7,20 +7,18 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import TEST_HOST, init_integration +from . import TEST_HOST from tests.common import MockConfigEntry -async def test_setup_entry(hass: HomeAssistant, client: MagicMock) -> None: +async def test_setup_entry( + hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +) -> None: """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 + assert integration.state == ConfigEntryState.LOADED + await hass.config_entries.async_unload(integration.entry_id) + assert integration.state == ConfigEntryState.NOT_LOADED async def test_setup_entry_fails(hass: HomeAssistant, client: MagicMock) -> None: @@ -39,3 +37,11 @@ async def test_setup_entry_fails(hass: HomeAssistant, client: MagicMock) -> None await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.SETUP_RETRY + + client.connect.return_value = True + client.async_configuration_loaded.return_value = False + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_RETRY