config_flow.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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, is_valid_time_zone
  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. time_zone = user_input.get(CONF_TIME_ZONE) or "Asia/Manila"
  116. if not is_valid_time_zone(time_zone):
  117. errors["base"] = "invalid_time_zone"
  118. else:
  119. try:
  120. info = await _validate_password_auth(self.hass, user_input)
  121. except InvalidAuth:
  122. errors["base"] = "invalid_auth"
  123. except CannotConnect:
  124. errors["base"] = "cannot_connect"
  125. except Exception:
  126. _LOGGER.exception("Unexpected exception in password step")
  127. errors["base"] = "unknown"
  128. else:
  129. await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
  130. self._abort_if_unique_id_configured()
  131. entry_data = {
  132. CONF_USER_ID: user_input[CONF_USER_ID],
  133. CONF_PASSWORD: user_input[CONF_PASSWORD],
  134. CONF_STATION_ID: user_input[CONF_STATION_ID],
  135. CONF_DEVICE_ID: user_input.get(CONF_DEVICE_ID, ""),
  136. CONF_TIME_ZONE: time_zone,
  137. CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
  138. CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
  139. CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
  140. CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
  141. }
  142. return self.async_create_entry(title=info["title"], data=entry_data)
  143. return self.async_show_form(
  144. step_id="password",
  145. data_schema=vol.Schema(
  146. {
  147. vol.Required(CONF_USER_ID): cv.string,
  148. vol.Required(CONF_PASSWORD): cv.string,
  149. vol.Required(CONF_STATION_ID): cv.string,
  150. vol.Optional(CONF_DEVICE_ID): cv.string,
  151. vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
  152. }
  153. ),
  154. errors=errors,
  155. description_placeholders={
  156. "docs_url": "https://github.com/Conexo-Casa/solar-of-things-ha"
  157. },
  158. )
  159. # ── Step 2b: legacy IOT token ─────────────────────────────────────────────
  160. async def async_step_token(self, user_input: dict[str, Any] | None = None) -> FlowResult:
  161. """Legacy IOT-token setup step."""
  162. errors: dict[str, str] = {}
  163. if user_input is not None:
  164. # The entry is created from this same dict, so trim it up front.
  165. user_input = normalise_config_fields(user_input)
  166. time_zone = user_input.get(CONF_TIME_ZONE) or "Asia/Manila"
  167. if not is_valid_time_zone(time_zone):
  168. errors["base"] = "invalid_time_zone"
  169. else:
  170. try:
  171. info = await _validate_token_auth(self.hass, user_input)
  172. except CannotConnect:
  173. errors["base"] = "cannot_connect"
  174. except Exception:
  175. _LOGGER.exception("Unexpected exception in token step")
  176. errors["base"] = "unknown"
  177. else:
  178. await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
  179. self._abort_if_unique_id_configured()
  180. return self.async_create_entry(title=info["title"], data=user_input)
  181. return self.async_show_form(
  182. step_id="token",
  183. data_schema=vol.Schema(
  184. {
  185. vol.Required(CONF_IOT_TOKEN): cv.string,
  186. vol.Required(CONF_STATION_ID): cv.string,
  187. vol.Optional(CONF_DEVICE_ID): cv.string,
  188. vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
  189. }
  190. ),
  191. errors=errors,
  192. description_placeholders={
  193. "docs_url": "https://github.com/Conexo-Casa/solar-of-things-ha"
  194. },
  195. )
  196. # ── Re-auth flow ───────────────────────────────────────────────────────────
  197. async def async_step_reauth(self, entry_data: dict[str, Any] | None = None) -> FlowResult:
  198. """Triggered by the coordinator when TokenExpiredError is raised."""
  199. return await self.async_step_reauth_confirm()
  200. async def async_step_reauth_confirm(
  201. self, user_input: dict[str, Any] | None = None
  202. ) -> FlowResult:
  203. """Ask the user to re-enter credentials or a fresh token."""
  204. errors: dict[str, str] = {}
  205. existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
  206. has_password = bool(
  207. existing_entry
  208. and (
  209. existing_entry.data.get(CONF_PASSWORD)
  210. or existing_entry.data.get(CONF_USER_ID)
  211. )
  212. )
  213. if user_input is not None:
  214. # Matters most for the token path: a freshly pasted IOT token is the
  215. # single most whitespace-prone value in this integration.
  216. user_input = normalise_config_fields(user_input)
  217. if has_password:
  218. # Re-authenticate with potentially updated email/password
  219. # Normalise the merge, not just user_input: the station/device
  220. # IDs come from the stored entry, which may pre-date trimming.
  221. merged = normalise_config_fields(
  222. {**(existing_entry.data if existing_entry else {}), **user_input}
  223. )
  224. try:
  225. info = await _validate_password_auth(self.hass, merged)
  226. except InvalidAuth:
  227. errors["base"] = "invalid_auth"
  228. except CannotConnect:
  229. errors["base"] = "cannot_connect"
  230. except Exception:
  231. _LOGGER.exception("Unexpected exception during re-auth")
  232. errors["base"] = "unknown"
  233. else:
  234. self.hass.config_entries.async_update_entry(
  235. existing_entry,
  236. data={
  237. **existing_entry.data,
  238. CONF_PASSWORD: user_input.get(CONF_PASSWORD, existing_entry.data.get(CONF_PASSWORD)),
  239. CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
  240. CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
  241. CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
  242. CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
  243. },
  244. )
  245. await self.hass.config_entries.async_reload(existing_entry.entry_id)
  246. return self.async_abort(reason="reauth_successful")
  247. else:
  248. # Legacy token mode: user provides a fresh token
  249. # Normalise the merge, not just user_input: the station/device
  250. # IDs come from the stored entry, which may pre-date trimming.
  251. merged = normalise_config_fields(
  252. {**(existing_entry.data if existing_entry else {}), **user_input}
  253. )
  254. try:
  255. await _validate_token_auth(self.hass, merged)
  256. except CannotConnect:
  257. errors["base"] = "cannot_connect"
  258. except Exception:
  259. _LOGGER.exception("Unexpected exception during re-auth (token)")
  260. errors["base"] = "unknown"
  261. else:
  262. self.hass.config_entries.async_update_entry(
  263. existing_entry,
  264. data={**existing_entry.data, CONF_IOT_TOKEN: user_input[CONF_IOT_TOKEN]},
  265. )
  266. await self.hass.config_entries.async_reload(existing_entry.entry_id)
  267. return self.async_abort(reason="reauth_successful")
  268. # Build schema based on whether the entry has stored credentials
  269. if has_password:
  270. schema = vol.Schema(
  271. {
  272. vol.Optional(
  273. CONF_PASSWORD,
  274. description={"suggested_value": ""},
  275. ): cv.string,
  276. }
  277. )
  278. else:
  279. schema = vol.Schema({vol.Required(CONF_IOT_TOKEN): cv.string})
  280. return self.async_show_form(
  281. step_id="reauth_confirm",
  282. data_schema=schema,
  283. errors=errors,
  284. )
  285. # ─── Custom exceptions ─────────────────────────────────────────────────────────
  286. class CannotConnect(HomeAssistantError):
  287. """Error to indicate we cannot connect."""
  288. class InvalidAuth(HomeAssistantError):
  289. """Error to indicate invalid authentication credentials."""