diff --git a/homeassistant/components/hotspring/__init__.py b/homeassistant/components/hotspring/__init__.py index effd51f0ecd3..afbc8f1850c4 100644 --- a/homeassistant/components/hotspring/__init__.py +++ b/homeassistant/components/hotspring/__init__.py @@ -9,6 +9,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/hotspring/icons.json b/homeassistant/components/hotspring/icons.json new file mode 100644 index 000000000000..d36d678665c0 --- /dev/null +++ b/homeassistant/components/hotspring/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "select": { + "heating_mode": { + "default": "mdi:radiator" + }, + "jet": { + "default": "mdi:pump" + } + } + } +} diff --git a/homeassistant/components/hotspring/quality_scale.yaml b/homeassistant/components/hotspring/quality_scale.yaml index fba94802633d..52357b521c3b 100644 --- a/homeassistant/components/hotspring/quality_scale.yaml +++ b/homeassistant/components/hotspring/quality_scale.yaml @@ -67,9 +67,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: - status: exempt - comment: Entity relies on standard platform default icons. + icon-translations: done reconfiguration-flow: done repair-issues: status: exempt diff --git a/homeassistant/components/hotspring/select.py b/homeassistant/components/hotspring/select.py new file mode 100644 index 000000000000..7974bbea4ac0 --- /dev/null +++ b/homeassistant/components/hotspring/select.py @@ -0,0 +1,227 @@ +"""Support for Hot Spring select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import cast, override + +from hotspring import HeatingMode, HotSpring, Jet, JetSpeed, Spa + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HotSpringConfigEntry, HotSpringDataUpdateCoordinator +from .entity import HotSpringEntity +from .helpers import hotspring_exception_handler + +PARALLEL_UPDATES = 1 + +OPTION_OFF = "off" +OPTION_LOW = "low" +OPTION_HIGH = "high" + +JET_SPEED_TO_OPTION: dict[JetSpeed, str] = { + JetSpeed.OFF: OPTION_OFF, + JetSpeed.LOW_SPEED: OPTION_LOW, + JetSpeed.HIGH_SPEED: OPTION_HIGH, +} + +OPTION_TO_JET_SPEED: dict[str, JetSpeed] = { + OPTION_OFF: JetSpeed.OFF, + OPTION_LOW: JetSpeed.LOW_SPEED, + OPTION_HIGH: JetSpeed.HIGH_SPEED, +} + +DUAL_SPEED_OPTIONS = [OPTION_OFF, OPTION_LOW, OPTION_HIGH] +SINGLE_SPEED_OPTIONS = [OPTION_OFF, OPTION_HIGH] + +OPTION_HEAT_SAVER = "heat_saver" +OPTION_HEAT_WITH_BOOST = "heat_with_boost" +OPTION_AUTO_SAVER = "auto_saver" +OPTION_AUTO_WITH_BOOST = "auto_with_boost" +OPTION_CHILL = "chill" + +HEATING_MODE_TO_OPTION: dict[HeatingMode, str] = { + HeatingMode.HEAT_SAVER: OPTION_HEAT_SAVER, + HeatingMode.HEAT_WITH_BOOST: OPTION_HEAT_WITH_BOOST, + HeatingMode.AUTO_SAVER: OPTION_AUTO_SAVER, + HeatingMode.AUTO_WITH_BOOST: OPTION_AUTO_WITH_BOOST, + HeatingMode.CHILL: OPTION_CHILL, +} + +OPTION_TO_HEATING_MODE: dict[str, HeatingMode] = { + OPTION_HEAT_SAVER: HeatingMode.HEAT_SAVER, + OPTION_HEAT_WITH_BOOST: HeatingMode.HEAT_WITH_BOOST, + OPTION_AUTO_SAVER: HeatingMode.AUTO_SAVER, + OPTION_AUTO_WITH_BOOST: HeatingMode.AUTO_WITH_BOOST, + OPTION_CHILL: HeatingMode.CHILL, +} + + +@dataclass(frozen=True, kw_only=True) +class HotSpringJetSelectEntityDescription(SelectEntityDescription): + """Class describing Hot Spring jet select entities.""" + + current_option_fn: Callable[[Jet], str | None] + select_option_fn: Callable[[HotSpring, int, str], Awaitable[None]] + options_fn: Callable[[Jet], list[str]] + exists_fn: Callable[[Jet], bool] = lambda jet: jet.is_enabled + + +@dataclass(frozen=True, kw_only=True) +class HotSpringHeatingModeSelectEntityDescription(SelectEntityDescription): + """Class describing Hot Spring heating mode select entities.""" + + current_option_fn: Callable[[Spa], str | None] + select_option_fn: Callable[[HotSpring, str], Awaitable[None]] + options_fn: Callable[[Spa], list[str]] + exists_fn: Callable[[Spa], bool] = lambda _: True + + +def _heating_mode_options(spa: Spa) -> list[str]: + """Return available heating mode options.""" + options = [ + OPTION_HEAT_SAVER, + OPTION_HEAT_WITH_BOOST, + OPTION_AUTO_SAVER, + OPTION_AUTO_WITH_BOOST, + ] + if spa.heater.heatpump_installed: + options.append(OPTION_CHILL) + return options + + +JET_DESCRIPTIONS: tuple[HotSpringJetSelectEntityDescription, ...] = ( + HotSpringJetSelectEntityDescription( + key="jet", + translation_key="jet", + options_fn=lambda jet: ( + DUAL_SPEED_OPTIONS if jet.is_dual_speed else SINGLE_SPEED_OPTIONS + ), + current_option_fn=lambda jet: JET_SPEED_TO_OPTION.get(jet.speed), + select_option_fn=lambda hotspring, jet_id, option: hotspring.set_jet( + jet_id, OPTION_TO_JET_SPEED[option] + ), + ), +) + +HEATING_MODE_DESCRIPTIONS: tuple[HotSpringHeatingModeSelectEntityDescription, ...] = ( + HotSpringHeatingModeSelectEntityDescription( + key="heating_mode", + translation_key="heating_mode", + entity_category=EntityCategory.CONFIG, + exists_fn=lambda spa: spa.heater.heating_mode in HEATING_MODE_TO_OPTION, + options_fn=_heating_mode_options, + current_option_fn=lambda spa: HEATING_MODE_TO_OPTION.get( + spa.heater.heating_mode + ), + select_option_fn=lambda hotspring, option: hotspring.set_heating_mode( + OPTION_TO_HEATING_MODE[option] + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HotSpringConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hot Spring select entities.""" + coordinator = entry.runtime_data + async_add_entities( + HotSpringJetSelectEntity(coordinator, description, jet.jet_id) + for description in JET_DESCRIPTIONS + for jet in coordinator.data.jets.values() + if description.exists_fn(jet) + ) + async_add_entities( + HotSpringHeatingModeSelectEntity(coordinator, description) + for description in HEATING_MODE_DESCRIPTIONS + if description.exists_fn(coordinator.data) + ) + + +class HotSpringJetSelectEntity(HotSpringEntity, SelectEntity): + """Defines a Hot Spring jet select entity.""" + + entity_description: HotSpringJetSelectEntityDescription + + def __init__( + self, + coordinator: HotSpringDataUpdateCoordinator, + description: HotSpringJetSelectEntityDescription, + jet_id: int, + ) -> None: + """Initialize the jet select entity.""" + super().__init__(coordinator, f"{description.key}_{jet_id}") + self.entity_description = description + self._jet_id = jet_id + self._attr_translation_placeholders = {"jet": str(jet_id)} + + @property + def _jet(self) -> Jet: + """Return the jet data.""" + return self.coordinator.data.jets[self._jet_id] + + @property + @override + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self.entity_description.options_fn(self._jet) + + @property + @override + def current_option(self) -> str | None: + """Return the current select option.""" + return self.entity_description.current_option_fn(self._jet) + + @hotspring_exception_handler + @override + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_option_fn( + self.coordinator.hotspring, self._jet_id, option + ) + self.coordinator.async_set_updated_data( + cast(Spa, self.coordinator.hotspring.spa) + ) + + +class HotSpringHeatingModeSelectEntity(HotSpringEntity, SelectEntity): + """Defines a Hot Spring heating mode select entity.""" + + entity_description: HotSpringHeatingModeSelectEntityDescription + + def __init__( + self, + coordinator: HotSpringDataUpdateCoordinator, + description: HotSpringHeatingModeSelectEntityDescription, + ) -> None: + """Initialize the heating mode select entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + @override + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self.entity_description.options_fn(self.coordinator.data) + + @property + @override + def current_option(self) -> str | None: + """Return the current select option.""" + return self.entity_description.current_option_fn(self.coordinator.data) + + @hotspring_exception_handler + @override + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_option_fn( + self.coordinator.hotspring, option + ) + self.coordinator.async_set_updated_data( + cast(Spa, self.coordinator.hotspring.spa) + ) diff --git a/homeassistant/components/hotspring/strings.json b/homeassistant/components/hotspring/strings.json index b845d4fd4942..20c3e5719cde 100644 --- a/homeassistant/components/hotspring/strings.json +++ b/homeassistant/components/hotspring/strings.json @@ -45,6 +45,26 @@ "name": "Target temperature" } }, + "select": { + "heating_mode": { + "name": "Heating mode", + "state": { + "auto_saver": "Auto saver", + "auto_with_boost": "Auto with boost", + "chill": "Chill", + "heat_saver": "Heat saver", + "heat_with_boost": "Heat with boost" + } + }, + "jet": { + "name": "Jet {jet}", + "state": { + "high": "High", + "low": "Low", + "off": "[%key:common::state::off%]" + } + } + }, "sensor": { "control_box_version": { "name": "Control box version" diff --git a/tests/components/hotspring/snapshots/test_select.ambr b/tests/components/hotspring/snapshots/test_select.ambr new file mode 100644 index 000000000000..77a4c0439f3a --- /dev/null +++ b/tests/components/hotspring/snapshots/test_select.ambr @@ -0,0 +1,186 @@ +# serializer version: 1 +# name: test_select_state[select.connectedspa_ddeeff_heating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'heat_saver', + 'heat_with_boost', + 'auto_saver', + 'auto_with_boost', + 'chill', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.connectedspa_ddeeff_heating_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heating mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating mode', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_mode', + 'unique_id': 'AA:BB:CC:DD:EE:FF_heating_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state[select.connectedspa_ddeeff_heating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF Heating mode', + : list([ + 'heat_saver', + 'heat_with_boost', + 'auto_saver', + 'auto_with_boost', + 'chill', + ]), + }), + 'context': , + 'entity_id': 'select.connectedspa_ddeeff_heating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_saver', + }) +# --- +# name: test_select_state[select.connectedspa_ddeeff_jet_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.connectedspa_ddeeff_jet_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Jet 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Jet 1', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'jet', + 'unique_id': 'AA:BB:CC:DD:EE:FF_jet_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state[select.connectedspa_ddeeff_jet_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF Jet 1', + : list([ + 'off', + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.connectedspa_ddeeff_jet_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_select_state[select.connectedspa_ddeeff_jet_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.connectedspa_ddeeff_jet_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Jet 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Jet 2', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'jet', + 'unique_id': 'AA:BB:CC:DD:EE:FF_jet_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state[select.connectedspa_ddeeff_jet_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF Jet 2', + : list([ + 'off', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.connectedspa_ddeeff_jet_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- diff --git a/tests/components/hotspring/test_select.py b/tests/components/hotspring/test_select.py new file mode 100644 index 000000000000..c94b2c58796f --- /dev/null +++ b/tests/components/hotspring/test_select.py @@ -0,0 +1,168 @@ +"""Tests for the Hot Spring select platform.""" + +from unittest.mock import MagicMock + +from hotspring import ( + HeatingMode, + HotSpringConnectionError, + HotSpringError, + Jet, + JetSpeed, + JetSpeedType, + Spa, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +JET_1_ENTITY_ID = "select.connectedspa_ddeeff_jet_1" +HEATING_MODE_ENTITY_ID = "select.connectedspa_ddeeff_heating_mode" + + +@pytest.mark.usefixtures("mock_hotspring") +async def test_select_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_fixture: Spa, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the select entities state.""" + device_fixture.jets[1].speed_type = JetSpeedType.DUAL_SPEED + device_fixture.jets[1].speed = JetSpeed.LOW_SPEED + device_fixture.jets[2].speed_type = JetSpeedType.SINGLE_SPEED + device_fixture.jets[2].speed = JetSpeed.HIGH_SPEED + device_fixture.heater.heatpump_installed = True + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SELECT]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "option", "method_name", "expected_args"), + [ + pytest.param( + JET_1_ENTITY_ID, + "high", + "set_jet", + (1, JetSpeed.HIGH_SPEED), + id="jet_speed", + ), + pytest.param( + HEATING_MODE_ENTITY_ID, + "heat_with_boost", + "set_heating_mode", + (HeatingMode.HEAT_WITH_BOOST,), + id="heating_mode", + ), + ], +) +async def test_select_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hotspring: MagicMock, + device_fixture: Spa, + entity_id: str, + option: str, + method_name: str, + expected_args: tuple[object, ...], +) -> None: + """Test selecting options for select entities.""" + device_fixture.jets[1].speed_type = JetSpeedType.DUAL_SPEED + device_fixture.jets[2].speed_type = JetSpeedType.SINGLE_SPEED + + def _set_jet(jet_id: int, speed: JetSpeed) -> None: + device_fixture.jets[jet_id].speed = speed + + def _set_heating_mode(mode: HeatingMode) -> None: + device_fixture.heater.heating_mode = mode + + mock_hotspring.set_jet.side_effect = _set_jet + mock_hotspring.set_heating_mode.side_effect = _set_heating_mode + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SELECT]) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, + blocking=True, + ) + + getattr(mock_hotspring, method_name).assert_called_once_with(*expected_args) + assert (state := hass.states.get(entity_id)) + assert state.state == option + + +@pytest.mark.parametrize( + ("exception", "match"), + [ + ( + HotSpringConnectionError, + "An error occurred while communicating with the Hot Spring API", + ), + (HotSpringError, "Invalid response received from the Hot Spring API"), + ], +) +async def test_select_option_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hotspring: MagicMock, + exception: type[Exception], + match: str, +) -> None: + """Test exception handling when changing select option.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SELECT]) + mock_hotspring.set_jet.side_effect = exception + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: JET_1_ENTITY_ID, ATTR_OPTION: "high"}, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_hotspring") +async def test_unsupported_entities_not_added( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_fixture: Spa, + entity_registry: er.EntityRegistry, +) -> None: + """Test disabled jet and unsupported heating mode are not added.""" + device_fixture.jets = { + 1: Jet(jet_id=1, speed=JetSpeed.OFF, is_enabled=False, on_seconds=0), + } + device_fixture.heater.heating_mode = HeatingMode.INVALID + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SELECT]) + + assert not entity_registry.async_is_registered(JET_1_ENTITY_ID) + assert not entity_registry.async_is_registered(HEATING_MODE_ENTITY_ID) + + +@pytest.mark.usefixtures("mock_hotspring") +async def test_heating_mode_without_heatpump( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_fixture: Spa, +) -> None: + """Test chill option is not present when heat pump is not installed.""" + device_fixture.heater.heatpump_installed = False + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SELECT]) + + assert (state := hass.states.get(HEATING_MODE_ENTITY_ID)) + assert "chill" not in state.attributes["options"]