config_flow.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. """Config flow for Solar of Things integration.
  2. Supports two setup modes:
  3. 1. User-ID + Password (recommended) – integration logs in automatically and
  4. refreshes the token without any user intervention.
  5. 2. IOT Token (legacy / advanced) – user pastes the token from DevTools.
  6. A re-auth flow is triggered when the token expires.
  7. Re-auth flow: HA calls async_step_reauth when a TokenExpiredError is caught by
  8. the coordinator. The user is asked only for the field(s) that need updating
  9. (usually just a fresh token, or new credentials).
  10. """
  11. from __future__ import annotations
  12. import logging
  13. from typing import Any
  14. import voluptuous as vol
  15. from homeassistant import config_entries
  16. from homeassistant.core import HomeAssistant, callback
  17. from homeassistant.data_entry_flow import FlowResult
  18. from homeassistant.exceptions import HomeAssistantError
  19. from homeassistant.helpers import config_validation as cv
  20. from .api import SolarOfThingsAPI, AuthenticationError, TokenExpiredError
  21. from .const import (
  22. DOMAIN,
  23. CONF_USER_ID,
  24. CONF_PASSWORD,
  25. CONF_IOT_TOKEN,
  26. CONF_STATION_ID,
  27. CONF_DEVICE_ID,
  28. CONF_TIME_ZONE,
  29. CONF_REFRESH_TOKEN,
  30. CONF_ACCESS_TOKEN_EXPIRES,
  31. CONF_REFRESH_TOKEN_EXPIRES,
  32. )
  33. _LOGGER = logging.getLogger(__name__)
  34. # ─── Validation helpers ────────────────────────────────────────────────────────
  35. async def _validate_password_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
  36. """Validate user-ID/password auth, return title."""
  37. api = SolarOfThingsAPI(
  38. user_id=data[CONF_USER_ID],
  39. password=data[CONF_PASSWORD],
  40. time_zone=data.get(CONF_TIME_ZONE),
  41. )
  42. try:
  43. await hass.async_add_executor_job(api.login)
  44. except AuthenticationError as err:
  45. raise InvalidAuth(str(err)) from err
  46. except Exception as err:
  47. raise CannotConnect(str(err)) from err
  48. # After login, validate station
  49. ok = await hass.async_add_executor_job(api.test_connection, data[CONF_STATION_ID])
  50. if not ok:
  51. raise CannotConnect("Station/device unreachable after login.")
  52. return {
  53. "title": f"Solar Station {data[CONF_STATION_ID]}",
  54. CONF_IOT_TOKEN: api.access_token,
  55. CONF_REFRESH_TOKEN: api.refresh_token,
  56. CONF_ACCESS_TOKEN_EXPIRES: api.access_token_expires_iso,
  57. CONF_REFRESH_TOKEN_EXPIRES: api.refresh_token_expires_iso,
  58. }
  59. async def _validate_token_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
  60. """Validate legacy IOT-token auth, return title."""
  61. api = SolarOfThingsAPI(
  62. iot_token=data[CONF_IOT_TOKEN],
  63. time_zone=data.get(CONF_TIME_ZONE),
  64. )
  65. station_id = data[CONF_STATION_ID]
  66. device_id = (data.get(CONF_DEVICE_ID) or "").strip()
  67. try:
  68. if device_id:
  69. res = await hass.async_add_executor_job(api.fetch_latest_data, device_id)
  70. ok = bool(res)
  71. else:
  72. ok = await hass.async_add_executor_job(api.test_connection, station_id)
  73. except Exception as err:
  74. _LOGGER.error("Token validation failed: %s", err)
  75. ok = False
  76. if not ok:
  77. raise CannotConnect("Cannot connect with provided token.")
  78. return {"title": f"Solar Station {station_id}"}
  79. # ─── Config Flow ───────────────────────────────────────────────────────────────
  80. class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
  81. """Handle a config flow for Solar of Things."""
  82. VERSION = 3 # bumped for new auth fields
  83. def __init__(self) -> None:
  84. self._auth_mode: str = "password" # "password" | "token"
  85. # ── Step 1: choose auth mode ───────────────────────────────────────────────
  86. async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
  87. """Offer choice of auth mode."""
  88. if user_input is not None:
  89. self._auth_mode = user_input.get("auth_mode", "password")
  90. if self._auth_mode == "token":
  91. return await self.async_step_token()
  92. return await self.async_step_password()
  93. return self.async_show_form(
  94. step_id="user",
  95. data_schema=vol.Schema(
  96. {
  97. vol.Required("auth_mode", default="password"): vol.In(
  98. {"password": "User ID + Password (recommended)", "token": "IOT Token (advanced)"}
  99. )
  100. }
  101. ),
  102. description_placeholders={
  103. "docs_url": "https://github.com/conexocasa/solar-of-things-ha"
  104. },
  105. )
  106. # ── Step 2a: user-id + password ────────────────────────────────────────────
  107. async def async_step_password(self, user_input: dict[str, Any] | None = None) -> FlowResult:
  108. """User-ID + password setup step."""
  109. errors: dict[str, str] = {}
  110. if user_input is not None:
  111. try:
  112. info = await _validate_password_auth(self.hass, user_input)
  113. except InvalidAuth:
  114. errors["base"] = "invalid_auth"
  115. except CannotConnect:
  116. errors["base"] = "cannot_connect"
  117. except Exception:
  118. _LOGGER.exception("Unexpected exception in password step")
  119. errors["base"] = "unknown"
  120. else:
  121. await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
  122. self._abort_if_unique_id_configured()
  123. entry_data = {
  124. CONF_USER_ID: user_input[CONF_USER_ID],
  125. CONF_PASSWORD: user_input[CONF_PASSWORD],
  126. CONF_STATION_ID: user_input[CONF_STATION_ID],
  127. CONF_DEVICE_ID: user_input.get(CONF_DEVICE_ID, ""),
  128. CONF_TIME_ZONE: user_input.get(CONF_TIME_ZONE, "Asia/Manila"),
  129. CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
  130. CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
  131. CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
  132. CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
  133. }
  134. return self.async_create_entry(title=info["title"], data=entry_data)
  135. return self.async_show_form(
  136. step_id="password",
  137. data_schema=vol.Schema(
  138. {
  139. vol.Required(CONF_USER_ID): cv.string,
  140. vol.Required(CONF_PASSWORD): cv.string,
  141. vol.Required(CONF_STATION_ID): cv.string,
  142. vol.Optional(CONF_DEVICE_ID): cv.string,
  143. vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
  144. }
  145. ),
  146. errors=errors,
  147. description_placeholders={
  148. "docs_url": "https://github.com/conexocasa/solar-of-things-ha"
  149. },
  150. )
  151. # ── Step 2b: legacy IOT token ─────────────────────────────────────────────
  152. async def async_step_token(self, user_input: dict[str, Any] | None = None) -> FlowResult:
  153. """Legacy IOT-token setup step."""
  154. errors: dict[str, str] = {}
  155. if user_input is not None:
  156. try:
  157. info = await _validate_token_auth(self.hass, user_input)
  158. except CannotConnect:
  159. errors["base"] = "cannot_connect"
  160. except Exception:
  161. _LOGGER.exception("Unexpected exception in token step")
  162. errors["base"] = "unknown"
  163. else:
  164. await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
  165. self._abort_if_unique_id_configured()
  166. return self.async_create_entry(title=info["title"], data=user_input)
  167. return self.async_show_form(
  168. step_id="token",
  169. data_schema=vol.Schema(
  170. {
  171. vol.Required(CONF_IOT_TOKEN): cv.string,
  172. vol.Required(CONF_STATION_ID): cv.string,
  173. vol.Optional(CONF_DEVICE_ID): cv.string,
  174. vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
  175. }
  176. ),
  177. errors=errors,
  178. description_placeholders={
  179. "docs_url": "https://github.com/Hyllesen/solar-of-things-solar-usage"
  180. },
  181. )
  182. # ── Re-auth flow ───────────────────────────────────────────────────────────
  183. async def async_step_reauth(self, entry_data: dict[str, Any] | None = None) -> FlowResult:
  184. """Triggered by the coordinator when TokenExpiredError is raised."""
  185. return await self.async_step_reauth_confirm()
  186. async def async_step_reauth_confirm(
  187. self, user_input: dict[str, Any] | None = None
  188. ) -> FlowResult:
  189. """Ask the user to re-enter credentials or a fresh token."""
  190. errors: dict[str, str] = {}
  191. existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
  192. has_password = bool(
  193. existing_entry
  194. and (
  195. existing_entry.data.get(CONF_PASSWORD)
  196. or existing_entry.data.get(CONF_USER_ID)
  197. )
  198. )
  199. if user_input is not None:
  200. if has_password:
  201. # Re-authenticate with potentially updated email/password
  202. merged = {**(existing_entry.data if existing_entry else {}), **user_input}
  203. try:
  204. info = await _validate_password_auth(self.hass, merged)
  205. except InvalidAuth:
  206. errors["base"] = "invalid_auth"
  207. except CannotConnect:
  208. errors["base"] = "cannot_connect"
  209. except Exception:
  210. _LOGGER.exception("Unexpected exception during re-auth")
  211. errors["base"] = "unknown"
  212. else:
  213. self.hass.config_entries.async_update_entry(
  214. existing_entry,
  215. data={
  216. **existing_entry.data,
  217. CONF_PASSWORD: user_input.get(CONF_PASSWORD, existing_entry.data.get(CONF_PASSWORD)),
  218. CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
  219. CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
  220. CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
  221. CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
  222. },
  223. )
  224. await self.hass.config_entries.async_reload(existing_entry.entry_id)
  225. return self.async_abort(reason="reauth_successful")
  226. else:
  227. # Legacy token mode: user provides a fresh token
  228. merged = {**(existing_entry.data if existing_entry else {}), **user_input}
  229. try:
  230. await _validate_token_auth(self.hass, merged)
  231. except CannotConnect:
  232. errors["base"] = "cannot_connect"
  233. except Exception:
  234. _LOGGER.exception("Unexpected exception during re-auth (token)")
  235. errors["base"] = "unknown"
  236. else:
  237. self.hass.config_entries.async_update_entry(
  238. existing_entry,
  239. data={**existing_entry.data, CONF_IOT_TOKEN: user_input[CONF_IOT_TOKEN]},
  240. )
  241. await self.hass.config_entries.async_reload(existing_entry.entry_id)
  242. return self.async_abort(reason="reauth_successful")
  243. # Build schema based on whether the entry has stored credentials
  244. if has_password:
  245. schema = vol.Schema(
  246. {
  247. vol.Optional(
  248. CONF_PASSWORD,
  249. description={"suggested_value": ""},
  250. ): cv.string,
  251. }
  252. )
  253. else:
  254. schema = vol.Schema({vol.Required(CONF_IOT_TOKEN): cv.string})
  255. return self.async_show_form(
  256. step_id="reauth_confirm",
  257. data_schema=schema,
  258. errors=errors,
  259. )
  260. # ─── Custom exceptions ─────────────────────────────────────────────────────────
  261. class CannotConnect(HomeAssistantError):
  262. """Error to indicate we cannot connect."""
  263. class InvalidAuth(HomeAssistantError):
  264. """Error to indicate invalid authentication credentials."""