-
-
Notifications
You must be signed in to change notification settings - Fork 38.3k
Add new Hotspring Integration #177992
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Add new Hotspring Integration #177992
Changes from 4 commits
ee7f743
be7f9fe
285f431
2265a95
313ba68
d221285
d8c421d
6acd63b
66ca955
d9d0774
f63884b
5ef2363
a2b32f3
bfaaeb6
e486f56
2be1611
a32bfd7
ab99e00
d24c681
3780822
ca207f2
036dae8
53ecf79
4976c4c
b91bf81
c1d11af
159bfe7
bea31f7
5165d9c
6d2c90b
708cc47
9d36084
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| """Config flow for Hot Spring.""" | ||
|
|
||
| from typing import Any, override | ||
|
|
||
| from hotspring import HotSpring, HotSpringConnectionError, Spa | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant.config_entries import ( | ||
| SOURCE_RECONFIGURE, | ||
| ConfigFlow, | ||
| ConfigFlowResult, | ||
| ) | ||
| from homeassistant.const import CONF_HOST | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
|
||
| from .const import DOMAIN | ||
|
|
||
|
|
||
| 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 self._async_get_spa(user_input[CONF_HOST]) | ||
| except HotSpringConnectionError: | ||
| errors["base"] = "cannot_connect" | ||
| else: | ||
| await self.async_set_unique_id( | ||
| spa.info.mac_address, raise_on_progress=False | ||
|
Moustachauve marked this conversation as resolved.
Outdated
|
||
| ) | ||
| if self.source == SOURCE_RECONFIGURE: | ||
|
Moustachauve marked this conversation as resolved.
Outdated
|
||
| entry = self._get_reconfigure_entry() | ||
| assert entry.unique_id is not None | ||
|
Moustachauve marked this conversation as resolved.
Outdated
|
||
| self._abort_if_unique_id_mismatch( | ||
| reason="unique_id_mismatch", | ||
| description_placeholders={ | ||
| "expected_mac": entry.unique_id.upper(), | ||
| "actual_mac": spa.info.mac_address.upper(), | ||
| }, | ||
| ) | ||
| return self.async_update_reload_and_abort( | ||
| entry, | ||
| data_updates={CONF_HOST: user_input[CONF_HOST]}, | ||
| ) | ||
| self._abort_if_unique_id_configured( | ||
| updates={CONF_HOST: user_input[CONF_HOST]} | ||
| ) | ||
|
Comment on lines
+35
to
+37
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's no need for a value, if we've setup the Mac address above. Plus it's a bit odd that we now let it check on the CONF_HOST. Best to omit it and just leave it without a parameter. |
||
| return self.async_create_entry( | ||
| title=spa.info.hostname or "Hot Spring Spa", | ||
| data={ | ||
| CONF_HOST: user_input[CONF_HOST], | ||
| }, | ||
| ) | ||
|
|
||
| data_schema = vol.Schema({vol.Required(CONF_HOST): str}) | ||
| if self.source == SOURCE_RECONFIGURE: | ||
| data_schema = self.add_suggested_values_to_schema( | ||
| data_schema, | ||
| self._get_reconfigure_entry().data, | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=data_schema, | ||
| errors=errors, | ||
| ) | ||
|
|
||
| async def async_step_reconfigure( | ||
|
Moustachauve marked this conversation as resolved.
Outdated
|
||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Handle reconfiguration of the Hot Spring spa.""" | ||
| return await self.async_step_user(user_input) | ||
|
|
||
| async def _async_get_spa(self, host: str) -> Spa: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this only use case? If you're re-adding the reconfigure flow I'd suggest to rename it to validate_input to standardize like other integrations. |
||
| """Get information from a Hot Spring spa.""" | ||
| api = HotSpring(host, session=async_get_clientsession(self.hass)) | ||
| return await api.update() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| """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(f"Error communicating with API: {error}") from error | ||
| except HotSpringError as error: | ||
| raise UpdateFailed(f"Invalid response from API: {error}") from error |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| """Diagnostics support for Hot Spring.""" | ||
|
|
||
| from dataclasses import asdict | ||
| from typing import Any | ||
|
|
||
| from homeassistant.components.diagnostics import async_redact_data | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from .coordinator import HotSpringConfigEntry | ||
|
|
||
| TO_REDACT = {"unique_id", "mac_address"} | ||
|
Moustachauve marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| async def async_get_config_entry_diagnostics( | ||
| hass: HomeAssistant, entry: HotSpringConfigEntry | ||
| ) -> dict[str, Any]: | ||
| """Return diagnostics for a config entry.""" | ||
| return async_redact_data(asdict(entry.runtime_data.data.info), TO_REDACT) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| """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 | ||
| 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 "Hot Spring Spa", | ||
| manufacturer="Hot Spring", | ||
| model=info.model or "Connected Spa", | ||
| ) |
|
Moustachauve marked this conversation as resolved.
Outdated
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "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"], | ||
| "requirements": ["python-hotspring==1.0.0"] | ||
|
Moustachauve marked this conversation as resolved.
Outdated
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| { | ||
| "config": { | ||
| "abort": { | ||
| "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", | ||
| "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", | ||
| "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", | ||
| "unique_id_mismatch": "MAC address does not match the configured device. Expected to connect to device with MAC: `{expected_mac}`, but connected to device with MAC: `{actual_mac}`. \n\nPlease ensure you reconfigure against the same device." | ||
| }, | ||
| "error": { | ||
| "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" | ||
| }, | ||
| "flow_title": "{name}", | ||
| "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." | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| """Support for Hot Spring water heater.""" | ||
|
|
||
| from typing import Any, override | ||
|
|
||
| from hotspring import HotSpringConnectionError, HotSpringError | ||
|
|
||
| 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.exceptions import HomeAssistantError | ||
| from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback | ||
|
|
||
| from .coordinator import HotSpringConfigEntry, HotSpringDataUpdateCoordinator | ||
| from .entity import HotSpringEntity | ||
|
|
||
|
|
||
| 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 | None: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How can the type be alternative None, if you always return a valid state constant? |
||
| """Return the current operation mode.""" | ||
| if self.coordinator.data.heater.is_on: | ||
| return STATE_ON | ||
| return STATE_OFF | ||
|
|
||
| @override | ||
| async def async_set_temperature(self, **kwargs: Any) -> None: | ||
| """Set new target temperature.""" | ||
| if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: | ||
| try: | ||
| await self.coordinator.hotspring.set_temperature(temperature) | ||
| except HotSpringConnectionError as error: | ||
| self.coordinator.last_update_success = False | ||
| self.coordinator.async_update_listeners() | ||
| raise HomeAssistantError("Error communicating with Hot Spring API") from error | ||
| except HotSpringError as error: | ||
| raise HomeAssistantError("Invalid response from Hot Spring API") from error | ||
| await self.coordinator.async_request_refresh() | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() |
Uh oh!
There was an error while loading. Please reload this page.