Add select platform to Hot Spring (#182554)

Co-authored-by: Moustachauve <2206577+Moustachauve@users.noreply.github.com>
This commit is contained in:
Christophe Gagnier
2026-09-23 09:14:30 +02:00
committed by GitHub
co-authored by Moustachauve
parent c6911d96d0
commit f35fa24e76
7 changed files with 615 additions and 3 deletions
@@ -9,6 +9,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
]
@@ -0,0 +1,12 @@
{
"entity": {
"select": {
"heating_mode": {
"default": "mdi:radiator"
},
"jet": {
"default": "mdi:pump"
}
}
}
}
@@ -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
@@ -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)
)
@@ -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"
@@ -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({
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'heat_saver',
'heat_with_boost',
'auto_saver',
'auto_with_boost',
'chill',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.connectedspa_ddeeff_heating_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'ConnectedSpa_DDEEFF Heating mode',
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'heat_saver',
'heat_with_boost',
'auto_saver',
'auto_with_boost',
'chill',
]),
}),
'context': <ANY>,
'entity_id': 'select.connectedspa_ddeeff_heating_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat_saver',
})
# ---
# name: test_select_state[select.connectedspa_ddeeff_jet_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'off',
'low',
'high',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'ConnectedSpa_DDEEFF Jet 1',
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'off',
'low',
'high',
]),
}),
'context': <ANY>,
'entity_id': 'select.connectedspa_ddeeff_jet_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'low',
})
# ---
# name: test_select_state[select.connectedspa_ddeeff_jet_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'off',
'high',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'ConnectedSpa_DDEEFF Jet 2',
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'off',
'high',
]),
}),
'context': <ANY>,
'entity_id': 'select.connectedspa_ddeeff_jet_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'high',
})
# ---
+168
View File
@@ -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"]