Add Hot Spring Connected Spa integration

Unofficial cloud integration for Watkins/Hot Spring "Connected Spa" hot tubs.
Reverse-engineered from the official Android app.

- climate entity (setpoint + current water temp) via heater/control
- sensors: water temperature, salt output level, salt cartridge
- REST (JWT) login + spa discovery; mTLS fetch of rotating HiveMQ broker creds;
  MQTT subscribe (telemetry) / publish (control)
- config flow prompts for account email + password (stored encrypted, never in repo)
- no secrets committed: vendor mTLS client cert is loaded from host PEM files
  (see README); .gitignore blocks pem/p12/der/key/env

Fixes bug-41mqxddz7zeh

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6B8b3iYNv6QUftK2FDfYb
This commit is contained in:
Hank Mueller
2026-09-01 14:22:37 +00:00
co-authored by Claude Opus 5
commit 8cda06f7f3
17 changed files with 1006 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Never commit credentials or vendor certificate/key material.
*.pem
*.p12
*.pfx
*.der
*.key
*.crt
certs/
secrets*
.env
*.env
# Python
__pycache__/
*.pyc
.venv/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fritzlab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+70
View File
@@ -0,0 +1,70 @@
# Hot Spring Connected Spa — Home Assistant integration
Monitor and control a **Watkins / Hot Spring "Connected Spa"** hot tub from Home
Assistant, without the vendor mobile app.
- **Climate** entity — target setpoint + current water temperature (heater).
- **Sensors** — water temperature, salt output level, salt cartridge state.
- Cloud-push: live telemetry over the vendor's HiveMQ Cloud MQTT broker.
This is an **unofficial** integration reverse-engineered from the official app.
It talks to Watkins' cloud; there is **no local control path**. If Watkins
changes their API it may break.
## Security / credentials
This repository contains **no secrets**:
- Your **account e-mail + password** are entered in the config-flow UI when you
add the integration and stored in Home Assistant's encrypted config entry —
never in this repo.
- The vendor **mutual-TLS client certificate** required to fetch broker
credentials is Watkins material (extractable from the app APK). It is **not
redistributed here**. You provide it as three PEM files on the host (below).
## Prerequisites: the client certificate
The integration needs three files in a directory on the Home Assistant host
(default `/config/hotspring/certs/`):
| File | Origin |
|---|---|
| `client1_cert.pem` | client cert from the app APK (`res/raw/client1.p12`) |
| `client1_key.pem` | its private key |
| `ca_cert.pem` | firmware CA (`res/raw/ca_cert.der`) |
To produce them from the APK's `client1.p12` and `ca_cert.der` (the P12 is
protected by a short static password embedded in the app — recover it from the
decompiled APK and pass it to `openssl` below):
```sh
P12PASS=... # static password embedded in the app APK
openssl pkcs12 -legacy -in client1.p12 -passin pass:"$P12PASS" -clcerts -nokeys -out client1_cert.pem
openssl pkcs12 -legacy -in client1.p12 -passin pass:"$P12PASS" -nocerts -nodes -out client1_key.pem
openssl x509 -inform der -in ca_cert.der -out ca_cert.pem
```
## Install
1. Copy `custom_components/hotspring/` into your Home Assistant `config/custom_components/`.
2. Place the three PEM files in `/config/hotspring/certs/`.
3. Restart Home Assistant.
4. **Settings → Devices & Services → Add Integration → “Hot Spring Connected
Spa”**, and sign in with your account.
## How it works
1. `POST iotsupportportalapi.watkinsmfg.com/api/v1/login/` → JWT.
2. `GET /user_spa_details/` → the spa's `rootTopic` (MQTT prefix).
3. mTLS `GET iotfirmwareota.watkinsmfg.com:8577/` (client cert) → rotating
HiveMQ broker host/user/pass.
4. Subscribe `<rootTopic>/#` for telemetry; publish
`<rootTopic>/<subsystem>/control` with `{"<subsystem>":{"control":{…}}}`
for commands (temperature is an integer string in the spa's unit).
The reusable protocol client lives under `custom_components/hotspring/api/`
(`cloud.py`, `spa.py`, `protocol.py`) and has no Home Assistant dependency.
## License
MIT — see [LICENSE](LICENSE).
+36
View File
@@ -0,0 +1,36 @@
"""The Hot Spring Connected Spa integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import HotSpringCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
type HotSpringConfigEntry = ConfigEntry[HotSpringCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: HotSpringConfigEntry) -> bool:
coordinator = HotSpringCoordinator(hass, entry)
try:
await coordinator.async_setup()
except Exception as err: # noqa: BLE001 - retried by HA as not-ready
raise ConfigEntryNotReady(f"Hot Spring setup failed: {err}") from err
entry.runtime_data = coordinator
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HotSpringConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
coordinator: HotSpringCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.async_shutdown()
return unload_ok
@@ -0,0 +1,19 @@
"""Reverse-engineered Hot Spring / Watkins Connected Spa client (cloud + MQTT)."""
from .cloud import (
HotSpringApiError,
HotSpringAuthError,
HotSpringCloud,
MqttCredentials,
Spa,
)
from .spa import HotSpringSpa
__all__ = [
"HotSpringCloud",
"HotSpringSpa",
"MqttCredentials",
"Spa",
"HotSpringAuthError",
"HotSpringApiError",
]
+126
View File
@@ -0,0 +1,126 @@
"""Cloud (REST) client: authenticate, discover spas, fetch live MQTT creds.
Synchronous + dependency-free (``urllib`` + ``ssl``). Home Assistant calls these
from the executor (they perform blocking network I/O), keeping the event loop
free. See ``coordinator.py``.
"""
from __future__ import annotations
import json
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Optional
from . import protocol
class HotSpringAuthError(Exception):
"""Login / token failure (surfaced as an auth error in the config flow)."""
class HotSpringApiError(Exception):
"""Any other API / transport failure."""
@dataclass
class Spa:
name: str
root_topic: str
raw: dict
@dataclass
class MqttCredentials:
url: str
port: int
user: str
password: str
raw: dict
class HotSpringCloud:
def __init__(self, email, password, client_cert, client_key, ca_cert, timeout=25):
self._email = email
self._password = password
self._client_cert = client_cert
self._client_key = client_key
self._ca_cert = ca_cert
self._timeout = timeout
self.access: Optional[str] = None
self.refresh: Optional[str] = None
def _request(self, url, *, method="GET", body=None, bearer=None, ssl_ctx=None):
headers = {"Accept": "application/json"}
data = None
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
if bearer:
headers["Authorization"] = "Bearer " + bearer
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=self._timeout, context=ssl_ctx) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
except (urllib.error.URLError, OSError) as e:
raise HotSpringApiError(str(e)) from e
def login(self) -> None:
status, raw = self._request(
protocol.API_BASE + protocol.LOGIN_PATH,
method="POST",
body={"email": self._email, "password": self._password},
)
if status in (400, 401, 403):
raise HotSpringAuthError(f"login rejected (HTTP {status})")
if status != 200:
raise HotSpringApiError(f"login failed: HTTP {status}: {raw[:200]}")
data = json.loads(raw)
self.access = data.get("access")
self.refresh = data.get("refresh")
if not self.access:
raise HotSpringAuthError("login returned no access token")
def refresh_access(self) -> None:
if not self.refresh:
raise HotSpringAuthError("no refresh token; login first")
status, raw = self._request(
protocol.API_BASE + protocol.REFRESH_PATH,
method="POST",
body={"refresh": self.refresh},
)
if status != 200:
raise HotSpringAuthError(f"refresh failed: HTTP {status}")
self.access = json.loads(raw).get("access", self.access)
def get_spas(self) -> list[Spa]:
if not self.access:
raise HotSpringAuthError("not authenticated")
status, raw = self._request(
protocol.API_BASE + protocol.SPA_DETAILS_PATH, bearer=self.access
)
if status != 200:
raise HotSpringApiError(f"spa details failed: HTTP {status}")
items = json.loads(raw)
if isinstance(items, dict):
items = items.get("data") or items.get("spas") or [items]
return [Spa(s.get("spaName", "Spa"), s["rootTopic"], s) for s in items]
def fetch_mqtt_credentials(self) -> MqttCredentials:
"""mTLS GET returning the rotating HiveMQ broker host/user/pass."""
ctx = ssl.create_default_context(cafile=self._ca_cert)
# The firmware CA is a self-signed cert lacking the modern CA
# extensions Python 3.12+ requires under VERIFY_X509_STRICT. The app
# pins this exact CA + hostname; we keep CA + hostname verification and
# only drop the strict-extension check.
ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT
ctx.load_cert_chain(self._client_cert, self._client_key)
status, raw = self._request(protocol.MQTT_CREDS_URL, ssl_ctx=ctx)
if status != 200:
raise HotSpringApiError(f"mqtt creds failed: HTTP {status}")
d = json.loads(raw)
return MqttCredentials(d["url"], int(d.get("port", 8883)), d["user"], d["pass"], d)
@@ -0,0 +1,54 @@
"""Vendor protocol facts + command builders for the Watkins / Hot Spring
"Connected Spa" cloud.
Reverse-engineered from the official Android app (``org.watkins.hotspring``).
This module holds every hard-coded vendor value in one auditable place and
contains no secrets: the account credentials come from the config entry, and the
mutual-TLS client certificate is loaded from files on the host (see the README).
"""
from __future__ import annotations
# --- REST (Django SimpleJWT) -------------------------------------------------
API_BASE = "https://iotsupportportalapi.watkinsmfg.com"
LOGIN_PATH = "/api/v1/login/"
REFRESH_PATH = "/api/v1/refresh/"
SPA_DETAILS_PATH = "/user_spa_details/"
# --- MQTT credential endpoint (mutual TLS) -----------------------------------
# Server cert is signed by the firmware CA bundled in the app; verified against
# the CA PEM provided on the host rather than the public trust store.
MQTT_CREDS_URL = "https://iotfirmwareota.watkinsmfg.com:8577/"
# --- Topics ------------------------------------------------------------------
STATUS_WILDCARD = "{root}/#"
CONTROL_TOPIC = "{root}/{subsystem}/control"
def build_control_payload(subsystem: str, control: dict) -> dict:
"""Wrap a control dict the way every command in the app does.
``build_control_payload("heater", {"temperatureABS": "104"})``
-> ``{"heater": {"control": {"temperatureABS": "104"}}}``
"""
return {subsystem: {"control": control}}
# High-level command factories -> (subsystem_path, control_dict).
# Temperature is an integer string in the spa's configured unit (the app sends
# ``String.valueOf((int) temp)``).
def cmd_set_temperature(value: int):
return "heater", {"temperatureABS": str(int(value))}
def cmd_nudge_temperature(up: bool):
return "heater", {"temperatureControl": "temp_up" if up else "temp_down"}
def cmd_set_water_care_level(level: int):
return "waterCare", {"level": str(int(level))}
def cmd_toggle_water_care_boost():
return "waterCare", {"boost": "toggle"}
+171
View File
@@ -0,0 +1,171 @@
"""MQTT client for one spa: subscribe to telemetry, publish control commands.
Uses paho-mqtt (bundled with Home Assistant). paho runs its network loop in its
own thread; telemetry is pushed out via the ``on_status`` callback, which the
coordinator bridges back onto the HA event loop.
"""
from __future__ import annotations
import json
import ssl
import threading
import time
from typing import Callable, Optional
import paho.mqtt.client as mqtt
from . import protocol
from .cloud import MqttCredentials
def _new_client(client_id: str) -> mqtt.Client:
"""Create an MQTTv5 client compatible with paho-mqtt 1.x and 2.x.
paho 2.0 requires the callback API version explicitly; the VERSION1
callback signatures used here match what this module implements.
"""
try:
from paho.mqtt.enums import CallbackAPIVersion
return mqtt.Client(
CallbackAPIVersion.VERSION1,
client_id=client_id,
protocol=mqtt.MQTTv5,
)
except ImportError: # paho-mqtt < 2.0
return mqtt.Client(client_id=client_id, protocol=mqtt.MQTTv5)
class HotSpringSpa:
def __init__(
self,
root_topic: str,
creds: MqttCredentials,
on_status: Optional[Callable[[str, dict], None]] = None,
):
self.root_topic = root_topic
self._creds = creds
self._on_status = on_status
self.state: dict[str, dict] = {} # topic-suffix -> payload
self.merged: dict = {} # deep-merged view for convenient lookups
self._client: Optional[mqtt.Client] = None
self._connected = threading.Event()
# -- connection -----------------------------------------------------------
def connect(self, keepalive: int = 30, wait: float = 15.0) -> None:
cl = _new_client(f"ha-hotspring-{int(time.time())}")
cl.username_pw_set(self._creds.user, self._creds.password)
cl.tls_set_context(ssl.create_default_context()) # broker uses a public CA
cl.on_connect = self._on_connect
cl.on_message = self._on_message
cl.reconnect_delay_set(min_delay=1, max_delay=60)
self._client = cl
cl.connect(self._creds.url, self._creds.port, keepalive=keepalive)
cl.loop_start()
if not self._connected.wait(wait):
raise TimeoutError("MQTT connect timed out")
def disconnect(self) -> None:
if self._client:
self._client.loop_stop()
self._client.disconnect()
@property
def connected(self) -> bool:
return bool(self._client) and self._connected.is_set()
def wait_for_state(self, timeout: float = 8.0) -> None:
"""Block until the heater setpoint arrives (retained telemetry)."""
deadline = time.time() + timeout
while time.time() < deadline:
if self.setpoint() is not None:
return
time.sleep(0.2)
# -- paho callbacks -------------------------------------------------------
def _on_connect(self, client, userdata, flags, rc, props=None):
if rc == 0:
client.subscribe(
protocol.STATUS_WILDCARD.format(root=self.root_topic), qos=1
)
self._connected.set()
def _on_message(self, client, userdata, msg):
suffix = msg.topic[len(self.root_topic) + 1:]
try:
payload = json.loads(msg.payload.decode("utf-8", "replace"))
except ValueError:
return
self.state[suffix] = payload
_deep_merge(self.merged, payload)
if self._on_status:
self._on_status(suffix, payload)
# -- parsed reads ---------------------------------------------------------
def _heater(self) -> dict:
return (self.merged.get("heater") or {}).get("status") or {}
def _fwss(self) -> dict:
return (((self.merged.get("waterCare") or {}).get("status") or {})
.get("FWSSstatus") or {})
def setpoint(self):
return _to_number(self._heater().get("setWaterTemperature"))
def current_temperature(self):
return _to_number(self._heater().get("currentWaterTemperature"))
def heater_on(self):
return self._heater().get("heater") == "on"
def temperature_unit(self):
return self._heater().get("temperatureUnit") # "DegF" / "DegC"
def salt_output_level(self):
return _to_number(self._fwss().get("Outputlevel"))
def cartridge_installed(self):
v = self._fwss().get("cartridgeInstalled")
return None if v is None else (v == "installed")
# -- writes ---------------------------------------------------------------
def publish_control(self, subsystem: str, control: dict, qos: int = 1) -> None:
if not self._client:
raise RuntimeError("not connected")
top = subsystem.split("/")[0]
topic = protocol.CONTROL_TOPIC.format(root=self.root_topic, subsystem=subsystem)
body = json.dumps(protocol.build_control_payload(top, control))
self._client.publish(topic, body.encode(), qos=qos)
def set_temperature(self, value: int):
self.publish_control(*protocol.cmd_set_temperature(value))
def nudge_temperature(self, up: bool):
self.publish_control(*protocol.cmd_nudge_temperature(up))
def set_water_care_level(self, level: int):
self.publish_control(*protocol.cmd_set_water_care_level(level))
def toggle_water_care_boost(self):
self.publish_control(*protocol.cmd_toggle_water_care_boost())
def _to_number(v):
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = "".join(ch for ch in str(v) if ch.isdigit() or ch in ".-")
try:
return float(s) if s else None
except ValueError:
return None
def _deep_merge(dst: dict, src: dict) -> None:
for k, v in src.items():
if isinstance(v, dict) and isinstance(dst.get(k), dict):
_deep_merge(dst[k], v)
else:
dst[k] = v
+88
View File
@@ -0,0 +1,88 @@
"""Climate entity: the spa's water heater setpoint + current temperature."""
from __future__ import annotations
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import HotSpringConfigEntry
from .const import DOMAIN, MANUFACTURER
from .coordinator import HotSpringCoordinator
# Hot Spot spas run 80-104 F.
MIN_TEMP_F = 80
MAX_TEMP_F = 104
async def async_setup_entry(
hass: HomeAssistant,
entry: HotSpringConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
async_add_entities([HotSpringClimate(entry.runtime_data)])
class HotSpringClimate(CoordinatorEntity[HotSpringCoordinator], ClimateEntity):
_attr_has_entity_name = True
_attr_name = None # use the device name
_attr_hvac_modes = [HVACMode.HEAT]
_attr_hvac_mode = HVACMode.HEAT
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_target_temperature_step = 1
_enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: HotSpringCoordinator) -> None:
super().__init__(coordinator)
root = coordinator.spa_info.root_topic if coordinator.spa_info else "spa"
self._attr_unique_id = f"{root}_climate"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, root)},
manufacturer=MANUFACTURER,
name=coordinator.spa_info.name if coordinator.spa_info else "Hot Spring Spa",
model="Connected Spa",
)
@property
def _celsius(self) -> bool:
return (self.coordinator.spa and self.coordinator.spa.temperature_unit()) == "DegC"
@property
def temperature_unit(self) -> str:
return UnitOfTemperature.CELSIUS if self._celsius else UnitOfTemperature.FAHRENHEIT
@property
def min_temp(self) -> float:
return MIN_TEMP_F if not self._celsius else round((MIN_TEMP_F - 32) / 1.8)
@property
def max_temp(self) -> float:
return MAX_TEMP_F if not self._celsius else round((MAX_TEMP_F - 32) / 1.8)
@property
def current_temperature(self) -> float | None:
return self.coordinator.spa.current_temperature() if self.coordinator.spa else None
@property
def target_temperature(self) -> float | None:
return self.coordinator.spa.setpoint() if self.coordinator.spa else None
async def async_set_temperature(self, **kwargs) -> None:
temp = kwargs.get(ATTR_TEMPERATURE)
if temp is None:
return
# The spa expects an integer in its configured unit.
await self.coordinator.async_publish_control(
"heater", {"temperatureABS": str(int(round(temp)))}
)
@callback
def _handle_coordinator_update(self) -> None:
self.async_write_ha_state()
+108
View File
@@ -0,0 +1,108 @@
"""Config flow: prompt for the Hot Spring account e-mail + password.
Credentials are validated by logging in, then stored in the config entry (Home
Assistant encrypts entry storage at rest). They are never written to this repo.
"""
from __future__ import annotations
import os
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .api import HotSpringApiError, HotSpringAuthError, HotSpringCloud
from .const import (
CA_FILE,
CERT_FILE,
CONF_CERT_DIR,
CONF_EMAIL,
CONF_PASSWORD,
DEFAULT_CERT_DIR,
DOMAIN,
KEY_FILE,
)
def _schema(defaults: dict[str, Any] | None = None) -> vol.Schema:
defaults = defaults or {}
return vol.Schema(
{
vol.Required(CONF_EMAIL, default=defaults.get(CONF_EMAIL, "")): str,
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
vol.Optional(
CONF_CERT_DIR, default=defaults.get(CONF_CERT_DIR, DEFAULT_CERT_DIR)
): str,
}
)
class HotSpringConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the UI setup."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
cert_dir = user_input[CONF_CERT_DIR]
missing = [
f
for f in (CERT_FILE, KEY_FILE, CA_FILE)
if not os.path.isfile(os.path.join(cert_dir, f))
]
if missing:
errors["base"] = "missing_certs"
else:
result = await self._validate(user_input)
if result is None:
spa_name = self._spa_name or "Hot Spring Spa"
await self.async_set_unique_id(self._unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=spa_name, data=user_input)
errors["base"] = result
return self.async_show_form(
step_id="user", data_schema=_schema(user_input), errors=errors
)
async def _validate(self, data: dict[str, Any]) -> str | None:
"""Return None on success, or an error key."""
cert_dir = data[CONF_CERT_DIR]
cloud = HotSpringCloud(
data[CONF_EMAIL],
data[CONF_PASSWORD],
os.path.join(cert_dir, CERT_FILE),
os.path.join(cert_dir, KEY_FILE),
os.path.join(cert_dir, CA_FILE),
)
def _check() -> tuple[str | None, str | None]:
cloud.login()
spas = cloud.get_spas()
if not spas:
return None, None
return spas[0].name, spas[0].root_topic
try:
self._spa_name, self._unique_id = await self.hass.async_add_executor_job(
_check
)
except HotSpringAuthError:
return "invalid_auth"
except HotSpringApiError:
return "cannot_connect"
except Exception: # noqa: BLE001 - surface as generic to the user
return "unknown"
return None
+23
View File
@@ -0,0 +1,23 @@
"""Constants for the Hot Spring Connected Spa integration."""
from __future__ import annotations
DOMAIN = "hotspring"
CONF_EMAIL = "email"
CONF_PASSWORD = "password" # noqa: S105 - config-flow field name, not a secret value
CONF_CERT_DIR = "cert_dir"
# The vendor mutual-TLS client certificate is NOT shipped in this repo (it is
# Watkins material extractable from the app APK). Place the three PEM files here
# on the Home Assistant host; see the README.
DEFAULT_CERT_DIR = "/config/hotspring/certs"
CERT_FILE = "client1_cert.pem"
KEY_FILE = "client1_key.pem"
CA_FILE = "ca_cert.pem"
# Broker credentials rotate; re-fetch and reconnect on this cadence as a
# safety net in addition to paho's own auto-reconnect.
CREDS_REFRESH_INTERVAL = 6 * 60 * 60 # seconds
MANUFACTURER = "Watkins Wellness"
+100
View File
@@ -0,0 +1,100 @@
"""Coordinator: owns the cloud session + MQTT connection for one spa and exposes
the latest telemetry to entities."""
from __future__ import annotations
import logging
import os
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .api import HotSpringCloud, HotSpringSpa, Spa
from .const import (
CA_FILE,
CERT_FILE,
CONF_CERT_DIR,
CONF_EMAIL,
CONF_PASSWORD,
DEFAULT_CERT_DIR,
DOMAIN,
KEY_FILE,
)
_LOGGER = logging.getLogger(__name__)
class HotSpringCoordinator(DataUpdateCoordinator[dict]):
"""Keeps a live MQTT connection and republishes state to entities.
Data flow is push-based: telemetry arrives on paho's thread and is bridged
onto the event loop via ``async_set_updated_data``. There is no polling
``_async_update_data`` (updates are event-driven), but the coordinator still
provides ``data`` and listener plumbing for entities.
"""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
super().__init__(hass, _LOGGER, name=DOMAIN)
self.entry = entry
self.cloud: HotSpringCloud | None = None
self.spa: HotSpringSpa | None = None
self.spa_info: Spa | None = None
def _cert_paths(self) -> tuple[str, str, str]:
d = self.entry.data.get(CONF_CERT_DIR, DEFAULT_CERT_DIR)
return (
os.path.join(d, CERT_FILE),
os.path.join(d, KEY_FILE),
os.path.join(d, CA_FILE),
)
async def async_setup(self) -> None:
"""Authenticate, resolve the spa, fetch broker creds, connect MQTT."""
cert, key, ca = self._cert_paths()
cloud = HotSpringCloud(
self.entry.data[CONF_EMAIL],
self.entry.data[CONF_PASSWORD],
cert,
key,
ca,
)
def _blocking_connect() -> HotSpringSpa:
cloud.login()
spas = cloud.get_spas()
if not spas:
raise RuntimeError("account has no spas")
self.spa_info = spas[0]
creds = cloud.fetch_mqtt_credentials()
spa = HotSpringSpa(
self.spa_info.root_topic, creds, on_status=self._on_status
)
spa.connect()
spa.wait_for_state()
return spa
self.cloud = cloud
self.spa = await self.hass.async_add_executor_job(_blocking_connect)
# Seed data so entities have an initial value.
self.async_set_updated_data(self.spa.merged)
@callback
def _on_status(self, suffix: str, payload: dict) -> None:
"""Runs on paho's thread; hop to the event loop to notify listeners."""
if self.spa is None:
return
merged = self.spa.merged
self.hass.loop.call_soon_threadsafe(self.async_set_updated_data, merged)
async def async_publish_control(self, subsystem: str, control: dict) -> None:
if self.spa is None:
raise RuntimeError("spa not connected")
await self.hass.async_add_executor_job(
self.spa.publish_control, subsystem, control
)
async def async_shutdown(self) -> None:
if self.spa is not None:
await self.hass.async_add_executor_job(self.spa.disconnect)
await super().async_shutdown()
+12
View File
@@ -0,0 +1,12 @@
{
"domain": "hotspring",
"name": "Hot Spring Connected Spa",
"codeowners": ["@fritzlab"],
"config_flow": true,
"documentation": "https://code.fritzlab.net/homeassistant/hotspring",
"issue_tracker": "https://code.fritzlab.net/homeassistant/hotspring/issues",
"iot_class": "cloud_push",
"integration_type": "hub",
"requirements": [],
"version": "0.1.0"
}
+94
View File
@@ -0,0 +1,94 @@
"""Sensors: salt output level, cartridge state, current water temperature."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import HotSpringConfigEntry
from .const import DOMAIN, MANUFACTURER
from .coordinator import HotSpringCoordinator
@dataclass(frozen=True, kw_only=True)
class HotSpringSensorDescription(SensorEntityDescription):
value_fn: Callable[[HotSpringCoordinator], float | str | None]
SENSORS: tuple[HotSpringSensorDescription, ...] = (
HotSpringSensorDescription(
key="water_temperature",
translation_key="water_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.spa.current_temperature() if c.spa else None,
),
HotSpringSensorDescription(
key="salt_output_level",
translation_key="salt_output_level",
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:shaker-outline",
value_fn=lambda c: c.spa.salt_output_level() if c.spa else None,
),
HotSpringSensorDescription(
key="cartridge",
translation_key="cartridge",
icon="mdi:filter-outline",
value_fn=lambda c: (
None
if not c.spa or c.spa.cartridge_installed() is None
else ("installed" if c.spa.cartridge_installed() else "missing")
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HotSpringConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator = entry.runtime_data
async_add_entities(HotSpringSensor(coordinator, d) for d in SENSORS)
class HotSpringSensor(CoordinatorEntity[HotSpringCoordinator], SensorEntity):
_attr_has_entity_name = True
entity_description: HotSpringSensorDescription
def __init__(
self,
coordinator: HotSpringCoordinator,
description: HotSpringSensorDescription,
) -> None:
super().__init__(coordinator)
self.entity_description = description
root = coordinator.spa_info.root_topic if coordinator.spa_info else "spa"
self._attr_unique_id = f"{root}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, root)},
manufacturer=MANUFACTURER,
name=coordinator.spa_info.name if coordinator.spa_info else "Hot Spring Spa",
model="Connected Spa",
)
@property
def native_value(self) -> float | str | None:
return self.entity_description.value_fn(self.coordinator)
@callback
def _handle_coordinator_update(self) -> None:
self.async_write_ha_state()
+31
View File
@@ -0,0 +1,31 @@
{
"config": {
"step": {
"user": {
"title": "Hot Spring Connected Spa",
"description": "Sign in with your Hot Spring Spas app account. The mutual-TLS client certificate must already be present on the host (see the integration README).",
"data": {
"email": "E-mail",
"password": "Password",
"cert_dir": "Certificate directory"
}
}
},
"error": {
"invalid_auth": "Invalid e-mail or password.",
"cannot_connect": "Could not reach the Hot Spring cloud.",
"missing_certs": "The client certificate files were not found in the certificate directory.",
"unknown": "Unexpected error."
},
"abort": {
"already_configured": "This spa account is already configured."
}
},
"entity": {
"sensor": {
"water_temperature": { "name": "Water temperature" },
"salt_output_level": { "name": "Salt output level" },
"cartridge": { "name": "Salt cartridge" }
}
}
}
@@ -0,0 +1,31 @@
{
"config": {
"step": {
"user": {
"title": "Hot Spring Connected Spa",
"description": "Sign in with your Hot Spring Spas app account. The mutual-TLS client certificate must already be present on the host (see the integration README).",
"data": {
"email": "E-mail",
"password": "Password",
"cert_dir": "Certificate directory"
}
}
},
"error": {
"invalid_auth": "Invalid e-mail or password.",
"cannot_connect": "Could not reach the Hot Spring cloud.",
"missing_certs": "The client certificate files were not found in the certificate directory.",
"unknown": "Unexpected error."
},
"abort": {
"already_configured": "This spa account is already configured."
}
},
"entity": {
"sensor": {
"water_temperature": { "name": "Water temperature" },
"salt_output_level": { "name": "Salt output level" },
"cartridge": { "name": "Salt cartridge" }
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Hot Spring Connected Spa",
"content_in_root": false,
"render_readme": true,
"homeassistant": "2024.6.0"
}