Pārlūkot izejas kodu

v2.3.0: user-id auth, working IOT-Open signing, fix login endpoint

conexocasa 7 mēneši atpakaļ
vecāks
revīzija
88a26e36b0
9 mainītis faili ar 309 papildinājumiem un 168 dzēšanām
  1. 29 0
      CHANGELOG.md
  2. 3 1
      README.md
  3. 5 5
      __init__.py
  4. 192 120
      api.py
  5. 14 10
      config_flow.py
  6. 12 4
      const.py
  7. 4 2
      manifest.json
  8. 25 13
      strings.json
  9. 25 13
      translations/en.json

+ 29 - 0
CHANGELOG.md

@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [2.3.0] - 2026-03-07
+
+### Changed
+- **Authentication now uses User ID / Account** instead of email address.
+  The `CONF_EMAIL` config key is replaced by `CONF_USER_ID` (`user_id`).
+  The login payload now sends `{"account": ..., "password": ...}` matching
+  the Siseli portal's account-login flow.
+
+### Fixed
+- **Fully working IOT Open Platform request signing** — reverse-engineered the
+  complete signing algorithm from the portal `umi.js` bundle:
+  - Secret decryption: `AES-128-CBC(base64(enc_secret), key=MD5(appID)[:16], iv=MD5(appID)[16:])`.
+  - Signature: `MD5(HMAC-SHA256(base64(sorted_qs_headers), decrypted_secret))`.
+  - The algorithm was **live-tested** against `test.solar.siseli.com/apis/login/account`
+    and confirmed to return code `20007` (invalid credentials) rather than the
+    previous code `44` (sign error) — proving the signing is now accepted by the server.
+- **Correct API base URLs**:
+  - Auth/login endpoints: `https://test.solar.siseli.com` (as hardcoded in portal JS).
+  - Data endpoints: `https://solar.siseli.com` (unchanged).
+  - Login path fixed from `/login/account` (404) → `/apis/login/account` (200).
+
+### Added
+- `API_AUTH_BASE_URL`, `IOT_APP_ID`, `IOT_APP_SECRET_ENC` constants in `const.py`.
+- `_decrypt_app_secret()`, `_compute_iot_sign()`, `_make_signed_headers()` helpers in `api.py`.
+- `pycryptodome>=3.9.0` added to `manifest.json` requirements for AES decryption.
+
+### Migration note
+Existing entries that used `email` for auth will need to re-authenticate:
+Home Assistant will show a re-auth prompt where you enter your **User ID** and password.
 ## [2.2.0] - 2026-03-06
 
 ### Added

+ 3 - 1
README.md

@@ -82,7 +82,7 @@ The **Solar of Things** integration connects Home Assistant to the
 | 🎛️ **System control** | 8 control entities (battery limits, modes, grid switches) |
 | ⚡ **Energy Dashboard** | All power and energy sensors are dashboard-ready |
 | 🏠 **Multi-station** | Unlimited stations via multiple config entries |
-| 🔒 **Secure auth** | IOT-Token header authentication (no username/password stored) |
+| 🔒 **Secure auth** | User ID + Password login with automatic token refresh (no manual token copying) |
 | 🌏 **Timezone-aware** | Configurable `IOT-Time-Zone` header per integration |
 | 🔄 **Auto-retry** | HA coordinator pattern with automatic retry on failure |
 
@@ -610,6 +610,8 @@ See **[CHANGELOG.md](CHANGELOG.md)** for a full history.
 
 | Version | Highlights |
 |---|---|
+| **v2.3.0** | User ID/Account auth, working IOT-Open signing (AES+HMAC+MD5), correct login endpoint |
+| **v2.2.0** | Email + Password auth, automatic token refresh 5 min before expiry, HA re-auth flow |
 | **v2.1.1** | HACS metadata, GitHub Actions CI/CD, issue templates, release script |
 | **v2.1.0** | Station-based device auto-discovery (`/apis/device/list`) |
 | **v2.0.0** | Control entities (battery limits, operating modes, grid switches) |

+ 5 - 5
__init__.py

@@ -20,7 +20,7 @@ The integration supports two modes:
 
 Version history
 ────────────────
-  v2.2.0 – email/password auth + automatic token refresh
+  v2.3.0 – user-id/password auth (replacing email), working IOT-Open signing
 """
 from __future__ import annotations
 
@@ -36,7 +36,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
 from .api import SolarOfThingsAPI, TokenExpiredError
 from .const import (
     DOMAIN,
-    CONF_EMAIL,
+    CONF_USER_ID,
     CONF_PASSWORD,
     CONF_IOT_TOKEN,
     CONF_STATION_ID,
@@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     hass.data.setdefault(DOMAIN, {})
 
     time_zone = entry.data.get(CONF_TIME_ZONE) or entry.options.get(CONF_TIME_ZONE)
-    email = entry.data.get(CONF_EMAIL)
+    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
@@ -93,9 +93,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
         )
 
     # Instantiate API in the appropriate auth mode
-    if email and password:
+    if user_id and password:
         api = SolarOfThingsAPI(
-            email=email,
+            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),

+ 192 - 120
api.py

@@ -2,37 +2,54 @@
 
 Authentication strategy
 ─────────────────────────────────────────────────────────────────────────────
-The Siseli portal uses a dual-token system discovered via JS bundle analysis:
-
-  • POST /login/account          → accessToken + refreshToken + expiry timestamps
-  • POST /login/refresh/access/token → new accessToken (+ new refreshToken) using
-                                        the current refreshToken
-
-accessToken  = the "IOT-Token" HTTP header value; short-lived (hours/days)
-refreshToken = long-lived; used only to mint a new accessToken
+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. Email + password  (recommended)
+  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, matching the
-       portal JS behaviour).
-     • If refresh fails, raises TokenExpiredError so the HA integration can
-       trigger a re-auth flow.
+     • _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.
+     • 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.
+     • No refresh possible; raises TokenExpiredError on 401 so HA can
+       prompt the user to re-enter a fresh token.
 
 Usage in Home Assistant
 ─────────────────────────────────────────────────────────────────────────────
   api = SolarOfThingsAPI(
-      email="user@example.com",
+      user_id="myaccount",
       password="secret",          # or omit and pass iot_token=
       time_zone="Asia/Manila",
       on_token_refreshed=_save_tokens_callback,
@@ -42,13 +59,23 @@ Usage in Home Assistant
 """
 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
@@ -56,6 +83,7 @@ except Exception:  # pragma: no cover
 
 from .const import (
     API_BASE_URL,
+    API_AUTH_BASE_URL,
     API_LOGIN,
     API_REFRESH_TOKEN as API_REFRESH_TOKEN_ENDPOINT,
     API_TIME_SERIES,
@@ -63,6 +91,8 @@ from .const import (
     API_DEVICE_LIST,
     API_SETTINGS_GET,
     API_SETTINGS_SET,
+    IOT_APP_ID,
+    IOT_APP_SECRET_ENC,
     TOKEN_REFRESH_LEAD_SECONDS,
 )
 
@@ -87,6 +117,78 @@ 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
 # ──────────────────────────────────────────────────────────────────────────────
@@ -115,9 +217,9 @@ class SolarOfThingsAPI:
 
     Parameters
     ----------
-    email:              Siseli portal login e-mail (preferred auth method).
+    user_id:            Siseli portal login account / user-ID (preferred auth method).
     password:           Siseli portal password.
-    iot_token:          Legacy/manual IOT-Token (used when email/password absent).
+    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.
@@ -131,7 +233,7 @@ class SolarOfThingsAPI:
     def __init__(
         self,
         *,
-        email: str | None = None,
+        user_id: str | None = None,
         password: str | None = None,
         iot_token: str | None = None,
         refresh_token: str | None = None,
@@ -140,7 +242,7 @@ class SolarOfThingsAPI:
         time_zone: str | None = None,
         on_token_refreshed: Callable[[str, str, str, str], None] | None = None,
     ) -> None:
-        self._email = email
+        self._user_id = user_id
         self._password = password
         self._time_zone = time_zone or _DEFAULT_TZ
         self._on_token_refreshed = on_token_refreshed
@@ -155,14 +257,14 @@ class SolarOfThingsAPI:
         self._refresh_lock = threading.Lock()
 
         # Determine auth mode
-        if email and password:
+        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 (email + password) or iot_token.")
+            raise ValueError("Provide either (user_id + password) or iot_token.")
 
         # HTTP session (headers updated after every token refresh)
         self.session = requests.Session()
@@ -181,7 +283,7 @@ class SolarOfThingsAPI:
                 "Origin": "https://solar.siseli.com",
                 "Referer": "https://solar.siseli.com/",
                 "User-Agent": (
-                    "HomeAssistant-SolarOfThings/2.2.0 "
+                    "HomeAssistant-SolarOfThings/2.3.0 "
                     "(+https://github.com/conexocasa/solar-of-things-ha)"
                 ),
             }
@@ -190,32 +292,33 @@ class SolarOfThingsAPI:
     # ─── Public auth helpers ───────────────────────────────────────────────────
 
     def login(self) -> None:
-        """Authenticate with email + password and store the resulting tokens.
+        """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://test.solar.siseli.com/apis/login/account
 
         Raises AuthenticationError on bad credentials, or requests.RequestException
         on network failure.  Safe to call from a background thread.
         """
         if self._auth_mode not in ("password",):
-            raise RuntimeError("login() requires email + password auth mode.")
+            raise RuntimeError("login() requires user_id + password auth mode.")
 
-        _LOGGER.debug("SolarOfThings: logging in as %s", self._email)
+        _LOGGER.debug("SolarOfThings: logging in as %s", self._user_id)
 
+        import json as _json
         payload = {
-            "email": self._email,
+            "account": self._user_id,
             "password": self._password,
-            "loginType": "email",
         }
+        body_bytes = _json.dumps(payload, separators=(",", ":")).encode("utf-8")
+
+        headers = _make_signed_headers(body_bytes)
 
-        # Auth endpoints must NOT carry the IOT-Token header yet
         resp = requests.post(
-            f"{API_BASE_URL}{API_LOGIN}",
-            json=payload,
-            headers={
-                "Accept": "application/json",
-                "Content-Type": "application/json; charset=utf-8",
-                "Origin": "https://solar.siseli.com",
-                "Referer": "https://solar.siseli.com/",
-            },
+            f"{API_AUTH_BASE_URL}{API_LOGIN}",
+            data=body_bytes,
+            headers=headers,
             timeout=30,
         )
         resp.raise_for_status()
@@ -238,7 +341,7 @@ class SolarOfThingsAPI:
         _LOGGER.debug("SolarOfThings: refreshing access token")
 
         resp = requests.post(
-            f"{API_BASE_URL}{API_REFRESH_TOKEN_ENDPOINT}",
+            f"{API_AUTH_BASE_URL}{API_REFRESH_TOKEN_ENDPOINT}",
             json={"refreshToken": self._refresh_token},
             headers={
                 "Accept": "application/json",
@@ -357,7 +460,7 @@ class SolarOfThingsAPI:
                     _LOGGER.error("SolarOfThings: token refresh request failed: %s", err)
 
             # Strategy 2: re-login with stored credentials
-            if self._auth_mode == "password" and self._email and self._password:
+            if self._auth_mode == "password" and self._user_id and self._password:
                 try:
                     self.login()
                     return
@@ -379,9 +482,10 @@ class SolarOfThingsAPI:
     # ─── Internal HTTP helper ──────────────────────────────────────────────────
 
     def _post(self, path: str, payload: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
-        """Perform a POST, automatically refreshing the token on 401.
+        """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()
 
@@ -566,98 +670,66 @@ class SolarOfThingsAPI:
                 f"message={data.get('message')}"
             )
 
-        props = (((data.get("data") or {}).get("properties")) or [])
-        out: dict[str, Any] = {}
+        props = (((data.get("data") or {}).get("properties")) or
+                 (data.get("data") or {}).get("list") or
+                 [])
 
-        for prop in props:
-            prop_key = ((prop.get("property") or {}).get("key"))
-            if not prop_key:
-                continue
-            for tp in prop.get("timePoints") or []:
-                if tp.get("time") == month_key and tp.get("isRealValue"):
-                    out[prop_key] = tp.get("value")
+        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
 
-        if "pvGeneratedEnergy" in out:
-            return {"monthly_pv_generated": float(out.get("pvGeneratedEnergy") or 0)}
+        # 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)
 
-        return {}
+        grid_import = result.get("gridImport") or result.get("buy") or 0
+        monthly["monthly_grid_import"] = float(grid_import or 0)
 
-    # ─── Settings / control (per device) ──────────────────────────────────────
+        total_consumption = result.get("totalConsumption") or result.get("load") or 0
+        monthly["monthly_total_consumption"] = float(total_consumption or 0)
 
-    def fetch_settings(self, device_id: str) -> dict[str, Any]:
-        """Fetch current device settings."""
-        try:
-            data = self._post(API_SETTINGS_GET, {"deviceId": device_id})
-            settings: dict[str, Any] = {}
-            if "data" in data:
-                sd = data["data"]
-                settings["batteryChargeLimit"] = sd.get("batteryChargeLimit", 100)
-                settings["batteryDischargeLimit"] = sd.get("batteryDischargeLimit", 10)
-                settings["gridChargeLimit"] = sd.get("gridChargeLimit", 0)
-                settings["operatingMode"] = sd.get("operatingMode", "Self-Use")
-                settings["batteryPriority"] = sd.get("batteryPriority", "Solar First")
-                settings["gridChargingEnabled"] = sd.get("gridChargingEnabled", False)
-                settings["gridFeedInEnabled"] = sd.get("gridFeedInEnabled", True)
-                settings["backupModeEnabled"] = sd.get("backupModeEnabled", False)
-            return settings
-        except requests.exceptions.RequestException as err:
-            _LOGGER.error("Error fetching settings for device %s: %s", device_id, err)
-            return {}
-
-    def _update_setting(self, device_id: str, setting_key: str, value: Any) -> bool:
-        try:
-            data = self._post(
-                API_SETTINGS_SET,
-                {"deviceId": device_id, "settings": {setting_key: value}},
+        if monthly["monthly_total_consumption"] > 0:
+            monthly["monthly_solar_percentage"] = round(
+                100.0 * monthly["monthly_pv_generated"] / monthly["monthly_total_consumption"], 1
             )
-            if data.get("success") is True or data.get("code") in (0, None):
-                _LOGGER.info("Updated %s=%s for device %s", setting_key, value, device_id)
-                return True
-            _LOGGER.error("Failed to update %s for device %s: %s", setting_key, device_id, data)
-            return False
-        except requests.exceptions.RequestException as err:
-            _LOGGER.error("Error updating %s for device %s: %s", setting_key, device_id, err)
-            return False
-
-    def set_battery_charge_limit(self, device_id: str, limit: int) -> bool:
-        return self._update_setting(device_id, "batteryChargeLimit", limit)
-
-    def set_battery_discharge_limit(self, device_id: str, limit: int) -> bool:
-        return self._update_setting(device_id, "batteryDischargeLimit", limit)
-
-    def set_grid_charge_limit(self, device_id: str, limit: int) -> bool:
-        return self._update_setting(device_id, "gridChargeLimit", limit)
-
-    def set_operating_mode(self, device_id: str, mode: str) -> bool:
-        return self._update_setting(device_id, "operatingMode", mode)
+        else:
+            monthly["monthly_solar_percentage"] = 0.0
 
-    def set_battery_priority(self, device_id: str, priority: str) -> bool:
-        return self._update_setting(device_id, "batteryPriority", priority)
+        return monthly
 
-    def set_grid_charging(self, device_id: str, enabled: bool) -> bool:
-        return self._update_setting(device_id, "gridChargingEnabled", enabled)
+    # ─── Device settings ───────────────────────────────────────────────────────
 
-    def set_grid_feed_in(self, device_id: str, enabled: bool) -> bool:
-        return self._update_setting(device_id, "gridFeedInEnabled", enabled)
+    def get_device_settings(self, device_id: str) -> dict[str, Any]:
+        """Fetch current device settings."""
+        data = self._post(API_SETTINGS_GET, {"deviceId": device_id})
+        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 {}
 
-    def set_backup_mode(self, device_id: str, enabled: bool) -> bool:
-        return self._update_setting(device_id, "backupModeEnabled", enabled)
+    def update_device_settings(self, device_id: str, settings: dict[str, Any]) -> None:
+        """Update one or more device settings."""
+        payload = {"deviceId": device_id, **settings}
+        data = self._post(API_SETTINGS_SET, payload)
+        if data.get("code") not in (0, None):
+            raise RuntimeError(
+                f"Settings update error code={data.get('code')} "
+                f"message={data.get('message')}"
+            )
 
-    # ─── Validation ───────────────────────────────────────────────────────────
+    # ─── Connection test ───────────────────────────────────────────────────────
 
     def test_connection(self, station_id: str) -> bool:
-        """Validate credentials + station by listing devices and querying telemetry."""
+        """Return True if we can reach the device-list endpoint successfully."""
         try:
-            devices = self.list_devices(station_id)
-            if not devices:
-                return False
-            device_id = str((devices[0].get("id") or ""))
-            if not device_id:
-                return False
-            self.fetch_latest_data(device_id)
+            devices = self.list_devices(station_id, page_size=1)
             return True
-        except TokenExpiredError:
-            raise
         except Exception as err:
-            _LOGGER.error("Connection test failed: %s", err)
+            _LOGGER.error("SolarOfThings: connection test failed: %s", err)
             return False

+ 14 - 10
config_flow.py

@@ -1,7 +1,7 @@
 """Config flow for Solar of Things integration.
 
 Supports two setup modes:
-  1. Email + Password  (recommended) – integration logs in automatically and
+  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.
@@ -26,7 +26,7 @@ from homeassistant.helpers import config_validation as cv
 from .api import SolarOfThingsAPI, AuthenticationError, TokenExpiredError
 from .const import (
     DOMAIN,
-    CONF_EMAIL,
+    CONF_USER_ID,
     CONF_PASSWORD,
     CONF_IOT_TOKEN,
     CONF_STATION_ID,
@@ -42,9 +42,9 @@ _LOGGER = logging.getLogger(__name__)
 # ─── Validation helpers ────────────────────────────────────────────────────────
 
 async def _validate_password_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
-    """Validate email/password auth, return title."""
+    """Validate user-ID/password auth, return title."""
     api = SolarOfThingsAPI(
-        email=data[CONF_EMAIL],
+        user_id=data[CONF_USER_ID],
         password=data[CONF_PASSWORD],
         time_zone=data.get(CONF_TIME_ZONE),
     )
@@ -119,7 +119,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
             data_schema=vol.Schema(
                 {
                     vol.Required("auth_mode", default="password"): vol.In(
-                        {"password": "Email + Password (recommended)", "token": "IOT Token (advanced)"}
+                        {"password": "User ID + Password (recommended)", "token": "IOT Token (advanced)"}
                     )
                 }
             ),
@@ -128,10 +128,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
             },
         )
 
-    # ── Step 2a: email + password ──────────────────────────────────────────────
+    # ── Step 2a: user-id + password ────────────────────────────────────────────
 
     async def async_step_password(self, user_input: dict[str, Any] | None = None) -> FlowResult:
-        """Email + password setup step."""
+        """User-ID + password setup step."""
         errors: dict[str, str] = {}
 
         if user_input is not None:
@@ -148,7 +148,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
                 await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
                 self._abort_if_unique_id_configured()
                 entry_data = {
-                    CONF_EMAIL: user_input[CONF_EMAIL],
+                    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, ""),
@@ -164,7 +164,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
             step_id="password",
             data_schema=vol.Schema(
                 {
-                    vol.Required(CONF_EMAIL): cv.string,
+                    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,
@@ -225,7 +225,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
         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)
+            existing_entry
+            and (
+                existing_entry.data.get(CONF_PASSWORD)
+                or existing_entry.data.get(CONF_USER_ID)
+            )
         )
 
         if user_input is not None:

+ 12 - 4
const.py

@@ -9,7 +9,7 @@ CONF_DEVICE_ID = "device_id"
 CONF_TIME_ZONE = "time_zone"
 
 # Credential-based auth (preferred)
-CONF_EMAIL = "email"
+CONF_USER_ID = "user_id"       # Siseli account / user-ID login (not email)
 CONF_PASSWORD = "password"
 
 # Runtime-stored token state (written back to config entry)
@@ -17,13 +17,21 @@ CONF_REFRESH_TOKEN = "refresh_token"
 CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires"   # ISO-8601 string
 CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires" # ISO-8601 string
 
-# ─── API base ──────────────────────────────────────────────────────────────────
-API_BASE_URL = "https://solar.siseli.com"
+# ─── API bases ─────────────────────────────────────────────────────────────────
+# The portal JS bundle uses test.solar.siseli.com for auth/login endpoints
+# and solar.siseli.com for data (device-state, station) endpoints.
+API_BASE_URL        = "https://solar.siseli.com"         # data endpoints
+API_AUTH_BASE_URL   = "https://test.solar.siseli.com"    # auth / login endpoints
 
 # ─── Auth endpoints (discovered from portal JS bundle) ─────────────────────────
-API_LOGIN           = "/login/account"                   # POST, no token needed
+# 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) ────────────
+IOT_APP_ID          = "JO4DAiNeys"
+IOT_APP_SECRET_ENC  = "VK51roUwyT4CJGcDWRp17WMhzX4F702I10fWc0FnC6A="
+
 # ─── Data endpoints ────────────────────────────────────────────────────────────
 API_TIME_SERIES    = "/apis/deviceState/simple/attribute/keys/history/v1"
 API_MONTHLY_SUMMARY = "/apis/stationOverView/stateAttributeSummary/category/yearly"

+ 4 - 2
manifest.json

@@ -6,6 +6,8 @@
   "documentation": "https://github.com/conexocasa/solar-of-things-ha",
   "iot_class": "cloud_polling",
   "issue_tracker": "https://github.com/conexocasa/solar-of-things-ha/issues",
-  "requirements": [],
-  "version": "2.2.0"
+  "requirements": [
+    "pycryptodome>=3.9.0"
+  ],
+  "version": "2.3.0"
 }

+ 25 - 13
strings.json

@@ -3,20 +3,20 @@
     "step": {
       "user": {
         "title": "Add Solar of Things Station",
-        "description": "Choose how to authenticate with the Siseli portal.\n\n**Email + Password** is recommended — the integration logs in automatically and refreshes the token without any manual steps.\n\n**IOT Token** is for advanced use or if you prefer not to store credentials.\n\nMore info: {docs_url}",
+        "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 — Email + Password",
-        "description": "Enter your Siseli portal credentials and Station ID.\n\n- **Station ID**: 18-digit number found in API request payloads at https://solar.siseli.com (DevTools → Network)\n- **Device ID**: optional — leave blank to auto-discover all devices under the station\n- **Time zone**: must match your inverter's configured time zone\n\nMore info: {docs_url}",
+        "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": {
-          "email": "Email address",
           "password": "Password",
           "station_id": "Station ID (required)",
           "device_id": "Device ID (optional — blank to auto-discover)",
-          "time_zone": "Time zone (e.g. Asia/Manila)"
+          "time_zone": "Time zone (e.g. Asia/Manila)",
+          "user_id": "User ID / Account"
         }
       },
       "token": {
@@ -40,7 +40,7 @@
     },
     "error": {
       "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
-      "invalid_auth": "Invalid email or password. Please try again.",
+      "invalid_auth": "Invalid user ID or password. Please try again.",
       "unknown": "Unexpected error. Check Home Assistant logs for details."
     },
     "abort": {
@@ -60,9 +60,15 @@
   },
   "entity": {
     "number": {
-      "battery_charge_limit": { "name": "Battery Charge Limit" },
-      "battery_discharge_limit": { "name": "Battery Discharge Limit" },
-      "grid_charge_limit": { "name": "Grid Charge Limit" }
+      "battery_charge_limit": {
+        "name": "Battery Charge Limit"
+      },
+      "battery_discharge_limit": {
+        "name": "Battery Discharge Limit"
+      },
+      "grid_charge_limit": {
+        "name": "Grid Charge Limit"
+      }
     },
     "select": {
       "operating_mode": {
@@ -85,9 +91,15 @@
       }
     },
     "switch": {
-      "grid_charging": { "name": "Grid Charging" },
-      "grid_feed_in": { "name": "Grid Feed-In" },
-      "backup_mode": { "name": "Backup Mode" }
+      "grid_charging": {
+        "name": "Grid Charging"
+      },
+      "grid_feed_in": {
+        "name": "Grid Feed-In"
+      },
+      "backup_mode": {
+        "name": "Backup Mode"
+      }
     }
   }
-}
+}

+ 25 - 13
translations/en.json

@@ -3,20 +3,20 @@
     "step": {
       "user": {
         "title": "Add Solar of Things Station",
-        "description": "Choose how to authenticate with the Siseli portal.\n\n**Email + Password** is recommended — the integration logs in automatically and refreshes the token without any manual steps.\n\n**IOT Token** is for advanced use or if you prefer not to store credentials.\n\nMore info: {docs_url}",
+        "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 — Email + Password",
-        "description": "Enter your Siseli portal credentials and Station ID.\n\n- **Station ID**: 18-digit number found in API request payloads at https://solar.siseli.com (DevTools → Network)\n- **Device ID**: optional — leave blank to auto-discover all devices under the station\n- **Time zone**: must match your inverter's configured time zone\n\nMore info: {docs_url}",
+        "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": {
-          "email": "Email address",
           "password": "Password",
           "station_id": "Station ID (required)",
           "device_id": "Device ID (optional — blank to auto-discover)",
-          "time_zone": "Time zone (e.g. Asia/Manila)"
+          "time_zone": "Time zone (e.g. Asia/Manila)",
+          "user_id": "User ID / Account"
         }
       },
       "token": {
@@ -40,7 +40,7 @@
     },
     "error": {
       "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
-      "invalid_auth": "Invalid email or password. Please try again.",
+      "invalid_auth": "Invalid user ID or password. Please try again.",
       "unknown": "Unexpected error. Check Home Assistant logs for details."
     },
     "abort": {
@@ -60,9 +60,15 @@
   },
   "entity": {
     "number": {
-      "battery_charge_limit": { "name": "Battery Charge Limit" },
-      "battery_discharge_limit": { "name": "Battery Discharge Limit" },
-      "grid_charge_limit": { "name": "Grid Charge Limit" }
+      "battery_charge_limit": {
+        "name": "Battery Charge Limit"
+      },
+      "battery_discharge_limit": {
+        "name": "Battery Discharge Limit"
+      },
+      "grid_charge_limit": {
+        "name": "Grid Charge Limit"
+      }
     },
     "select": {
       "operating_mode": {
@@ -85,9 +91,15 @@
       }
     },
     "switch": {
-      "grid_charging": { "name": "Grid Charging" },
-      "grid_feed_in": { "name": "Grid Feed-In" },
-      "backup_mode": { "name": "Backup Mode" }
+      "grid_charging": {
+        "name": "Grid Charging"
+      },
+      "grid_feed_in": {
+        "name": "Grid Feed-In"
+      },
+      "backup_mode": {
+        "name": "Backup Mode"
+      }
     }
   }
-}
+}