-
-
Notifications
You must be signed in to change notification settings - Fork 38.2k
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 all 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
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,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,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.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, 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( | ||
| 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], | ||
| }, | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=vol.Schema({vol.Required(CONF_HOST): str}), | ||
| errors=errors, | ||
| ) | ||
|
|
||
| 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,53 @@ | ||
| """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", | ||
| translation_placeholders={"error": str(error)}, | ||
|
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. Errors are badly translatable. I advise to omit them. :)
Contributor
Author
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. Hmm but then how would I be able to satisfy the gold quality scale rule that states all errors must be translated? Seems like this is how WLED, for example, handles errors:
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. If you're referencing to the exception translations: https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/exception-translations It doesn't require the exception to be literally translated, only the exception and use that as a generic description. See the Portainer integration as an example. :) |
||
| ) from error | ||
| except HotSpringError as error: | ||
| raise UpdateFailed( | ||
| translation_domain=DOMAIN, | ||
| translation_key="invalid_response", | ||
| translation_placeholders={"error": str(error)}, | ||
| ) from error | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
|
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. What is this used for? Dynamic devices? |
||
| 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", | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| """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", | ||
| translation_placeholders={"error": str(error)}, | ||
|
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. Idem feedback on the translations of errors.
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. Idem feedback on the translations of errors. |
||
| ) from error | ||
| except HotSpringError as error: | ||
| raise HomeAssistantError( | ||
| translation_domain=DOMAIN, | ||
| translation_key="invalid_response", | ||
| translation_placeholders={"error": str(error)}, | ||
| ) from error | ||
|
|
||
| return handler | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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": "Error communicating with Hot Spring API: {error}" | ||
| }, | ||
| "invalid_response": { | ||
| "message": "Invalid response from Hot Spring API: {error}" | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is and how unique is the value of
root_topic?