Selaa lähdekoodia

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

conexocasa 7 kuukautta sitten
vanhempi
sitoutus
88a26e36b0
9 muutettua tiedostoa jossa 309 lisäystä ja 168 poistoa
  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/),
 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).
 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
 ## [2.2.0] - 2026-03-06
 
 
 ### Added
 ### 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) |
 | 🎛️ **System control** | 8 control entities (battery limits, modes, grid switches) |
 | ⚡ **Energy Dashboard** | All power and energy sensors are dashboard-ready |
 | ⚡ **Energy Dashboard** | All power and energy sensors are dashboard-ready |
 | 🏠 **Multi-station** | Unlimited stations via multiple config entries |
 | 🏠 **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 |
 | 🌏 **Timezone-aware** | Configurable `IOT-Time-Zone` header per integration |
 | 🔄 **Auto-retry** | HA coordinator pattern with automatic retry on failure |
 | 🔄 **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 |
 | 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.1** | HACS metadata, GitHub Actions CI/CD, issue templates, release script |
 | **v2.1.0** | Station-based device auto-discovery (`/apis/device/list`) |
 | **v2.1.0** | Station-based device auto-discovery (`/apis/device/list`) |
 | **v2.0.0** | Control entities (battery limits, operating modes, grid switches) |
 | **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
 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
 from __future__ import annotations
 
 
@@ -36,7 +36,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
 from .api import SolarOfThingsAPI, TokenExpiredError
 from .api import SolarOfThingsAPI, TokenExpiredError
 from .const import (
 from .const import (
     DOMAIN,
     DOMAIN,
-    CONF_EMAIL,
+    CONF_USER_ID,
     CONF_PASSWORD,
     CONF_PASSWORD,
     CONF_IOT_TOKEN,
     CONF_IOT_TOKEN,
     CONF_STATION_ID,
     CONF_STATION_ID,
@@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     hass.data.setdefault(DOMAIN, {})
     hass.data.setdefault(DOMAIN, {})
 
 
     time_zone = entry.data.get(CONF_TIME_ZONE) or entry.options.get(CONF_TIME_ZONE)
     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)
     password = entry.data.get(CONF_PASSWORD)
 
 
     # Build the token-refreshed callback *before* constructing the API so the
     # 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
     # Instantiate API in the appropriate auth mode
-    if email and password:
+    if user_id and password:
         api = SolarOfThingsAPI(
         api = SolarOfThingsAPI(
-            email=email,
+            user_id=user_id,
             password=password,
             password=password,
             iot_token=entry.data.get(CONF_IOT_TOKEN),          # cached token (avoids login on every restart)
             iot_token=entry.data.get(CONF_IOT_TOKEN),          # cached token (avoids login on every restart)
             refresh_token=entry.data.get(CONF_REFRESH_TOKEN),
             refresh_token=entry.data.get(CONF_REFRESH_TOKEN),

+ 192 - 120
api.py

@@ -2,37 +2,54 @@
 
 
 Authentication strategy
 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:
 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.
      • 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
   2. Token-pair (accessToken + refreshToken) without password
      • User pastes both tokens from DevTools.
      • 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)
   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
 Usage in Home Assistant
 ─────────────────────────────────────────────────────────────────────────────
 ─────────────────────────────────────────────────────────────────────────────
   api = SolarOfThingsAPI(
   api = SolarOfThingsAPI(
-      email="user@example.com",
+      user_id="myaccount",
       password="secret",          # or omit and pass iot_token=
       password="secret",          # or omit and pass iot_token=
       time_zone="Asia/Manila",
       time_zone="Asia/Manila",
       on_token_refreshed=_save_tokens_callback,
       on_token_refreshed=_save_tokens_callback,
@@ -42,13 +59,23 @@ Usage in Home Assistant
 """
 """
 from __future__ import annotations
 from __future__ import annotations
 
 
+import base64
+import hashlib
+import hmac as _hmac
 import logging
 import logging
+import os
 import threading
 import threading
 from datetime import datetime, timezone, timedelta
 from datetime import datetime, timezone, timedelta
 from typing import Any, Callable
 from typing import Any, Callable
 
 
 import requests
 import requests
 
 
+try:
+    from Crypto.Cipher import AES as _AES
+    _CRYPTO_AVAILABLE = True
+except ImportError:
+    _CRYPTO_AVAILABLE = False
+
 try:
 try:
     from zoneinfo import ZoneInfo
     from zoneinfo import ZoneInfo
 except Exception:  # pragma: no cover
 except Exception:  # pragma: no cover
@@ -56,6 +83,7 @@ except Exception:  # pragma: no cover
 
 
 from .const import (
 from .const import (
     API_BASE_URL,
     API_BASE_URL,
+    API_AUTH_BASE_URL,
     API_LOGIN,
     API_LOGIN,
     API_REFRESH_TOKEN as API_REFRESH_TOKEN_ENDPOINT,
     API_REFRESH_TOKEN as API_REFRESH_TOKEN_ENDPOINT,
     API_TIME_SERIES,
     API_TIME_SERIES,
@@ -63,6 +91,8 @@ from .const import (
     API_DEVICE_LIST,
     API_DEVICE_LIST,
     API_SETTINGS_GET,
     API_SETTINGS_GET,
     API_SETTINGS_SET,
     API_SETTINGS_SET,
+    IOT_APP_ID,
+    IOT_APP_SECRET_ENC,
     TOKEN_REFRESH_LEAD_SECONDS,
     TOKEN_REFRESH_LEAD_SECONDS,
 )
 )
 
 
@@ -87,6 +117,78 @@ class AuthenticationError(Exception):
     """Raised when login credentials are rejected by the server."""
     """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
 # Helper: parse Siseli ISO expiry strings safely
 # ──────────────────────────────────────────────────────────────────────────────
 # ──────────────────────────────────────────────────────────────────────────────
@@ -115,9 +217,9 @@ class SolarOfThingsAPI:
 
 
     Parameters
     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.
     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).
     refresh_token:      Stored refresh token (persisted between HA restarts).
     access_token_expires: ISO-8601 string of current access-token expiry.
     access_token_expires: ISO-8601 string of current access-token expiry.
     refresh_token_expires: ISO-8601 string of current refresh-token expiry.
     refresh_token_expires: ISO-8601 string of current refresh-token expiry.
@@ -131,7 +233,7 @@ class SolarOfThingsAPI:
     def __init__(
     def __init__(
         self,
         self,
         *,
         *,
-        email: str | None = None,
+        user_id: str | None = None,
         password: str | None = None,
         password: str | None = None,
         iot_token: str | None = None,
         iot_token: str | None = None,
         refresh_token: str | None = None,
         refresh_token: str | None = None,
@@ -140,7 +242,7 @@ class SolarOfThingsAPI:
         time_zone: str | None = None,
         time_zone: str | None = None,
         on_token_refreshed: Callable[[str, str, str, str], None] | None = None,
         on_token_refreshed: Callable[[str, str, str, str], None] | None = None,
     ) -> None:
     ) -> None:
-        self._email = email
+        self._user_id = user_id
         self._password = password
         self._password = password
         self._time_zone = time_zone or _DEFAULT_TZ
         self._time_zone = time_zone or _DEFAULT_TZ
         self._on_token_refreshed = on_token_refreshed
         self._on_token_refreshed = on_token_refreshed
@@ -155,14 +257,14 @@ class SolarOfThingsAPI:
         self._refresh_lock = threading.Lock()
         self._refresh_lock = threading.Lock()
 
 
         # Determine auth mode
         # Determine auth mode
-        if email and password:
+        if user_id and password:
             self._auth_mode = "password"
             self._auth_mode = "password"
         elif iot_token and refresh_token:
         elif iot_token and refresh_token:
             self._auth_mode = "token_pair"
             self._auth_mode = "token_pair"
         elif iot_token:
         elif iot_token:
             self._auth_mode = "legacy"
             self._auth_mode = "legacy"
         else:
         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)
         # HTTP session (headers updated after every token refresh)
         self.session = requests.Session()
         self.session = requests.Session()
@@ -181,7 +283,7 @@ class SolarOfThingsAPI:
                 "Origin": "https://solar.siseli.com",
                 "Origin": "https://solar.siseli.com",
                 "Referer": "https://solar.siseli.com/",
                 "Referer": "https://solar.siseli.com/",
                 "User-Agent": (
                 "User-Agent": (
-                    "HomeAssistant-SolarOfThings/2.2.0 "
+                    "HomeAssistant-SolarOfThings/2.3.0 "
                     "(+https://github.com/conexocasa/solar-of-things-ha)"
                     "(+https://github.com/conexocasa/solar-of-things-ha)"
                 ),
                 ),
             }
             }
@@ -190,32 +292,33 @@ class SolarOfThingsAPI:
     # ─── Public auth helpers ───────────────────────────────────────────────────
     # ─── Public auth helpers ───────────────────────────────────────────────────
 
 
     def login(self) -> None:
     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
         Raises AuthenticationError on bad credentials, or requests.RequestException
         on network failure.  Safe to call from a background thread.
         on network failure.  Safe to call from a background thread.
         """
         """
         if self._auth_mode not in ("password",):
         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 = {
         payload = {
-            "email": self._email,
+            "account": self._user_id,
             "password": self._password,
             "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(
         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,
             timeout=30,
         )
         )
         resp.raise_for_status()
         resp.raise_for_status()
@@ -238,7 +341,7 @@ class SolarOfThingsAPI:
         _LOGGER.debug("SolarOfThings: refreshing access token")
         _LOGGER.debug("SolarOfThings: refreshing access token")
 
 
         resp = requests.post(
         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},
             json={"refreshToken": self._refresh_token},
             headers={
             headers={
                 "Accept": "application/json",
                 "Accept": "application/json",
@@ -357,7 +460,7 @@ class SolarOfThingsAPI:
                     _LOGGER.error("SolarOfThings: token refresh request failed: %s", err)
                     _LOGGER.error("SolarOfThings: token refresh request failed: %s", err)
 
 
             # Strategy 2: re-login with stored credentials
             # 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:
                 try:
                     self.login()
                     self.login()
                     return
                     return
@@ -379,9 +482,10 @@ class SolarOfThingsAPI:
     # ─── Internal HTTP helper ──────────────────────────────────────────────────
     # ─── Internal HTTP helper ──────────────────────────────────────────────────
 
 
     def _post(self, path: str, payload: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
     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.
         On second 401 (after refresh) raises TokenExpiredError.
+        Uses API_BASE_URL (solar.siseli.com) — not the auth base URL.
         """
         """
         self._ensure_token_valid()
         self._ensure_token_valid()
 
 
@@ -566,98 +670,66 @@ class SolarOfThingsAPI:
                 f"message={data.get('message')}"
                 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:
     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:
         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
             return True
-        except TokenExpiredError:
-            raise
         except Exception as err:
         except Exception as err:
-            _LOGGER.error("Connection test failed: %s", err)
+            _LOGGER.error("SolarOfThings: connection test failed: %s", err)
             return False
             return False

+ 14 - 10
config_flow.py

@@ -1,7 +1,7 @@
 """Config flow for Solar of Things integration.
 """Config flow for Solar of Things integration.
 
 
 Supports two setup modes:
 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.
      refreshes the token without any user intervention.
   2. IOT Token (legacy / advanced) – user pastes the token from DevTools.
   2. IOT Token (legacy / advanced) – user pastes the token from DevTools.
      A re-auth flow is triggered when the token expires.
      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 .api import SolarOfThingsAPI, AuthenticationError, TokenExpiredError
 from .const import (
 from .const import (
     DOMAIN,
     DOMAIN,
-    CONF_EMAIL,
+    CONF_USER_ID,
     CONF_PASSWORD,
     CONF_PASSWORD,
     CONF_IOT_TOKEN,
     CONF_IOT_TOKEN,
     CONF_STATION_ID,
     CONF_STATION_ID,
@@ -42,9 +42,9 @@ _LOGGER = logging.getLogger(__name__)
 # ─── Validation helpers ────────────────────────────────────────────────────────
 # ─── Validation helpers ────────────────────────────────────────────────────────
 
 
 async def _validate_password_auth(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
 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(
     api = SolarOfThingsAPI(
-        email=data[CONF_EMAIL],
+        user_id=data[CONF_USER_ID],
         password=data[CONF_PASSWORD],
         password=data[CONF_PASSWORD],
         time_zone=data.get(CONF_TIME_ZONE),
         time_zone=data.get(CONF_TIME_ZONE),
     )
     )
@@ -119,7 +119,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
             data_schema=vol.Schema(
             data_schema=vol.Schema(
                 {
                 {
                     vol.Required("auth_mode", default="password"): vol.In(
                     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:
     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] = {}
         errors: dict[str, str] = {}
 
 
         if user_input is not None:
         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]}")
                 await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
                 self._abort_if_unique_id_configured()
                 self._abort_if_unique_id_configured()
                 entry_data = {
                 entry_data = {
-                    CONF_EMAIL: user_input[CONF_EMAIL],
+                    CONF_USER_ID: user_input[CONF_USER_ID],
                     CONF_PASSWORD: user_input[CONF_PASSWORD],
                     CONF_PASSWORD: user_input[CONF_PASSWORD],
                     CONF_STATION_ID: user_input[CONF_STATION_ID],
                     CONF_STATION_ID: user_input[CONF_STATION_ID],
                     CONF_DEVICE_ID: user_input.get(CONF_DEVICE_ID, ""),
                     CONF_DEVICE_ID: user_input.get(CONF_DEVICE_ID, ""),
@@ -164,7 +164,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
             step_id="password",
             step_id="password",
             data_schema=vol.Schema(
             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_PASSWORD): cv.string,
                     vol.Required(CONF_STATION_ID): cv.string,
                     vol.Required(CONF_STATION_ID): cv.string,
                     vol.Optional(CONF_DEVICE_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] = {}
         errors: dict[str, str] = {}
         existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
         existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
         has_password = bool(
         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:
         if user_input is not None:

+ 12 - 4
const.py

@@ -9,7 +9,7 @@ CONF_DEVICE_ID = "device_id"
 CONF_TIME_ZONE = "time_zone"
 CONF_TIME_ZONE = "time_zone"
 
 
 # Credential-based auth (preferred)
 # Credential-based auth (preferred)
-CONF_EMAIL = "email"
+CONF_USER_ID = "user_id"       # Siseli account / user-ID login (not email)
 CONF_PASSWORD = "password"
 CONF_PASSWORD = "password"
 
 
 # Runtime-stored token state (written back to config entry)
 # 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_ACCESS_TOKEN_EXPIRES = "access_token_expires"   # ISO-8601 string
 CONF_REFRESH_TOKEN_EXPIRES = "refresh_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) ─────────────────────────
 # ─── 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
 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 ────────────────────────────────────────────────────────────
 # ─── Data endpoints ────────────────────────────────────────────────────────────
 API_TIME_SERIES    = "/apis/deviceState/simple/attribute/keys/history/v1"
 API_TIME_SERIES    = "/apis/deviceState/simple/attribute/keys/history/v1"
 API_MONTHLY_SUMMARY = "/apis/stationOverView/stateAttributeSummary/category/yearly"
 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",
   "documentation": "https://github.com/conexocasa/solar-of-things-ha",
   "iot_class": "cloud_polling",
   "iot_class": "cloud_polling",
   "issue_tracker": "https://github.com/conexocasa/solar-of-things-ha/issues",
   "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": {
     "step": {
       "user": {
       "user": {
         "title": "Add Solar of Things Station",
         "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": {
         "data": {
           "auth_mode": "Authentication method"
           "auth_mode": "Authentication method"
         }
         }
       },
       },
       "password": {
       "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": {
         "data": {
-          "email": "Email address",
           "password": "Password",
           "password": "Password",
           "station_id": "Station ID (required)",
           "station_id": "Station ID (required)",
           "device_id": "Device ID (optional — blank to auto-discover)",
           "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": {
       "token": {
@@ -40,7 +40,7 @@
     },
     },
     "error": {
     "error": {
       "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
       "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."
       "unknown": "Unexpected error. Check Home Assistant logs for details."
     },
     },
     "abort": {
     "abort": {
@@ -60,9 +60,15 @@
   },
   },
   "entity": {
   "entity": {
     "number": {
     "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": {
     "select": {
       "operating_mode": {
       "operating_mode": {
@@ -85,9 +91,15 @@
       }
       }
     },
     },
     "switch": {
     "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": {
     "step": {
       "user": {
       "user": {
         "title": "Add Solar of Things Station",
         "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": {
         "data": {
           "auth_mode": "Authentication method"
           "auth_mode": "Authentication method"
         }
         }
       },
       },
       "password": {
       "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": {
         "data": {
-          "email": "Email address",
           "password": "Password",
           "password": "Password",
           "station_id": "Station ID (required)",
           "station_id": "Station ID (required)",
           "device_id": "Device ID (optional — blank to auto-discover)",
           "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": {
       "token": {
@@ -40,7 +40,7 @@
     },
     },
     "error": {
     "error": {
       "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
       "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."
       "unknown": "Unexpected error. Check Home Assistant logs for details."
     },
     },
     "abort": {
     "abort": {
@@ -60,9 +60,15 @@
   },
   },
   "entity": {
   "entity": {
     "number": {
     "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": {
     "select": {
       "operating_mode": {
       "operating_mode": {
@@ -85,9 +91,15 @@
       }
       }
     },
     },
     "switch": {
     "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"
+      }
     }
     }
   }
   }
-}
+}