Przeglądaj źródła

fix: HACS compliance, missing API methods, translation keys, device identifiers

- Move integration to custom_components/solar_of_things/ (required HACS structure)
- manifest.json: add integration_type=hub, homeassistant>=2023.6.0, fix codeowners
- hacs.json: update min HA version, remove zip_release/filename
- api.py: add missing set_battery_charge_limit, set_battery_discharge_limit,
  set_grid_charge_limit methods (crashed number entity interactions)
- sensor.py: fix device_info identifiers, add has_entity_name + translation_key
- switch.py / select.py / number.py: fix device_info identifiers to (DOMAIN, device_id)
- strings.json / translations/en.json: fix select state keys to match real API
  option values (USO/SUB/SBU, CSO/SNU/OSO); add full sensor translations
- config_flow.py: make async_step_reauth entry_data param optional (HA 2024.x)
- brand/icon.png: add required HACS brand asset
- .gitignore: exclude graphify-out/ dev cache
Conexo Casa 4 miesięcy temu
rodzic
commit
80b6e395eb

+ 3 - 0
.gitignore

@@ -143,3 +143,6 @@ config.json
 
 # Test configuration
 test_config.json
+-e 
+# Graphify knowledge graph (local dev tool)
+graphify-out/

BIN
brand/icon.png


+ 270 - 0
custom_components/solar_of_things/__init__.py

@@ -0,0 +1,270 @@
+"""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.3.0 – user-id/password auth (replacing email), working IOT-Open signing
+"""
+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, callback
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .api import SolarOfThingsAPI, TokenExpiredError
+from .const import (
+    DOMAIN,
+    CONF_USER_ID,
+    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__)
+
+PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SELECT, Platform.SWITCH]
+
+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, {})
+
+    time_zone = entry.data.get(CONF_TIME_ZONE) or entry.options.get(CONF_TIME_ZONE)
+    user_id = entry.data.get(CONF_USER_ID)
+    password = entry.data.get(CONF_PASSWORD)
+
+    # 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 user_id and password:
+        api = SolarOfThingsAPI(
+            user_id=user_id,
+            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 = SolarOfThingsStationCoordinator(
+        hass=hass,
+        api=api,
+        station_id=station_id,
+        entry=entry,
+    )
+    await station_coordinator.async_config_entry_first_refresh()
+
+    # ── Per-device coordinators ────────────────────────────────────────────────
+    devices: list[dict[str, Any]] = (
+        station_coordinator.data.get("devices", []) if station_coordinator.data else []
+    )
+
+    if configured_device_id:
+        filtered = [d for d in devices if str(d.get("id")) == 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
+
+    hass.data[DOMAIN][entry.entry_id] = {
+        "api": api,
+        "station_id": station_id,
+        "station_coordinator": station_coordinator,
+        "device_coordinators": device_coordinators,
+        "devices": devices,
+    }
+
+    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+    return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+    """Unload a config entry."""
+    if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+        hass.data[DOMAIN].pop(entry.entry_id, None)
+    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,
+        entry: ConfigEntry,
+    ) -> None:
+        self.api = api
+        self.station_id = station_id
+        self._entry = entry
+        super().__init__(
+            hass,
+            _LOGGER,
+            name=f"{DOMAIN}_station_{station_id}",
+            update_interval=STATION_UPDATE_INTERVAL,
+        )
+
+    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
+            )
+            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}") from err
+
+
+class SolarOfThingsDeviceCoordinator(DataUpdateCoordinator):
+    """Fetch device-level telemetry + settings."""
+
+    def __init__(
+        self,
+        hass: HomeAssistant,
+        api: SolarOfThingsAPI,
+        station_id: str,
+        device: str,
+        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,
+            name=f"{DOMAIN}_device_{device}",
+            update_interval=DEVICE_UPDATE_INTERVAL,
+        )
+
+    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
+            )
+            return {
+                "time_series": time_series,
+                "settings": settings,
+                "device": self.device_id,
+                "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}"
+            ) from err

+ 828 - 0
custom_components/solar_of_things/api.py

@@ -0,0 +1,828 @@
+"""API client for Solar of Things (solar.siseli.com).
+
+Authentication strategy
+─────────────────────────────────────────────────────────────────────────────
+The Siseli portal uses a dual-token system.  Login requires the IOT Open
+Platform signing scheme discovered via JS bundle analysis (umi.js):
+
+  Endpoint : POST https://test.solar.siseli.com/apis/login/account
+  Signed   : yes — IOT-Open-AppID, IOT-Open-Nonce, IOT-Open-Body-Hash,
+                    IOT-Open-Sign headers
+
+Signing algorithm (reverse-engineered from portal umi.js):
+  1. Build a dict of signing headers:
+       {"IOT-Open-AppID": appId,
+        "IOT-Open-Body-Hash": sha256(body_bytes).lower(),
+        "IOT-Open-Nonce": random_32_char_hex}
+  2. Sort keys alphabetically, join as k1=v1&k2=v2 (no URL-encoding).
+  3. base64-encode the resulting string.
+  4. HMAC-SHA256(b64_str, decrypted_app_secret)  [bytes result]
+  5. MD5(hmac_bytes).hexdigest()  → IOT-Open-Sign value
+
+App secret decryption (qe() in umi.js):
+  key = MD5(appId).lower()[:16]  treated as ASCII bytes  (16 bytes = AES-128)
+  iv  = MD5(appId).lower()[16:]  treated as ASCII bytes  (16 bytes)
+  AES-128-CBC-ZeroPadding decrypt of base64(encrypted_secret)
+
+After successful login the server returns an accessToken (used as
+IOT-Token header for data requests) and a refreshToken.
+
+This class supports three auth modes, tried in priority order:
+
+  1. User-ID + 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, mirroring 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(
+      user_id="myaccount",
+      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 base64
+import hashlib
+import hmac as _hmac
+import logging
+import os
+import threading
+from datetime import datetime, timezone, timedelta
+from typing import Any, Callable
+
+import requests
+
+try:
+    from Crypto.Cipher import AES as _AES
+    _CRYPTO_AVAILABLE = True
+except ImportError:
+    _CRYPTO_AVAILABLE = False
+
+try:
+    from zoneinfo import ZoneInfo
+except Exception:  # pragma: no cover
+    ZoneInfo = None  # type: ignore[assignment]
+
+from .const import (
+    API_BASE_URL,
+    API_AUTH_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,
+    IOT_APP_ID,
+    IOT_APP_SECRET_ENC,
+    TOKEN_REFRESH_LEAD_SECONDS,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+_DEFAULT_TZ = "Asia/Manila"
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Custom exceptions
+# ──────────────────────────────────────────────────────────────────────────────
+
+class TokenExpiredError(Exception):
+    """Raised when the access token has expired and cannot be refreshed.
+
+    The HA integration should catch this and call
+    config_entry.async_start_reauth() so the user can re-enter credentials.
+    """
+
+
+class AuthenticationError(Exception):
+    """Raised when login credentials are rejected by the server."""
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Signing helpers  (reverse-engineered from portal umi.js)
+# ──────────────────────────────────────────────────────────────────────────────
+
+def _decrypt_app_secret(app_id: str, encrypted_b64: str) -> str:
+    """AES-128-CBC decrypt the embedded app secret.
+
+    Key derivation mirrors the portal qe() function:
+      key = MD5(app_id).lower()[:16]  as ASCII bytes
+      iv  = MD5(app_id).lower()[16:]  as ASCII bytes
+    The ciphertext is the base64-decoded encrypted_b64 value.
+    """
+    if not _CRYPTO_AVAILABLE:
+        raise RuntimeError(
+            "pycryptodome is not installed. "
+            "Add 'pycryptodome' to the integration requirements."
+        )
+    md5_hex = hashlib.md5(app_id.encode("utf-8")).hexdigest()
+    key = md5_hex[:16].encode("ascii")   # 16 bytes — AES-128
+    iv  = md5_hex[16:].encode("ascii")   # 16 bytes — CBC IV
+    ciphertext = base64.b64decode(encrypted_b64)
+    cipher = _AES.new(key, _AES.MODE_CBC, iv)
+    plaintext = cipher.decrypt(ciphertext).rstrip(b"\x00")
+    return plaintext.decode("utf-8")
+
+
+def _compute_iot_sign(app_id: str, nonce: str, body_hash: str, secret: str) -> str:
+    """Compute the IOT-Open-Sign header value.
+
+    Algorithm (Ye() in portal umi.js):
+      1. Sort signing headers alphabetically by key.
+      2. qs.stringify → "IOT-Open-AppID=X&IOT-Open-Body-Hash=Y&IOT-Open-Nonce=Z"
+      3. Base64-encode the qs string.
+      4. HMAC-SHA256(b64_qs, secret) → raw bytes.
+      5. MD5(hmac_bytes).hexdigest() → sign value.
+    """
+    sign_headers = {
+        "IOT-Open-AppID": app_id,
+        "IOT-Open-Body-Hash": body_hash,
+        "IOT-Open-Nonce": nonce,
+    }
+    qs_str = "&".join(f"{k}={sign_headers[k]}" for k in sorted(sign_headers.keys()))
+    b64_qs = base64.b64encode(qs_str.encode("utf-8")).decode("ascii")
+    hmac_bytes = _hmac.new(secret.encode("utf-8"), b64_qs.encode("utf-8"), hashlib.sha256).digest()
+    return hashlib.md5(hmac_bytes).hexdigest()
+
+
+def _make_signed_headers(body_bytes: bytes, extra: dict[str, str] | None = None) -> dict[str, str]:
+    """Build the complete set of IOT Open Platform signed request headers.
+
+    Returns headers suitable for POST to API_AUTH_BASE_URL endpoints.
+    """
+    secret = _decrypt_app_secret(IOT_APP_ID, IOT_APP_SECRET_ENC)
+    nonce = os.urandom(16).hex()          # 32-char hex nonce
+    body_hash = hashlib.sha256(body_bytes).hexdigest()
+    sign = _compute_iot_sign(IOT_APP_ID, nonce, body_hash, secret)
+
+    headers: dict[str, str] = {
+        "Accept": "application/json",
+        "Content-Type": "application/json; charset=utf-8",
+        "Origin": "https://solar.siseli.com",
+        "Referer": "https://solar.siseli.com/",
+        "IOT-Open-AppID": IOT_APP_ID,
+        "IOT-Open-Nonce": nonce,
+        "IOT-Open-Body-Hash": body_hash,
+        "IOT-Open-Sign": sign,
+    }
+    if extra:
+        headers.update(extra)
+    return headers
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# 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
+    ----------
+    user_id:            Siseli portal login account / user-ID (preferred auth method).
+    password:           Siseli portal password.
+    iot_token:          Legacy/manual IOT-Token (used when user_id/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,
+        *,
+        user_id: 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._user_id = user_id
+        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 user_id 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 (user_id + 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._access_token,
+                "IOT-Time-Zone": self._time_zone,
+                "Origin": "https://solar.siseli.com",
+                "Referer": "https://solar.siseli.com/",
+                "User-Agent": (
+                    "HomeAssistant-SolarOfThings/2.3.0 "
+                    "(+https://github.com/conexocasa/solar-of-things-ha)"
+                ),
+            }
+        )
+
+    # ─── Public auth helpers ───────────────────────────────────────────────────
+
+    def login(self) -> None:
+        """Authenticate with user-ID + password and store the resulting tokens.
+
+        Uses the IOT Open Platform signed request format discovered from the
+        portal JS bundle.  The login endpoint is:
+          POST https://solar.siseli.com/apis/login/account
+
+        The password is sent as MD5(plaintext_password) lowercase hex — this
+        is how the portal processes it before transmitting.
+
+        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 user_id + password auth mode.")
+
+        _LOGGER.debug("SolarOfThings: logging in as %s", self._user_id)
+
+        import json as _json
+        # The portal sends the password as MD5(plaintext_password) lowercase hex.
+        # Sending plaintext returns code 7 "password error" even with valid creds.
+        password_md5 = hashlib.md5(self._password.encode("utf-8")).hexdigest()
+        payload = {
+            "account": self._user_id,
+            "password": password_md5,
+        }
+        body_bytes = _json.dumps(payload, separators=(",", ":")).encode("utf-8")
+
+        headers = _make_signed_headers(body_bytes)
+
+        resp = requests.post(
+            f"{API_AUTH_BASE_URL}{API_LOGIN}",
+            data=body_bytes,
+            headers=headers,
+            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_AUTH_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._user_id 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 to a data endpoint, automatically refreshing the token on 401.
+
+        On second 401 (after refresh) raises TokenExpiredError.
+        Uses API_BASE_URL (solar.siseli.com) — not the auth base URL.
+        """
+        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))
+            except Exception:
+                return datetime.now()
+        return datetime.now()
+
+    def _format_time(self, dt: datetime) -> str:
+        if ZoneInfo:
+            try:
+                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 (paginated)."""
+        devices: list[dict[str, Any]] = []
+        page = 1
+        total: int | None = None
+
+        while True:
+            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')} "
+                    f"message={data.get('message')}"
+                )
+
+            d = data.get("data") or {}
+            total = d.get("total", total)
+            batch = d.get("list") or []
+            if not isinstance(batch, list):
+                batch = []
+
+            devices.extend(batch)
+
+            if total is None:
+                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) ──────────────────────────────────────────────
+
+    def fetch_latest_data(self, device_id: str) -> dict[str, Any]:
+        """Fetch the latest readings for a device (last 1 hour)."""
+        end_time = self._now()
+        start_time = end_time - timedelta(hours=1)
+
+        keys = [
+            "pvInputPower",
+            "acOutputActivePower",
+            "batteryDischargeCurrent",
+            "batteryChargingCurrent",
+            "batteryVoltage",
+            "feedInPower",
+            "batterySOC",
+        ]
+
+        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')} "
+                f"message={data.get('message')}"
+            )
+
+        payload_data = (data.get("data") or {}).get("payload") or {}
+        fields = payload_data.get("fields") or {}
+
+        latest_values: dict[str, Any] = {}
+        for key, arr in fields.items():
+            if isinstance(arr, list) and arr:
+                latest_values[key] = arr[-1]
+
+        # Unit normalisation: acOutputActivePower is kW in API → W
+        if "acOutputActivePower" in latest_values:
+            try:
+                latest_values["acOutputActivePower"] = (
+                    float(latest_values["acOutputActivePower"]) * 1000.0
+                )
+            except Exception:
+                pass
+
+        # Derived values
+        voltage = float(latest_values.get("batteryVoltage") or 0)
+        discharge = float(latest_values.get("batteryDischargeCurrent") or 0)
+        charge = float(latest_values.get("batteryChargingCurrent") or 0)
+        latest_values["batteryPower"] = (discharge - charge) * voltage
+
+        pv_power = float(latest_values.get("pvInputPower") or 0)
+        ac_output = float(latest_values.get("acOutputActivePower") or 0)
+        feed_in = float(latest_values.get("feedInPower") or 0)
+        battery_power = float(latest_values.get("batteryPower") or 0)
+
+        latest_values["gridPower"] = max(0.0, ac_output - pv_power + battery_power + feed_in)
+        latest_values["loadPower"] = ac_output
+
+        return latest_values
+
+    # ─── Monthly summary (station) ─────────────────────────────────────────────
+
+    def fetch_monthly_summary(self, station_id: str) -> dict[str, Any]:
+        """Fetch monthly PV summary for the current month."""
+        now = self._now()
+        year = now.year
+        month_key = f"{year}-{str(now.month).zfill(2)}"
+
+        self._ensure_token_valid()
+        resp = self.session.post(
+            f"{API_BASE_URL}{API_MONTHLY_SUMMARY}"
+            f"?stationId={station_id}&summaryCategoryKey=pvInverterElectricityQuantityClass",
+            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')} "
+                f"message={data.get('message')}"
+            )
+
+        props = (((data.get("data") or {}).get("properties")) or
+                 (data.get("data") or {}).get("list") or
+                 [])
+
+        result: dict[str, Any] = {}
+        for item in props if isinstance(props, list) else []:
+            k = item.get("key") or item.get("name")
+            v = item.get("value")
+            if k and v is not None:
+                result[k] = v
+
+        # Extract monthly totals (fallback: look for known keys)
+        monthly: dict[str, Any] = {}
+        pv_total = result.get(month_key) or result.get("pvTotal") or result.get("pv") or 0
+        monthly["monthly_pv_generated"] = float(pv_total or 0)
+
+        grid_import = result.get("gridImport") or result.get("buy") or 0
+        monthly["monthly_grid_import"] = float(grid_import or 0)
+
+        total_consumption = result.get("totalConsumption") or result.get("load") or 0
+        monthly["monthly_total_consumption"] = float(total_consumption or 0)
+
+        if monthly["monthly_total_consumption"] > 0:
+            monthly["monthly_solar_percentage"] = round(
+                100.0 * monthly["monthly_pv_generated"] / monthly["monthly_total_consumption"], 1
+            )
+        else:
+            monthly["monthly_solar_percentage"] = 0.0
+
+        return monthly
+
+    # ─── Device settings ───────────────────────────────────────────────────────
+    # The remote config endpoints require only a plain IOT-Token header (which the
+    # session already carries) and pass deviceId as a URL query parameter rather
+    # than in the JSON body.
+
+    def _write_setting(self, device_id: str, key: str, value: Any) -> None:
+        """Write a single device setting key=value via the remote config write API."""
+        self._ensure_token_valid()
+        url = f"{API_BASE_URL}{API_SETTINGS_SET}?deviceId={device_id}"
+        payload = {"deviceId": device_id, "key": key, "value": value}
+        resp = self.session.post(url, json=payload, timeout=30)
+        resp.raise_for_status()
+        data = resp.json()
+        if data.get("code") not in (0, None):
+            raise RuntimeError(
+                f"Settings write error code={data.get('code')} "
+                f"message={data.get('message')} (key={key})"
+            )
+
+    def get_device_settings(self, device_id: str) -> dict[str, Any]:
+        """Fetch the cached device settings from the remote config API.
+
+        Returns a flat dict of {settingKey: settingObject} where each value
+        contains at least 'key', 'value', and 'valueDisplay' fields.
+        The endpoint accepts a plain IOT-Token header (no IOT-Open-Sign).
+        """
+        self._ensure_token_valid()
+        url = f"{API_BASE_URL}{API_SETTINGS_GET}?deviceId={device_id}"
+        resp = self.session.post(url, json={}, timeout=30)
+        resp.raise_for_status()
+        data = resp.json()
+        if data.get("code") not in (0, None):
+            raise RuntimeError(
+                f"Settings fetch error code={data.get('code')} "
+                f"message={data.get('message')}"
+            )
+        return data.get("data") or {}
+
+    # Alias used by the coordinator in __init__.py
+    fetch_settings = get_device_settings
+
+    def update_device_settings(self, device_id: str, settings: dict[str, Any]) -> None:
+        """Write multiple settings (one API call per key)."""
+        for key, value in settings.items():
+            self._write_setting(device_id, key, value)
+
+    # ─── Convenience control helpers (called by select.py / switch.py) ─────────
+    # Key names are the real device attribute keys returned by get_device_settings.
+    # Output Source Priority:   USO=0, SUB=1, SBU=2
+    # Charger Source Priority:  CSO=0, SNU=1, OSO=2
+    # batteryPowerLimitingSetting: 0=OFF, 1=ON  (GRID switch)
+    # acInputRangeSetting:         0=Appliance, 1=UPS
+
+    # Operating-mode select maps HA option strings to integer values
+    _OUTPUT_MODE_MAP: dict[str, int] = {
+        "Utility First (USO)": 0,
+        "Solar First (SUB)": 1,
+        "Solar+Battery First (SBU)": 2,
+    }
+    _OUTPUT_MODE_REVERSE: dict[int, str] = {v: k for k, v in _OUTPUT_MODE_MAP.items()}
+
+    # Charger-priority select
+    _CHARGER_PRIORITY_MAP: dict[str, int] = {
+        "Solar + Utility (CSO)": 0,
+        "Solar First (SNU)": 1,
+        "Solar Only (OSO)": 2,
+    }
+    _CHARGER_PRIORITY_REVERSE: dict[int, str] = {v: k for k, v in _CHARGER_PRIORITY_MAP.items()}
+
+    def set_operating_mode(self, device_id: str, mode: str) -> None:
+        """Set Output Source Priority.  mode is one of _OUTPUT_MODE_MAP keys."""
+        value = self._OUTPUT_MODE_MAP.get(mode)
+        if value is None:
+            raise ValueError(f"Unknown operating mode: {mode!r}. "
+                             f"Valid options: {list(self._OUTPUT_MODE_MAP)!r}")
+        self._write_setting(device_id, "outputSourcePrioritySetting", value)
+
+    def set_battery_priority(self, device_id: str, mode: str) -> None:
+        """Set Charger Source Priority.  mode is one of _CHARGER_PRIORITY_MAP keys."""
+        value = self._CHARGER_PRIORITY_MAP.get(mode)
+        if value is None:
+            raise ValueError(f"Unknown battery priority: {mode!r}. "
+                             f"Valid options: {list(self._CHARGER_PRIORITY_MAP)!r}")
+        self._write_setting(device_id, "chargerSourcePrioritySetting", value)
+
+    def set_grid_charging(self, device_id: str, enabled: bool) -> None:
+        """Set AC Input Range: Appliance (0, grid charging allowed) / UPS (1, bypass)."""
+        self._write_setting(device_id, "acInputRangeSetting", 0 if enabled else 1)
+
+    def set_grid_feed_in(self, device_id: str, enabled: bool) -> None:
+        """Enable or disable the GRID grid switch (batteryPowerLimitingSetting)."""
+        self._write_setting(device_id, "batteryPowerLimitingSetting", 1 if enabled else 0)
+
+    def set_backup_mode(self, device_id: str, enabled: bool) -> None:
+        """Set Output Source Priority to SBU (backup/off-grid priority) when True,
+        or SUB (solar-first, grid-supplemented) when False."""
+        value = 2 if enabled else 1   # SBU=2 (battery before grid), SUB=1
+        self._write_setting(device_id, "outputSourcePrioritySetting", value)
+
+    def set_battery_charge_limit(self, device_id: str, percent: int) -> None:
+        """Set battery charge limit (0–100 %)."""
+        self._write_setting(device_id, "batteryChargeLimit", percent)
+
+    def set_battery_discharge_limit(self, device_id: str, percent: int) -> None:
+        """Set battery discharge limit / minimum SOC (0–100 %)."""
+        self._write_setting(device_id, "batteryDischargeLimit", percent)
+
+    def set_grid_charge_limit(self, device_id: str, watts: int) -> None:
+        """Set maximum grid charge power (0–5000 W)."""
+        self._write_setting(device_id, "gridChargeLimit", watts)
+
+    def test_connection(self, station_id: str) -> bool:
+        """Return True if we can reach the device-list endpoint successfully."""
+        try:
+            devices = self.list_devices(station_id, page_size=1)
+            return True
+        except Exception as err:
+            _LOGGER.error("SolarOfThings: connection test failed: %s", err)
+            return False

+ 307 - 0
custom_components/solar_of_things/config_flow.py

@@ -0,0 +1,307 @@
+"""Config flow for Solar of Things integration.
+
+Supports two setup modes:
+  1. User-ID + 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
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+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, AuthenticationError, TokenExpiredError
+from .const import (
+    DOMAIN,
+    CONF_USER_ID,
+    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 user-ID/password auth, return title."""
+    api = SolarOfThingsAPI(
+        user_id=data[CONF_USER_ID],
+        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.")
+
+    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,
+    }
+
+
+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 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("Token validation failed: %s", err)
+        ok = False
+
+    if not ok:
+        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 = 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": "User ID + Password (recommended)", "token": "IOT Token (advanced)"}
+                    )
+                }
+            ),
+            description_placeholders={
+                "docs_url": "https://github.com/conexocasa/solar-of-things-ha"
+            },
+        )
+
+    # ── Step 2a: user-id + password ────────────────────────────────────────────
+
+    async def async_step_password(self, user_input: dict[str, Any] | None = None) -> FlowResult:
+        """User-ID + password setup step."""
+        errors: dict[str, str] = {}
+
+        if user_input is not None:
+            try:
+                info = await _validate_password_auth(self.hass, user_input)
+            except InvalidAuth:
+                errors["base"] = "invalid_auth"
+            except CannotConnect:
+                errors["base"] = "cannot_connect"
+            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()
+                entry_data = {
+                    CONF_USER_ID: user_input[CONF_USER_ID],
+                    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)
+
+        return self.async_show_form(
+            step_id="password",
+            data_schema=vol.Schema(
+                {
+                    vol.Required(CONF_USER_ID): 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="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] | None = None) -> 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)
+                or existing_entry.data.get(CONF_USER_ID)
+            )
+        )
+
+        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."""

+ 152 - 0
custom_components/solar_of_things/const.py

@@ -0,0 +1,152 @@
+"""Constants for the Solar of Things integration."""
+
+DOMAIN = "solar_of_things"
+
+# ─── 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"
+
+# Credential-based auth (preferred)
+CONF_USER_ID = "user_id"       # Siseli account / user-ID login (not 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 bases ─────────────────────────────────────────────────────────────────
+# Both auth and data endpoints live on the production server solar.siseli.com.
+# The portal JS bundle embeds both test/prod AppIDs; AppID rBrTRfAPXz is the
+# one accepted by solar.siseli.com (confirmed by live API testing 2026-03-07).
+API_BASE_URL        = "https://solar.siseli.com"         # data endpoints
+API_AUTH_BASE_URL   = "https://solar.siseli.com"         # auth / login endpoints
+
+# ─── Auth endpoints (discovered from portal JS bundle) ─────────────────────────
+# The login endpoint requires IOT-Open-AppID signing (see api.py _sign_request).
+API_LOGIN           = "/apis/login/account"              # POST + signed headers
+API_REFRESH_TOKEN   = "/login/refresh/access/token"      # POST, no token needed
+
+# ─── IOT Open Platform app credentials (embedded in portal umi.js) ────────────
+# rBrTRfAPXz is the production AppID accepted by solar.siseli.com.
+# JO4DAiNeys is the test AppID (accepted only by test.solar.siseli.com).
+IOT_APP_ID          = "rBrTRfAPXz"
+IOT_APP_SECRET_ENC  = "I4D0KRr2339z3pQ/at91V9BpFAOe54DaTafwSm6suIQ="
+
+# ─── Data endpoints ────────────────────────────────────────────────────────────
+API_TIME_SERIES    = "/apis/deviceState/simple/attribute/keys/history/v1"
+API_MONTHLY_SUMMARY = "/apis/stationOverView/stateAttributeSummary/category/yearly"
+# Remote device config endpoints (discovered 2026-03-07 from live API testing).
+# These accept a plain IOT-Token header (no IOT-Open-Sign) and use the device ID
+# as a query parameter.  Write sends one setting key+value per call.
+API_SETTINGS_GET   = "/apis/remote/device/configs/cache/get"  # ?deviceId=<id>
+API_SETTINGS_SET   = "/apis/remote/device/config/write"       # ?deviceId=<id>
+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 = [
+    "pvInputPower",
+    "acOutputActivePower",
+    "batteryDischargeCurrent",
+    "batteryChargingCurrent",
+    "batteryVoltage",
+    "feedInPower",
+    "batteryPower",
+    "batterySOC",
+    "gridPower",
+    "loadPower",
+]
+
+SENSOR_DEFINITIONS = {
+    "pvInputPower": {
+        "name": "PV Input Power",
+        "unit": "W",
+        "device_class": "power",
+        "icon": "mdi:solar-power",
+    },
+    "acOutputActivePower": {
+        "name": "AC Output Power",
+        "unit": "W",
+        "device_class": "power",
+        "icon": "mdi:power-plug",
+    },
+    "batteryDischargeCurrent": {
+        "name": "Battery Discharge Current",
+        "unit": "A",
+        "device_class": "current",
+        "icon": "mdi:battery-arrow-down",
+    },
+    "batteryChargingCurrent": {
+        "name": "Battery Charging Current",
+        "unit": "A",
+        "device_class": "current",
+        "icon": "mdi:battery-arrow-up",
+    },
+    "batteryVoltage": {
+        "name": "Battery Voltage",
+        "unit": "V",
+        "device_class": "voltage",
+        "icon": "mdi:battery",
+    },
+    "batteryPower": {
+        "name": "Battery Power",
+        "unit": "W",
+        "device_class": "power",
+        "icon": "mdi:battery-charging",
+    },
+    "batterySOC": {
+        "name": "Battery State of Charge",
+        "unit": "%",
+        "device_class": "battery",
+        "icon": "mdi:battery",
+    },
+    "feedInPower": {
+        "name": "Grid Feed-in Power",
+        "unit": "W",
+        "device_class": "power",
+        "icon": "mdi:transmission-tower-export",
+    },
+    "gridPower": {
+        "name": "Grid Import Power",
+        "unit": "W",
+        "device_class": "power",
+        "icon": "mdi:transmission-tower-import",
+    },
+    "loadPower": {
+        "name": "Load Power",
+        "unit": "W",
+        "device_class": "power",
+        "icon": "mdi:home-lightning-bolt",
+    },
+    # Monthly summary sensors
+    "monthly_pv_generated": {
+        "name": "Monthly PV Generated",
+        "unit": "kWh",
+        "device_class": "energy",
+        "icon": "mdi:solar-power",
+    },
+    "monthly_grid_import": {
+        "name": "Monthly Grid Import",
+        "unit": "kWh",
+        "device_class": "energy",
+        "icon": "mdi:transmission-tower-import",
+    },
+    "monthly_total_consumption": {
+        "name": "Monthly Total Consumption",
+        "unit": "kWh",
+        "device_class": "energy",
+        "icon": "mdi:home-lightning-bolt",
+    },
+    "monthly_solar_percentage": {
+        "name": "Monthly Solar Coverage",
+        "unit": "%",
+        "icon": "mdi:percent",
+    },
+}

+ 15 - 0
custom_components/solar_of_things/manifest.json

@@ -0,0 +1,15 @@
+{
+  "domain": "solar_of_things",
+  "name": "Solar of Things",
+  "codeowners": ["@Conexo-Casa"],
+  "config_flow": true,
+  "documentation": "https://github.com/Conexo-Casa/solar-of-things-ha",
+  "homeassistant": "2023.6.0",
+  "integration_type": "hub",
+  "iot_class": "cloud_polling",
+  "issue_tracker": "https://github.com/Conexo-Casa/solar-of-things-ha/issues",
+  "requirements": [
+    "pycryptodome>=3.9.0"
+  ],
+  "version": "2.3.3"
+}

+ 131 - 0
custom_components/solar_of_things/number.py

@@ -0,0 +1,131 @@
+"""Number platform for Solar of Things integration."""
+from __future__ import annotations
+
+import logging
+
+from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import PERCENTAGE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    entry: ConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+) -> None:
+    """Set up number entities (controls) for each device."""
+
+    data = hass.data[DOMAIN][entry.entry_id]
+    api = data["api"]
+    station_id: str = data["station_id"]
+    device_coordinators = data["device_coordinators"]
+
+    entities: list[NumberEntity] = []
+
+    for device_id, coordinator in device_coordinators.items():
+        device_name = (coordinator.device_meta or {}).get("name") or device_id
+        entities.extend(
+            [
+                SolarOfThingsBatteryChargeLimitNumber(api, coordinator, station_id, device_id, device_name),
+                SolarOfThingsBatteryDischargeLimitNumber(api, coordinator, station_id, device_id, device_name),
+                SolarOfThingsGridChargeLimitNumber(api, coordinator, station_id, device_id, device_name),
+            ]
+        )
+
+    async_add_entities(entities)
+
+
+class _BaseNumber(CoordinatorEntity, NumberEntity):
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(coordinator)
+        self._api = api
+        self._station_id = station_id
+        self._device_id = device_id
+        self._device_name = device_name
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {(DOMAIN, self._device_id)},
+            "name": self._device_name,
+            "manufacturer": "Siseli",
+            "model": (self.coordinator.data.get("device_meta") or {}).get("model") if self.coordinator.data else None,
+            "via_device": (DOMAIN, self._station_id),
+        }
+
+
+class SolarOfThingsBatteryChargeLimitNumber(_BaseNumber):
+    _setting_key = "batteryChargeLimit"
+
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(api, coordinator, station_id, device_id, device_name)
+        self._attr_name = f"{device_name} Battery Charge Limit"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_battery_charge_limit"
+        self._attr_native_min_value = 0
+        self._attr_native_max_value = 100
+        self._attr_native_step = 1
+        self._attr_native_unit_of_measurement = PERCENTAGE
+        self._attr_mode = NumberMode.SLIDER
+        self._attr_icon = "mdi:battery-arrow-up"
+
+    @property
+    def native_value(self):
+        return ((self.coordinator.data or {}).get("settings") or {}).get(self._setting_key)
+
+    async def async_set_native_value(self, value: float) -> None:
+        await self.hass.async_add_executor_job(self._api.set_battery_charge_limit, self._device_id, int(value))
+        await self.coordinator.async_request_refresh()
+
+
+class SolarOfThingsBatteryDischargeLimitNumber(_BaseNumber):
+    _setting_key = "batteryDischargeLimit"
+
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(api, coordinator, station_id, device_id, device_name)
+        self._attr_name = f"{device_name} Battery Discharge Limit"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_battery_discharge_limit"
+        self._attr_native_min_value = 0
+        self._attr_native_max_value = 100
+        self._attr_native_step = 1
+        self._attr_native_unit_of_measurement = PERCENTAGE
+        self._attr_mode = NumberMode.SLIDER
+        self._attr_icon = "mdi:battery-arrow-down"
+
+    @property
+    def native_value(self):
+        return ((self.coordinator.data or {}).get("settings") or {}).get(self._setting_key)
+
+    async def async_set_native_value(self, value: float) -> None:
+        await self.hass.async_add_executor_job(self._api.set_battery_discharge_limit, self._device_id, int(value))
+        await self.coordinator.async_request_refresh()
+
+
+class SolarOfThingsGridChargeLimitNumber(_BaseNumber):
+    _setting_key = "gridChargeLimit"
+
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(api, coordinator, station_id, device_id, device_name)
+        self._attr_name = f"{device_name} Grid Charge Limit"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_grid_charge_limit"
+        self._attr_native_min_value = 0
+        self._attr_native_max_value = 5000
+        self._attr_native_step = 100
+        self._attr_native_unit_of_measurement = "W"
+        self._attr_mode = NumberMode.SLIDER
+        self._attr_device_class = NumberDeviceClass.POWER
+        self._attr_icon = "mdi:transmission-tower-import"
+
+    @property
+    def native_value(self):
+        return ((self.coordinator.data or {}).get("settings") or {}).get(self._setting_key)
+
+    async def async_set_native_value(self, value: float) -> None:
+        await self.hass.async_add_executor_job(self._api.set_grid_charge_limit, self._device_id, int(value))
+        await self.coordinator.async_request_refresh()

+ 135 - 0
custom_components/solar_of_things/select.py

@@ -0,0 +1,135 @@
+"""Select platform for Solar of Things integration."""
+from __future__ import annotations
+
+import logging
+
+from homeassistant.components.select import SelectEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+# Real API key: outputSourcePrioritySetting  (integer value)
+# 0 = USO  Utility first
+# 1 = SUB  Solar first (grid supplement)
+# 2 = SBU  Solar + Battery first (grid only last resort)
+OUTPUT_MODE_BY_VALUE: dict[int, str] = {
+    0: "Utility First (USO)",
+    1: "Solar First (SUB)",
+    2: "Solar+Battery First (SBU)",
+}
+OUTPUT_MODES = list(OUTPUT_MODE_BY_VALUE.values())
+
+# Real API key: chargerSourcePrioritySetting  (integer value)
+# 0 = CSO  Solar + Utility charging (utility has priority)
+# 1 = SNU  Solar First for charging, utility as fallback
+# 2 = OSO  Solar Only charging
+CHARGER_PRIORITY_BY_VALUE: dict[int, str] = {
+    0: "Solar + Utility (CSO)",
+    1: "Solar First (SNU)",
+    2: "Solar Only (OSO)",
+}
+CHARGER_PRIORITIES = list(CHARGER_PRIORITY_BY_VALUE.values())
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
+    data = hass.data[DOMAIN][entry.entry_id]
+    api = data["api"]
+    station_id: str = data["station_id"]
+    device_coordinators = data["device_coordinators"]
+
+    entities: list[SelectEntity] = []
+
+    for device_id, coordinator in device_coordinators.items():
+        device_name = (coordinator.device_meta or {}).get("name") or device_id
+        entities.extend(
+            [
+                SolarOfThingsOperatingModeSelect(api, coordinator, station_id, device_id, device_name),
+                SolarOfThingsBatteryPrioritySelect(api, coordinator, station_id, device_id, device_name),
+            ]
+        )
+
+    async_add_entities(entities)
+
+
+class _BaseSelect(CoordinatorEntity, SelectEntity):
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(coordinator)
+        self._api = api
+        self._station_id = station_id
+        self._device_id = device_id
+        self._device_name = device_name
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {(DOMAIN, self._device_id)},
+            "name": self._device_name,
+            "manufacturer": "Siseli",
+            "model": (self.coordinator.data.get("device_meta") or {}).get("model") if self.coordinator.data else None,
+            "via_device": (DOMAIN, self._station_id),
+        }
+
+
+class SolarOfThingsOperatingModeSelect(_BaseSelect):
+    """Select entity for Output Source Priority (outputSourcePrioritySetting).
+
+    Reflects the real device API key.  Values 0/1/2 map to USO/SUB/SBU.
+    """
+
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(api, coordinator, station_id, device_id, device_name)
+        self._attr_name = f"{device_name} Output Source Priority"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_operating_mode"
+        self._attr_options = OUTPUT_MODES
+        self._attr_icon = "mdi:cog"
+
+    @property
+    def current_option(self) -> str | None:
+        settings = ((self.coordinator.data or {}).get("settings") or {})
+        entry = settings.get("outputSourcePrioritySetting")
+        if entry is None:
+            return None
+        raw = entry.get("value") if isinstance(entry, dict) else entry
+        try:
+            return OUTPUT_MODE_BY_VALUE.get(int(raw))
+        except (TypeError, ValueError):
+            return None
+
+    async def async_select_option(self, option: str) -> None:
+        await self.hass.async_add_executor_job(self._api.set_operating_mode, self._device_id, option)
+        await self.coordinator.async_request_refresh()
+
+
+class SolarOfThingsBatteryPrioritySelect(_BaseSelect):
+    """Select entity for Charger Source Priority (chargerSourcePrioritySetting).
+
+    Reflects the real device API key.  Values 0/1/2 map to CSO/SNU/OSO.
+    """
+
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(api, coordinator, station_id, device_id, device_name)
+        self._attr_name = f"{device_name} Charger Source Priority"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_battery_priority"
+        self._attr_options = CHARGER_PRIORITIES
+        self._attr_icon = "mdi:battery-sync"
+
+    @property
+    def current_option(self) -> str | None:
+        settings = ((self.coordinator.data or {}).get("settings") or {})
+        entry = settings.get("chargerSourcePrioritySetting")
+        if entry is None:
+            return None
+        raw = entry.get("value") if isinstance(entry, dict) else entry
+        try:
+            return CHARGER_PRIORITY_BY_VALUE.get(int(raw))
+        except (TypeError, ValueError):
+            return None
+
+    async def async_select_option(self, option: str) -> None:
+        await self.hass.async_add_executor_job(self._api.set_battery_priority, self._device_id, option)
+        await self.coordinator.async_request_refresh()

+ 216 - 0
custom_components/solar_of_things/sensor.py

@@ -0,0 +1,216 @@
+"""Sensor platform for Solar of Things integration."""
+from __future__ import annotations
+
+import logging
+
+from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+    PERCENTAGE,
+    UnitOfElectricCurrent,
+    UnitOfElectricPotential,
+    UnitOfEnergy,
+    UnitOfPower,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, SENSOR_DEFINITIONS
+
+_LOGGER = logging.getLogger(__name__)
+
+# Map sensor key → translation_key (snake_case)
+_TRANSLATION_KEYS: dict[str, str] = {
+    "pvInputPower": "pv_input_power",
+    "acOutputActivePower": "ac_output_active_power",
+    "batteryDischargeCurrent": "battery_discharge_current",
+    "batteryChargingCurrent": "battery_charging_current",
+    "batteryVoltage": "battery_voltage",
+    "batteryPower": "battery_power",
+    "batterySOC": "battery_soc",
+    "feedInPower": "feed_in_power",
+    "gridPower": "grid_power",
+    "loadPower": "load_power",
+    "monthly_pv_generated": "monthly_pv_generated",
+    "monthly_grid_import": "monthly_grid_import",
+    "monthly_total_consumption": "monthly_total_consumption",
+    "monthly_solar_percentage": "monthly_solar_percentage",
+}
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    entry: ConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+) -> None:
+    """Set up Solar of Things sensors."""
+
+    data = hass.data[DOMAIN][entry.entry_id]
+    station_id: str = data["station_id"]
+    device_coordinators = data["device_coordinators"]
+    station_coordinator = data["station_coordinator"]
+
+    entities: list[SensorEntity] = []
+
+    # Per-device sensors
+    for device_id, coordinator in device_coordinators.items():
+        device_name = (coordinator.device_meta or {}).get("name") or device_id
+
+        for key, definition in SENSOR_DEFINITIONS.items():
+            if key.startswith("monthly_"):
+                continue
+
+            entities.append(
+                SolarOfThingsDeviceSensor(
+                    coordinator=coordinator,
+                    station_id=station_id,
+                    device_id=device_id,
+                    device_name=device_name,
+                    sensor_key=key,
+                    sensor_definition=definition,
+                )
+            )
+
+    # Station-level monthly sensors
+    if station_coordinator:
+        for key, definition in SENSOR_DEFINITIONS.items():
+            if not key.startswith("monthly_"):
+                continue
+
+            entities.append(
+                SolarOfThingsStationMonthlySensor(
+                    coordinator=station_coordinator,
+                    station_id=station_id,
+                    sensor_key=key,
+                    sensor_definition=definition,
+                )
+            )
+
+    async_add_entities(entities)
+
+
+class SolarOfThingsDeviceSensor(CoordinatorEntity, SensorEntity):
+    """Per-device telemetry sensor."""
+
+    def __init__(
+        self,
+        coordinator,
+        station_id: str,
+        device_id: str,
+        device_name: str,
+        sensor_key: str,
+        sensor_definition: dict,
+    ) -> None:
+        super().__init__(coordinator)
+
+        self._station_id = station_id
+        self._device_id = device_id
+        self._device_name = device_name
+        self._sensor_key = sensor_key
+        self._sensor_definition = sensor_definition
+
+        self._attr_has_entity_name = True
+        self._attr_translation_key = _TRANSLATION_KEYS.get(sensor_key)
+        # Fallback name if no translation key
+        if not self._attr_translation_key:
+            self._attr_name = f"{device_name} {sensor_definition['name']}"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_{sensor_key}"
+        self._attr_icon = sensor_definition.get("icon")
+
+        unit = sensor_definition.get("unit")
+        if unit == "W":
+            self._attr_device_class = SensorDeviceClass.POWER
+            self._attr_native_unit_of_measurement = UnitOfPower.WATT
+            self._attr_state_class = SensorStateClass.MEASUREMENT
+        elif unit == "kWh":
+            self._attr_device_class = SensorDeviceClass.ENERGY
+            self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
+            self._attr_state_class = SensorStateClass.TOTAL_INCREASING
+        elif unit == "A":
+            self._attr_device_class = SensorDeviceClass.CURRENT
+            self._attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE
+            self._attr_state_class = SensorStateClass.MEASUREMENT
+        elif unit == "V":
+            self._attr_device_class = SensorDeviceClass.VOLTAGE
+            self._attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT
+            self._attr_state_class = SensorStateClass.MEASUREMENT
+        elif unit == "%":
+            if "battery" in sensor_key.lower():
+                self._attr_device_class = SensorDeviceClass.BATTERY
+            self._attr_native_unit_of_measurement = PERCENTAGE
+            self._attr_state_class = SensorStateClass.MEASUREMENT
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {(DOMAIN, self._device_id)},
+            "name": self._device_name,
+            "manufacturer": "Siseli",
+            "model": (self.coordinator.data.get("device_meta") or {}).get("model") if self.coordinator.data else None,
+            "via_device": (DOMAIN, self._station_id),
+        }
+
+    @property
+    def native_value(self):
+        ts = (self.coordinator.data or {}).get("time_series") or {}
+        val = ts.get(self._sensor_key)
+        if val is None:
+            return None
+        try:
+            return round(float(val), 2)
+        except Exception:
+            return None
+
+
+class SolarOfThingsStationMonthlySensor(CoordinatorEntity, SensorEntity):
+    """Station-level monthly summary sensor."""
+
+    def __init__(
+        self,
+        coordinator,
+        station_id: str,
+        sensor_key: str,
+        sensor_definition: dict,
+    ) -> None:
+        super().__init__(coordinator)
+
+        self._station_id = station_id
+        self._sensor_key = sensor_key
+        self._sensor_definition = sensor_definition
+
+        self._attr_has_entity_name = True
+        self._attr_translation_key = _TRANSLATION_KEYS.get(sensor_key)
+        if not self._attr_translation_key:
+            self._attr_name = f"Station {station_id} {sensor_definition['name']}"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{sensor_key}"
+        self._attr_icon = sensor_definition.get("icon")
+
+        unit = sensor_definition.get("unit")
+        if unit == "kWh":
+            self._attr_device_class = SensorDeviceClass.ENERGY
+            self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
+            self._attr_state_class = SensorStateClass.TOTAL
+        elif unit == "%":
+            self._attr_native_unit_of_measurement = PERCENTAGE
+            self._attr_state_class = SensorStateClass.MEASUREMENT
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {(DOMAIN, self._station_id)},
+            "name": f"Solar Station {self._station_id}",
+            "manufacturer": "Siseli",
+            "model": "Station",
+        }
+
+    @property
+    def native_value(self):
+        monthly = (self.coordinator.data or {}).get("monthly") or {}
+        val = monthly.get(self._sensor_key)
+        if val is None:
+            return None
+        try:
+            return round(float(val), 2)
+        except Exception:
+            return None

+ 137 - 0
custom_components/solar_of_things/strings.json

@@ -0,0 +1,137 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "title": "Add Solar of Things Station",
+        "description": "Choose how to authenticate with the Siseli portal.\n\n**User ID + 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 — User ID + Password",
+        "description": "Enter your Siseli portal login credentials and Station ID.\n\n- **User ID**: your account/login name on https://solar.siseli.com\n- **Station ID**: 18-digit number found in API request payloads (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": {
+          "user_id": "User ID / Account",
+          "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 — blank to auto-discover)",
+          "time_zone": "Time zone (e.g. Asia/Manila)"
+        }
+      },
+      "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": {
+          "password": "Password",
+          "iot_token": "Fresh IOT Token"
+        }
+      }
+    },
+    "error": {
+      "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
+      "invalid_auth": "Invalid user ID or password. Please try again.",
+      "unknown": "Unexpected error. Check Home Assistant logs for details."
+    },
+    "abort": {
+      "already_configured": "This station is already configured.",
+      "reauth_successful": "Re-authentication successful."
+    }
+  },
+  "entity": {
+    "number": {
+      "battery_charge_limit": {
+        "name": "Battery Charge Limit"
+      },
+      "battery_discharge_limit": {
+        "name": "Battery Discharge Limit"
+      },
+      "grid_charge_limit": {
+        "name": "Grid Charge Limit"
+      }
+    },
+    "select": {
+      "operating_mode": {
+        "name": "Output Source Priority",
+        "state": {
+          "utility_first_uso": "Utility First (USO)",
+          "solar_first_sub": "Solar First (SUB)",
+          "solar_battery_first_sbu": "Solar+Battery First (SBU)"
+        }
+      },
+      "battery_priority": {
+        "name": "Charger Source Priority",
+        "state": {
+          "solar_utility_cso": "Solar + Utility (CSO)",
+          "solar_first_snu": "Solar First (SNU)",
+          "solar_only_oso": "Solar Only (OSO)"
+        }
+      }
+    },
+    "switch": {
+      "grid_charging": {
+        "name": "Grid Charging (AC Input Range)"
+      },
+      "grid_feed_in": {
+        "name": "Grid Feed-In"
+      },
+      "backup_mode": {
+        "name": "Backup Mode (SBU Priority)"
+      }
+    },
+    "sensor": {
+      "pv_input_power": {
+        "name": "PV Input Power"
+      },
+      "ac_output_active_power": {
+        "name": "AC Output Power"
+      },
+      "battery_discharge_current": {
+        "name": "Battery Discharge Current"
+      },
+      "battery_charging_current": {
+        "name": "Battery Charging Current"
+      },
+      "battery_voltage": {
+        "name": "Battery Voltage"
+      },
+      "battery_power": {
+        "name": "Battery Power"
+      },
+      "battery_soc": {
+        "name": "Battery State of Charge"
+      },
+      "feed_in_power": {
+        "name": "Grid Feed-in Power"
+      },
+      "grid_power": {
+        "name": "Grid Import Power"
+      },
+      "load_power": {
+        "name": "Load Power"
+      },
+      "monthly_pv_generated": {
+        "name": "Monthly PV Generated"
+      },
+      "monthly_grid_import": {
+        "name": "Monthly Grid Import"
+      },
+      "monthly_total_consumption": {
+        "name": "Monthly Total Consumption"
+      },
+      "monthly_solar_percentage": {
+        "name": "Monthly Solar Coverage"
+      }
+    }
+  }
+}

+ 161 - 0
custom_components/solar_of_things/switch.py

@@ -0,0 +1,161 @@
+"""Switch platform for Solar of Things integration."""
+from __future__ import annotations
+
+import logging
+
+from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
+    data = hass.data[DOMAIN][entry.entry_id]
+    api = data["api"]
+    station_id: str = data["station_id"]
+    device_coordinators = data["device_coordinators"]
+
+    entities: list[SwitchEntity] = []
+
+    for device_id, coordinator in device_coordinators.items():
+        device_name = (coordinator.device_meta or {}).get("name") or device_id
+        entities.extend(
+            [
+                SolarOfThingsGridChargingSwitch(api, coordinator, station_id, device_id, device_name),
+                SolarOfThingsGridFeedInSwitch(api, coordinator, station_id, device_id, device_name),
+                SolarOfThingsBackupModeSwitch(api, coordinator, station_id, device_id, device_name),
+            ]
+        )
+
+    async_add_entities(entities)
+
+
+def _setting_value(coordinator_data: dict | None, key: str) -> int | None:
+    """Extract the integer value for a device setting key from coordinator data."""
+    settings = ((coordinator_data or {}).get("settings") or {})
+    entry = settings.get(key)
+    if entry is None:
+        return None
+    raw = entry.get("value") if isinstance(entry, dict) else entry
+    try:
+        return int(raw)
+    except (TypeError, ValueError):
+        return None
+
+
+class _BaseSwitch(CoordinatorEntity, SwitchEntity):
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(coordinator)
+        self._api = api
+        self._station_id = station_id
+        self._device_id = device_id
+        self._device_name = device_name
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {(DOMAIN, self._device_id)},
+            "name": self._device_name,
+            "manufacturer": "Siseli",
+            "model": (self.coordinator.data.get("device_meta") or {}).get("model") if self.coordinator.data else None,
+            "via_device": (DOMAIN, self._station_id),
+        }
+
+
+class SolarOfThingsGridChargingSwitch(_BaseSwitch):
+    """Switch for AC Input Range setting (acInputRangeSetting).
+
+    0 = Appliance mode – wide input voltage range, grid charging allowed.
+    1 = UPS mode – narrow voltage range, stricter bypass behaviour.
+    The switch reports ON when the inverter is in Appliance (grid-charging) mode.
+    """
+
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(api, coordinator, station_id, device_id, device_name)
+        self._attr_name = f"{device_name} Grid Charging (AC Input Range)"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_grid_charging"
+        self._attr_device_class = SwitchDeviceClass.SWITCH
+        self._attr_icon = "mdi:transmission-tower"
+
+    @property
+    def is_on(self) -> bool | None:
+        val = _setting_value(self.coordinator.data, "acInputRangeSetting")
+        if val is None:
+            return None
+        return val == 0  # 0=Appliance (charging OK), 1=UPS (bypass)
+
+    async def async_turn_on(self, **kwargs):
+        await self.hass.async_add_executor_job(self._api.set_grid_charging, self._device_id, True)
+        await self.coordinator.async_request_refresh()
+
+    async def async_turn_off(self, **kwargs):
+        await self.hass.async_add_executor_job(self._api.set_grid_charging, self._device_id, False)
+        await self.coordinator.async_request_refresh()
+
+
+class SolarOfThingsGridFeedInSwitch(_BaseSwitch):
+    """Switch for GRID grid switch (batteryPowerLimitingSetting).
+
+    0 = OFF (grid switch disabled / feed-in off).
+    1 = ON  (grid switch enabled / feed-in on).
+    """
+
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(api, coordinator, station_id, device_id, device_name)
+        self._attr_name = f"{device_name} Grid Feed-In"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_grid_feed_in"
+        self._attr_device_class = SwitchDeviceClass.SWITCH
+        self._attr_icon = "mdi:transmission-tower-export"
+
+    @property
+    def is_on(self) -> bool | None:
+        val = _setting_value(self.coordinator.data, "batteryPowerLimitingSetting")
+        if val is None:
+            return None
+        return val == 1  # 1=ON
+
+    async def async_turn_on(self, **kwargs):
+        await self.hass.async_add_executor_job(self._api.set_grid_feed_in, self._device_id, True)
+        await self.coordinator.async_request_refresh()
+
+    async def async_turn_off(self, **kwargs):
+        await self.hass.async_add_executor_job(self._api.set_grid_feed_in, self._device_id, False)
+        await self.coordinator.async_request_refresh()
+
+
+class SolarOfThingsBackupModeSwitch(_BaseSwitch):
+    """Switch that maps to Output Source Priority SBU (backup/off-grid biased).
+
+    ON  → outputSourcePrioritySetting = 2 (SBU: Solar+Battery first, grid last).
+    OFF → outputSourcePrioritySetting = 1 (SUB: Solar first, grid as supplement).
+
+    Note: turning this switch ON will also change the Operating Mode select entity
+    to 'Solar+Battery First (SBU)', which is the expected behaviour.
+    """
+
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
+        super().__init__(api, coordinator, station_id, device_id, device_name)
+        self._attr_name = f"{device_name} Backup Mode (SBU Priority)"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_backup_mode"
+        self._attr_device_class = SwitchDeviceClass.SWITCH
+        self._attr_icon = "mdi:battery-lock"
+
+    @property
+    def is_on(self) -> bool | None:
+        val = _setting_value(self.coordinator.data, "outputSourcePrioritySetting")
+        if val is None:
+            return None
+        return val == 2  # SBU
+
+    async def async_turn_on(self, **kwargs):
+        await self.hass.async_add_executor_job(self._api.set_backup_mode, self._device_id, True)
+        await self.coordinator.async_request_refresh()
+
+    async def async_turn_off(self, **kwargs):
+        await self.hass.async_add_executor_job(self._api.set_backup_mode, self._device_id, False)
+        await self.coordinator.async_request_refresh()

+ 137 - 0
custom_components/solar_of_things/translations/en.json

@@ -0,0 +1,137 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "title": "Add Solar of Things Station",
+        "description": "Choose how to authenticate with the Siseli portal.\n\n**User ID + 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 — User ID + Password",
+        "description": "Enter your Siseli portal login credentials and Station ID.\n\n- **User ID**: your account/login name on https://solar.siseli.com\n- **Station ID**: 18-digit number found in API request payloads (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": {
+          "user_id": "User ID / Account",
+          "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 — blank to auto-discover)",
+          "time_zone": "Time zone (e.g. Asia/Manila)"
+        }
+      },
+      "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": {
+          "password": "Password",
+          "iot_token": "Fresh IOT Token"
+        }
+      }
+    },
+    "error": {
+      "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
+      "invalid_auth": "Invalid user ID or password. Please try again.",
+      "unknown": "Unexpected error. Check Home Assistant logs for details."
+    },
+    "abort": {
+      "already_configured": "This station is already configured.",
+      "reauth_successful": "Re-authentication successful."
+    }
+  },
+  "entity": {
+    "number": {
+      "battery_charge_limit": {
+        "name": "Battery Charge Limit"
+      },
+      "battery_discharge_limit": {
+        "name": "Battery Discharge Limit"
+      },
+      "grid_charge_limit": {
+        "name": "Grid Charge Limit"
+      }
+    },
+    "select": {
+      "operating_mode": {
+        "name": "Output Source Priority",
+        "state": {
+          "utility_first_uso": "Utility First (USO)",
+          "solar_first_sub": "Solar First (SUB)",
+          "solar_battery_first_sbu": "Solar+Battery First (SBU)"
+        }
+      },
+      "battery_priority": {
+        "name": "Charger Source Priority",
+        "state": {
+          "solar_utility_cso": "Solar + Utility (CSO)",
+          "solar_first_snu": "Solar First (SNU)",
+          "solar_only_oso": "Solar Only (OSO)"
+        }
+      }
+    },
+    "switch": {
+      "grid_charging": {
+        "name": "Grid Charging (AC Input Range)"
+      },
+      "grid_feed_in": {
+        "name": "Grid Feed-In"
+      },
+      "backup_mode": {
+        "name": "Backup Mode (SBU Priority)"
+      }
+    },
+    "sensor": {
+      "pv_input_power": {
+        "name": "PV Input Power"
+      },
+      "ac_output_active_power": {
+        "name": "AC Output Power"
+      },
+      "battery_discharge_current": {
+        "name": "Battery Discharge Current"
+      },
+      "battery_charging_current": {
+        "name": "Battery Charging Current"
+      },
+      "battery_voltage": {
+        "name": "Battery Voltage"
+      },
+      "battery_power": {
+        "name": "Battery Power"
+      },
+      "battery_soc": {
+        "name": "Battery State of Charge"
+      },
+      "feed_in_power": {
+        "name": "Grid Feed-in Power"
+      },
+      "grid_power": {
+        "name": "Grid Import Power"
+      },
+      "load_power": {
+        "name": "Load Power"
+      },
+      "monthly_pv_generated": {
+        "name": "Monthly PV Generated"
+      },
+      "monthly_grid_import": {
+        "name": "Monthly Grid Import"
+      },
+      "monthly_total_consumption": {
+        "name": "Monthly Total Consumption"
+      },
+      "monthly_solar_percentage": {
+        "name": "Monthly Solar Coverage"
+      }
+    }
+  }
+}

+ 3 - 4
hacs.json

@@ -1,8 +1,7 @@
 {
   "name": "Solar of Things",
   "render_readme": true,
-  "domains": ["sensor", "number", "select", "switch"],
-  "homeassistant": "2023.1.0",
-  "zip_release": true,
-  "filename": "solar-of-things-ha.zip"
+  "homeassistant": "2023.6.0",
+  "zip_release": false,
+  "content_in_root": false
 }

+ 6 - 4
manifest.json

@@ -1,13 +1,15 @@
 {
   "domain": "solar_of_things",
   "name": "Solar of Things",
-  "codeowners": [],
+  "codeowners": ["@Conexo-Casa"],
   "config_flow": true,
-  "documentation": "https://github.com/conexocasa/solar-of-things-ha",
+  "documentation": "https://github.com/Conexo-Casa/solar-of-things-ha",
+  "homeassistant": "2023.6.0",
+  "integration_type": "hub",
   "iot_class": "cloud_polling",
-  "issue_tracker": "https://github.com/conexocasa/solar-of-things-ha/issues",
+  "issue_tracker": "https://github.com/Conexo-Casa/solar-of-things-ha/issues",
   "requirements": [
     "pycryptodome>=3.9.0"
   ],
   "version": "2.3.3"
-}
+}