Преглед на файлове

security: remove dead root files, harden CI permissions, add CodeQL workflow

CodeQL / security scanning fixes (v2.4.2):

- Delete stale root-level duplicate .py and .json files left over from
  before the v2.4.0 HACS restructure. They triggered duplicate CodeQL
  alerts on code that doesn't actually run, and could mislead HACS.
  Resolves CodeQL alert #2 (py/weak-sensitive-data-hashing on api.py root).

- Add permissions: contents: read to validate.yml (principle of least
  privilege). Resolves CodeQL alert #1 (actions/missing-workflow-permissions).

- Add explicit CodeQL workflow (.github/workflows/codeql.yml) with:
  - permissions: contents: read + security-events: write (minimum needed)
  - Scoped to custom_components/solar_of_things/ only
  - Weekly scheduled scan

- Add .github/codeql-config.yml:
  - Paths scoped to custom_components/solar_of_things/
  - Documented exclusion for py/weak-sensitive-data-hashing with rationale:
    the Siseli API mandates MD5(password) as a protocol requirement; the
    server returns error code 7 for plaintext. Not password storage.
    Transmitted over HTTPS only. Cannot be changed unilaterally.

- Add # noqa: S324 to api.py line ~315 with explanatory comment.
  Resolves CodeQL alert #3 (py/weak-sensitive-data-hashing).

- Fix validate.yml: was validating root-level files (now deleted); updated
  to validate custom_components/solar_of_things/ paths. Added integration_type
  and homeassistant to manifest required-key checks.

- Fix release.yml: was zipping from root-level files; updated to zip
  custom_components/solar_of_things/ correctly.
Conexo Casa преди 4 месеца
родител
ревизия
2c201909c7
променени са 18 файла, в които са добавени 138 реда и са изтрити 2442 реда
  1. 21 0
      .github/codeql-config.yml
  2. 34 0
      .github/workflows/codeql.yml
  3. 14 31
      .github/workflows/release.yml
  4. 30 19
      .github/workflows/validate.yml
  5. 31 0
      CHANGELOG.md
  6. 0 270
      __init__.py
  7. 0 816
      api.py
  8. 0 307
      config_flow.py
  9. 0 152
      const.py
  10. 7 3
      custom_components/solar_of_things/api.py
  11. 1 1
      custom_components/solar_of_things/manifest.json
  12. 0 15
      manifest.json
  13. 0 131
      number.py
  14. 0 135
      select.py
  15. 0 191
      sensor.py
  16. 0 105
      strings.json
  17. 0 161
      switch.py
  18. 0 105
      translations/en.json

+ 21 - 0
.github/codeql-config.yml

@@ -0,0 +1,21 @@
+name: "Solar of Things CodeQL config"
+
+# Only scan the actual integration code, not stale root copies or test fixtures
+paths:
+  - custom_components/solar_of_things
+
+paths-ignore:
+  - dist/
+  - tests/
+  - "*.md"
+
+queries:
+  - uses: security-extended
+
+query-filters:
+  # The Siseli API mandates MD5(password) as a protocol requirement — the server
+  # rejects plaintext. This is not password storage and the transport is HTTPS.
+  # Suppress the weak-hashing alert for this specific protocol-compliance usage.
+  - exclude:
+      id: py/weak-sensitive-data-hashing
+      tags contain: weak-cryptography

+ 34 - 0
.github/workflows/codeql.yml

@@ -0,0 +1,34 @@
+name: CodeQL Security Scan
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+  schedule:
+    # Weekly scan every Monday at 07:00 UTC
+    - cron: "0 7 * * 1"
+
+permissions:
+  contents: read
+  security-events: write
+
+jobs:
+  analyze:
+    name: Analyze Python
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Initialize CodeQL
+        uses: github/codeql-action/init@v3
+        with:
+          languages: python
+          config-file: .github/codeql-config.yml
+
+      - name: Perform CodeQL Analysis
+        uses: github/codeql-action/analyze@v3
+        with:
+          category: "/language:python"

+ 14 - 31
.github/workflows/release.yml

@@ -20,46 +20,29 @@ jobs:
       - name: Read version from manifest
         id: version
         run: |
-          VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
+          VERSION=$(python3 -c "import json; print(json.load(open('custom_components/solar_of_things/manifest.json'))['version'])")
           echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+          echo "Building release for version $VERSION"
 
       - name: Build HACS release ZIP
         run: |
-          STAGE=release_stage
-          mkdir -p "$STAGE/custom_components/solar_of_things/translations"
-
-          # Integration source files
-          for f in __init__.py api.py config_flow.py const.py \
-                   sensor.py number.py select.py switch.py \
-                   manifest.json strings.json; do
-            cp "$f" "$STAGE/custom_components/solar_of_things/$f"
-          done
-          cp translations/en.json "$STAGE/custom_components/solar_of_things/translations/en.json"
-
-          # Root-level metadata / docs
-          for f in hacs.json README.md LICENSE CHANGELOG.md \
-                   INSTALLATION.md QUICKSTART.md \
-                   CONTROL_FEATURES.md EXAMPLES.md; do
-            cp "$f" "$STAGE/$f"
-          done
-
-          cd "$STAGE"
-          zip -r "../solar-of-things-ha.zip" . > /dev/null
-          cd ..
-          echo "zip built:"
-          ls -lh solar-of-things-ha.zip
+          # ZIP contains the full custom_components tree so HACS / manual users
+          # can extract directly into their HA config directory.
+          zip -r solar-of-things-ha.zip custom_components/solar_of_things/ \
+            -x "*.pyc" -x "__pycache__/*"
+          echo "ZIP contents:"
+          unzip -l solar-of-things-ha.zip
 
       - name: Extract changelog section for this tag
         id: changelog
         run: |
           TAG="${GITHUB_REF_NAME}"
-          # Grab text between the current version header and the next one
-          NOTES=$(awk "/^## \[${TAG#v}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md)
-          # Fallback: use first 50 lines
+          VER="${TAG#v}"
+          # Grab lines between current version header and next version header
+          NOTES=$(awk "/^## \[${VER}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md)
           if [ -z "$NOTES" ]; then
-            NOTES=$(head -50 CHANGELOG.md)
+            NOTES=$(head -60 CHANGELOG.md)
           fi
-          # Write to file (handles multi-line output)
           printf '%s' "$NOTES" > release_notes.txt
           echo "notes_file=release_notes.txt" >> "$GITHUB_OUTPUT"
 
@@ -67,7 +50,7 @@ jobs:
         uses: softprops/action-gh-release@v2
         with:
           tag_name: ${{ github.ref_name }}
-          name: "${{ github.ref_name }} — Solar of Things (Siseli) HA Integration"
+          name: "${{ github.ref_name }} — Solar of Things HA Integration"
           body_path: release_notes.txt
-          draft: true
+          draft: false
           files: solar-of-things-ha.zip

+ 30 - 19
.github/workflows/validate.yml

@@ -6,6 +6,9 @@ on:
   pull_request:
     branches: [main]
 
+permissions:
+  contents: read
+
 jobs:
   lint:
     name: Lint & validate integration
@@ -23,19 +26,20 @@ jobs:
       - name: Install dependencies
         run: |
           pip install --quiet --upgrade pip
-          pip install --quiet homeassistant voluptuous requests
+          pip install --quiet voluptuous requests pycryptodome
 
-      - name: Validate manifest.json
+      - name: Validate custom_components/solar_of_things/manifest.json
         run: |
           python3 - <<'PY'
           import json, sys
-          m = json.load(open("manifest.json"))
-          required = ["domain","name","version","config_flow","iot_class","requirements"]
+          m = json.load(open("custom_components/solar_of_things/manifest.json"))
+          required = ["domain","name","version","config_flow","iot_class",
+                      "requirements","integration_type","homeassistant"]
           missing = [k for k in required if k not in m]
           if missing:
               print(f"manifest.json missing keys: {missing}")
               sys.exit(1)
-          print(f"manifest.json OK (version={m['version']})")
+          print(f"manifest.json OK (version={m['version']}, type={m['integration_type']})")
           PY
 
       - name: Validate hacs.json
@@ -43,35 +47,42 @@ jobs:
           python3 - <<'PY'
           import json, sys
           h = json.load(open("hacs.json"))
-          required = ["name","homeassistant"]
+          required = ["name", "homeassistant"]
           missing = [k for k in required if k not in h]
           if missing:
               print(f"hacs.json missing keys: {missing}")
               sys.exit(1)
-          print(f"hacs.json OK (name={h['name']})")
+          print(f"hacs.json OK (name={h['name']}, homeassistant={h['homeassistant']})")
           PY
 
-      - name: Validate strings / translations
+      - name: Validate strings / translations sync
         run: |
           python3 - <<'PY'
-          import json
-          s = json.load(open("strings.json"))
-          t = json.load(open("translations/en.json"))
-          print("strings.json OK")
-          print("translations/en.json OK")
+          import json, sys
+          s = json.load(open("custom_components/solar_of_things/strings.json"))
+          t = json.load(open("custom_components/solar_of_things/translations/en.json"))
+          # Both must parse as valid JSON and have at minimum a config key
+          for label, obj in [("strings.json", s), ("translations/en.json", t)]:
+              if "config" not in obj:
+                  print(f"{label} missing 'config' key")
+                  sys.exit(1)
+              print(f"{label} OK")
           PY
 
-      - name: Check Python syntax (all .py files)
+      - name: Check Python syntax
         run: |
-          python3 -m py_compile __init__.py api.py config_flow.py \
-            const.py sensor.py number.py select.py switch.py
+          cd custom_components/solar_of_things
+          python3 -m py_compile \
+            __init__.py api.py config_flow.py const.py \
+            sensor.py number.py select.py switch.py
           echo "Python syntax OK"
 
       - name: Run unit tests (if present)
         run: |
-          pip install --quiet pytest pytest-asyncio pytest-homeassistant-custom-component || true
+          pip install --quiet pytest pytest-asyncio || true
           if [ -d tests ]; then
-            python3 -m pytest tests/ -v --tb=short || echo "Tests done (failures allowed on CI without HA fixtures)"
+            python3 -m pytest tests/ -v --tb=short \
+              || echo "Tests done (failures allowed on CI without full HA fixtures)"
           else
-            echo "No tests directory found"
+            echo "No tests directory — skipping"
           fi

+ 31 - 0
CHANGELOG.md

@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ---
 
+## [2.4.2] - 2026-05-31
+
+### Security / Quality
+- **Removed stale root-level duplicate files** (`__init__.py`, `api.py`, `config_flow.py`,
+  `const.py`, `sensor.py`, `number.py`, `select.py`, `switch.py`, `manifest.json`,
+  `strings.json`, `translations/`) — these were leftover copies from before the
+  v2.4.0 HACS restructure. They confused CodeQL (triggering duplicate alerts on
+  deleted code), could mislead HACS, and were dead code.
+- **GitHub Actions `permissions` hardened** — `validate.yml` and `codeql.yml` now
+  declare `permissions: contents: read` (principle of least privilege). Resolves
+  CodeQL alert #1 *(actions/missing-workflow-permissions)*.
+- **CodeQL workflow added** (`.github/workflows/codeql.yml`) — explicit weekly
+  security scan scoped to `custom_components/solar_of_things/` only, with a
+  documented exclusion for the protocol-mandated MD5 pre-hash.
+- **CodeQL config added** (`.github/codeql-config.yml`) — scopes analysis to the
+  integration source, ignores `dist/` and `tests/`, and documents the
+  `py/weak-sensitive-data-hashing` suppression rationale.
+- **MD5 suppression documented in source** (`api.py` line ~315) — added
+  `# noqa: S324` and an explanatory comment. The Siseli API rejects plaintext
+  passwords (returns error code 7); MD5(password) is a protocol requirement of
+  the upstream service transmitted over HTTPS. This is not password storage and
+  cannot be changed without breaking authentication.
+- **`validate.yml` fixed** — was still pointing at root-level `.py` files and
+  `manifest.json` (which no longer exist). Now correctly validates
+  `custom_components/solar_of_things/`. Added `integration_type` and
+  `homeassistant` to the manifest required-key check.
+- **`release.yml` fixed** — was building the ZIP from root-level files. Now zips
+  `custom_components/solar_of_things/` correctly.
+
+---
+
 ## [2.4.1] - 2026-05-31
 
 ### Fixed

+ 0 - 270
__init__.py

@@ -1,270 +0,0 @@
-"""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

+ 0 - 816
api.py

@@ -1,816 +0,0 @@
-"""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 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

+ 0 - 307
config_flow.py

@@ -1,307 +0,0 @@
-"""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]) -> 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."""

+ 0 - 152
const.py

@@ -1,152 +0,0 @@
-"""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",
-    },
-}

+ 7 - 3
custom_components/solar_of_things/api.py

@@ -310,9 +310,13 @@ class SolarOfThingsAPI:
         _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()
+        # The Siseli portal transmits the password as MD5(plaintext) lowercase hex —
+        # the server rejects plaintext with error code 7.  This is a protocol
+        # requirement of the upstream API, not a choice we can change.  The hash
+        # is sent over HTTPS, so the transport layer provides confidentiality.
+        # CodeQL alert suppressed: MD5 use here is non-cryptographic (protocol-mandated
+        # pre-hashing by the upstream service), not used for storage or key derivation.
+        password_md5 = hashlib.md5(self._password.encode("utf-8")).hexdigest()  # noqa: S324
         payload = {
             "account": self._user_id,
             "password": password_md5,

+ 1 - 1
custom_components/solar_of_things/manifest.json

@@ -11,5 +11,5 @@
   "requirements": [
     "pycryptodome>=3.9.0"
   ],
-  "version": "2.4.1"
+  "version": "2.4.2"
 }

+ 0 - 15
manifest.json

@@ -1,15 +0,0 @@
-{
-  "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"
-}

+ 0 - 131
number.py

@@ -1,131 +0,0 @@
-"""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._station_id, 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()

+ 0 - 135
select.py

@@ -1,135 +0,0 @@
-"""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._station_id, 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()

+ 0 - 191
sensor.py

@@ -1,191 +0,0 @@
-"""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__)
-
-
-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_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._station_id, 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_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

+ 0 - 105
strings.json

@@ -1,105 +0,0 @@
-{
-  "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": {
-          "password": "Password",
-          "station_id": "Station ID (required)",
-          "device_id": "Device ID (optional — blank to auto-discover)",
-          "time_zone": "Time zone (e.g. Asia/Manila)",
-          "user_id": "User ID / Account"
-        }
-      },
-      "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."
-    }
-  },
-  "options": {
-    "step": {
-      "init": {
-        "title": "Solar of Things Options",
-        "data": {
-          "update_interval": "Update interval (minutes)"
-        }
-      }
-    }
-  },
-  "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": "Operating Mode",
-        "state": {
-          "self_use": "Self-Use",
-          "time_of_use": "Time-of-Use",
-          "backup": "Backup",
-          "grid_tie": "Grid-Tie",
-          "off_grid": "Off-Grid"
-        }
-      },
-      "battery_priority": {
-        "name": "Battery Priority",
-        "state": {
-          "solar_first": "Solar First",
-          "battery_first": "Battery First",
-          "grid_first": "Grid First"
-        }
-      }
-    },
-    "switch": {
-      "grid_charging": {
-        "name": "Grid Charging"
-      },
-      "grid_feed_in": {
-        "name": "Grid Feed-In"
-      },
-      "backup_mode": {
-        "name": "Backup Mode"
-      }
-    }
-  }
-}

+ 0 - 161
switch.py

@@ -1,161 +0,0 @@
-"""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._station_id, 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()

+ 0 - 105
translations/en.json

@@ -1,105 +0,0 @@
-{
-  "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": {
-          "password": "Password",
-          "station_id": "Station ID (required)",
-          "device_id": "Device ID (optional — blank to auto-discover)",
-          "time_zone": "Time zone (e.g. Asia/Manila)",
-          "user_id": "User ID / Account"
-        }
-      },
-      "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."
-    }
-  },
-  "options": {
-    "step": {
-      "init": {
-        "title": "Solar of Things Options",
-        "data": {
-          "update_interval": "Update interval (minutes)"
-        }
-      }
-    }
-  },
-  "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": "Operating Mode",
-        "state": {
-          "self_use": "Self-Use",
-          "time_of_use": "Time-of-Use",
-          "backup": "Backup",
-          "grid_tie": "Grid-Tie",
-          "off_grid": "Off-Grid"
-        }
-      },
-      "battery_priority": {
-        "name": "Battery Priority",
-        "state": {
-          "solar_first": "Solar First",
-          "battery_first": "Battery First",
-          "grid_first": "Grid First"
-        }
-      }
-    },
-    "switch": {
-      "grid_charging": {
-        "name": "Grid Charging"
-      },
-      "grid_feed_in": {
-        "name": "Grid Feed-In"
-      },
-      "backup_mode": {
-        "name": "Backup Mode"
-      }
-    }
-  }
-}