Use pybalboa 1.0.0 (#87214)

* Use pybalboa 1.0.0

* Code changes per PR review
This commit is contained in:
Nathan Spencer 2023-02-05 11:10:35 -07:00 committed by GitHub
parent 899342d391
commit 11ccd166fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 527 additions and 594 deletions

View File

@ -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

View File

@ -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

View File

@ -1,29 +1,27 @@
"""The Balboa Spa Client integration."""
import asyncio
from datetime import datetime, timedelta
import time
from __future__ import annotations
from pybalboa import BalboaSpaWifi
from datetime import datetime, timedelta
import logging
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 async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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(

View File

@ -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):
@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 BalboaBinarySensorEntity(BalboaEntity, BinarySensorEntity):
"""Representation of a Balboa Spa binary sensor entity."""
_attr_device_class = BinarySensorDeviceClass.MOVING
entity_description: BalboaBinarySensorEntityDescription
class BalboaSpaCircPump(BalboaSpaBinarySensor):
"""Representation of a Balboa Spa circulation pump."""
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 self._client.get_circ_pump()
"""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:water-pump" if self.is_on else "mdi:water-pump-off"
class BalboaSpaFilter(BalboaSpaBinarySensor):
"""Representation of a Balboa Spa Filter."""
@property
def is_on(self) -> bool:
"""Return true if the filter is on."""
return FILTER_STATES[self._client.get_filtermode()][self._num - 1]
@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]

View File

@ -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.const import (
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 preset_mode(self):
def min_temp(self) -> float:
"""Return the minimum temperature supported by the spa."""
return self._client.temperature_minimum
@property
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])

View File

@ -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 @@ from homeassistant.helpers.schema_config_entry_flow import (
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 @@ OPTIONS_FLOW = {
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
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
task = asyncio.create_task(spa.listen())
await spa.spa_configured()
mac_addr = format_mac(spa.get_macaddr())
model = spa.get_model_name()
task.cancel()
await spa.disconnect()
return {"title": model, "formatted_mac": mac_addr}
return {"title": model, "formatted_mac": mac}
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)

View File

@ -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_{}"

View File

@ -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.
class BalboaBaseEntity(Entity):
"""Balboa base entity."""
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.
"""
def __init__(self, client: SpaClient, name: str | None = None) -> None:
"""Initialize the control."""
mac = client.mac_address
model = client.model
_attr_should_poll = False
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))

View File

@ -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"]
}

View File

@ -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"
}
}
}
}
}

View File

@ -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"
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
from . import init_integration
from tests.common import MockConfigEntry
@pytest.fixture(name="integration")
async def integration_fixture(hass: HomeAssistant) -> MockConfigEntry:
"""Set up the balboa integration."""
return await init_integration(hass)
@pytest.fixture(name="client")
def client_fixture() -> Generator[None, MagicMock, None]:
"""Mock balboa."""
def client_fixture() -> Generator[MagicMock, None, None]:
"""Mock balboa spa client."""
with patch(
"homeassistant.components.balboa.BalboaSpaWifi", autospec=True
"homeassistant.components.balboa.SpaClient", 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"
callback: list[Callable] = []
# 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"
def on(_, _callback: Callable): # pylint: disable=invalid-name
callback.append(_callback)
return lambda: None
# 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
def emit(_):
for _cb in callback:
_cb()
# 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
client.on.side_effect = on
client.emit.side_effect = emit
def get_heatmode(text=False):
"""Ask for the current heatmode."""
if text:
return text_heatmode[client.heatmode]
return client.heatmode
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
client.get_heatmode.side_effect = get_heatmode
yield client
@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

View File

@ -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

View File

@ -3,10 +3,13 @@ from __future__ import annotations
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 @@ from homeassistant.components.climate import (
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,9 +41,29 @@ HVAC_SETTINGS = [
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
assert (
state.attributes["supported_features"]
== ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_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
async def test_spa_defaults_fake_tscale(
hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry
) -> None:
"""Test supported features flags."""
client.temperature_unit = 1
state = hass.states.get(ENTITY_CLIMATE)
@ -58,39 +75,95 @@ 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:
"""Test supported features flags."""
client.get_tempscale.return_value = 1
async def test_spa_temperature(
hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry
) -> None:
"""Test spa temperature settings."""
# flip the spa into F
# set temp to a valid number
state = await _patch_spa_settemp(hass, client, 0, 100)
assert state.attributes.get(ATTR_TEMPERATURE) == 38.0
await init_integration(hass)
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
):
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, integration: MockConfigEntry
) -> None:
"""Test hvac modes."""
# try out the different heat modes
for heat_mode in list(HeatMode)[:2]:
state = await _patch_spa_heatmode(hass, client, heat_mode)
modes = state.attributes.get(ATTR_HVAC_MODES)
assert modes == [HVACMode.HEAT, HVACMode.OFF]
assert state.state == HVAC_SETTINGS[heat_mode]
async def test_spa_hvac_action(
hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry
) -> None:
"""Test setting of the HVAC action."""
# try out the different heat states
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, client, 2)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE
async def test_spa_preset_modes(
hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry
) -> None:
"""Test the various preset modes."""
state = hass.states.get(ENTITY_CLIMATE)
assert state
assert (
state.attributes["supported_features"]
== ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_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
modes = state.attributes.get(ATTR_PRESET_MODES)
assert modes == ["ready", "rest"]
# Put it in Ready and Rest
modelist = ["ready", "rest"]
for mode in modelist:
client.heat_mode.state = HeatMode[mode.upper()]
await common.async_set_preset_mode(hass, mode, 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.heat_mode.state = HeatMode.READY_IN_REST
state = await _client_update(hass, client)
assert state
assert state.attributes[ATTR_PRESET_MODE] == "ready_in_rest"
async def test_spa_with_blower(hass: HomeAssistant, client: MagicMock) -> None:
"""Test supported features flags."""
client.have_blower.return_value = True
blower = MagicMock(SpaControl)
blower.state = OffLowMediumHighState.OFF
blower.options = list(OffLowMediumHighState)
client.blowers = [blower]
config_entry = await init_integration(hass)
# force a refresh
await client.new_data_cb()
await hass.async_block_till_done()
await init_integration(hass)
state = hass.states.get(ENTITY_CLIMATE)
@ -101,149 +174,62 @@ async def test_spa_with_blower(hass: HomeAssistant, client: MagicMock) -> None:
| 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:
"""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
assert state.attributes.get(ATTR_TEMPERATURE) == 38.0
async def test_spa_temperature_unit(hass: HomeAssistant, client: MagicMock) -> 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
assert state.attributes.get(ATTR_TEMPERATURE) == 15.0
async def test_spa_hvac_modes(hass: HomeAssistant, client: MagicMock) -> 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
modes = state.attributes.get(ATTR_HVAC_MODES)
assert [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] == modes
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:
"""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
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
state = await _patch_spa_heatstate(hass, config_entry, 0, client)
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
async def test_spa_preset_modes(hass: HomeAssistant, client: MagicMock) -> 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
# Put it in Ready and Rest
modelist = ["Ready", "Rest"]
for mode in modelist:
client.heatmode = modelist.index(mode)
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)
assert state
assert state.attributes[ATTR_PRESET_MODE] == mode
# put it in RNR and test assertion
client.heatmode = 2
with pytest.raises(ValueError):
await common.async_set_preset_mode(hass, 2, ENTITY_CLIMATE)
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()
async def _client_update(hass: HomeAssistant, client: MagicMock) -> State:
"""Update the client."""
client.emit("")
await hass.async_block_till_done()
return hass.states.get(ENTITY_CLIMATE)
assert (state := hass.states.get(ENTITY_CLIMATE)) is not None
return state
async def _patch_spa_settemp(hass, config_entry, tscale, settemp, client):
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: HomeAssistant, client: MagicMock, tscale: int, settemp: float
) -> State:
"""Patch the settemp."""
client.get_tempscale.return_value = tscale
client.get_settemp.return_value = 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 hass.states.get(ENTITY_CLIMATE)
return await _client_update(hass, client)
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)

View File

@ -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",

View File

@ -7,20 +7,18 @@ from homeassistant.config_entries import ConfigEntryState
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