Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ee7f743
Add Hot Spring integration
Moustachauve Jul 30, 2026
be7f9fe
Add handling for HotSpringConnectionError in data update
Moustachauve Jul 30, 2026
285f431
Simplify Hot Spring exception handling and clarify HNA in config flow
Moustachauve Jul 30, 2026
2265a95
Remove zeroconf discovery from initial PR
Moustachauve Jul 30, 2026
313ba68
Add quality scale and error translation keys
Moustachauve Aug 2, 2026
d221285
Merge remote-tracking branch 'upstream/dev' into hotspring-init
Moustachauve Aug 2, 2026
d8c421d
Address review feedback for Hot Spring
Moustachauve Aug 2, 2026
6acd63b
Remove diagnostics support and related tests for Hot Spring integration
Moustachauve Aug 2, 2026
66ca955
Revert accidental change in mdi_icons.py
Moustachauve Aug 2, 2026
d9d0774
Mark diagnostic and discovery as todo instead of exempt
Moustachauve Aug 2, 2026
f63884b
Refactor Hot Spring tests to handle multiple error types and adjust t…
Moustachauve Aug 2, 2026
5ef2363
Implement exception handling for Hot Spring API calls in entity and w…
Moustachauve Aug 2, 2026
a2b32f3
Update python-hotspring requirement to version 1.0.1
Moustachauve Aug 2, 2026
bfaaeb6
Implement reviewer comment fixes
Moustachauve Aug 3, 2026
e486f56
test: remove redundant mock_hotspring fixture from test_form_cannot_c…
Moustachauve Aug 3, 2026
2be1611
Use python-hotpsring 1.2.0
Moustachauve Aug 3, 2026
a32bfd7
Add Hot Spring integration
Moustachauve Jul 30, 2026
ab99e00
Add handling for HotSpringConnectionError in data update
Moustachauve Jul 30, 2026
d24c681
Simplify Hot Spring exception handling and clarify HNA in config flow
Moustachauve Jul 30, 2026
3780822
Remove zeroconf discovery from initial PR
Moustachauve Jul 30, 2026
ca207f2
Add quality scale and error translation keys
Moustachauve Aug 2, 2026
036dae8
Address review feedback for Hot Spring
Moustachauve Aug 2, 2026
53ecf79
Remove diagnostics support and related tests for Hot Spring integration
Moustachauve Aug 2, 2026
4976c4c
Revert accidental change in mdi_icons.py
Moustachauve Aug 2, 2026
b91bf81
Mark diagnostic and discovery as todo instead of exempt
Moustachauve Aug 2, 2026
c1d11af
Refactor Hot Spring tests to handle multiple error types and adjust t…
Moustachauve Aug 2, 2026
159bfe7
Implement exception handling for Hot Spring API calls in entity and w…
Moustachauve Aug 2, 2026
bea31f7
Update python-hotspring requirement to version 1.0.1
Moustachauve Aug 2, 2026
5165d9c
Implement reviewer comment fixes
Moustachauve Aug 3, 2026
6d2c90b
test: remove redundant mock_hotspring fixture from test_form_cannot_c…
Moustachauve Aug 3, 2026
708cc47
Use python-hotpsring 1.2.0
Moustachauve Aug 3, 2026
9d36084
Merge branch 'hotspring-init' of https://github.com/Moustachauve/HA-c…
Moustachauve Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions homeassistant/components/hotspring/__init__.py
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)
54 changes: 54 additions & 0 deletions homeassistant/components/hotspring/config_flow.py
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

Copy link
Copy Markdown
Member

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?

)
self._abort_if_unique_id_configured(
updates={CONF_HOST: user_input[CONF_HOST]}
)
Comment on lines +35 to +37

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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()
9 changes: 9 additions & 0 deletions homeassistant/components/hotspring/const.py
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)
53 changes: 53 additions & 0 deletions homeassistant/components/hotspring/coordinator.py
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)},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors are badly translatable. I advise to omit them. :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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:

        except WLEDError as error:
            raise UpdateFailed(
                translation_domain=DOMAIN,
                translation_key="invalid_response_wled_error",
                translation_placeholders={"error": str(error)},
            ) from error

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
30 changes: 30 additions & 0 deletions homeassistant/components/hotspring/entity.py
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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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",
)
41 changes: 41 additions & 0 deletions homeassistant/components/hotspring/helpers.py
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)},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idem feedback on the translations of errors.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
12 changes: 12 additions & 0 deletions homeassistant/components/hotspring/manifest.json
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"]
}
86 changes: 86 additions & 0 deletions homeassistant/components/hotspring/quality_scale.yaml
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
30 changes: 30 additions & 0 deletions homeassistant/components/hotspring/strings.json
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}"
}
}
}
Loading
Loading