__init__.py 11 KB

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