Ver código fonte

v2.2.0: email/password auth + automatic token refresh + HA re-auth flow

conexocasa 7 meses atrás
pai
commit
d01a627ecd
8 arquivos alterados com 974 adições e 232 exclusões
  1. 40 0
      CHANGELOG.md
  2. 153 31
      __init__.py
  3. 444 106
      api.py
  4. 234 25
      config_flow.py
  5. 27 12
      const.py
  6. 2 2
      manifest.json
  7. 37 28
      strings.json
  8. 37 28
      translations/en.json

+ 40 - 0
CHANGELOG.md

@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [2.2.0] - 2026-03-06
+
+### Added
+- **Email + Password authentication** (recommended) — the integration now logs in with Siseli portal credentials (`email`, `password`) and handles the full token lifecycle automatically. Users no longer need to copy IOT tokens from DevTools.
+- **Automatic token refresh** — `SolarOfThingsAPI._ensure_token_valid()` proactively refreshes the access token 5 minutes before expiry using `POST /login/refresh/access/token` (mirroring the portal JS behaviour). Falls back to re-login with stored credentials if the refresh token has also expired.
+- **HA re-auth flow** — if all refresh strategies fail, coordinators call `entry.async_start_reauth()` so HA displays a notification and the user is guided through re-authentication without losing their config.
+- **`on_token_refreshed` callback** — fresh tokens are persisted back to the config entry after every refresh, so the integration survives HA restarts without a new login.
+- **Legacy IOT-token mode preserved** — users who prefer not to store credentials can still paste a token manually; re-auth is triggered when it expires.
+- New `const.py` keys: `CONF_EMAIL`, `CONF_PASSWORD`, `CONF_REFRESH_TOKEN`, `CONF_ACCESS_TOKEN_EXPIRES`, `CONF_REFRESH_TOKEN_EXPIRES`, `API_LOGIN`, `API_REFRESH_TOKEN`, `TOKEN_REFRESH_LEAD_SECONDS`.
+- New config-flow steps: `password`, `token`, `reauth_confirm` (step `user` now offers a mode selector).
+- `TokenExpiredError` and `AuthenticationError` custom exceptions in `api.py`.
+
+### Changed
+- `SolarOfThingsAPI.__init__` signature updated: keyword-only arguments (`email`, `password`, `iot_token`, `refresh_token`, etc.).
+- `__init__.py` coordinators now catch `TokenExpiredError` and trigger re-auth instead of silently failing.
+- `strings.json` / `translations/en.json` updated for new flow steps and error messages.
+- `manifest.json` version bumped to **2.2.0** (`config_flow` version bumped to **3**).
+
+### Known issues
+- The `loginType` field value and exact shape of the `/login/account` response are inferred from portal JS analysis; tested against live API but may need adjustment for non-email login types.
+
+
+## [2.2.0] - 2026-03-06
+
+### Added
+- **Email + Password authentication** (recommended) — logs in automatically; no manual token copying needed.
+- **Automatic token refresh** — proactively refreshes access token 5 min before expiry via `POST /login/refresh/access/token`; falls back to re-login with stored credentials.
+- **HA re-auth flow** — on total token failure, coordinators call `entry.async_start_reauth()` to prompt user without losing config.
+- **`on_token_refreshed` callback** — fresh tokens persisted to config entry after every refresh (survives HA restarts).
+- **Legacy IOT-token mode preserved** — paste-a-token still works; re-auth triggered on expiry.
+- New constants: `CONF_EMAIL`, `CONF_PASSWORD`, `CONF_REFRESH_TOKEN`, `CONF_ACCESS_TOKEN_EXPIRES`, `CONF_REFRESH_TOKEN_EXPIRES`, `API_LOGIN`, `API_REFRESH_TOKEN`, `TOKEN_REFRESH_LEAD_SECONDS`.
+- New config-flow steps: `password`, `token`, `reauth_confirm`; step `user` is now a mode selector.
+- `TokenExpiredError` and `AuthenticationError` custom exceptions in `api.py`.
+
+### Changed
+- `SolarOfThingsAPI.__init__` uses keyword-only arguments.
+- Coordinators catch `TokenExpiredError` and trigger re-auth.
+- `strings.json`/`translations/en.json` updated for new flow steps.
+- `manifest.json` version **2.2.0**, config flow `VERSION = 3`.
+
 ## [2.1.1] - 2026-03-05
 
 ### Added

+ 153 - 31
__init__.py

@@ -1,16 +1,51 @@
-"""The Solar of Things integration."""
+"""The Solar of Things integration.
+
+Authentication notes
+────────────────────
+The integration supports two modes:
+
+  Email + Password (MODE_PASSWORD):
+    • api.login() is called at setup → obtains accessToken + refreshToken.
+    • _ensure_token_valid() is called before every API request; it proactively
+      refreshes the token 5 minutes before expiry using the refresh-token
+      endpoint, and falls back to re-login with stored credentials if the
+      refresh token is also expired.
+    • on_token_refreshed callback updates the config entry with fresh tokens so
+      the state survives HA restarts without having to re-login every time.
+
+  Legacy IOT-Token (MODE_TOKEN):
+    • The stored token is used as-is.
+    • On 401 / TokenExpiredError, the coordinator raises UpdateFailed and also
+      calls entry.async_start_reauth() so the user is prompted for a fresh token.
+
+Version history
+────────────────
+  v2.2.0 – email/password auth + automatic token refresh
+"""
 from __future__ import annotations
 
 import logging
 from datetime import timedelta
+from typing import Any
 
 from homeassistant.config_entries import ConfigEntry
 from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
 
-from .api import SolarOfThingsAPI
-from .const import DOMAIN, CONF_DEVICE_ID
+from .api import SolarOfThingsAPI, TokenExpiredError
+from .const import (
+    DOMAIN,
+    CONF_EMAIL,
+    CONF_PASSWORD,
+    CONF_IOT_TOKEN,
+    CONF_STATION_ID,
+    CONF_DEVICE_ID,
+    CONF_TIME_ZONE,
+    CONF_REFRESH_TOKEN,
+    CONF_ACCESS_TOKEN_EXPIRES,
+    CONF_REFRESH_TOKEN_EXPIRES,
+)
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -20,52 +55,102 @@ DEVICE_UPDATE_INTERVAL = timedelta(minutes=5)
 STATION_UPDATE_INTERVAL = timedelta(minutes=30)
 
 
+# ──────────────────────────────────────────────────────────────────────────────
+# Entry setup / teardown
+# ──────────────────────────────────────────────────────────────────────────────
+
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Solar of Things from a config entry."""
     hass.data.setdefault(DOMAIN, {})
 
-    api = SolarOfThingsAPI(
-        iot_token=entry.data["iot_token"],
-        time_zone=entry.data.get("time_zone") or entry.options.get("time_zone"),
-    )
+    time_zone = entry.data.get(CONF_TIME_ZONE) or entry.options.get(CONF_TIME_ZONE)
+    email = entry.data.get(CONF_EMAIL)
+    password = entry.data.get(CONF_PASSWORD)
 
-    station_id = entry.data["station_id"]
+    # Build the token-refreshed callback *before* constructing the API so the
+    # callback reference is captured.
+    @callback
+    def _on_token_refreshed(
+        access_token: str,
+        refresh_token: str,
+        access_expires: str,
+        refresh_expires: str,
+    ) -> None:
+        """Persist refreshed token back to the config entry (non-blocking)."""
+        hass.config_entries.async_update_entry(
+            entry,
+            data={
+                **entry.data,
+                CONF_IOT_TOKEN: access_token,
+                CONF_REFRESH_TOKEN: refresh_token,
+                CONF_ACCESS_TOKEN_EXPIRES: access_expires,
+                CONF_REFRESH_TOKEN_EXPIRES: refresh_expires,
+            },
+        )
+        _LOGGER.debug(
+            "SolarOfThings [%s]: access token refreshed; entry data updated",
+            entry.entry_id,
+        )
+
+    # Instantiate API in the appropriate auth mode
+    if email and password:
+        api = SolarOfThingsAPI(
+            email=email,
+            password=password,
+            iot_token=entry.data.get(CONF_IOT_TOKEN),          # cached token (avoids login on every restart)
+            refresh_token=entry.data.get(CONF_REFRESH_TOKEN),
+            access_token_expires=entry.data.get(CONF_ACCESS_TOKEN_EXPIRES),
+            refresh_token_expires=entry.data.get(CONF_REFRESH_TOKEN_EXPIRES),
+            time_zone=time_zone,
+            on_token_refreshed=_on_token_refreshed,
+        )
+        # If the cached token is already expired (or absent), login immediately.
+        if not api.access_token:
+            await hass.async_add_executor_job(api.login)
+    else:
+        # Legacy IOT-token mode
+        api = SolarOfThingsAPI(
+            iot_token=entry.data[CONF_IOT_TOKEN],
+            time_zone=time_zone,
+            on_token_refreshed=_on_token_refreshed,
+        )
+
+    station_id = entry.data[CONF_STATION_ID]
     configured_device_id = (entry.data.get(CONF_DEVICE_ID) or "").strip()
 
-    # Station coordinator (device list + monthly)
+    # ── Station coordinator (device list + monthly) ────────────────────────────
     station_coordinator = SolarOfThingsStationCoordinator(
         hass=hass,
         api=api,
         station_id=station_id,
+        entry=entry,
     )
     await station_coordinator.async_config_entry_first_refresh()
 
-    # Create one coordinator per device
-    devices = station_coordinator.data.get("devices", []) if station_coordinator.data else []
+    # ── Per-device coordinators ────────────────────────────────────────────────
+    devices: list[dict[str, Any]] = (
+        station_coordinator.data.get("devices", []) if station_coordinator.data else []
+    )
 
-    # If the user configured a specific deviceId, only create entities for that device.
-    # (We still keep station-level data like monthly summary.)
     if configured_device_id:
         filtered = [d for d in devices if str(d.get("id")) == configured_device_id]
-        if filtered:
-            devices = filtered
-        else:
-            # Fallback: allow the integration to work even if the device isn't returned
-            # by the station device list (permissions / pagination / API quirks).
-            devices = [{"id": configured_device_id, "name": configured_device_id}]
+        devices = filtered if filtered else [
+            {"id": configured_device_id, "name": configured_device_id}
+        ]
+
     device_coordinators: dict[str, SolarOfThingsDeviceCoordinator] = {}
 
     for dev in devices:
         device_id = str(dev.get("id") or "")
         if not device_id:
             continue
-
         c = SolarOfThingsDeviceCoordinator(
             hass=hass,
             api=api,
             station_id=station_id,
             device=device_id,
             device_meta=dev,
+            entry=entry,
         )
         await c.async_config_entry_first_refresh()
         device_coordinators[device_id] = c
@@ -89,12 +174,23 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     return unload_ok
 
 
+# ──────────────────────────────────────────────────────────────────────────────
+# Coordinators
+# ──────────────────────────────────────────────────────────────────────────────
+
 class SolarOfThingsStationCoordinator(DataUpdateCoordinator):
     """Fetch station-level data (device list + monthly stats)."""
 
-    def __init__(self, hass: HomeAssistant, api: SolarOfThingsAPI, station_id: str) -> None:
+    def __init__(
+        self,
+        hass: HomeAssistant,
+        api: SolarOfThingsAPI,
+        station_id: str,
+        entry: ConfigEntry,
+    ) -> None:
         self.api = api
         self.station_id = station_id
+        self._entry = entry
         super().__init__(
             hass,
             _LOGGER,
@@ -102,13 +198,24 @@ class SolarOfThingsStationCoordinator(DataUpdateCoordinator):
             update_interval=STATION_UPDATE_INTERVAL,
         )
 
-    async def _async_update_data(self):
+    async def _async_update_data(self) -> dict[str, Any]:
         try:
-            devices = await self.hass.async_add_executor_job(self.api.list_devices, self.station_id)
-            monthly = await self.hass.async_add_executor_job(self.api.fetch_monthly_summary, self.station_id)
+            devices = await self.hass.async_add_executor_job(
+                self.api.list_devices, self.station_id
+            )
+            monthly = await self.hass.async_add_executor_job(
+                self.api.fetch_monthly_summary, self.station_id
+            )
             return {"devices": devices, "monthly": monthly}
+        except TokenExpiredError as err:
+            _LOGGER.error(
+                "SolarOfThings station %s: token expired — triggering re-auth: %s",
+                self.station_id, err,
+            )
+            self._entry.async_start_reauth(self.hass)
+            raise UpdateFailed(f"Token expired: {err}") from err
         except Exception as err:
-            raise UpdateFailed(f"Station update failed: {err}")
+            raise UpdateFailed(f"Station update failed: {err}") from err
 
 
 class SolarOfThingsDeviceCoordinator(DataUpdateCoordinator):
@@ -120,12 +227,14 @@ class SolarOfThingsDeviceCoordinator(DataUpdateCoordinator):
         api: SolarOfThingsAPI,
         station_id: str,
         device: str,
-        device_meta: dict,
+        device_meta: dict[str, Any],
+        entry: ConfigEntry,
     ) -> None:
         self.api = api
         self.station_id = station_id
         self.device_id = device
         self.device_meta = device_meta
+        self._entry = entry
         super().__init__(
             hass,
             _LOGGER,
@@ -133,10 +242,14 @@ class SolarOfThingsDeviceCoordinator(DataUpdateCoordinator):
             update_interval=DEVICE_UPDATE_INTERVAL,
         )
 
-    async def _async_update_data(self):
+    async def _async_update_data(self) -> dict[str, Any]:
         try:
-            time_series = await self.hass.async_add_executor_job(self.api.fetch_latest_data, self.device_id)
-            settings = await self.hass.async_add_executor_job(self.api.fetch_settings, self.device_id)
+            time_series = await self.hass.async_add_executor_job(
+                self.api.fetch_latest_data, self.device_id
+            )
+            settings = await self.hass.async_add_executor_job(
+                self.api.fetch_settings, self.device_id
+            )
             return {
                 "time_series": time_series,
                 "settings": settings,
@@ -144,5 +257,14 @@ class SolarOfThingsDeviceCoordinator(DataUpdateCoordinator):
                 "station_id": self.station_id,
                 "device_meta": self.device_meta,
             }
+        except TokenExpiredError as err:
+            _LOGGER.error(
+                "SolarOfThings device %s: token expired — triggering re-auth: %s",
+                self.device_id, err,
+            )
+            self._entry.async_start_reauth(self.hass)
+            raise UpdateFailed(f"Token expired: {err}") from err
         except Exception as err:
-            raise UpdateFailed(f"Device update failed for {self.device_id}: {err}")
+            raise UpdateFailed(
+                f"Device update failed for {self.device_id}: {err}"
+            ) from err

+ 444 - 106
api.py

@@ -1,25 +1,69 @@
-"""API client for Solar of Things (solar.siseli.com)."""
+"""API client for Solar of Things (solar.siseli.com).
+
+Authentication strategy
+─────────────────────────────────────────────────────────────────────────────
+The Siseli portal uses a dual-token system discovered via JS bundle analysis:
+
+  • POST /login/account          → accessToken + refreshToken + expiry timestamps
+  • POST /login/refresh/access/token → new accessToken (+ new refreshToken) using
+                                        the current refreshToken
+
+accessToken  = the "IOT-Token" HTTP header value; short-lived (hours/days)
+refreshToken = long-lived; used only to mint a new accessToken
+
+This class supports three auth modes, tried in priority order:
+
+  1. Email + password  (recommended)
+     • Call login() at startup → stores both tokens in memory.
+     • _ensure_token_valid() checks expiry before every API call and proactively
+       refreshes (TOKEN_REFRESH_LEAD_SECONDS = 5 min before expiry, matching the
+       portal JS behaviour).
+     • If refresh fails, raises TokenExpiredError so the HA integration can
+       trigger a re-auth flow.
+
+  2. Token-pair (accessToken + refreshToken) without password
+     • User pastes both tokens from DevTools.
+     • Same proactive-refresh logic; re-auth needed when refreshToken expires.
+
+  3. Legacy IOT-token only (backwards compatibility)
+     • No refresh possible; raises TokenExpiredError on 401 so HA can prompt
+       the user to re-enter a fresh token.
+
+Usage in Home Assistant
+─────────────────────────────────────────────────────────────────────────────
+  api = SolarOfThingsAPI(
+      email="user@example.com",
+      password="secret",          # or omit and pass iot_token=
+      time_zone="Asia/Manila",
+      on_token_refreshed=_save_tokens_callback,
+  )
+  await hass.async_add_executor_job(api.login)
+  data = await hass.async_add_executor_job(api.fetch_latest_data, device_id)
+"""
 from __future__ import annotations
 
 import logging
-from datetime import datetime, timedelta
-from typing import Any
+import threading
+from datetime import datetime, timezone, timedelta
+from typing import Any, Callable
 
 import requests
 
 try:
-    # Python 3.9+
     from zoneinfo import ZoneInfo
 except Exception:  # pragma: no cover
-    ZoneInfo = None
+    ZoneInfo = None  # type: ignore[assignment]
 
 from .const import (
     API_BASE_URL,
+    API_LOGIN,
+    API_REFRESH_TOKEN as API_REFRESH_TOKEN_ENDPOINT,
     API_TIME_SERIES,
     API_MONTHLY_SUMMARY,
     API_DEVICE_LIST,
     API_SETTINGS_GET,
     API_SETTINGS_SET,
+    TOKEN_REFRESH_LEAD_SECONDS,
 )
 
 _LOGGER = logging.getLogger(__name__)
@@ -27,70 +71,387 @@ _LOGGER = logging.getLogger(__name__)
 _DEFAULT_TZ = "Asia/Manila"
 
 
-class SolarOfThingsAPI:
-    """Solar of Things API wrapper.
+# ──────────────────────────────────────────────────────────────────────────────
+# Custom exceptions
+# ──────────────────────────────────────────────────────────────────────────────
+
+class TokenExpiredError(Exception):
+    """Raised when the access token has expired and cannot be refreshed.
 
-    Design note:
-    - token + timezone live on this object
-    - stationId/deviceId are passed per call (so one integration can handle many devices)
+    The HA integration should catch this and call
+    config_entry.async_start_reauth() so the user can re-enter credentials.
     """
 
-    def __init__(self, iot_token: str, time_zone: str | None = None) -> None:
-        self.iot_token = iot_token
-        self.time_zone = time_zone or _DEFAULT_TZ
 
+class AuthenticationError(Exception):
+    """Raised when login credentials are rejected by the server."""
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Helper: parse Siseli ISO expiry strings safely
+# ──────────────────────────────────────────────────────────────────────────────
+
+def _parse_expiry(value: str | None) -> datetime | None:
+    """Return an aware UTC datetime from an ISO-8601 string, or None."""
+    if not value:
+        return None
+    try:
+        # Python 3.7+ fromisoformat doesn't handle trailing 'Z'
+        cleaned = value.replace("Z", "+00:00")
+        dt = datetime.fromisoformat(cleaned)
+        if dt.tzinfo is None:
+            dt = dt.replace(tzinfo=timezone.utc)
+        return dt.astimezone(timezone.utc)
+    except Exception:
+        return None
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Main API client
+# ──────────────────────────────────────────────────────────────────────────────
+
+class SolarOfThingsAPI:
+    """Solar of Things API wrapper with automatic token refresh.
+
+    Parameters
+    ----------
+    email:              Siseli portal login e-mail (preferred auth method).
+    password:           Siseli portal password.
+    iot_token:          Legacy/manual IOT-Token (used when email/password absent).
+    refresh_token:      Stored refresh token (persisted between HA restarts).
+    access_token_expires: ISO-8601 string of current access-token expiry.
+    refresh_token_expires: ISO-8601 string of current refresh-token expiry.
+    time_zone:          IOT-Time-Zone header value.
+    on_token_refreshed: Optional callback(access_token, refresh_token,
+                        access_expires_iso, refresh_expires_iso) called after
+                        every successful token refresh so the HA entry can
+                        persist the new tokens without restarting.
+    """
+
+    def __init__(
+        self,
+        *,
+        email: str | None = None,
+        password: str | None = None,
+        iot_token: str | None = None,
+        refresh_token: str | None = None,
+        access_token_expires: str | None = None,
+        refresh_token_expires: str | None = None,
+        time_zone: str | None = None,
+        on_token_refreshed: Callable[[str, str, str, str], None] | None = None,
+    ) -> None:
+        self._email = email
+        self._password = password
+        self._time_zone = time_zone or _DEFAULT_TZ
+        self._on_token_refreshed = on_token_refreshed
+
+        # Token state
+        self._access_token: str = iot_token or ""
+        self._refresh_token: str = refresh_token or ""
+        self._access_expires: datetime | None = _parse_expiry(access_token_expires)
+        self._refresh_expires: datetime | None = _parse_expiry(refresh_token_expires)
+
+        # Thread-safety for concurrent refresh calls
+        self._refresh_lock = threading.Lock()
+
+        # Determine auth mode
+        if email and password:
+            self._auth_mode = "password"
+        elif iot_token and refresh_token:
+            self._auth_mode = "token_pair"
+        elif iot_token:
+            self._auth_mode = "legacy"
+        else:
+            raise ValueError("Provide either (email + password) or iot_token.")
+
+        # HTTP session (headers updated after every token refresh)
         self.session = requests.Session()
+        self._apply_token_headers()
+
+    # ─── Session headers ───────────────────────────────────────────────────────
+
+    def _apply_token_headers(self) -> None:
+        """Write the current access token into the session headers."""
         self.session.headers.update(
             {
                 "Accept": "application/json",
                 "Content-Type": "application/json; charset=utf-8",
-                "IOT-Token": self.iot_token,
-                "IOT-Time-Zone": self.time_zone,
+                "IOT-Token": self._access_token,
+                "IOT-Time-Zone": self._time_zone,
                 "Origin": "https://solar.siseli.com",
                 "Referer": "https://solar.siseli.com/",
-                "User-Agent": "HomeAssistant-SolarOfThings/2.1.0 (+https://www.home-assistant.io)",
+                "User-Agent": (
+                    "HomeAssistant-SolarOfThings/2.2.0 "
+                    "(+https://github.com/conexocasa/solar-of-things-ha)"
+                ),
             }
         )
 
+    # ─── Public auth helpers ───────────────────────────────────────────────────
+
+    def login(self) -> None:
+        """Authenticate with email + password and store the resulting tokens.
+
+        Raises AuthenticationError on bad credentials, or requests.RequestException
+        on network failure.  Safe to call from a background thread.
+        """
+        if self._auth_mode not in ("password",):
+            raise RuntimeError("login() requires email + password auth mode.")
+
+        _LOGGER.debug("SolarOfThings: logging in as %s", self._email)
+
+        payload = {
+            "email": self._email,
+            "password": self._password,
+            "loginType": "email",
+        }
+
+        # Auth endpoints must NOT carry the IOT-Token header yet
+        resp = requests.post(
+            f"{API_BASE_URL}{API_LOGIN}",
+            json=payload,
+            headers={
+                "Accept": "application/json",
+                "Content-Type": "application/json; charset=utf-8",
+                "Origin": "https://solar.siseli.com",
+                "Referer": "https://solar.siseli.com/",
+            },
+            timeout=30,
+        )
+        resp.raise_for_status()
+        data = resp.json()
+
+        if data.get("code") not in (0, None, "0"):
+            msg = data.get("message") or data.get("msg") or str(data)
+            raise AuthenticationError(f"Login failed: {msg}")
+
+        self._store_tokens(data.get("data") or data)
+
+    def refresh_access_token(self) -> None:
+        """Use the stored refresh token to obtain a new access token.
+
+        Raises TokenExpiredError if the refresh token is also expired or invalid.
+        """
+        if not self._refresh_token:
+            raise TokenExpiredError("No refresh token available.")
+
+        _LOGGER.debug("SolarOfThings: refreshing access token")
+
+        resp = requests.post(
+            f"{API_BASE_URL}{API_REFRESH_TOKEN_ENDPOINT}",
+            json={"refreshToken": self._refresh_token},
+            headers={
+                "Accept": "application/json",
+                "Content-Type": "application/json; charset=utf-8",
+                "Origin": "https://solar.siseli.com",
+                "Referer": "https://solar.siseli.com/",
+            },
+            timeout=30,
+        )
+
+        if resp.status_code in (401, 403):
+            raise TokenExpiredError("Refresh token rejected by server (expired or invalid).")
+
+        resp.raise_for_status()
+        data = resp.json()
+
+        if data.get("code") not in (0, None, "0"):
+            raise TokenExpiredError(
+                f"Refresh failed: code={data.get('code')} message={data.get('message')}"
+            )
+
+        self._store_tokens(data.get("data") or data)
+
+    # ─── Internal token management ─────────────────────────────────────────────
+
+    def _store_tokens(self, payload: dict[str, Any]) -> None:
+        """Extract tokens from a login/refresh response payload and persist them."""
+        access = (
+            payload.get("accessToken")
+            or payload.get("iotToken")
+            or payload.get("token")
+            or ""
+        )
+        refresh = payload.get("refreshToken") or ""
+        access_exp = (
+            payload.get("accessTokenWillExpiredAt")
+            or payload.get("accessTokenExpiredAt")
+            or ""
+        )
+        refresh_exp = (
+            payload.get("refreshTokenWillExpiredAt")
+            or payload.get("refreshTokenExpiredAt")
+            or ""
+        )
+
+        if not access:
+            raise AuthenticationError(
+                f"Login/refresh response did not contain an access token. "
+                f"Keys received: {list(payload.keys())}"
+            )
+
+        self._access_token = access
+        self._refresh_token = refresh
+        self._access_expires = _parse_expiry(access_exp)
+        self._refresh_expires = _parse_expiry(refresh_exp)
+
+        # Update session header immediately
+        self._apply_token_headers()
+
+        _LOGGER.debug(
+            "SolarOfThings: token updated, expires=%s",
+            self._access_expires.isoformat() if self._access_expires else "unknown",
+        )
+
+        # Notify the HA integration so it can persist the new token state
+        if self._on_token_refreshed:
+            try:
+                self._on_token_refreshed(
+                    self._access_token,
+                    self._refresh_token,
+                    self._access_expires.isoformat() if self._access_expires else "",
+                    self._refresh_expires.isoformat() if self._refresh_expires else "",
+                )
+            except Exception as cb_err:  # pragma: no cover
+                _LOGGER.warning("Token-refresh callback raised: %s", cb_err)
+
+    def _token_needs_refresh(self) -> bool:
+        """Return True if the access token is absent or about to expire."""
+        if not self._access_token:
+            return True
+        if self._access_expires is None:
+            # Unknown expiry: only refresh if we already have a refresh token
+            return bool(self._refresh_token)
+        lead = timedelta(seconds=TOKEN_REFRESH_LEAD_SECONDS)
+        return datetime.now(timezone.utc) >= (self._access_expires - lead)
+
+    def _ensure_token_valid(self) -> None:
+        """Proactively refresh the access token if needed.
+
+        Thread-safe: uses a lock so parallel coordinator updates don't
+        trigger multiple simultaneous refresh calls.
+
+        Raises TokenExpiredError when all refresh strategies are exhausted.
+        """
+        if not self._token_needs_refresh():
+            return
+
+        with self._refresh_lock:
+            # Double-check inside the lock (another thread may have refreshed)
+            if not self._token_needs_refresh():
+                return
+
+            _LOGGER.info("SolarOfThings: access token expiring; attempting refresh")
+
+            # Strategy 1: use refresh token
+            if self._refresh_token:
+                try:
+                    self.refresh_access_token()
+                    return
+                except TokenExpiredError:
+                    _LOGGER.warning(
+                        "SolarOfThings: refresh token expired/invalid; "
+                        "attempting re-login"
+                    )
+                except Exception as err:
+                    _LOGGER.error("SolarOfThings: token refresh request failed: %s", err)
+
+            # Strategy 2: re-login with stored credentials
+            if self._auth_mode == "password" and self._email and self._password:
+                try:
+                    self.login()
+                    return
+                except AuthenticationError as err:
+                    raise TokenExpiredError(
+                        f"Re-login failed (credentials rejected): {err}"
+                    ) from err
+                except Exception as err:
+                    raise TokenExpiredError(
+                        f"Re-login failed (network error): {err}"
+                    ) from err
+
+            # Strategy 3: nothing left — tell HA to trigger re-auth
+            raise TokenExpiredError(
+                "Access token expired and no refresh strategy succeeded. "
+                "Please re-authenticate in Home Assistant."
+            )
+
+    # ─── Internal HTTP helper ──────────────────────────────────────────────────
+
+    def _post(self, path: str, payload: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
+        """Perform a POST, automatically refreshing the token on 401.
+
+        On second 401 (after refresh) raises TokenExpiredError.
+        """
+        self._ensure_token_valid()
+
+        resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
+
+        if resp.status_code == 401:
+            _LOGGER.warning("SolarOfThings: received 401; forcing token refresh")
+            # Force an immediate refresh even if _token_needs_refresh() is False
+            self._access_expires = None
+            self._ensure_token_valid()
+            resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
+
+        resp.raise_for_status()
+        return resp.json()
+
+    # ─── Public properties (for persistence in HA config entry) ───────────────
+
+    @property
+    def access_token(self) -> str:
+        return self._access_token
+
+    @property
+    def refresh_token(self) -> str:
+        return self._refresh_token
+
+    @property
+    def access_token_expires_iso(self) -> str:
+        return self._access_expires.isoformat() if self._access_expires else ""
+
+    @property
+    def refresh_token_expires_iso(self) -> str:
+        return self._refresh_expires.isoformat() if self._refresh_expires else ""
+
+    # ─── Time helpers ──────────────────────────────────────────────────────────
+
     def _now(self) -> datetime:
         if ZoneInfo:
             try:
-                return datetime.now(tz=ZoneInfo(self.time_zone))
+                return datetime.now(tz=ZoneInfo(self._time_zone))
             except Exception:
                 return datetime.now()
         return datetime.now()
 
     def _format_time(self, dt: datetime) -> str:
-        """Format timestamp like the Siseli web client expects."""
         if ZoneInfo:
             try:
-                dt = dt.astimezone(ZoneInfo(self.time_zone))
+                dt = dt.astimezone(ZoneInfo(self._time_zone))
             except Exception:
                 pass
         return dt.replace(microsecond=0).isoformat()
 
-    # -------------------------
-    # Station -> device listing
-    # -------------------------
-    def list_devices(self, station_id: str, page_size: int = 50) -> list[dict[str, Any]]:
-        """Return all devices under a station.
+    # ─── Station → device listing ──────────────────────────────────────────────
 
-        Uses endpoint discovered from the portal JS bundle: /apis/device/list.
-        Expected payload: {page, count, stationId}
-        """
+    def list_devices(self, station_id: str, page_size: int = 50) -> list[dict[str, Any]]:
+        """Return all devices under a station (paginated)."""
         devices: list[dict[str, Any]] = []
-
         page = 1
-        total = None
+        total: int | None = None
 
         while True:
-            payload = {"page": page, "count": page_size, "stationId": station_id}
-            resp = self.session.post(f"{API_BASE_URL}{API_DEVICE_LIST}", json=payload, timeout=30)
-            resp.raise_for_status()
-            data = resp.json()
+            data = self._post(
+                API_DEVICE_LIST,
+                {"page": page, "count": page_size, "stationId": station_id},
+            )
 
             if data.get("code") not in (0, None):
-                raise RuntimeError(f"Device list error code={data.get('code')} message={data.get('message')}")
+                raise RuntimeError(
+                    f"Device list error code={data.get('code')} "
+                    f"message={data.get('message')}"
+                )
 
             d = data.get("data") or {}
             total = d.get("total", total)
@@ -101,25 +462,21 @@ class SolarOfThingsAPI:
             devices.extend(batch)
 
             if total is None:
-                # no pagination info; stop when returned less than requested
                 if len(batch) < page_size:
                     break
             else:
                 if len(devices) >= int(total):
                     break
-
             if not batch:
                 break
-
             page += 1
 
         return devices
 
-    # -------------------------
-    # Time-series (per device)
-    # -------------------------
+    # ─── Time-series (per device) ──────────────────────────────────────────────
+
     def fetch_latest_data(self, device_id: str) -> dict[str, Any]:
-        """Fetch the latest readings for a device."""
+        """Fetch the latest readings for a device (last 1 hour)."""
         end_time = self._now()
         start_time = end_time - timedelta(hours=1)
 
@@ -133,22 +490,24 @@ class SolarOfThingsAPI:
             "batterySOC",
         ]
 
-        payload = {
-            "deviceId": device_id,
-            "count": 2000,
-            "page": 1,
-            "fromTime": self._format_time(start_time),
-            "toTime": self._format_time(end_time),
-            "orderByTimeAsc": True,
-            "keys": keys,
-        }
-
-        resp = self.session.post(f"{API_BASE_URL}{API_TIME_SERIES}", json=payload, timeout=30)
-        resp.raise_for_status()
-        data = resp.json()
+        data = self._post(
+            API_TIME_SERIES,
+            {
+                "deviceId": device_id,
+                "count": 2000,
+                "page": 1,
+                "fromTime": self._format_time(start_time),
+                "toTime": self._format_time(end_time),
+                "orderByTimeAsc": True,
+                "keys": keys,
+            },
+        )
 
         if data.get("code") not in (0, None):
-            raise RuntimeError(f"Timeseries error code={data.get('code')} message={data.get('message')}")
+            raise RuntimeError(
+                f"Timeseries error code={data.get('code')} "
+                f"message={data.get('message')}"
+            )
 
         payload_data = (data.get("data") or {}).get("payload") or {}
         fields = payload_data.get("fields") or {}
@@ -158,12 +517,12 @@ class SolarOfThingsAPI:
             if isinstance(arr, list) and arr:
                 latest_values[key] = arr[-1]
 
-        # Unit normalization:
-        # - pvInputPower appears to be W
-        # - acOutputActivePower is kW in API; normalize to W
+        # Unit normalisation: acOutputActivePower is kW in API → W
         if "acOutputActivePower" in latest_values:
             try:
-                latest_values["acOutputActivePower"] = float(latest_values["acOutputActivePower"]) * 1000.0
+                latest_values["acOutputActivePower"] = (
+                    float(latest_values["acOutputActivePower"]) * 1000.0
+                )
             except Exception:
                 pass
 
@@ -183,26 +542,29 @@ class SolarOfThingsAPI:
 
         return latest_values
 
-    # -------------------------
-    # Monthly summary (station)
-    # -------------------------
+    # ─── Monthly summary (station) ─────────────────────────────────────────────
+
     def fetch_monthly_summary(self, station_id: str) -> dict[str, Any]:
-        """Fetch monthly PV summary for current month."""
+        """Fetch monthly PV summary for the current month."""
         now = self._now()
         year = now.year
         month_key = f"{year}-{str(now.month).zfill(2)}"
 
-        url = (
+        self._ensure_token_valid()
+        resp = self.session.post(
             f"{API_BASE_URL}{API_MONTHLY_SUMMARY}"
-            f"?stationId={station_id}&summaryCategoryKey=pvInverterElectricityQuantityClass"
+            f"?stationId={station_id}&summaryCategoryKey=pvInverterElectricityQuantityClass",
+            json={"time": str(year)},
+            timeout=30,
         )
-
-        resp = self.session.post(url, json={"time": str(year)}, timeout=30)
         resp.raise_for_status()
         data = resp.json()
 
         if data.get("code") not in (0, None):
-            raise RuntimeError(f"Monthly summary error code={data.get('code')} message={data.get('message')}")
+            raise RuntimeError(
+                f"Monthly summary error code={data.get('code')} "
+                f"message={data.get('message')}"
+            )
 
         props = (((data.get("data") or {}).get("properties")) or [])
         out: dict[str, Any] = {}
@@ -220,26 +582,12 @@ class SolarOfThingsAPI:
 
         return {}
 
-    # -------------------------
-    # Settings / control (per device)
-    # -------------------------
-    def fetch_settings(self, device_id: str) -> dict[str, Any]:
-        """Fetch current device settings.
-
-        NOTE: These endpoints may vary by account/device firmware. If your portal uses
-        different endpoints, update const.py accordingly.
-        """
-        payload = {"deviceId": device_id}
+    # ─── Settings / control (per device) ──────────────────────────────────────
 
+    def fetch_settings(self, device_id: str) -> dict[str, Any]:
+        """Fetch current device settings."""
         try:
-            response = self.session.post(
-                f"{API_BASE_URL}{API_SETTINGS_GET}",
-                json=payload,
-                timeout=30,
-            )
-            response.raise_for_status()
-            data = response.json()
-
+            data = self._post(API_SETTINGS_GET, {"deviceId": device_id})
             settings: dict[str, Any] = {}
             if "data" in data:
                 sd = data["data"]
@@ -251,32 +599,22 @@ class SolarOfThingsAPI:
                 settings["gridChargingEnabled"] = sd.get("gridChargingEnabled", False)
                 settings["gridFeedInEnabled"] = sd.get("gridFeedInEnabled", True)
                 settings["backupModeEnabled"] = sd.get("backupModeEnabled", False)
-
             return settings
-
         except requests.exceptions.RequestException as err:
             _LOGGER.error("Error fetching settings for device %s: %s", device_id, err)
             return {}
 
     def _update_setting(self, device_id: str, setting_key: str, value: Any) -> bool:
-        payload = {"deviceId": device_id, "settings": {setting_key: value}}
-
         try:
-            response = self.session.post(
-                f"{API_BASE_URL}{API_SETTINGS_SET}",
-                json=payload,
-                timeout=30,
+            data = self._post(
+                API_SETTINGS_SET,
+                {"deviceId": device_id, "settings": {setting_key: value}},
             )
-            response.raise_for_status()
-            data = response.json()
-
             if data.get("success") is True or data.get("code") in (0, None):
                 _LOGGER.info("Updated %s=%s for device %s", setting_key, value, device_id)
                 return True
-
             _LOGGER.error("Failed to update %s for device %s: %s", setting_key, device_id, data)
             return False
-
         except requests.exceptions.RequestException as err:
             _LOGGER.error("Error updating %s for device %s: %s", setting_key, device_id, err)
             return False
@@ -305,21 +643,21 @@ class SolarOfThingsAPI:
     def set_backup_mode(self, device_id: str, enabled: bool) -> bool:
         return self._update_setting(device_id, "backupModeEnabled", enabled)
 
-    # -------------------------
-    # Validation
-    # -------------------------
+    # ─── Validation ───────────────────────────────────────────────────────────
+
     def test_connection(self, station_id: str) -> bool:
-        """Validate token + station by listing devices and calling timeseries on first."""
+        """Validate credentials + station by listing devices and querying telemetry."""
         try:
             devices = self.list_devices(station_id)
             if not devices:
                 return False
-            first = devices[0]
-            device_id = str(first.get("id") or "")
+            device_id = str((devices[0].get("id") or ""))
             if not device_id:
                 return False
-            _ = self.fetch_latest_data(device_id)
+            self.fetch_latest_data(device_id)
             return True
+        except TokenExpiredError:
+            raise
         except Exception as err:
             _LOGGER.error("Connection test failed: %s", err)
             return False

+ 234 - 25
config_flow.py

@@ -1,4 +1,15 @@
-"""Config flow for Solar of Things integration."""
+"""Config flow for Solar of Things integration.
+
+Supports two setup modes:
+  1. Email + Password  (recommended) – integration logs in automatically and
+     refreshes the token without any user intervention.
+  2. IOT Token (legacy / advanced) – user pastes the token from DevTools.
+     A re-auth flow is triggered when the token expires.
+
+Re-auth flow:  HA calls async_step_reauth when a TokenExpiredError is caught by
+the coordinator.  The user is asked only for the field(s) that need updating
+(usually just a fresh token, or new credentials).
+"""
 from __future__ import annotations
 
 import logging
@@ -7,88 +18,286 @@ from typing import Any
 import voluptuous as vol
 
 from homeassistant import config_entries
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
 from homeassistant.data_entry_flow import FlowResult
 from homeassistant.exceptions import HomeAssistantError
 from homeassistant.helpers import config_validation as cv
 
-from .api import SolarOfThingsAPI
+from .api import SolarOfThingsAPI, AuthenticationError, TokenExpiredError
 from .const import (
     DOMAIN,
+    CONF_EMAIL,
+    CONF_PASSWORD,
     CONF_IOT_TOKEN,
     CONF_STATION_ID,
     CONF_DEVICE_ID,
     CONF_TIME_ZONE,
+    CONF_REFRESH_TOKEN,
+    CONF_ACCESS_TOKEN_EXPIRES,
+    CONF_REFRESH_TOKEN_EXPIRES,
 )
 
 _LOGGER = logging.getLogger(__name__)
 
+# ─── Validation helpers ────────────────────────────────────────────────────────
+
+async def _validate_password_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
+    """Validate email/password auth, return title."""
+    api = SolarOfThingsAPI(
+        email=data[CONF_EMAIL],
+        password=data[CONF_PASSWORD],
+        time_zone=data.get(CONF_TIME_ZONE),
+    )
+    try:
+        await hass.async_add_executor_job(api.login)
+    except AuthenticationError as err:
+        raise InvalidAuth(str(err)) from err
+    except Exception as err:
+        raise CannotConnect(str(err)) from err
+
+    # After login, validate station
+    ok = await hass.async_add_executor_job(api.test_connection, data[CONF_STATION_ID])
+    if not ok:
+        raise CannotConnect("Station/device unreachable after login.")
 
-async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
-    """Validate the user input allows us to connect."""
+    return {
+        "title": f"Solar Station {data[CONF_STATION_ID]}",
+        CONF_IOT_TOKEN: api.access_token,
+        CONF_REFRESH_TOKEN: api.refresh_token,
+        CONF_ACCESS_TOKEN_EXPIRES: api.access_token_expires_iso,
+        CONF_REFRESH_TOKEN_EXPIRES: api.refresh_token_expires_iso,
+    }
 
-    api = SolarOfThingsAPI(iot_token=data[CONF_IOT_TOKEN], time_zone=data.get(CONF_TIME_ZONE))
 
+async def _validate_token_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
+    """Validate legacy IOT-token auth, return title."""
+    api = SolarOfThingsAPI(
+        iot_token=data[CONF_IOT_TOKEN],
+        time_zone=data.get(CONF_TIME_ZONE),
+    )
     station_id = data[CONF_STATION_ID]
     device_id = (data.get(CONF_DEVICE_ID) or "").strip()
 
     try:
-        # If the user provides a deviceId, validate it directly.
-        # Otherwise validate the station by listing devices and querying the first device.
         if device_id:
             res = await hass.async_add_executor_job(api.fetch_latest_data, device_id)
             ok = bool(res)
         else:
             ok = await hass.async_add_executor_job(api.test_connection, station_id)
     except Exception as err:
-        _LOGGER.error("Validation failed: %s", err)
+        _LOGGER.error("Token validation failed: %s", err)
         ok = False
+
     if not ok:
-        raise CannotConnect
+        raise CannotConnect("Cannot connect with provided token.")
 
     return {"title": f"Solar Station {station_id}"}
 
 
+# ─── Config Flow ───────────────────────────────────────────────────────────────
+
 class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
     """Handle a config flow for Solar of Things."""
 
-    VERSION = 2
+    VERSION = 3  # bumped for new auth fields
+
+    def __init__(self) -> None:
+        self._auth_mode: str = "password"  # "password" | "token"
+
+    # ── Step 1: choose auth mode ───────────────────────────────────────────────
 
     async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
+        """Offer choice of auth mode."""
+        if user_input is not None:
+            self._auth_mode = user_input.get("auth_mode", "password")
+            if self._auth_mode == "token":
+                return await self.async_step_token()
+            return await self.async_step_password()
+
+        return self.async_show_form(
+            step_id="user",
+            data_schema=vol.Schema(
+                {
+                    vol.Required("auth_mode", default="password"): vol.In(
+                        {"password": "Email + Password (recommended)", "token": "IOT Token (advanced)"}
+                    )
+                }
+            ),
+            description_placeholders={
+                "docs_url": "https://github.com/conexocasa/solar-of-things-ha"
+            },
+        )
+
+    # ── Step 2a: email + password ──────────────────────────────────────────────
+
+    async def async_step_password(self, user_input: dict[str, Any] | None = None) -> FlowResult:
+        """Email + password setup step."""
         errors: dict[str, str] = {}
 
         if user_input is not None:
             try:
-                info = await validate_input(self.hass, user_input)
+                info = await _validate_password_auth(self.hass, user_input)
+            except InvalidAuth:
+                errors["base"] = "invalid_auth"
             except CannotConnect:
                 errors["base"] = "cannot_connect"
-            except Exception:  # pylint: disable=broad-except
-                _LOGGER.exception("Unexpected exception")
+            except Exception:
+                _LOGGER.exception("Unexpected exception in password step")
                 errors["base"] = "unknown"
             else:
                 await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
                 self._abort_if_unique_id_configured()
-                return self.async_create_entry(title=info["title"], data=user_input)
+                entry_data = {
+                    CONF_EMAIL: user_input[CONF_EMAIL],
+                    CONF_PASSWORD: user_input[CONF_PASSWORD],
+                    CONF_STATION_ID: user_input[CONF_STATION_ID],
+                    CONF_DEVICE_ID: user_input.get(CONF_DEVICE_ID, ""),
+                    CONF_TIME_ZONE: user_input.get(CONF_TIME_ZONE, "Asia/Manila"),
+                    CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
+                    CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
+                    CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
+                    CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
+                }
+                return self.async_create_entry(title=info["title"], data=entry_data)
 
-        data_schema = vol.Schema(
-            {
-                vol.Required(CONF_IOT_TOKEN): cv.string,
-                vol.Required(CONF_STATION_ID): cv.string,
-                # Optional: user can force a single device under the station.
-                vol.Optional(CONF_DEVICE_ID): cv.string,
-                vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
-            }
+        return self.async_show_form(
+            step_id="password",
+            data_schema=vol.Schema(
+                {
+                    vol.Required(CONF_EMAIL): cv.string,
+                    vol.Required(CONF_PASSWORD): cv.string,
+                    vol.Required(CONF_STATION_ID): cv.string,
+                    vol.Optional(CONF_DEVICE_ID): cv.string,
+                    vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
+                }
+            ),
+            errors=errors,
+            description_placeholders={
+                "docs_url": "https://github.com/conexocasa/solar-of-things-ha"
+            },
         )
 
+    # ── Step 2b: legacy IOT token ─────────────────────────────────────────────
+
+    async def async_step_token(self, user_input: dict[str, Any] | None = None) -> FlowResult:
+        """Legacy IOT-token setup step."""
+        errors: dict[str, str] = {}
+
+        if user_input is not None:
+            try:
+                info = await _validate_token_auth(self.hass, user_input)
+            except CannotConnect:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception in token step")
+                errors["base"] = "unknown"
+            else:
+                await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
+                self._abort_if_unique_id_configured()
+                return self.async_create_entry(title=info["title"], data=user_input)
+
         return self.async_show_form(
-            step_id="user",
-            data_schema=data_schema,
+            step_id="token",
+            data_schema=vol.Schema(
+                {
+                    vol.Required(CONF_IOT_TOKEN): cv.string,
+                    vol.Required(CONF_STATION_ID): cv.string,
+                    vol.Optional(CONF_DEVICE_ID): cv.string,
+                    vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
+                }
+            ),
             errors=errors,
             description_placeholders={
                 "docs_url": "https://github.com/Hyllesen/solar-of-things-solar-usage"
             },
         )
 
+    # ── Re-auth flow ───────────────────────────────────────────────────────────
+
+    async def async_step_reauth(self, entry_data: dict[str, Any]) -> FlowResult:
+        """Triggered by the coordinator when TokenExpiredError is raised."""
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> FlowResult:
+        """Ask the user to re-enter credentials or a fresh token."""
+        errors: dict[str, str] = {}
+        existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
+        has_password = bool(
+            existing_entry and existing_entry.data.get(CONF_PASSWORD)
+        )
+
+        if user_input is not None:
+            if has_password:
+                # Re-authenticate with potentially updated email/password
+                merged = {**(existing_entry.data if existing_entry else {}), **user_input}
+                try:
+                    info = await _validate_password_auth(self.hass, merged)
+                except InvalidAuth:
+                    errors["base"] = "invalid_auth"
+                except CannotConnect:
+                    errors["base"] = "cannot_connect"
+                except Exception:
+                    _LOGGER.exception("Unexpected exception during re-auth")
+                    errors["base"] = "unknown"
+                else:
+                    self.hass.config_entries.async_update_entry(
+                        existing_entry,
+                        data={
+                            **existing_entry.data,
+                            CONF_PASSWORD: user_input.get(CONF_PASSWORD, existing_entry.data.get(CONF_PASSWORD)),
+                            CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
+                            CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
+                            CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
+                            CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
+                        },
+                    )
+                    await self.hass.config_entries.async_reload(existing_entry.entry_id)
+                    return self.async_abort(reason="reauth_successful")
+            else:
+                # Legacy token mode: user provides a fresh token
+                merged = {**(existing_entry.data if existing_entry else {}), **user_input}
+                try:
+                    await _validate_token_auth(self.hass, merged)
+                except CannotConnect:
+                    errors["base"] = "cannot_connect"
+                except Exception:
+                    _LOGGER.exception("Unexpected exception during re-auth (token)")
+                    errors["base"] = "unknown"
+                else:
+                    self.hass.config_entries.async_update_entry(
+                        existing_entry,
+                        data={**existing_entry.data, CONF_IOT_TOKEN: user_input[CONF_IOT_TOKEN]},
+                    )
+                    await self.hass.config_entries.async_reload(existing_entry.entry_id)
+                    return self.async_abort(reason="reauth_successful")
+
+        # Build schema based on whether the entry has stored credentials
+        if has_password:
+            schema = vol.Schema(
+                {
+                    vol.Optional(
+                        CONF_PASSWORD,
+                        description={"suggested_value": ""},
+                    ): cv.string,
+                }
+            )
+        else:
+            schema = vol.Schema({vol.Required(CONF_IOT_TOKEN): cv.string})
+
+        return self.async_show_form(
+            step_id="reauth_confirm",
+            data_schema=schema,
+            errors=errors,
+        )
+
+
+# ─── Custom exceptions ─────────────────────────────────────────────────────────
 
 class CannotConnect(HomeAssistantError):
     """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(HomeAssistantError):
+    """Error to indicate invalid authentication credentials."""

+ 27 - 12
const.py

@@ -2,25 +2,41 @@
 
 DOMAIN = "solar_of_things"
 
-# Configuration keys
-CONF_IOT_TOKEN = "iot_token"
+# ─── Configuration keys ────────────────────────────────────────────────────────
+CONF_IOT_TOKEN = "iot_token"          # legacy / advanced manual entry
 CONF_STATION_ID = "station_id"
 CONF_DEVICE_ID = "device_id"
 CONF_TIME_ZONE = "time_zone"
 
-# API endpoints
+# Credential-based auth (preferred)
+CONF_EMAIL = "email"
+CONF_PASSWORD = "password"
+
+# Runtime-stored token state (written back to config entry)
+CONF_REFRESH_TOKEN = "refresh_token"
+CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires"   # ISO-8601 string
+CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires" # ISO-8601 string
+
+# ─── API base ──────────────────────────────────────────────────────────────────
 API_BASE_URL = "https://solar.siseli.com"
-# Endpoints based on the working reference client
-# https://github.com/Hyllesen/solar-of-things-solar-usage
-API_TIME_SERIES = "/apis/deviceState/simple/attribute/keys/history/v1"
+
+# ─── Auth endpoints (discovered from portal JS bundle) ─────────────────────────
+API_LOGIN           = "/login/account"                   # POST, no token needed
+API_REFRESH_TOKEN   = "/login/refresh/access/token"      # POST, no token needed
+
+# ─── Data endpoints ────────────────────────────────────────────────────────────
+API_TIME_SERIES    = "/apis/deviceState/simple/attribute/keys/history/v1"
 API_MONTHLY_SUMMARY = "/apis/stationOverView/stateAttributeSummary/category/yearly"
-API_SETTINGS_GET = "/api/device/settings/v1"
-API_SETTINGS_SET = "/api/device/settings/update/v1"
+API_SETTINGS_GET   = "/api/device/settings/v1"
+API_SETTINGS_SET   = "/api/device/settings/update/v1"
+API_DEVICE_LIST    = "/apis/device/list"
 
-# Station -> devices
-API_DEVICE_LIST = "/apis/device/list"
+# ─── Token refresh window ──────────────────────────────────────────────────────
+# Refresh the access token this many seconds *before* its stated expiry.
+# Mirrors the portal JS which refreshes when ≤300 s remain.
+TOKEN_REFRESH_LEAD_SECONDS = 300  # 5 minutes
 
-# Sensor keys
+# ─── Sensor keys ───────────────────────────────────────────────────────────────
 SENSOR_KEYS = [
     "pvInputPower",
     "acOutputActivePower",
@@ -34,7 +50,6 @@ SENSOR_KEYS = [
     "loadPower",
 ]
 
-# Sensor names and units
 SENSOR_DEFINITIONS = {
     "pvInputPower": {
         "name": "PV Input Power",

+ 2 - 2
manifest.json

@@ -7,5 +7,5 @@
   "iot_class": "cloud_polling",
   "issue_tracker": "https://github.com/conexocasa/solar-of-things-ha/issues",
   "requirements": [],
-  "version": "2.1.1"
-}
+  "version": "2.2.0"
+}

+ 37 - 28
strings.json

@@ -3,28 +3,49 @@
     "step": {
       "user": {
         "title": "Add Solar of Things Station",
-        "description": "Configure your Solar of Things station. You can add multiple stations by configuring this integration multiple times.\n\nTo find your credentials:\n1. Log into https://solar.siseli.com\n2. Open browser developer tools (F12) → Network tab\n3. Refresh the page and look at API requests\n4. Copy the IOT-Token from request headers\n5. Find stationId and deviceId in the request URLs or payloads\n\nMore info: {docs_url}",
+        "description": "Choose how to authenticate with the Siseli portal.\n\n**Email + Password** is recommended — the integration logs in automatically and refreshes the token without any manual steps.\n\n**IOT Token** is for advanced use or if you prefer not to store credentials.\n\nMore info: {docs_url}",
+        "data": {
+          "auth_mode": "Authentication method"
+        }
+      },
+      "password": {
+        "title": "Solar of Things — Email + Password",
+        "description": "Enter your Siseli portal credentials and Station ID.\n\n- **Station ID**: 18-digit number found in API request payloads at https://solar.siseli.com (DevTools → Network)\n- **Device ID**: optional — leave blank to auto-discover all devices under the station\n- **Time zone**: must match your inverter's configured time zone\n\nMore info: {docs_url}",
+        "data": {
+          "email": "Email address",
+          "password": "Password",
+          "station_id": "Station ID (required)",
+          "device_id": "Device ID (optional — blank to auto-discover)",
+          "time_zone": "Time zone (e.g. Asia/Manila)"
+        }
+      },
+      "token": {
+        "title": "Solar of Things — IOT Token",
+        "description": "Enter an IOT-Token from https://solar.siseli.com (DevTools → Network → request headers).\n\nNote: tokens expire. When expired you will be prompted to enter a new one.\n\nMore info: {docs_url}",
         "data": {
           "iot_token": "IOT Token",
           "station_id": "Station ID (required)",
-          "device_id": "Device ID (optional; leave blank to auto-discover all devices)",
-          "time_zone": "Time zone (IOT-Time-Zone header)"
+          "device_id": "Device ID (optional — blank to auto-discover)",
+          "time_zone": "Time zone (e.g. Asia/Manila)"
         }
       },
-      "add_another": {
-        "title": "Add Another Station?",
-        "description": "You have added {station_count} station(s). Would you like to add another?",
+      "reauth_confirm": {
+        "title": "Re-authenticate Solar of Things",
+        "description": "The access token has expired. Enter your updated password (or a fresh IOT Token) to continue.",
         "data": {
-          "add_another": "Add another station"
+          "password": "Password",
+          "iot_token": "Fresh IOT Token"
         }
       }
     },
     "error": {
-      "cannot_connect": "Failed to connect to Solar of Things API. Please check your IOT Token and IDs.",
-      "unknown": "Unexpected error occurred. Please check the logs."
+      "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
+      "invalid_auth": "Invalid email or password. Please try again.",
+      "unknown": "Unexpected error. Check Home Assistant logs for details."
     },
     "abort": {
-      "already_configured": "This station is already configured."
+      "already_configured": "This station is already configured.",
+      "reauth_successful": "Re-authentication successful."
     }
   },
   "options": {
@@ -39,15 +60,9 @@
   },
   "entity": {
     "number": {
-      "battery_charge_limit": {
-        "name": "Battery Charge Limit"
-      },
-      "battery_discharge_limit": {
-        "name": "Battery Discharge Limit"
-      },
-      "grid_charge_limit": {
-        "name": "Grid Charge Limit"
-      }
+      "battery_charge_limit": { "name": "Battery Charge Limit" },
+      "battery_discharge_limit": { "name": "Battery Discharge Limit" },
+      "grid_charge_limit": { "name": "Grid Charge Limit" }
     },
     "select": {
       "operating_mode": {
@@ -70,15 +85,9 @@
       }
     },
     "switch": {
-      "grid_charging": {
-        "name": "Grid Charging"
-      },
-      "grid_feed_in": {
-        "name": "Grid Feed-In"
-      },
-      "backup_mode": {
-        "name": "Backup Mode"
-      }
+      "grid_charging": { "name": "Grid Charging" },
+      "grid_feed_in": { "name": "Grid Feed-In" },
+      "backup_mode": { "name": "Backup Mode" }
     }
   }
 }

+ 37 - 28
translations/en.json

@@ -3,28 +3,49 @@
     "step": {
       "user": {
         "title": "Add Solar of Things Station",
-        "description": "Configure your Solar of Things station. You can add multiple stations by configuring this integration multiple times.\n\nTo find your credentials:\n1. Log into https://solar.siseli.com\n2. Open browser developer tools (F12) → Network tab\n3. Refresh the page and look at API requests\n4. Copy the IOT-Token from request headers\n5. Find stationId and deviceId in the request URLs or payloads\n\nMore info: {docs_url}",
+        "description": "Choose how to authenticate with the Siseli portal.\n\n**Email + Password** is recommended — the integration logs in automatically and refreshes the token without any manual steps.\n\n**IOT Token** is for advanced use or if you prefer not to store credentials.\n\nMore info: {docs_url}",
+        "data": {
+          "auth_mode": "Authentication method"
+        }
+      },
+      "password": {
+        "title": "Solar of Things — Email + Password",
+        "description": "Enter your Siseli portal credentials and Station ID.\n\n- **Station ID**: 18-digit number found in API request payloads at https://solar.siseli.com (DevTools → Network)\n- **Device ID**: optional — leave blank to auto-discover all devices under the station\n- **Time zone**: must match your inverter's configured time zone\n\nMore info: {docs_url}",
+        "data": {
+          "email": "Email address",
+          "password": "Password",
+          "station_id": "Station ID (required)",
+          "device_id": "Device ID (optional — blank to auto-discover)",
+          "time_zone": "Time zone (e.g. Asia/Manila)"
+        }
+      },
+      "token": {
+        "title": "Solar of Things — IOT Token",
+        "description": "Enter an IOT-Token from https://solar.siseli.com (DevTools → Network → request headers).\n\nNote: tokens expire. When expired you will be prompted to enter a new one.\n\nMore info: {docs_url}",
         "data": {
           "iot_token": "IOT Token",
           "station_id": "Station ID (required)",
-          "device_id": "Device ID (optional; leave blank to auto-discover all devices)",
-          "time_zone": "Time zone (IOT-Time-Zone header)"
+          "device_id": "Device ID (optional — blank to auto-discover)",
+          "time_zone": "Time zone (e.g. Asia/Manila)"
         }
       },
-      "add_another": {
-        "title": "Add Another Station?",
-        "description": "You have added {station_count} station(s). Would you like to add another?",
+      "reauth_confirm": {
+        "title": "Re-authenticate Solar of Things",
+        "description": "The access token has expired. Enter your updated password (or a fresh IOT Token) to continue.",
         "data": {
-          "add_another": "Add another station"
+          "password": "Password",
+          "iot_token": "Fresh IOT Token"
         }
       }
     },
     "error": {
-      "cannot_connect": "Failed to connect to Solar of Things API. Please check your IOT Token and IDs.",
-      "unknown": "Unexpected error occurred. Please check the logs."
+      "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
+      "invalid_auth": "Invalid email or password. Please try again.",
+      "unknown": "Unexpected error. Check Home Assistant logs for details."
     },
     "abort": {
-      "already_configured": "This station is already configured."
+      "already_configured": "This station is already configured.",
+      "reauth_successful": "Re-authentication successful."
     }
   },
   "options": {
@@ -39,15 +60,9 @@
   },
   "entity": {
     "number": {
-      "battery_charge_limit": {
-        "name": "Battery Charge Limit"
-      },
-      "battery_discharge_limit": {
-        "name": "Battery Discharge Limit"
-      },
-      "grid_charge_limit": {
-        "name": "Grid Charge Limit"
-      }
+      "battery_charge_limit": { "name": "Battery Charge Limit" },
+      "battery_discharge_limit": { "name": "Battery Discharge Limit" },
+      "grid_charge_limit": { "name": "Grid Charge Limit" }
     },
     "select": {
       "operating_mode": {
@@ -70,15 +85,9 @@
       }
     },
     "switch": {
-      "grid_charging": {
-        "name": "Grid Charging"
-      },
-      "grid_feed_in": {
-        "name": "Grid Feed-In"
-      },
-      "backup_mode": {
-        "name": "Backup Mode"
-      }
+      "grid_charging": { "name": "Grid Charging" },
+      "grid_feed_in": { "name": "Grid Feed-In" },
+      "backup_mode": { "name": "Backup Mode" }
     }
   }
 }