Files
hotspring/custom_components/hotspring/config_flow.py
T
Hank MuellerandClaude Opus 5 cde2bbc772 Add lights/jets/salt control, app-traffic mimicry, and optional local read path
- New entities: number (salt output level 0-10), switch (lights, jets); the
  read-only salt sensor is replaced by the settable number.
- Look identical to the official app on the wire: send the app's Dalvik UA on
  REST, okhttp/4.12.0 on the mTLS creds fetch, and "mqtt-<uuid>" client ids.
- Consolidate every constant value into constants.py (well commented); protocol.py
  keeps only the command builders; const.py removed.
- Optional local read path: a `local_status_url` config option polls the dongle's
  local /status endpoint for telemetry instead of the cloud MQTT subscription
  (control stays cloud MQTT). State getters handle both schemas.

No private/internal values in the repo (the spa IP is runtime config only).

Fixes bug-41mqxddz7zeh

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6B8b3iYNv6QUftK2FDfYb
2026-09-01 15:12:24 +00:00

114 lines
3.5 KiB
Python

"""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 .constants import (
CA_FILE,
CERT_FILE,
CONF_CERT_DIR,
CONF_EMAIL,
CONF_LOCAL_STATUS_URL,
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,
vol.Optional(
CONF_LOCAL_STATUS_URL,
default=defaults.get(CONF_LOCAL_STATUS_URL, ""),
): 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