config_flow.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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. from .util import normalise_config_fields
  34. _LOGGER = logging.getLogger(__name__)
  35. # ─── Validation helpers ────────────────────────────────────────────────────────
  36. async def _validate_password_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
  37. """Validate user-ID/password auth, return title."""
  38. api = SolarOfThingsAPI(
  39. user_id=data[CONF_USER_ID],
  40. password=data[CONF_PASSWORD],
  41. time_zone=data.get(CONF_TIME_ZONE),
  42. )
  43. try:
  44. await hass.async_add_executor_job(api.login)
  45. except AuthenticationError as err:
  46. raise InvalidAuth(str(err)) from err
  47. except Exception as err:
  48. raise CannotConnect(str(err)) from err
  49. # After login, validate station
  50. ok = await hass.async_add_executor_job(api.test_connection, data[CONF_STATION_ID])
  51. if not ok:
  52. raise CannotConnect("Station/device unreachable after login.")
  53. return {
  54. "title": f"Solar Station {data[CONF_STATION_ID]}",
  55. CONF_IOT_TOKEN: api.access_token,
  56. CONF_REFRESH_TOKEN: api.refresh_token,
  57. CONF_ACCESS_TOKEN_EXPIRES: api.access_token_expires_iso,
  58. CONF_REFRESH_TOKEN_EXPIRES: api.refresh_token_expires_iso,
  59. }
  60. async def _validate_token_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
  61. """Validate legacy IOT-token auth, return title."""
  62. api = SolarOfThingsAPI(
  63. iot_token=data[CONF_IOT_TOKEN],
  64. time_zone=data.get(CONF_TIME_ZONE),
  65. )
  66. station_id = data[CONF_STATION_ID]
  67. device_id = (data.get(CONF_DEVICE_ID) or "").strip()
  68. try:
  69. if device_id:
  70. res = await hass.async_add_executor_job(api.fetch_latest_data, device_id)
  71. ok = bool(res)
  72. else:
  73. ok = await hass.async_add_executor_job(api.test_connection, station_id)
  74. except Exception as err:
  75. _LOGGER.error("Token validation failed: %s", err)
  76. ok = False
  77. if not ok:
  78. raise CannotConnect("Cannot connect with provided token.")
  79. return {"title": f"Solar Station {station_id}"}
  80. # ─── Config Flow ───────────────────────────────────────────────────────────────
  81. class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
  82. """Handle a config flow for Solar of Things."""
  83. VERSION = 3 # bumped for new auth fields
  84. def __init__(self) -> None:
  85. self._auth_mode: str = "password" # "password" | "token"
  86. # ── Step 1: choose auth mode ───────────────────────────────────────────────
  87. async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
  88. """Offer choice of auth mode."""
  89. if user_input is not None:
  90. self._auth_mode = user_input.get("auth_mode", "password")
  91. if self._auth_mode == "token":
  92. return await self.async_step_token()
  93. return await self.async_step_password()
  94. return self.async_show_form(
  95. step_id="user",
  96. data_schema=vol.Schema(
  97. {
  98. vol.Required("auth_mode", default="password"): vol.In(
  99. {"password": "User ID + Password (recommended)", "token": "IOT Token (advanced)"}
  100. )
  101. }
  102. ),
  103. description_placeholders={
  104. "docs_url": "https://github.com/Conexo-Casa/solar-of-things-ha"
  105. },
  106. )
  107. # ── Step 2a: user-id + password ────────────────────────────────────────────
  108. async def async_step_password(self, user_input: dict[str, Any] | None = None) -> FlowResult:
  109. """User-ID + password setup step."""
  110. errors: dict[str, str] = {}
  111. if user_input is not None:
  112. # Trim before validating *and* before storing, so the value that is
  113. # checked is the same one that gets persisted.
  114. user_input = normalise_config_fields(user_input)
  115. try:
  116. info = await _validate_password_auth(self.hass, user_input)
  117. except InvalidAuth:
  118. errors["base"] = "invalid_auth"
  119. except CannotConnect:
  120. errors["base"] = "cannot_connect"
  121. except Exception:
  122. _LOGGER.exception("Unexpected exception in password step")
  123. errors["base"] = "unknown"
  124. else:
  125. await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
  126. self._abort_if_unique_id_configured()
  127. entry_data = {
  128. CONF_USER_ID: user_input[CONF_USER_ID],
  129. CONF_PASSWORD: user_input[CONF_PASSWORD],
  130. CONF_STATION_ID: user_input[CONF_STATION_ID],
  131. CONF_DEVICE_ID: user_input.get(CONF_DEVICE_ID, ""),
  132. CONF_TIME_ZONE: user_input.get(CONF_TIME_ZONE, "Asia/Manila"),
  133. CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
  134. CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
  135. CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
  136. CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
  137. }
  138. return self.async_create_entry(title=info["title"], data=entry_data)
  139. return self.async_show_form(
  140. step_id="password",
  141. data_schema=vol.Schema(
  142. {
  143. vol.Required(CONF_USER_ID): cv.string,
  144. vol.Required(CONF_PASSWORD): cv.string,
  145. vol.Required(CONF_STATION_ID): cv.string,
  146. vol.Optional(CONF_DEVICE_ID): cv.string,
  147. vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
  148. }
  149. ),
  150. errors=errors,
  151. description_placeholders={
  152. "docs_url": "https://github.com/Conexo-Casa/solar-of-things-ha"
  153. },
  154. )
  155. # ── Step 2b: legacy IOT token ─────────────────────────────────────────────
  156. async def async_step_token(self, user_input: dict[str, Any] | None = None) -> FlowResult:
  157. """Legacy IOT-token setup step."""
  158. errors: dict[str, str] = {}
  159. if user_input is not None:
  160. # The entry is created from this same dict, so trim it up front.
  161. user_input = normalise_config_fields(user_input)
  162. try:
  163. info = await _validate_token_auth(self.hass, user_input)
  164. except CannotConnect:
  165. errors["base"] = "cannot_connect"
  166. except Exception:
  167. _LOGGER.exception("Unexpected exception in token step")
  168. errors["base"] = "unknown"
  169. else:
  170. await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
  171. self._abort_if_unique_id_configured()
  172. return self.async_create_entry(title=info["title"], data=user_input)
  173. return self.async_show_form(
  174. step_id="token",
  175. data_schema=vol.Schema(
  176. {
  177. vol.Required(CONF_IOT_TOKEN): cv.string,
  178. vol.Required(CONF_STATION_ID): cv.string,
  179. vol.Optional(CONF_DEVICE_ID): cv.string,
  180. vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
  181. }
  182. ),
  183. errors=errors,
  184. description_placeholders={
  185. "docs_url": "https://github.com/Conexo-Casa/solar-of-things-ha"
  186. },
  187. )
  188. # ── Re-auth flow ───────────────────────────────────────────────────────────
  189. async def async_step_reauth(self, entry_data: dict[str, Any] | None = None) -> FlowResult:
  190. """Triggered by the coordinator when TokenExpiredError is raised."""
  191. return await self.async_step_reauth_confirm()
  192. async def async_step_reauth_confirm(
  193. self, user_input: dict[str, Any] | None = None
  194. ) -> FlowResult:
  195. """Ask the user to re-enter credentials or a fresh token."""
  196. errors: dict[str, str] = {}
  197. existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
  198. has_password = bool(
  199. existing_entry
  200. and (
  201. existing_entry.data.get(CONF_PASSWORD)
  202. or existing_entry.data.get(CONF_USER_ID)
  203. )
  204. )
  205. if user_input is not None:
  206. # Matters most for the token path: a freshly pasted IOT token is the
  207. # single most whitespace-prone value in this integration.
  208. user_input = normalise_config_fields(user_input)
  209. if has_password:
  210. # Re-authenticate with potentially updated email/password
  211. # Normalise the merge, not just user_input: the station/device
  212. # IDs come from the stored entry, which may pre-date trimming.
  213. merged = normalise_config_fields(
  214. {**(existing_entry.data if existing_entry else {}), **user_input}
  215. )
  216. try:
  217. info = await _validate_password_auth(self.hass, merged)
  218. except InvalidAuth:
  219. errors["base"] = "invalid_auth"
  220. except CannotConnect:
  221. errors["base"] = "cannot_connect"
  222. except Exception:
  223. _LOGGER.exception("Unexpected exception during re-auth")
  224. errors["base"] = "unknown"
  225. else:
  226. self.hass.config_entries.async_update_entry(
  227. existing_entry,
  228. data={
  229. **existing_entry.data,
  230. CONF_PASSWORD: user_input.get(CONF_PASSWORD, existing_entry.data.get(CONF_PASSWORD)),
  231. CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
  232. CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
  233. CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
  234. CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
  235. },
  236. )
  237. await self.hass.config_entries.async_reload(existing_entry.entry_id)
  238. return self.async_abort(reason="reauth_successful")
  239. else:
  240. # Legacy token mode: user provides a fresh token
  241. # Normalise the merge, not just user_input: the station/device
  242. # IDs come from the stored entry, which may pre-date trimming.
  243. merged = normalise_config_fields(
  244. {**(existing_entry.data if existing_entry else {}), **user_input}
  245. )
  246. try:
  247. await _validate_token_auth(self.hass, merged)
  248. except CannotConnect:
  249. errors["base"] = "cannot_connect"
  250. except Exception:
  251. _LOGGER.exception("Unexpected exception during re-auth (token)")
  252. errors["base"] = "unknown"
  253. else:
  254. self.hass.config_entries.async_update_entry(
  255. existing_entry,
  256. data={**existing_entry.data, CONF_IOT_TOKEN: user_input[CONF_IOT_TOKEN]},
  257. )
  258. await self.hass.config_entries.async_reload(existing_entry.entry_id)
  259. return self.async_abort(reason="reauth_successful")
  260. # Build schema based on whether the entry has stored credentials
  261. if has_password:
  262. schema = vol.Schema(
  263. {
  264. vol.Optional(
  265. CONF_PASSWORD,
  266. description={"suggested_value": ""},
  267. ): cv.string,
  268. }
  269. )
  270. else:
  271. schema = vol.Schema({vol.Required(CONF_IOT_TOKEN): cv.string})
  272. return self.async_show_form(
  273. step_id="reauth_confirm",
  274. data_schema=schema,
  275. errors=errors,
  276. )
  277. # ─── Custom exceptions ─────────────────────────────────────────────────────────
  278. class CannotConnect(HomeAssistantError):
  279. """Error to indicate we cannot connect."""
  280. class InvalidAuth(HomeAssistantError):
  281. """Error to indicate invalid authentication credentials."""