| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- """Config flow for Solar of Things integration.
- Supports two setup modes:
- 1. User-ID + Password (recommended) – integration logs in automatically and
- refreshes the token without any user intervention.
- 2. IOT Token (legacy / advanced) – user pastes the token from DevTools.
- A re-auth flow is triggered when the token expires.
- Re-auth flow: HA calls async_step_reauth when a TokenExpiredError is caught by
- the coordinator. The user is asked only for the field(s) that need updating
- (usually just a fresh token, or new credentials).
- """
- from __future__ import annotations
- import logging
- from typing import Any
- import voluptuous as vol
- from homeassistant import config_entries
- from homeassistant.core import HomeAssistant, callback
- from homeassistant.data_entry_flow import FlowResult
- from homeassistant.exceptions import HomeAssistantError
- from homeassistant.helpers import config_validation as cv
- from .api import SolarOfThingsAPI, AuthenticationError, TokenExpiredError
- from .const import (
- DOMAIN,
- CONF_USER_ID,
- CONF_PASSWORD,
- CONF_IOT_TOKEN,
- CONF_STATION_ID,
- CONF_DEVICE_ID,
- CONF_TIME_ZONE,
- CONF_REFRESH_TOKEN,
- CONF_ACCESS_TOKEN_EXPIRES,
- CONF_REFRESH_TOKEN_EXPIRES,
- )
- _LOGGER = logging.getLogger(__name__)
- # ─── Validation helpers ────────────────────────────────────────────────────────
- async def _validate_password_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
- """Validate user-ID/password auth, return title."""
- api = SolarOfThingsAPI(
- user_id=data[CONF_USER_ID],
- password=data[CONF_PASSWORD],
- time_zone=data.get(CONF_TIME_ZONE),
- )
- try:
- await hass.async_add_executor_job(api.login)
- except AuthenticationError as err:
- raise InvalidAuth(str(err)) from err
- except Exception as err:
- raise CannotConnect(str(err)) from err
- # After login, validate station
- ok = await hass.async_add_executor_job(api.test_connection, data[CONF_STATION_ID])
- if not ok:
- raise CannotConnect("Station/device unreachable after login.")
- return {
- "title": f"Solar Station {data[CONF_STATION_ID]}",
- CONF_IOT_TOKEN: api.access_token,
- CONF_REFRESH_TOKEN: api.refresh_token,
- CONF_ACCESS_TOKEN_EXPIRES: api.access_token_expires_iso,
- CONF_REFRESH_TOKEN_EXPIRES: api.refresh_token_expires_iso,
- }
- async def _validate_token_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
- """Validate legacy IOT-token auth, return title."""
- api = SolarOfThingsAPI(
- iot_token=data[CONF_IOT_TOKEN],
- time_zone=data.get(CONF_TIME_ZONE),
- )
- station_id = data[CONF_STATION_ID]
- device_id = (data.get(CONF_DEVICE_ID) or "").strip()
- try:
- if device_id:
- res = await hass.async_add_executor_job(api.fetch_latest_data, device_id)
- ok = bool(res)
- else:
- ok = await hass.async_add_executor_job(api.test_connection, station_id)
- except Exception as err:
- _LOGGER.error("Token validation failed: %s", err)
- ok = False
- if not ok:
- raise CannotConnect("Cannot connect with provided token.")
- return {"title": f"Solar Station {station_id}"}
- # ─── Config Flow ───────────────────────────────────────────────────────────────
- class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
- """Handle a config flow for Solar of Things."""
- VERSION = 3 # bumped for new auth fields
- def __init__(self) -> None:
- self._auth_mode: str = "password" # "password" | "token"
- # ── Step 1: choose auth mode ───────────────────────────────────────────────
- async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
- """Offer choice of auth mode."""
- if user_input is not None:
- self._auth_mode = user_input.get("auth_mode", "password")
- if self._auth_mode == "token":
- return await self.async_step_token()
- return await self.async_step_password()
- return self.async_show_form(
- step_id="user",
- data_schema=vol.Schema(
- {
- vol.Required("auth_mode", default="password"): vol.In(
- {"password": "User ID + Password (recommended)", "token": "IOT Token (advanced)"}
- )
- }
- ),
- description_placeholders={
- "docs_url": "https://github.com/conexocasa/solar-of-things-ha"
- },
- )
- # ── Step 2a: user-id + password ────────────────────────────────────────────
- async def async_step_password(self, user_input: dict[str, Any] | None = None) -> FlowResult:
- """User-ID + password setup step."""
- errors: dict[str, str] = {}
- if user_input is not None:
- try:
- info = await _validate_password_auth(self.hass, user_input)
- except InvalidAuth:
- errors["base"] = "invalid_auth"
- except CannotConnect:
- errors["base"] = "cannot_connect"
- except Exception:
- _LOGGER.exception("Unexpected exception in password step")
- errors["base"] = "unknown"
- else:
- await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
- self._abort_if_unique_id_configured()
- entry_data = {
- CONF_USER_ID: user_input[CONF_USER_ID],
- CONF_PASSWORD: user_input[CONF_PASSWORD],
- CONF_STATION_ID: user_input[CONF_STATION_ID],
- CONF_DEVICE_ID: user_input.get(CONF_DEVICE_ID, ""),
- CONF_TIME_ZONE: user_input.get(CONF_TIME_ZONE, "Asia/Manila"),
- CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
- CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
- CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
- CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
- }
- return self.async_create_entry(title=info["title"], data=entry_data)
- return self.async_show_form(
- step_id="password",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_USER_ID): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_STATION_ID): cv.string,
- vol.Optional(CONF_DEVICE_ID): cv.string,
- vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
- }
- ),
- errors=errors,
- description_placeholders={
- "docs_url": "https://github.com/conexocasa/solar-of-things-ha"
- },
- )
- # ── Step 2b: legacy IOT token ─────────────────────────────────────────────
- async def async_step_token(self, user_input: dict[str, Any] | None = None) -> FlowResult:
- """Legacy IOT-token setup step."""
- errors: dict[str, str] = {}
- if user_input is not None:
- try:
- info = await _validate_token_auth(self.hass, user_input)
- except CannotConnect:
- errors["base"] = "cannot_connect"
- except Exception:
- _LOGGER.exception("Unexpected exception in token step")
- errors["base"] = "unknown"
- else:
- await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
- self._abort_if_unique_id_configured()
- return self.async_create_entry(title=info["title"], data=user_input)
- return self.async_show_form(
- step_id="token",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_IOT_TOKEN): cv.string,
- vol.Required(CONF_STATION_ID): cv.string,
- vol.Optional(CONF_DEVICE_ID): cv.string,
- vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
- }
- ),
- errors=errors,
- description_placeholders={
- "docs_url": "https://github.com/Hyllesen/solar-of-things-solar-usage"
- },
- )
- # ── Re-auth flow ───────────────────────────────────────────────────────────
- async def async_step_reauth(self, entry_data: dict[str, Any] | None = None) -> FlowResult:
- """Triggered by the coordinator when TokenExpiredError is raised."""
- return await self.async_step_reauth_confirm()
- async def async_step_reauth_confirm(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Ask the user to re-enter credentials or a fresh token."""
- errors: dict[str, str] = {}
- existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
- has_password = bool(
- existing_entry
- and (
- existing_entry.data.get(CONF_PASSWORD)
- or existing_entry.data.get(CONF_USER_ID)
- )
- )
- if user_input is not None:
- if has_password:
- # Re-authenticate with potentially updated email/password
- merged = {**(existing_entry.data if existing_entry else {}), **user_input}
- try:
- info = await _validate_password_auth(self.hass, merged)
- except InvalidAuth:
- errors["base"] = "invalid_auth"
- except CannotConnect:
- errors["base"] = "cannot_connect"
- except Exception:
- _LOGGER.exception("Unexpected exception during re-auth")
- errors["base"] = "unknown"
- else:
- self.hass.config_entries.async_update_entry(
- existing_entry,
- data={
- **existing_entry.data,
- CONF_PASSWORD: user_input.get(CONF_PASSWORD, existing_entry.data.get(CONF_PASSWORD)),
- CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
- CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
- CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
- CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
- },
- )
- await self.hass.config_entries.async_reload(existing_entry.entry_id)
- return self.async_abort(reason="reauth_successful")
- else:
- # Legacy token mode: user provides a fresh token
- merged = {**(existing_entry.data if existing_entry else {}), **user_input}
- try:
- await _validate_token_auth(self.hass, merged)
- except CannotConnect:
- errors["base"] = "cannot_connect"
- except Exception:
- _LOGGER.exception("Unexpected exception during re-auth (token)")
- errors["base"] = "unknown"
- else:
- self.hass.config_entries.async_update_entry(
- existing_entry,
- data={**existing_entry.data, CONF_IOT_TOKEN: user_input[CONF_IOT_TOKEN]},
- )
- await self.hass.config_entries.async_reload(existing_entry.entry_id)
- return self.async_abort(reason="reauth_successful")
- # Build schema based on whether the entry has stored credentials
- if has_password:
- schema = vol.Schema(
- {
- vol.Optional(
- CONF_PASSWORD,
- description={"suggested_value": ""},
- ): cv.string,
- }
- )
- else:
- schema = vol.Schema({vol.Required(CONF_IOT_TOKEN): cv.string})
- return self.async_show_form(
- step_id="reauth_confirm",
- data_schema=schema,
- errors=errors,
- )
- # ─── Custom exceptions ─────────────────────────────────────────────────────────
- class CannotConnect(HomeAssistantError):
- """Error to indicate we cannot connect."""
- class InvalidAuth(HomeAssistantError):
- """Error to indicate invalid authentication credentials."""
|