__init__.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  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.3.0 – user-id/password auth (replacing email), working IOT-Open signing
  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 import device_registry as dr
  29. from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
  30. from .api import SolarOfThingsAPI, TokenExpiredError
  31. from .const import (
  32. DOMAIN,
  33. CONF_USER_ID,
  34. CONF_PASSWORD,
  35. CONF_IOT_TOKEN,
  36. CONF_STATION_ID,
  37. CONF_DEVICE_ID,
  38. CONF_TIME_ZONE,
  39. CONF_REFRESH_TOKEN,
  40. CONF_ACCESS_TOKEN_EXPIRES,
  41. CONF_REFRESH_TOKEN_EXPIRES,
  42. )
  43. _LOGGER = logging.getLogger(__name__)
  44. PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SELECT, Platform.SWITCH]
  45. DEVICE_UPDATE_INTERVAL = timedelta(minutes=5)
  46. STATION_UPDATE_INTERVAL = timedelta(minutes=30)
  47. # ──────────────────────────────────────────────────────────────────────────────
  48. # Entry setup / teardown
  49. # ──────────────────────────────────────────────────────────────────────────────
  50. async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
  51. """Set up Solar of Things from a config entry."""
  52. hass.data.setdefault(DOMAIN, {})
  53. time_zone = entry.data.get(CONF_TIME_ZONE) or entry.options.get(CONF_TIME_ZONE)
  54. user_id = entry.data.get(CONF_USER_ID)
  55. password = entry.data.get(CONF_PASSWORD)
  56. # Build the token-refreshed callback *before* constructing the API so the
  57. # callback reference is captured.
  58. # NOTE: this callback is invoked from a background executor thread (inside
  59. # api.login / api.refresh_access_token). async_update_entry must only be
  60. # called from the event loop, so we schedule it with call_soon_threadsafe.
  61. def _on_token_refreshed(
  62. access_token: str,
  63. refresh_token: str,
  64. access_expires: str,
  65. refresh_expires: str,
  66. ) -> None:
  67. """Persist refreshed token back to the config entry (thread-safe)."""
  68. @callback
  69. def _update() -> None:
  70. hass.config_entries.async_update_entry(
  71. entry,
  72. data={
  73. **entry.data,
  74. CONF_IOT_TOKEN: access_token,
  75. CONF_REFRESH_TOKEN: refresh_token,
  76. CONF_ACCESS_TOKEN_EXPIRES: access_expires,
  77. CONF_REFRESH_TOKEN_EXPIRES: refresh_expires,
  78. },
  79. )
  80. _LOGGER.debug(
  81. "SolarOfThings [%s]: access token refreshed; entry data updated",
  82. entry.entry_id,
  83. )
  84. hass.loop.call_soon_threadsafe(_update)
  85. # Instantiate API in the appropriate auth mode
  86. if user_id and password:
  87. api = SolarOfThingsAPI(
  88. user_id=user_id,
  89. password=password,
  90. iot_token=entry.data.get(CONF_IOT_TOKEN), # cached token (avoids login on every restart)
  91. refresh_token=entry.data.get(CONF_REFRESH_TOKEN),
  92. access_token_expires=entry.data.get(CONF_ACCESS_TOKEN_EXPIRES),
  93. refresh_token_expires=entry.data.get(CONF_REFRESH_TOKEN_EXPIRES),
  94. time_zone=time_zone,
  95. on_token_refreshed=_on_token_refreshed,
  96. )
  97. # If the cached token is already expired (or absent), login immediately.
  98. if not api.access_token:
  99. await hass.async_add_executor_job(api.login)
  100. else:
  101. # Legacy IOT-token mode
  102. api = SolarOfThingsAPI(
  103. iot_token=entry.data[CONF_IOT_TOKEN],
  104. time_zone=time_zone,
  105. on_token_refreshed=_on_token_refreshed,
  106. )
  107. station_id = entry.data[CONF_STATION_ID]
  108. configured_device_id = (entry.data.get(CONF_DEVICE_ID) or "").strip()
  109. # ── Station coordinator (device list + monthly) ────────────────────────────
  110. station_coordinator = SolarOfThingsStationCoordinator(
  111. hass=hass,
  112. api=api,
  113. station_id=station_id,
  114. entry=entry,
  115. )
  116. await station_coordinator.async_config_entry_first_refresh()
  117. # ── Per-device coordinators ────────────────────────────────────────────────
  118. devices: list[dict[str, Any]] = (
  119. station_coordinator.data.get("devices", []) if station_coordinator.data else []
  120. )
  121. if configured_device_id:
  122. filtered = [d for d in devices if str(d.get("id")) == configured_device_id]
  123. devices = filtered if filtered else [
  124. {"id": configured_device_id, "name": configured_device_id}
  125. ]
  126. device_coordinators: dict[str, SolarOfThingsDeviceCoordinator] = {}
  127. for dev in devices:
  128. device_id = str(dev.get("id") or "")
  129. if not device_id:
  130. continue
  131. c = SolarOfThingsDeviceCoordinator(
  132. hass=hass,
  133. api=api,
  134. station_id=station_id,
  135. device=device_id,
  136. device_meta=dev,
  137. entry=entry,
  138. )
  139. await c.async_config_entry_first_refresh()
  140. device_coordinators[device_id] = c
  141. hass.data[DOMAIN][entry.entry_id] = {
  142. "api": api,
  143. "station_id": station_id,
  144. "station_coordinator": station_coordinator,
  145. "device_coordinators": device_coordinators,
  146. "devices": devices,
  147. }
  148. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
  149. # Explicitly register the station hub device so that per-device entities
  150. # can reference it via via_device without triggering the
  151. # "non existing via_device" warning introduced in HA 2025.12.
  152. dev_reg = dr.async_get(hass)
  153. dev_reg.async_get_or_create(
  154. config_entry_id=entry.entry_id,
  155. identifiers={(DOMAIN, station_id)},
  156. name=f"Solar Station {station_id}",
  157. manufacturer="Siseli",
  158. model="Station",
  159. )
  160. return True
  161. async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
  162. """Unload a config entry."""
  163. if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
  164. hass.data[DOMAIN].pop(entry.entry_id, None)
  165. return unload_ok
  166. # ──────────────────────────────────────────────────────────────────────────────
  167. # Coordinators
  168. # ──────────────────────────────────────────────────────────────────────────────
  169. class SolarOfThingsStationCoordinator(DataUpdateCoordinator):
  170. """Fetch station-level data (device list + monthly stats)."""
  171. def __init__(
  172. self,
  173. hass: HomeAssistant,
  174. api: SolarOfThingsAPI,
  175. station_id: str,
  176. entry: ConfigEntry,
  177. ) -> None:
  178. self.api = api
  179. self.station_id = station_id
  180. self._entry = entry
  181. super().__init__(
  182. hass,
  183. _LOGGER,
  184. name=f"{DOMAIN}_station_{station_id}",
  185. update_interval=STATION_UPDATE_INTERVAL,
  186. )
  187. async def _async_update_data(self) -> dict[str, Any]:
  188. try:
  189. devices = await self.hass.async_add_executor_job(
  190. self.api.list_devices, self.station_id
  191. )
  192. monthly = await self.hass.async_add_executor_job(
  193. self.api.fetch_monthly_summary, self.station_id
  194. )
  195. return {"devices": devices, "monthly": monthly}
  196. except TokenExpiredError as err:
  197. _LOGGER.error(
  198. "SolarOfThings station %s: token expired — triggering re-auth: %s",
  199. self.station_id, err,
  200. )
  201. self._entry.async_start_reauth(self.hass)
  202. raise UpdateFailed(f"Token expired: {err}") from err
  203. except Exception as err:
  204. raise UpdateFailed(f"Station update failed: {err}") from err
  205. class SolarOfThingsDeviceCoordinator(DataUpdateCoordinator):
  206. """Fetch device-level telemetry + settings."""
  207. def __init__(
  208. self,
  209. hass: HomeAssistant,
  210. api: SolarOfThingsAPI,
  211. station_id: str,
  212. device: str,
  213. device_meta: dict[str, Any],
  214. entry: ConfigEntry,
  215. ) -> None:
  216. self.api = api
  217. self.station_id = station_id
  218. self.device_id = device
  219. self.device_meta = device_meta
  220. self._entry = entry
  221. super().__init__(
  222. hass,
  223. _LOGGER,
  224. name=f"{DOMAIN}_device_{device}",
  225. update_interval=DEVICE_UPDATE_INTERVAL,
  226. )
  227. async def _async_update_data(self) -> dict[str, Any]:
  228. try:
  229. time_series = await self.hass.async_add_executor_job(
  230. self.api.fetch_latest_data, self.device_id
  231. )
  232. settings = await self.hass.async_add_executor_job(
  233. self.api.fetch_settings, self.device_id
  234. )
  235. return {
  236. "time_series": time_series,
  237. "settings": settings,
  238. "device": self.device_id,
  239. "station_id": self.station_id,
  240. "device_meta": self.device_meta,
  241. }
  242. except TokenExpiredError as err:
  243. _LOGGER.error(
  244. "SolarOfThings device %s: token expired — triggering re-auth: %s",
  245. self.device_id, err,
  246. )
  247. self._entry.async_start_reauth(self.hass)
  248. raise UpdateFailed(f"Token expired: {err}") from err
  249. except Exception as err:
  250. raise UpdateFailed(
  251. f"Device update failed for {self.device_id}: {err}"
  252. ) from err