parent
899342d391
commit
11ccd166fe
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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_{}"
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue