diff --git a/.strict-typing b/.strict-typing index 5295d7c9363c1..a5f86cae09259 100644 --- a/.strict-typing +++ b/.strict-typing @@ -284,6 +284,7 @@ homeassistant.components.homekit_controller.storage homeassistant.components.homekit_controller.utils homeassistant.components.homewizard.* homeassistant.components.homeworks.* +homeassistant.components.hotspring.* homeassistant.components.hr_energy_qube.* homeassistant.components.http.* homeassistant.components.huawei_lte.* diff --git a/CODEOWNERS b/CODEOWNERS index 6399a23dcb90a..200b453964884 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -797,6 +797,8 @@ CLAUDE.md @home-assistant/core /tests/components/honeywell/ @mkmer /homeassistant/components/honeywell_string_lights/ @balloob /tests/components/honeywell_string_lights/ @balloob +/homeassistant/components/hotspring/ @Moustachauve +/tests/components/hotspring/ @Moustachauve /homeassistant/components/hr_energy_qube/ @MattieGit /tests/components/hr_energy_qube/ @MattieGit /homeassistant/components/html5/ @alexyao2015 @tr4nt0r diff --git a/homeassistant/components/hotspring/__init__.py b/homeassistant/components/hotspring/__init__.py new file mode 100644 index 0000000000000..cb84e5c002852 --- /dev/null +++ b/homeassistant/components/hotspring/__init__.py @@ -0,0 +1,25 @@ +"""The Hot Spring integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import HotSpringConfigEntry, HotSpringDataUpdateCoordinator + +PLATFORMS = [Platform.WATER_HEATER] + + +async def async_setup_entry(hass: HomeAssistant, entry: HotSpringConfigEntry) -> bool: + """Set up Hot Spring from a config entry.""" + coordinator = HotSpringDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HotSpringConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hotspring/config_flow.py b/homeassistant/components/hotspring/config_flow.py new file mode 100644 index 0000000000000..3ee01ca721903 --- /dev/null +++ b/homeassistant/components/hotspring/config_flow.py @@ -0,0 +1,54 @@ +"""Config flow for Hot Spring.""" + +from typing import Any, override + +from hotspring import HotSpring, HotSpringConnectionError, HotSpringError, Spa +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Spa: + """Validate the user input allows us to connect.""" + api = HotSpring(data[CONF_HOST], session=async_get_clientsession(hass)) + return await api.update() + + +class HotSpringConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hot Spring.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors = {} + if user_input is not None: + try: + spa = await validate_input(self.hass, user_input) + except HotSpringConnectionError, HotSpringError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id( + spa.info.mac_address or spa.info.root_topic + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=spa.info.hostname or "Hot Spring Spa", + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) diff --git a/homeassistant/components/hotspring/const.py b/homeassistant/components/hotspring/const.py new file mode 100644 index 0000000000000..25658c9452216 --- /dev/null +++ b/homeassistant/components/hotspring/const.py @@ -0,0 +1,9 @@ +"""Constants for the Hot Spring integration.""" + +from datetime import timedelta +import logging + +DOMAIN = "hotspring" + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/hotspring/coordinator.py b/homeassistant/components/hotspring/coordinator.py new file mode 100644 index 0000000000000..f2a5d305ac28b --- /dev/null +++ b/homeassistant/components/hotspring/coordinator.py @@ -0,0 +1,51 @@ +"""DataUpdateCoordinator for Hot Spring.""" + +from typing import override + +from hotspring import HotSpring, HotSpringConnectionError, HotSpringError, Spa + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +type HotSpringConfigEntry = ConfigEntry[HotSpringDataUpdateCoordinator] + + +class HotSpringDataUpdateCoordinator(DataUpdateCoordinator[Spa]): + """Class to manage fetching Hot Spring data from a single endpoint.""" + + config_entry: HotSpringConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: HotSpringConfigEntry) -> None: + """Initialize global Hot Spring data updater.""" + self.hotspring = HotSpring( + config_entry.data[CONF_HOST], + session=async_get_clientsession(hass), + ) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + @override + async def _async_update_data(self) -> Spa: + """Fetch data from Hot Spring.""" + try: + return await self.hotspring.update() + except HotSpringConnectionError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from error + except HotSpringError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_response", + ) from error diff --git a/homeassistant/components/hotspring/entity.py b/homeassistant/components/hotspring/entity.py new file mode 100644 index 0000000000000..4e7a4676b742e --- /dev/null +++ b/homeassistant/components/hotspring/entity.py @@ -0,0 +1,30 @@ +"""Entity for Hot Spring.""" + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HotSpringDataUpdateCoordinator + + +class HotSpringEntity(CoordinatorEntity[HotSpringDataUpdateCoordinator]): + """Defines a base Hot Spring entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HotSpringDataUpdateCoordinator, key: str) -> None: + """Initialize a base Hot Spring entity.""" + super().__init__(coordinator) + info = self.coordinator.data.info + identifier = info.mac_address or info.root_topic + self._attr_unique_id = f"{identifier}_{key}" + connections = set() + if info.mac_address: + connections.add((CONNECTION_NETWORK_MAC, info.mac_address)) + self._attr_device_info = DeviceInfo( + connections=connections, + identifiers={(DOMAIN, identifier)}, + name=info.hostname or "Hot Spring Spa", + manufacturer="Hot Spring", + model="Connected Spa", + ) diff --git a/homeassistant/components/hotspring/helpers.py b/homeassistant/components/hotspring/helpers.py new file mode 100644 index 0000000000000..e55ee4e4d9987 --- /dev/null +++ b/homeassistant/components/hotspring/helpers.py @@ -0,0 +1,39 @@ +"""Helpers for Hot Spring.""" + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from hotspring import HotSpringConnectionError, HotSpringError + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import HotSpringEntity + + +def hotspring_exception_handler[_HotSpringEntityT: HotSpringEntity, **_P]( + func: Callable[Concatenate[_HotSpringEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_HotSpringEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Hot Spring calls to handle Hot Spring exceptions. + + A decorator that wraps the passed in function, catches Hot Spring errors, + and raises a translated HomeAssistantError. + """ + + async def handler( + self: _HotSpringEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + except HotSpringConnectionError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from error + except HotSpringError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_response", + ) from error + + return handler diff --git a/homeassistant/components/hotspring/manifest.json b/homeassistant/components/hotspring/manifest.json new file mode 100644 index 0000000000000..4a04ab850ab35 --- /dev/null +++ b/homeassistant/components/hotspring/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "hotspring", + "name": "Hot Spring", + "codeowners": ["@Moustachauve"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hotspring", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["hotspring"], + "quality_scale": "silver", + "requirements": ["python-hotspring==1.2.0"] +} diff --git a/homeassistant/components/hotspring/quality_scale.yaml b/homeassistant/components/hotspring/quality_scale.yaml new file mode 100644 index 0000000000000..e7425093e847e --- /dev/null +++ b/homeassistant/components/hotspring/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not have custom actions. + docs-conditions: + status: exempt + comment: Integration does not have custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not have custom triggers. + entity-event-setup: + status: exempt + comment: Integration does not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not have custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not have custom post-install parameters. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Local polling integration, no authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: One device per config entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: Entity relies on standard platform default icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not raise repair issues. + stale-devices: + status: exempt + comment: One device per config entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/hotspring/strings.json b/homeassistant/components/hotspring/strings.json new file mode 100644 index 0000000000000..e9f7c2c53d9ad --- /dev/null +++ b/homeassistant/components/hotspring/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Hot Spring Home Network Adapter (HNA)." + }, + "description": "Set up your Hot Spring Home Network Adapter (HNA) to integrate with Home Assistant." + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while communicating with the Hot Spring API." + }, + "invalid_response": { + "message": "Invalid response received from the Hot Spring API." + } + } +} diff --git a/homeassistant/components/hotspring/water_heater.py b/homeassistant/components/hotspring/water_heater.py new file mode 100644 index 0000000000000..d381d7bb141d3 --- /dev/null +++ b/homeassistant/components/hotspring/water_heater.py @@ -0,0 +1,68 @@ +"""Support for Hot Spring water heater.""" + +from typing import Any, override + +from homeassistant.components.water_heater import ( + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, STATE_ON, UnitOfTemperature +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 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HotSpringConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Hot Spring water heater entity.""" + async_add_entities([HotSpringWaterHeaterEntity(entry.runtime_data)]) + + +class HotSpringWaterHeaterEntity(HotSpringEntity, WaterHeaterEntity): + """Defines a Hot Spring water heater entity.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_temp = 80.0 + _attr_max_temp = 104.0 + _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + + def __init__(self, coordinator: HotSpringDataUpdateCoordinator) -> None: + """Initialize the water heater entity.""" + super().__init__(coordinator, "water_heater") + + @property + @override + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.coordinator.data.heater.current_temperature + + @property + @override + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.coordinator.data.heater.set_temperature + + @property + @override + def current_operation(self) -> str: + """Return the current operation mode.""" + if self.coordinator.data.heater.is_on: + return STATE_ON + return STATE_OFF + + @hotspring_exception_handler + @override + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + await self.coordinator.hotspring.set_temperature(kwargs[ATTR_TEMPERATURE]) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6dc4879c1433a..519c088902d29 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -335,6 +335,7 @@ "homeworks", "honeywell", "honeywell_string_lights", + "hotspring", "hr_energy_qube", "html5", "huawei_lte", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9f402fdc62bcc..cbf351b28f7eb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3058,6 +3058,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "hotspring": { + "name": "Hot Spring", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "hp_ilo": { "name": "HP Integrated Lights-Out (ILO)", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 02c15d95cbf4d..38682211c064d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2597,6 +2597,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.hotspring.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.hr_energy_qube.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 08acfb05ff548..9b9256613b7e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2691,6 +2691,9 @@ python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard python-homewizard-energy==10.2.0 +# homeassistant.components.hotspring +python-hotspring==1.2.0 + # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/tests/components/hotspring/__init__.py b/tests/components/hotspring/__init__.py new file mode 100644 index 0000000000000..9534636e3648a --- /dev/null +++ b/tests/components/hotspring/__init__.py @@ -0,0 +1,18 @@ +"""Tests for the Hot Spring integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the Hot Spring integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch("homeassistant.components.hotspring.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/hotspring/conftest.py b/tests/components/hotspring/conftest.py new file mode 100644 index 0000000000000..5639b296f0d5e --- /dev/null +++ b/tests/components/hotspring/conftest.py @@ -0,0 +1,85 @@ +"""Fixtures for Hot Spring integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from hotspring import Heater, Spa, SpaInfo +import pytest + +from homeassistant.components.hotspring.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="AA:BB:CC:DD:EE:FF", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.hotspring.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def device_fixture() -> Spa: + """Return the device fixture for a Hot Spring spa.""" + spa = MagicMock(spec=Spa) + spa.info = SpaInfo( + hostname="ConnectedSpa_DDEEFF", + root_topic="mySpaAABBCCDDEEFF", + sna_ready=True, + brand_name="Hot Spring", + collection_type="Highlife", + model_type="Relay", + volume=335, + ) + heater = MagicMock(spec=Heater) + heater.current_temperature = 102.0 + heater.set_temperature = 104.0 + heater.is_on = True + spa.heater = heater + return spa + + +@pytest.fixture +def mock_hotspring(device_fixture: Spa) -> Generator[MagicMock]: + """Return a mocked HotSpring client.""" + with ( + patch( + "homeassistant.components.hotspring.coordinator.HotSpring", autospec=True + ) as hotspring_mock, + patch( + "homeassistant.components.hotspring.config_flow.HotSpring", + new=hotspring_mock, + ), + ): + client = hotspring_mock.return_value + client.update.return_value = device_fixture + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hotspring: MagicMock, +) -> MockConfigEntry: + """Set up the Hot Spring integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/hotspring/snapshots/test_water_heater.ambr b/tests/components/hotspring/snapshots/test_water_heater.ambr new file mode 100644 index 0000000000000..e88b3018f5d5e --- /dev/null +++ b/tests/components/hotspring/snapshots/test_water_heater.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_water_heater_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 38.9, + : 'ConnectedSpa_DDEEFF', + : 40.0, + : 26.7, + : , + : None, + : None, + : 40.0, + }), + 'context': , + 'entity_id': 'water_heater.connectedspa_ddeeff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_water_heater_state.1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 26.7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.connectedspa_ddeeff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'AA:BB:CC:DD:EE:FF_water_heater', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/hotspring/test_config_flow.py b/tests/components/hotspring/test_config_flow.py new file mode 100644 index 0000000000000..fb7147625e90e --- /dev/null +++ b/tests/components/hotspring/test_config_flow.py @@ -0,0 +1,89 @@ +"""Tests for the Hot Spring config flow.""" + +from unittest.mock import MagicMock + +from hotspring import HotSpringConnectionError, HotSpringError +import pytest + +from homeassistant.components.hotspring.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_hotspring") +async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.100"} + ) + + assert result["title"] == "ConnectedSpa_DDEEFF" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "192.168.1.100" + assert result["result"].unique_id == "AA:BB:CC:DD:EE:FF" + + +@pytest.mark.usefixtures("mock_hotspring") +async def test_user_device_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort the config flow if Hot Spring spa is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "exception", + [HotSpringConnectionError, HotSpringError], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_cannot_connect( + hass: HomeAssistant, mock_hotspring: MagicMock, exception: type[Exception] +) -> None: + """Test we show user form on Hot Spring connection error and recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + mock_hotspring.update.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.100"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + mock_hotspring.update.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.100"} + ) + + assert result["title"] == "ConnectedSpa_DDEEFF" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "192.168.1.100" + assert result["result"].unique_id == "AA:BB:CC:DD:EE:FF" diff --git a/tests/components/hotspring/test_init.py b/tests/components/hotspring/test_init.py new file mode 100644 index 0000000000000..719c9201d4fcd --- /dev/null +++ b/tests/components/hotspring/test_init.py @@ -0,0 +1,40 @@ +"""Tests for the Hot Spring integration.""" + +from unittest.mock import MagicMock + +from hotspring import HotSpringConnectionError, HotSpringError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test a successful setup entry and unload.""" + assert init_integration.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "exception", + [HotSpringConnectionError, HotSpringError], +) +async def test_async_setup_error( + hass: HomeAssistant, + mock_hotspring: MagicMock, + mock_config_entry: MockConfigEntry, + exception: type[Exception], +) -> None: + """Test a setup error when updating spa data.""" + mock_hotspring.update.side_effect = exception + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/hotspring/test_water_heater.py b/tests/components/hotspring/test_water_heater.py new file mode 100644 index 0000000000000..3526813708f48 --- /dev/null +++ b/tests/components/hotspring/test_water_heater.py @@ -0,0 +1,132 @@ +"""Tests for the Hot Spring water heater platform.""" + +from unittest.mock import MagicMock + +from hotspring import HotSpringConnectionError, HotSpringError, Spa, SpaInfo +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.hotspring.const import DOMAIN +from homeassistant.components.water_heater import ( + ATTR_TEMPERATURE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry + +ENTITY_ID = "water_heater.connectedspa_ddeeff" + + +async def test_water_heater_state( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the water heater entity state.""" + state = hass.states.get(ENTITY_ID) + assert state == snapshot + + entry = entity_registry.async_get(ENTITY_ID) + assert entry == snapshot + + +async def test_set_temperature( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hotspring: MagicMock, +) -> None: + """Test setting target temperature.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 38, + }, + blocking=True, + ) + + mock_hotspring.set_temperature.assert_called_once_with(100.4) + + +@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_set_temperature_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hotspring: MagicMock, + exception: type[Exception], + match: str, +) -> None: + """Test exception handling when setting target temperature.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + mock_hotspring.set_temperature.side_effect = exception + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 38, + }, + blocking=True, + ) + + +async def test_water_heater_no_mac_address( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hotspring: MagicMock, + device_fixture: Spa, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the water heater entity when mac_address is not available.""" + device_fixture.info = SpaInfo( + hostname="ConnectedSpa_DDEEFF", + root_topic="unknownTopic123", + sna_ready=True, + brand_name="Hot Spring", + collection_type="Highlife", + model_type="Relay", + volume=335, + ) + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + + entry = entity_registry.async_get(ENTITY_ID) + assert entry is not None + assert entry.unique_id == "unknownTopic123_water_heater" + + device = device_registry.async_get(entry.device_id) + assert device is not None + assert (DOMAIN, "unknownTopic123") in device.identifiers + assert not device.connections