__init__.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. """The Solar of Things integration.
  2. Authentication notes
  3. ────────────────────
  4. The integration supports two modes:
  5. Email + Password (MODE_PASSWORD):
  6. • api.login() is called at setup → obtains accessToken + refreshToken.
  7. • _ensure_token_valid() is called before every API request; it proactively
  8. refreshes the token 5 minutes before expiry using the refresh-token
  9. endpoint, and falls back to re-login with stored credentials if the
  10. refresh token is also expired.
  11. • on_token_refreshed callback updates the config entry with fresh tokens so
  12. the state survives HA restarts without having to re-login every time.
  13. Legacy IOT-Token (MODE_TOKEN):
  14. • The stored token is used as-is.
  15. • On 401 / TokenExpiredError, the coordinator raises UpdateFailed and also
  16. calls entry.async_start_reauth() so the user is prompted for a fresh token.
  17. Version history
  18. ────────────────
  19. v2.2.0 – email/password auth + automatic token refresh
  20. """
  21. from __future__ import annotations
  22. import logging
  23. from datetime import timedelta
  24. from typing import Any
  25. from homeassistant.config_entries import ConfigEntry
  26. from homeassistant.const import Platform
  27. from homeassistant.core import HomeAssistant, callback
  28. from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
  29. from .api import SolarOfThingsAPI, TokenExpiredError
  30. from .const import (
  31. DOMAIN,
  32. CONF_EMAIL,
  33. CONF_PASSWORD,
  34. CONF_IOT_TOKEN,
  35. CONF_STATION_ID,
  36. CONF_DEVICE_ID,
  37. CONF_TIME_ZONE,
  38. CONF_REFRESH_TOKEN,
  39. CONF_ACCESS_TOKEN_EXPIRES,
  40. CONF_REFRESH_TOKEN_EXPIRES,
  41. )
  42. _LOGGER = logging.getLogger(__name__)
  43. PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SELECT, Platform.SWITCH]
  44. DEVICE_UPDATE_INTERVAL = timedelta(minutes=5)
  45. STATION_UPDATE_INTERVAL = timedelta(minutes=30)
  46. # ──────────────────────────────────────────────────────────────────────────────
  47. # Entry setup / teardown
  48. # ──────────────────────────────────────────────────────────────────────────────
  49. async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
  50. """Set up Solar of Things from a config entry."""
  51. hass.data.setdefault(DOMAIN, {})
  52. time_zone = entry.data.get(CONF_TIME_ZONE) or entry.options.get(CONF_TIME_ZONE)
  53. email = entry.data.get(CONF_EMAIL)
  54. password = entry.data.get(CONF_PASSWORD)
  55. # Build the token-refreshed callback *before* constructing the API so the
  56. # callback reference is captured.
  57. @callback
  58. def _on_token_refreshed(
  59. access_token: str,
  60. refresh_token: str,
  61. access_expires: str,
  62. refresh_expires: str,
  63. ) -> None:
  64. """Persist refreshed token back to the config entry (non-blocking)."""
  65. hass.config_entries.async_update_entry(
  66. entry,
  67. data={
  68. **entry.data,
  69. CONF_IOT_TOKEN: access_token,
  70. CONF_REFRESH_TOKEN: refresh_token,
  71. CONF_ACCESS_TOKEN_EXPIRES: access_expires,
  72. CONF_REFRESH_TOKEN_EXPIRES: refresh_expires,
  73. },
  74. )
  75. _LOGGER.debug(
  76. "SolarOfThings [%s]: access token refreshed; entry data updated",
  77. entry.entry_id,
  78. )
  79. # Instantiate API in the appropriate auth mode
  80. if email and password:
  81. api = SolarOfThingsAPI(
  82. email=email,
  83. password=password,
  84. iot_token=entry.data.get(CONF_IOT_TOKEN), # cached token (avoids login on every restart)
  85. refresh_token=entry.data.get(CONF_REFRESH_TOKEN),
  86. access_token_expires=entry.data.get(CONF_ACCESS_TOKEN_EXPIRES),
  87. refresh_token_expires=entry.data.get(CONF_REFRESH_TOKEN_EXPIRES),
  88. time_zone=time_zone,
  89. on_token_refreshed=_on_token_refreshed,
  90. )
  91. # If the cached token is already expired (or absent), login immediately.
  92. if not api.access_token:
  93. await hass.async_add_executor_job(api.login)
  94. else:
  95. # Legacy IOT-token mode
  96. api = SolarOfThingsAPI(
  97. iot_token=entry.data[CONF_IOT_TOKEN],
  98. time_zone=time_zone,
  99. on_token_refreshed=_on_token_refreshed,
  100. )
  101. station_id = entry.data[CONF_STATION_ID]
  102. configured_device_id = (entry.data.get(CONF_DEVICE_ID) or "").strip()
  103. # ── Station coordinator (device list + monthly) ────────────────────────────
  104. station_coordinator = SolarOfThingsStationCoordinator(
  105. hass=hass,
  106. api=api,
  107. station_id=station_id,
  108. entry=entry,
  109. )
  110. await station_coordinator.async_config_entry_first_refresh()
  111. # ── Per-device coordinators ────────────────────────────────────────────────
  112. devices: list[dict[str, Any]] = (
  113. station_coordinator.data.get("devices", []) if station_coordinator.data else []
  114. )
  115. if configured_device_id:
  116. filtered = [d for d in devices if str(d.get("id")) == configured_device_id]
  117. devices = filtered if filtered else [
  118. {"id": configured_device_id, "name": configured_device_id}
  119. ]
  120. device_coordinators: dict[str, SolarOfThingsDeviceCoordinator] = {}
  121. for dev in devices:
  122. device_id = str(dev.get("id") or "")
  123. if not device_id:
  124. continue
  125. c = SolarOfThingsDeviceCoordinator(
  126. hass=hass,
  127. api=api,
  128. station_id=station_id,
  129. device=device_id,
  130. device_meta=dev,
  131. entry=entry,
  132. )
  133. await c.async_config_entry_first_refresh()
  134. device_coordinators[device_id] = c
  135. hass.data[DOMAIN][entry.entry_id] = {
  136. "api": api,
  137. "station_id": station_id,
  138. "station_coordinator": station_coordinator,
  139. "device_coordinators": device_coordinators,
  140. "devices": devices,
  141. }
  142. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
  143. return True
  144. async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
  145. """Unload a config entry."""
  146. if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
  147. hass.data[DOMAIN].pop(entry.entry_id, None)
  148. return unload_ok
  149. # ──────────────────────────────────────────────────────────────────────────────
  150. # Coordinators
  151. # ──────────────────────────────────────────────────────────────────────────────
  152. class SolarOfThingsStationCoordinator(DataUpdateCoordinator):
  153. """Fetch station-level data (device list + monthly stats)."""
  154. def __init__(
  155. self,
  156. hass: HomeAssistant,
  157. api: SolarOfThingsAPI,
  158. station_id: str,
  159. entry: ConfigEntry,
  160. ) -> None:
  161. self.api = api
  162. self.station_id = station_id
  163. self._entry = entry
  164. super().__init__(
  165. hass,
  166. _LOGGER,
  167. name=f"{DOMAIN}_station_{station_id}",
  168. update_interval=STATION_UPDATE_INTERVAL,
  169. )
  170. async def _async_update_data(self) -> dict[str, Any]:
  171. try:
  172. devices = await self.hass.async_add_executor_job(
  173. self.api.list_devices, self.station_id
  174. )
  175. monthly = await self.hass.async_add_executor_job(
  176. self.api.fetch_monthly_summary, self.station_id
  177. )
  178. return {"devices": devices, "monthly": monthly}
  179. except TokenExpiredError as err:
  180. _LOGGER.error(
  181. "SolarOfThings station %s: token expired — triggering re-auth: %s",
  182. self.station_id, err,
  183. )
  184. self._entry.async_start_reauth(self.hass)
  185. raise UpdateFailed(f"Token expired: {err}") from err
  186. except Exception as err:
  187. raise UpdateFailed(f"Station update failed: {err}") from err
  188. class SolarOfThingsDeviceCoordinator(DataUpdateCoordinator):
  189. """Fetch device-level telemetry + settings."""
  190. def __init__(
  191. self,
  192. hass: HomeAssistant,
  193. api: SolarOfThingsAPI,
  194. station_id: str,
  195. device: str,
  196. device_meta: dict[str, Any],
  197. entry: ConfigEntry,
  198. ) -> None:
  199. self.api = api
  200. self.station_id = station_id
  201. self.device_id = device
  202. self.device_meta = device_meta
  203. self._entry = entry
  204. super().__init__(
  205. hass,
  206. _LOGGER,
  207. name=f"{DOMAIN}_device_{device}",
  208. update_interval=DEVICE_UPDATE_INTERVAL,
  209. )
  210. async def _async_update_data(self) -> dict[str, Any]:
  211. try:
  212. time_series = await self.hass.async_add_executor_job(
  213. self.api.fetch_latest_data, self.device_id
  214. )
  215. settings = await self.hass.async_add_executor_job(
  216. self.api.fetch_settings, self.device_id
  217. )
  218. return {
  219. "time_series": time_series,
  220. "settings": settings,
  221. "device": self.device_id,
  222. "station_id": self.station_id,
  223. "device_meta": self.device_meta,
  224. }
  225. except TokenExpiredError as err:
  226. _LOGGER.error(
  227. "SolarOfThings device %s: token expired — triggering re-auth: %s",
  228. self.device_id, err,
  229. )
  230. self._entry.async_start_reauth(self.hass)
  231. raise UpdateFailed(f"Token expired: {err}") from err
  232. except Exception as err:
  233. raise UpdateFailed(
  234. f"Device update failed for {self.device_id}: {err}"
  235. ) from err