Add new Hotspring Integration (#177992)

This commit is contained in:
Christophe Gagnier
2026-08-12 13:51:03 +02:00
committed by GitHub
parent addee9de01
commit 783b5e58ed
23 changed files with 965 additions and 0 deletions
+1
View File
@@ -285,6 +285,7 @@ homeassistant.components.homekit_controller.utils
homeassistant.components.homewizard.*
homeassistant.components.homeworks.*
homeassistant.components.hortimax.*
homeassistant.components.hotspring.*
homeassistant.components.hr_energy_qube.*
homeassistant.components.http.*
homeassistant.components.huawei_lte.*
Generated
+2
View File
@@ -799,6 +799,8 @@ CLAUDE.md @home-assistant/core
/tests/components/honeywell_string_lights/ @balloob
/homeassistant/components/hortimax/ @wildekek
/tests/components/hortimax/ @wildekek
/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
@@ -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.NUMBER]
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)
@@ -0,0 +1,64 @@
"""Config flow for Hot Spring."""
from typing import 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 homeassistant.helpers.selector import TextSelector
from .const import DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): TextSelector(),
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> Spa:
"""Validate the user input allows us to connect."""
api = HotSpring(data[CONF_HOST], session=async_get_clientsession(hass))
spa = await api.update()
if not spa.info.mac_address:
raise HotSpringError("No MAC address found")
return spa
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, str] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}
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)
self._abort_if_unique_id_configured(
updates={CONF_HOST: user_input[CONF_HOST]}
)
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=STEP_USER_DATA_SCHEMA,
errors=errors,
)
@@ -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)
@@ -0,0 +1,62 @@
"""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:
spa = 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
if (
not spa.info.mac_address
or spa.info.mac_address != self.config_entry.unique_id
):
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_response",
)
return spa
@@ -0,0 +1,28 @@
"""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
versions = self.coordinator.data.versions
self._attr_unique_id = f"{info.mac_address}_{key}"
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, info.mac_address)},
identifiers={(DOMAIN, info.mac_address)},
name=info.hostname or None,
manufacturer=info.brand_name or None,
model=info.model_name or None,
sw_version=versions.wifi_dongle or None,
)
@@ -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
@@ -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.3.0"]
}
@@ -0,0 +1,67 @@
"""Support for Hot Spring number entities."""
from typing import override
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import 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
TARGET_TEMPERATURE_DESCRIPTION = NumberEntityDescription(
key="target_temperature",
translation_key="target_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
native_min_value=80.0,
native_max_value=104.0,
native_step=1.0,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HotSpringConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hot Spring number entities."""
async_add_entities(
[HotSpringNumberEntity(entry.runtime_data, TARGET_TEMPERATURE_DESCRIPTION)]
)
class HotSpringNumberEntity(HotSpringEntity, NumberEntity):
"""Defines a Hot Spring number entity."""
entity_description: NumberEntityDescription
def __init__(
self,
coordinator: HotSpringDataUpdateCoordinator,
description: NumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator, description.key)
self.entity_description = description
@property
@override
def native_value(self) -> float | None:
"""Return the current target temperature."""
return self.coordinator.data.heater.set_temperature
@hotspring_exception_handler
@override
async def async_set_native_value(self, value: float) -> None:
"""Set the target temperature."""
await self.coordinator.hotspring.set_temperature(round(value))
await self.coordinator.async_request_refresh()
@@ -0,0 +1,84 @@
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: done
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
@@ -0,0 +1,37 @@
{
"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."
}
}
},
"entity": {
"number": {
"target_temperature": {
"name": "Target temperature"
}
}
},
"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."
}
}
}
+1
View File
@@ -336,6 +336,7 @@ FLOWS = {
"honeywell",
"honeywell_string_lights",
"hortimax",
"hotspring",
"hr_energy_qube",
"html5",
"huawei_lte",
@@ -3070,6 +3070,12 @@
"config_flow": true,
"iot_class": "cloud_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",
Generated
+10
View File
@@ -2607,6 +2607,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
+3
View File
@@ -2694,6 +2694,9 @@ python-homeassistant-analytics==0.9.0
# homeassistant.components.homewizard
python-homewizard-energy==10.2.0
# homeassistant.components.hotspring
python-hotspring==1.3.0
# homeassistant.components.hp_ilo
python-hpilo==4.4.3
+18
View File
@@ -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()
+101
View File
@@ -0,0 +1,101 @@
"""Fixtures for Hot Spring integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from hotspring import Heater, Spa, SpaBrand, SpaInfo, Versions
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=SpaBrand.HOTSPRING,
brand_name="Hot Spring",
collection="Highlife",
model_name="Relay",
brand_id="1",
collection_id="1",
model_id="1",
volume=335,
)
spa.versions = Versions(
control_box="3.0.0",
control_panel="2.0.0",
fwss="",
fwiq="",
btxr="",
cool_zone="",
wifi_dongle="1.0.0",
amp="",
dosing="",
logolight="",
)
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
@@ -0,0 +1,35 @@
# serializer version: 1
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'hotspring',
'AA:BB:CC:DD:EE:FF',
),
}),
'labels': set({
}),
'manufacturer': 'Hot Spring',
'model': 'Relay',
'model_id': None,
'name': 'ConnectedSpa_DDEEFF',
'name_by_user': None,
'serial_number': None,
'sw_version': '1.0.0',
'via_device_id': None,
})
# ---
@@ -0,0 +1,62 @@
# serializer version: 1
# name: test_number_state[number.connectedspa_ddeeff_target_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 26.6,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.connectedspa_ddeeff_target_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Target temperature',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Target temperature',
'platform': 'hotspring',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'target_temperature',
'unique_id': 'AA:BB:CC:DD:EE:FF_target_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_number_state[number.connectedspa_ddeeff_target_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'ConnectedSpa_DDEEFF Target temperature',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 40.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 26.6,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1.0,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.connectedspa_ddeeff_target_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '40.0',
})
# ---
@@ -0,0 +1,134 @@
"""Tests for the Hot Spring config flow."""
from unittest.mock import MagicMock
from hotspring import HotSpringConnectionError, HotSpringError, Spa, SpaBrand, SpaInfo
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},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "192.168.1.200"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_HOST] == "192.168.1.200"
@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"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_form_no_mac_address(
hass: HomeAssistant, mock_hotspring: MagicMock, device_fixture: Spa
) -> None:
"""Test we show user form on missing MAC address and recover."""
valid_info = device_fixture.info
device_fixture.info = SpaInfo(
hostname="ConnectedSpa_DDEEFF",
root_topic="unknownTopic123",
sna_ready=True,
brand=SpaBrand.HOTSPRING,
brand_name="Hot Spring",
collection="Highlife",
model_name="Relay",
brand_id="1",
collection_id="1",
model_id="1",
volume=335,
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
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"}
device_fixture.info = valid_info
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"
+79
View File
@@ -0,0 +1,79 @@
"""Tests for the Hot Spring integration."""
from unittest.mock import MagicMock
from hotspring import HotSpringConnectionError, HotSpringError, Spa
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.hotspring.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
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
async def test_device_info(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test device registry entry creation with updated info."""
device = device_registry.async_get_device_by_identifier(
(DOMAIN, "AA:BB:CC:DD:EE:FF"), init_integration.entry_id
)
assert device is not None
assert device == snapshot
@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
@pytest.mark.parametrize(
"root_topic",
[
"unknownTopic123",
"mySpa112233445566",
],
)
async def test_async_setup_mac_mismatch(
hass: HomeAssistant,
mock_hotspring: MagicMock,
mock_config_entry: MockConfigEntry,
device_fixture: Spa,
root_topic: str,
) -> None:
"""Test setup fails when spa MAC is missing or mismatched."""
device_fixture.info.root_topic = root_topic
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
+86
View File
@@ -0,0 +1,86 @@
"""Tests for the Hot Spring number platform."""
from unittest.mock import MagicMock
from hotspring import HotSpringConnectionError, HotSpringError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
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
ENTITY_ID = "number.connectedspa_ddeeff_target_temperature"
async def test_number_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_hotspring: MagicMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the number entity state."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_set_target_temperature(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_hotspring: MagicMock,
) -> None:
"""Test setting target temperature."""
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_VALUE: 38,
},
blocking=True,
)
mock_hotspring.set_temperature.assert_called_once_with(100)
@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_target_temperature_error(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_hotspring: MagicMock,
exception: type[Exception],
match: str,
) -> None:
"""Test exception handling when setting target temperature."""
mock_hotspring.set_temperature.side_effect = exception
with pytest.raises(HomeAssistantError, match=match):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_VALUE: 38,
},
blocking=True,
)