Explorar o código

v2.1.0: auto-discover devices by station

conexocasa hai 7 meses
pai
achega
d6826df217
Modificáronse 18 ficheiros con 908 adicións e 973 borrados
  1. 11 0
      CHANGELOG.md
  2. 9 6
      INSTALLATION.md
  3. 4 4
      QUICKSTART.md
  4. 8 7
      README.md
  5. 91 53
      __init__.py
  6. 266 217
      api.py
  7. 37 108
      config_flow.py
  8. 8 2
      const.py
  9. 1 1
      hacs.json
  10. 3 3
      manifest.json
  11. 74 156
      number.py
  12. 54 120
      select.py
  13. 84 93
      sensor.py
  14. 168 0
      setup_github.sh
  15. 3 2
      strings.json
  16. 79 196
      switch.py
  17. 5 3
      tests/test_config_flow.py
  18. 3 2
      translations/en.json

+ 11 - 0
CHANGELOG.md

@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [2.1.0] - 2026-02-26
+
+### Added
+- Auto-discover all device IDs under a station using `POST /apis/device/list` (no need to manually enter deviceId).
+- Config flow support for optional `device_id`:
+  - Leave blank to create entities for *all* devices found under the station.
+  - Provide a `device_id` to restrict HA entities to a single device.
+
+### Changed
+- Manifest metadata updated (documentation/issue tracker URLs) and integration version bumped to 2.1.0.
+
 ## [2.0.0] - 2024-02-10
 
 ### Added

+ 9 - 6
INSTALLATION.md

@@ -18,7 +18,7 @@
    - Click on "Integrations"
    - Click the three dots (⋮) in the top right corner
    - Select "Custom repositories"
-   - Add repository URL: `https://github.com/yourusername/solar-of-things-ha`
+   - Add repository URL: `https://github.com/conexocasa/solar-of-things-ha`
    - Category: Integration
    - Click "Add"
 
@@ -39,7 +39,7 @@
    ```bash
    # SSH into your Home Assistant instance or use the File Editor add-on
    cd /config/custom_components/
-   git clone https://github.com/yourusername/solar-of-things-ha.git solar_of_things
+   git clone https://github.com/conexocasa/solar-of-things-ha.git solar_of_things
    ```
 
    Or download and extract the ZIP file:
@@ -97,8 +97,9 @@ IOT-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
 While still in the Network tab:
 
 1. Look for requests to endpoints like:
-   - `/api/attribute/keys/history/v1`
-   - `/api/stateAttributeSummary/category/yearly`
+   - `/apis/device/list`
+   - `/apis/deviceState/simple/attribute/keys/history/v1`
+   - `/apis/stationOverView/stateAttributeSummary/category/yearly`
 
 2. Click on a request and look at the **Payload** or **Request** section
 
@@ -119,9 +120,11 @@ When adding the integration, you'll be prompted for:
 ### Required:
 - **IOT Token**: Your authentication token from the Siseli portal
 
+### Required:
+- **Station ID**: Required (used to auto-discover your device IDs)
+
 ### Optional:
-- **Device ID**: Required for real-time sensor data (highly recommended)
-- **Station ID**: Required for monthly summary statistics
+- **Device ID**: Optional; set it only if you want to restrict Home Assistant entities to a single device under the station
 
 ### Update Interval:
 - Default: 5 minutes

+ 4 - 4
QUICKSTART.md

@@ -28,7 +28,7 @@ Get your Solar of Things integration running in 5 minutes!
 ### Option A: HACS (Recommended)
 1. Open HACS → Integrations
 2. Click ⋮ → Custom repositories
-3. Add: `https://github.com/yourusername/solar-of-things-ha`
+3. Add: `https://github.com/conexocasa/solar-of-things-ha`
 4. Install "Solar of Things"
 
 ### Option B: Manual
@@ -44,8 +44,8 @@ Get your Solar of Things integration running in 5 minutes!
 4. Enter your credentials:
    ```
    IOT Token:  [paste your token]
-   Device ID:  [your 18-digit device ID]
-   Station ID: [your 18-digit station ID] (optional)
+   Station ID: [your 18-digit station ID]
+   Device ID:  [optional; leave blank to auto-discover all devices]
    ```
 5. Click **Submit**
 
@@ -90,7 +90,7 @@ See [EXAMPLES.md](EXAMPLES.md) for more advanced cards!
 - 📖 [Full Documentation](README.md)
 - 🛠️ [Installation Guide](INSTALLATION.md)
 - 💡 [Examples](EXAMPLES.md)
-- 🐛 [Report Issue](https://github.com/yourusername/solar-of-things-ha/issues)
+- 🐛 [Report Issue](https://github.com/conexocasa/solar-of-things-ha/issues)
 
 ## Next Steps
 

+ 8 - 7
README.md

@@ -70,10 +70,10 @@ Before configuring the integration, you need to obtain your IOT Token, Station I
 3. Search for "Solar of Things"
 4. Enter your credentials:
    - **IOT Token** (required): Your authentication token
-   - **Device ID** (required): For real-time sensor data
-   - **Station ID** (optional): For monthly summary statistics
+   - **Station ID** (required): Used to auto-discover all devices and enable monthly stats
+   - **Device ID** (optional): If set, Home Assistant will only create entities for that one device
 
-### Multiple Stations
+### Multiple Stations / Multiple Accounts
 
 To add multiple stations:
 1. Add the integration once for each station/device combination
@@ -196,17 +196,18 @@ entities:
 
 ## API Information
 
-This integration uses the Solar of Things API endpoints:
+This integration uses the same Solar of Things endpoints as the reference client [Source](https://github.com/Hyllesen/solar-of-things-solar-usage):
 
-- **Time-Series Data**: `/api/attribute/keys/history/v1`
-- **Monthly Summary**: `/api/stateAttributeSummary/category/yearly`
+- **Station → Device list**: `/apis/device/list`
+- **Time-Series Data**: `/apis/deviceState/simple/attribute/keys/history/v1`
+- **Monthly Summary**: `/apis/stationOverView/stateAttributeSummary/category/yearly`
 
 API documentation: [GitHub - solar-of-things-solar-usage](https://github.com/Hyllesen/solar-of-things-solar-usage)
 
 ## Support
 
 For issues, questions, or feature requests:
-- Open an issue on [GitHub](https://github.com/yourusername/solar-of-things-ha/issues)
+- Open an issue on [GitHub](https://github.com/conexocasa/solar-of-things-ha/issues)
 - Check the [API documentation](https://github.com/Hyllesen/solar-of-things-solar-usage)
 - Visit the Home Assistant community forums
 

+ 91 - 53
__init__.py

@@ -9,102 +9,140 @@ from homeassistant.const import Platform
 from homeassistant.core import HomeAssistant
 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
 
-from .const import DOMAIN
 from .api import SolarOfThingsAPI
+from .const import DOMAIN, CONF_DEVICE_ID
 
 _LOGGER = logging.getLogger(__name__)
 
 PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SELECT, Platform.SWITCH]
 
-UPDATE_INTERVAL = timedelta(minutes=5)
+DEVICE_UPDATE_INTERVAL = timedelta(minutes=5)
+STATION_UPDATE_INTERVAL = timedelta(minutes=30)
 
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     """Set up Solar of Things from a config entry."""
     hass.data.setdefault(DOMAIN, {})
-    
-    # Create API instance
+
     api = SolarOfThingsAPI(
         iot_token=entry.data["iot_token"],
-        station_id=entry.data.get("station_id"),
-        device_id=entry.data.get("device_id")
+        time_zone=entry.data.get("time_zone") or entry.options.get("time_zone"),
     )
-    
-    # Create coordinator
-    coordinator = SolarOfThingsDataUpdateCoordinator(
-        hass,
+
+    station_id = entry.data["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,
-        entry=entry,
+        station_id=station_id,
     )
-    
-    # Fetch initial data
-    await coordinator.async_config_entry_first_refresh()
-    
+    await station_coordinator.async_config_entry_first_refresh()
+
+    # Create one coordinator per device
+    devices = station_coordinator.data.get("devices", []) if station_coordinator.data else []
+
+    # If the user configured a specific deviceId, only create entities for that device.
+    # (We still keep station-level data like monthly summary.)
+    if configured_device_id:
+        filtered = [d for d in devices if str(d.get("id")) == configured_device_id]
+        if filtered:
+            devices = filtered
+        else:
+            # Fallback: allow the integration to work even if the device isn't returned
+            # by the station device list (permissions / pagination / API quirks).
+            devices = [{"id": configured_device_id, "name": configured_device_id}]
+    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,
+        )
+        await c.async_config_entry_first_refresh()
+        device_coordinators[device_id] = c
+
     hass.data[DOMAIN][entry.entry_id] = {
-        "coordinator": coordinator,
         "api": api,
+        "station_id": station_id,
+        "station_coordinator": station_coordinator,
+        "device_coordinators": device_coordinators,
+        "devices": devices,
     }
-    
-    # Setup platforms
+
     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)
-    
+        hass.data[DOMAIN].pop(entry.entry_id, None)
     return unload_ok
 
 
-class SolarOfThingsDataUpdateCoordinator(DataUpdateCoordinator):
-    """Class to manage fetching Solar of Things data."""
+class SolarOfThingsStationCoordinator(DataUpdateCoordinator):
+    """Fetch station-level data (device list + monthly stats)."""
+
+    def __init__(self, hass: HomeAssistant, api: SolarOfThingsAPI, station_id: str) -> None:
+        self.api = api
+        self.station_id = station_id
+        super().__init__(
+            hass,
+            _LOGGER,
+            name=f"{DOMAIN}_station_{station_id}",
+            update_interval=STATION_UPDATE_INTERVAL,
+        )
+
+    async def _async_update_data(self):
+        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 Exception as err:
+            raise UpdateFailed(f"Station update failed: {err}")
+
+
+class SolarOfThingsDeviceCoordinator(DataUpdateCoordinator):
+    """Fetch device-level telemetry + settings."""
 
     def __init__(
         self,
         hass: HomeAssistant,
         api: SolarOfThingsAPI,
-        entry: ConfigEntry,
+        station_id: str,
+        device: str,
+        device_meta: dict,
     ) -> None:
-        """Initialize."""
         self.api = api
-        self.entry = entry
-        
+        self.station_id = station_id
+        self.device_id = device
+        self.device_meta = device_meta
         super().__init__(
             hass,
             _LOGGER,
-            name=DOMAIN,
-            update_interval=UPDATE_INTERVAL,
+            name=f"{DOMAIN}_device_{device}",
+            update_interval=DEVICE_UPDATE_INTERVAL,
         )
 
     async def _async_update_data(self):
-        """Update data via library."""
         try:
-            # Fetch time-series data
-            time_series_data = await self.hass.async_add_executor_job(
-                self.api.fetch_latest_data
-            )
-            
-            # Fetch monthly summary if station_id is available
-            monthly_data = None
-            if self.entry.data.get("station_id"):
-                monthly_data = await self.hass.async_add_executor_job(
-                    self.api.fetch_monthly_summary
-                )
-            
-            # Fetch device settings
-            settings_data = await self.hass.async_add_executor_job(
-                self.api.fetch_settings
-            )
-            
+            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_data,
-                "monthly": monthly_data,
-                "settings": settings_data,
-                "station_id": self.entry.data.get("station_id"),
-                "device_id": self.entry.data.get("device_id"),
+                "time_series": time_series,
+                "settings": settings,
+                "device": self.device_id,
+                "station_id": self.station_id,
+                "device_meta": self.device_meta,
             }
         except Exception as err:
-            raise UpdateFailed(f"Error communicating with API: {err}")
+            raise UpdateFailed(f"Device update failed for {self.device_id}: {err}")

+ 266 - 217
api.py

@@ -1,4 +1,4 @@
-"""API client for Solar of Things."""
+"""API client for Solar of Things (solar.siseli.com)."""
 from __future__ import annotations
 
 import logging
@@ -7,270 +7,319 @@ from typing import Any
 
 import requests
 
+try:
+    # Python 3.9+
+    from zoneinfo import ZoneInfo
+except Exception:  # pragma: no cover
+    ZoneInfo = None
+
 from .const import (
     API_BASE_URL,
     API_TIME_SERIES,
     API_MONTHLY_SUMMARY,
+    API_DEVICE_LIST,
     API_SETTINGS_GET,
     API_SETTINGS_SET,
 )
 
 _LOGGER = logging.getLogger(__name__)
 
+_DEFAULT_TZ = "Asia/Manila"
+
 
 class SolarOfThingsAPI:
-    """API client for Solar of Things."""
-
-    def __init__(
-        self,
-        iot_token: str,
-        station_id: str | None = None,
-        device_id: str | None = None,
-    ) -> None:
-        """Initialize the API client."""
+    """Solar of Things API wrapper.
+
+    Design note:
+    - token + timezone live on this object
+    - stationId/deviceId are passed per call (so one integration can handle many devices)
+    """
+
+    def __init__(self, iot_token: str, time_zone: str | None = None) -> None:
         self.iot_token = iot_token
-        self.station_id = station_id
-        self.device_id = device_id
+        self.time_zone = time_zone or _DEFAULT_TZ
+
         self.session = requests.Session()
-        self.session.headers.update({
-            "IOT-Token": self.iot_token,
-            "Content-Type": "application/json",
-        })
-
-    def fetch_latest_data(self) -> dict[str, Any]:
-        """Fetch the latest time-series data."""
-        if not self.device_id:
-            return {}
-        
-        # Get data for the last hour
-        end_time = datetime.now()
+        self.session.headers.update(
+            {
+                "Accept": "application/json",
+                "Content-Type": "application/json; charset=utf-8",
+                "IOT-Token": self.iot_token,
+                "IOT-Time-Zone": self.time_zone,
+                "Origin": "https://solar.siseli.com",
+                "Referer": "https://solar.siseli.com/",
+                "User-Agent": "HomeAssistant-SolarOfThings/2.1.0 (+https://www.home-assistant.io)",
+            }
+        )
+
+    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:
+        """Format timestamp like the Siseli web client expects."""
+        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.
+
+        Uses endpoint discovered from the portal JS bundle: /apis/device/list.
+        Expected payload: {page, count, stationId}
+        """
+        devices: list[dict[str, Any]] = []
+
+        page = 1
+        total = None
+
+        while True:
+            payload = {"page": page, "count": page_size, "stationId": station_id}
+            resp = self.session.post(f"{API_BASE_URL}{API_DEVICE_LIST}", json=payload, timeout=30)
+            resp.raise_for_status()
+            data = resp.json()
+
+            if data.get("code") not in (0, None):
+                raise RuntimeError(f"Device list error code={data.get('code')} 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:
+                # no pagination info; stop when returned less than requested
+                if len(batch) < page_size:
+                    break
+            else:
+                if len(devices) >= int(total):
+                    break
+
+            if not batch:
+                break
+
+            page += 1
+
+        return devices
+
+    # -------------------------
+    # Time-series (per device)
+    # -------------------------
+    def fetch_latest_data(self, device_id: str) -> dict[str, Any]:
+        """Fetch the latest readings for a device."""
+        end_time = self._now()
         start_time = end_time - timedelta(hours=1)
-        
+
+        keys = [
+            "pvInputPower",
+            "acOutputActivePower",
+            "batteryDischargeCurrent",
+            "batteryChargingCurrent",
+            "batteryVoltage",
+            "feedInPower",
+            "batterySOC",
+        ]
+
         payload = {
-            "deviceId": self.device_id,
-            "startTime": start_time.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
-            "endTime": end_time.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
-            "keys": [
-                "pvInputPower",
-                "acOutputActivePower",
-                "batteryDischargeCurrent",
-                "batteryChargingCurrent",
-                "batteryVoltage",
-                "feedInPower",
-                "batteryPower",
-                "batterySOC",
-            ],
+            "deviceId": device_id,
+            "count": 2000,
+            "page": 1,
+            "fromTime": self._format_time(start_time),
+            "toTime": self._format_time(end_time),
+            "orderByTimeAsc": True,
+            "keys": keys,
         }
-        
+
+        resp = self.session.post(f"{API_BASE_URL}{API_TIME_SERIES}", json=payload, timeout=30)
+        resp.raise_for_status()
+        data = resp.json()
+
+        if data.get("code") not in (0, None):
+            raise RuntimeError(f"Timeseries error code={data.get('code')} 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 normalization:
+        # - pvInputPower appears to be W
+        # - acOutputActivePower is kW in API; normalize to 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 current month."""
+        now = self._now()
+        year = now.year
+        month_key = f"{year}-{str(now.month).zfill(2)}"
+
+        url = (
+            f"{API_BASE_URL}{API_MONTHLY_SUMMARY}"
+            f"?stationId={station_id}&summaryCategoryKey=pvInverterElectricityQuantityClass"
+        )
+
+        resp = self.session.post(url, json={"time": str(year)}, timeout=30)
+        resp.raise_for_status()
+        data = resp.json()
+
+        if data.get("code") not in (0, None):
+            raise RuntimeError(f"Monthly summary error code={data.get('code')} message={data.get('message')}")
+
+        props = (((data.get("data") or {}).get("properties")) or [])
+        out: dict[str, Any] = {}
+
+        for prop in props:
+            prop_key = ((prop.get("property") or {}).get("key"))
+            if not prop_key:
+                continue
+            for tp in prop.get("timePoints") or []:
+                if tp.get("time") == month_key and tp.get("isRealValue"):
+                    out[prop_key] = tp.get("value")
+
+        if "pvGeneratedEnergy" in out:
+            return {"monthly_pv_generated": float(out.get("pvGeneratedEnergy") or 0)}
+
+        return {}
+
+    # -------------------------
+    # Settings / control (per device)
+    # -------------------------
+    def fetch_settings(self, device_id: str) -> dict[str, Any]:
+        """Fetch current device settings.
+
+        NOTE: These endpoints may vary by account/device firmware. If your portal uses
+        different endpoints, update const.py accordingly.
+        """
+        payload = {"deviceId": device_id}
+
         try:
             response = self.session.post(
-                f"{API_BASE_URL}{API_TIME_SERIES}",
+                f"{API_BASE_URL}{API_SETTINGS_GET}",
                 json=payload,
                 timeout=30,
             )
             response.raise_for_status()
             data = response.json()
-            
-            # Extract latest values from each key
-            latest_values = {}
+
+            settings: dict[str, Any] = {}
             if "data" in data:
-                for key, values in data["data"].items():
-                    if values and len(values) > 0:
-                        # Get the most recent value
-                        latest_values[key] = values[-1]["value"]
-            
-            # Calculate derived values
-            if "batteryVoltage" in latest_values:
-                voltage = float(latest_values.get("batteryVoltage", 0))
-                discharge = float(latest_values.get("batteryDischargeCurrent", 0))
-                charge = float(latest_values.get("batteryChargingCurrent", 0))
-                
-                # Battery power = (discharge - charge) * voltage
-                latest_values["batteryPower"] = (discharge - charge) * voltage
-                
-                # Grid power can be calculated if we have all components
-                pv_power = float(latest_values.get("pvInputPower", 0))
-                ac_output = float(latest_values.get("acOutputActivePower", 0))
-                feed_in = float(latest_values.get("feedInPower", 0))
-                battery_power = latest_values["batteryPower"]
-                
-                # Grid import = Load - PV - Battery discharge + Feed-in
-                # Simplified: Grid = AC output - PV - Battery power + Feed-in
-                latest_values["gridPower"] = max(0, ac_output - pv_power + battery_power + feed_in)
-                latest_values["loadPower"] = ac_output
-            
-            return latest_values
-            
+                sd = data["data"]
+                settings["batteryChargeLimit"] = sd.get("batteryChargeLimit", 100)
+                settings["batteryDischargeLimit"] = sd.get("batteryDischargeLimit", 10)
+                settings["gridChargeLimit"] = sd.get("gridChargeLimit", 0)
+                settings["operatingMode"] = sd.get("operatingMode", "Self-Use")
+                settings["batteryPriority"] = sd.get("batteryPriority", "Solar First")
+                settings["gridChargingEnabled"] = sd.get("gridChargingEnabled", False)
+                settings["gridFeedInEnabled"] = sd.get("gridFeedInEnabled", True)
+                settings["backupModeEnabled"] = sd.get("backupModeEnabled", False)
+
+            return settings
+
         except requests.exceptions.RequestException as err:
-            _LOGGER.error("Error fetching time-series data: %s", err)
+            _LOGGER.error("Error fetching settings for device %s: %s", device_id, err)
             return {}
 
-    def fetch_monthly_summary(self) -> dict[str, Any]:
-        """Fetch monthly summary data."""
-        if not self.station_id:
-            return {}
-        
-        # Get current year and month
-        now = datetime.now()
-        
-        payload = {
-            "stationId": self.station_id,
-            "year": now.year,
-        }
-        
+    def _update_setting(self, device_id: str, setting_key: str, value: Any) -> bool:
+        payload = {"deviceId": device_id, "settings": {setting_key: value}}
+
         try:
             response = self.session.post(
-                f"{API_BASE_URL}{API_MONTHLY_SUMMARY}",
+                f"{API_BASE_URL}{API_SETTINGS_SET}",
                 json=payload,
                 timeout=30,
             )
             response.raise_for_status()
             data = response.json()
-            
-            # Extract current month data
-            monthly_data = {}
-            if "data" in data and "summary" in data["data"]:
-                for month_data in data["data"]["summary"]:
-                    if month_data.get("month") == now.month:
-                        # PV generation in kWh
-                        pv_generated = float(month_data.get("pvGenerated", 0))
-                        monthly_data["monthly_pv_generated"] = pv_generated
-                        
-                        # Note: The API doesn't provide grid import directly
-                        # These would need to be calculated from time-series data
-                        # For now, we'll just provide PV generation
-                        break
-            
-            return monthly_data
-            
-        except requests.exceptions.RequestException as err:
-            _LOGGER.error("Error fetching monthly summary: %s", err)
-            return {}
 
-    def test_connection(self) -> bool:
-        """Test the API connection."""
-        try:
-            # Try to fetch a small amount of data
-            end_time = datetime.now()
-            start_time = end_time - timedelta(minutes=10)
-            
-            payload = {
-                "deviceId": self.device_id,
-                "startTime": start_time.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
-                "endTime": end_time.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
-                "keys": ["pvInputPower"],
-            }
-            
-            response = self.session.post(
-                f"{API_BASE_URL}{API_TIME_SERIES}",
-                json=payload,
-                timeout=10,
-            )
-            response.raise_for_status()
-            return True
-            
-        except requests.exceptions.RequestException:
+            if data.get("success") is True or data.get("code") in (0, None):
+                _LOGGER.info("Updated %s=%s for device %s", setting_key, value, device_id)
+                return True
+
+            _LOGGER.error("Failed to update %s for device %s: %s", setting_key, device_id, data)
             return False
 
-    def fetch_settings(self) -> dict[str, Any]:
-        """Fetch current device settings."""
-        if not self.device_id:
-            return {}
-        
-        payload = {
-            "deviceId": self.device_id,
-        }
-        
-        try:
-            response = self.session.post(
-                f"{API_BASE_URL}{API_SETTINGS_GET}",
-                json=payload,
-                timeout=30,
-            )
-            response.raise_for_status()
-            data = response.json()
-            
-            # Extract settings from response
-            settings = {}
-            if "data" in data:
-                settings_data = data["data"]
-                settings["batteryChargeLimit"] = settings_data.get("batteryChargeLimit", 100)
-                settings["batteryDischargeLimit"] = settings_data.get("batteryDischargeLimit", 10)
-                settings["gridChargeLimit"] = settings_data.get("gridChargeLimit", 0)
-                settings["operatingMode"] = settings_data.get("operatingMode", "Self-Use")
-                settings["batteryPriority"] = settings_data.get("batteryPriority", "Solar First")
-                settings["gridChargingEnabled"] = settings_data.get("gridChargingEnabled", False)
-                settings["gridFeedInEnabled"] = settings_data.get("gridFeedInEnabled", True)
-                settings["backupModeEnabled"] = settings_data.get("backupModeEnabled", False)
-            
-            return settings
-            
         except requests.exceptions.RequestException as err:
-            _LOGGER.error("Error fetching settings: %s", err)
-            return {}
+            _LOGGER.error("Error updating %s for device %s: %s", setting_key, device_id, err)
+            return False
 
-    def set_battery_charge_limit(self, limit: int) -> bool:
-        """Set battery charge limit (0-100%)."""
-        return self._update_setting("batteryChargeLimit", limit)
+    def set_battery_charge_limit(self, device_id: str, limit: int) -> bool:
+        return self._update_setting(device_id, "batteryChargeLimit", limit)
 
-    def set_battery_discharge_limit(self, limit: int) -> bool:
-        """Set battery discharge limit (0-100%)."""
-        return self._update_setting("batteryDischargeLimit", limit)
+    def set_battery_discharge_limit(self, device_id: str, limit: int) -> bool:
+        return self._update_setting(device_id, "batteryDischargeLimit", limit)
 
-    def set_grid_charge_limit(self, limit: int) -> bool:
-        """Set grid charging power limit (W)."""
-        return self._update_setting("gridChargeLimit", limit)
+    def set_grid_charge_limit(self, device_id: str, limit: int) -> bool:
+        return self._update_setting(device_id, "gridChargeLimit", limit)
 
-    def set_operating_mode(self, mode: str) -> bool:
-        """Set system operating mode."""
-        return self._update_setting("operatingMode", mode)
+    def set_operating_mode(self, device_id: str, mode: str) -> bool:
+        return self._update_setting(device_id, "operatingMode", mode)
 
-    def set_battery_priority(self, priority: str) -> bool:
-        """Set battery priority mode."""
-        return self._update_setting("batteryPriority", priority)
+    def set_battery_priority(self, device_id: str, priority: str) -> bool:
+        return self._update_setting(device_id, "batteryPriority", priority)
 
-    def set_grid_charging(self, enabled: bool) -> bool:
-        """Enable or disable grid charging."""
-        return self._update_setting("gridChargingEnabled", enabled)
+    def set_grid_charging(self, device_id: str, enabled: bool) -> bool:
+        return self._update_setting(device_id, "gridChargingEnabled", enabled)
 
-    def set_grid_feed_in(self, enabled: bool) -> bool:
-        """Enable or disable grid feed-in."""
-        return self._update_setting("gridFeedInEnabled", enabled)
+    def set_grid_feed_in(self, device_id: str, enabled: bool) -> bool:
+        return self._update_setting(device_id, "gridFeedInEnabled", enabled)
 
-    def set_backup_mode(self, enabled: bool) -> bool:
-        """Enable or disable backup mode."""
-        return self._update_setting("backupModeEnabled", enabled)
+    def set_backup_mode(self, device_id: str, enabled: bool) -> bool:
+        return self._update_setting(device_id, "backupModeEnabled", enabled)
 
-    def _update_setting(self, setting_key: str, value: Any) -> bool:
-        """Update a specific setting."""
-        if not self.device_id:
-            _LOGGER.error("Device ID is required for settings update")
-            return False
-        
-        payload = {
-            "deviceId": self.device_id,
-            "settings": {
-                setting_key: value
-            }
-        }
-        
+    # -------------------------
+    # Validation
+    # -------------------------
+    def test_connection(self, station_id: str) -> bool:
+        """Validate token + station by listing devices and calling timeseries on first."""
         try:
-            response = self.session.post(
-                f"{API_BASE_URL}{API_SETTINGS_SET}",
-                json=payload,
-                timeout=30,
-            )
-            response.raise_for_status()
-            data = response.json()
-            
-            # Check if update was successful
-            if "success" in data and data["success"]:
-                _LOGGER.info("Successfully updated %s to %s", setting_key, value)
-                return True
-            else:
-                _LOGGER.error("Failed to update %s: %s", setting_key, data.get("message", "Unknown error"))
+            devices = self.list_devices(station_id)
+            if not devices:
                 return False
-            
-        except requests.exceptions.RequestException as err:
-            _LOGGER.error("Error updating setting %s: %s", setting_key, err)
+            first = devices[0]
+            device_id = str(first.get("id") or "")
+            if not device_id:
+                return False
+            _ = self.fetch_latest_data(device_id)
+            return True
+        except Exception as err:
+            _LOGGER.error("Connection test failed: %s", err)
             return False

+ 37 - 108
config_flow.py

@@ -7,49 +7,56 @@ from typing import Any
 import voluptuous as vol
 
 from homeassistant import config_entries
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
 from homeassistant.data_entry_flow import FlowResult
 from homeassistant.exceptions import HomeAssistantError
 from homeassistant.helpers import config_validation as cv
 
 from .api import SolarOfThingsAPI
-from .const import DOMAIN, CONF_IOT_TOKEN, CONF_STATION_ID, CONF_DEVICE_ID
+from .const import (
+    DOMAIN,
+    CONF_IOT_TOKEN,
+    CONF_STATION_ID,
+    CONF_DEVICE_ID,
+    CONF_TIME_ZONE,
+)
 
 _LOGGER = logging.getLogger(__name__)
 
 
 async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
     """Validate the user input allows us to connect."""
-    
-    api = SolarOfThingsAPI(
-        iot_token=data[CONF_IOT_TOKEN],
-        station_id=data.get(CONF_STATION_ID),
-        device_id=data.get(CONF_DEVICE_ID),
-    )
-    
-    # Test the connection
-    if not await hass.async_add_executor_job(api.test_connection):
+
+    api = SolarOfThingsAPI(iot_token=data[CONF_IOT_TOKEN], time_zone=data.get(CONF_TIME_ZONE))
+
+    station_id = data[CONF_STATION_ID]
+    device_id = (data.get(CONF_DEVICE_ID) or "").strip()
+
+    try:
+        # If the user provides a deviceId, validate it directly.
+        # Otherwise validate the station by listing devices and querying the first device.
+        if device_id:
+            res = await hass.async_add_executor_job(api.fetch_latest_data, device_id)
+            ok = bool(res)
+        else:
+            ok = await hass.async_add_executor_job(api.test_connection, station_id)
+    except Exception as err:
+        _LOGGER.error("Validation failed: %s", err)
+        ok = False
+    if not ok:
         raise CannotConnect
-    
-    # Return info that you want to store in the config entry.
-    return {"title": f"Solar Station {data.get(CONF_STATION_ID, data.get(CONF_DEVICE_ID))}"}
+
+    return {"title": f"Solar Station {station_id}"}
 
 
 class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
     """Handle a config flow for Solar of Things."""
 
-    VERSION = 1
-
-    def __init__(self) -> None:
-        """Initialize the config flow."""
-        self._stations: list[dict[str, Any]] = []
+    VERSION = 2
 
-    async def async_step_user(
-        self, user_input: dict[str, Any] | None = None
-    ) -> FlowResult:
-        """Handle the initial step."""
+    async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
         errors: dict[str, str] = {}
-        
+
         if user_input is not None:
             try:
                 info = await validate_input(self.hass, user_input)
@@ -59,24 +66,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
                 _LOGGER.exception("Unexpected exception")
                 errors["base"] = "unknown"
             else:
-                # Check if this station is already configured
-                await self.async_set_unique_id(
-                    f"{user_input.get(CONF_STATION_ID)}_{user_input.get(CONF_DEVICE_ID)}"
-                )
+                await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
                 self._abort_if_unique_id_configured()
-                
-                # Store this station
-                self._stations.append(user_input)
-                
-                # Ask if user wants to add more stations
-                return await self.async_step_add_another()
-
-        # Show the form
+                return self.async_create_entry(title=info["title"], data=user_input)
+
         data_schema = vol.Schema(
             {
                 vol.Required(CONF_IOT_TOKEN): cv.string,
-                vol.Optional(CONF_STATION_ID): cv.string,
+                vol.Required(CONF_STATION_ID): cv.string,
+                # Optional: user can force a single device under the station.
                 vol.Optional(CONF_DEVICE_ID): cv.string,
+                vol.Optional(CONF_TIME_ZONE, default="Asia/Manila"): cv.string,
             }
         )
 
@@ -89,77 +89,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
             },
         )
 
-    async def async_step_add_another(
-        self, user_input: dict[str, Any] | None = None
-    ) -> FlowResult:
-        """Ask if user wants to add another station."""
-        if user_input is not None:
-            if user_input.get("add_another"):
-                # Reset for next station
-                return await self.async_step_user()
-            else:
-                # Create entries for all stations
-                entries = []
-                for station in self._stations:
-                    entries.append(
-                        self.async_create_entry(
-                            title=f"Solar Station {station.get(CONF_STATION_ID, station.get(CONF_DEVICE_ID))}",
-                            data=station,
-                        )
-                    )
-                
-                # Return the first entry (HA limitation - can only return one)
-                # Others will be created via async_step_user calls
-                if entries:
-                    return entries[0]
-        
-        return self.async_show_form(
-            step_id="add_another",
-            data_schema=vol.Schema(
-                {
-                    vol.Required("add_another", default=False): cv.boolean,
-                }
-            ),
-            description_placeholders={
-                "station_count": str(len(self._stations))
-            },
-        )
-
-    @staticmethod
-    @callback
-    def async_get_options_flow(
-        config_entry: config_entries.ConfigEntry,
-    ) -> OptionsFlow:
-        """Get the options flow for this handler."""
-        return OptionsFlow(config_entry)
-
-
-class OptionsFlow(config_entries.OptionsFlow):
-    """Handle options flow for Solar of Things."""
-
-    def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
-        """Initialize options flow."""
-        self.config_entry = config_entry
-
-    async def async_step_init(
-        self, user_input: dict[str, Any] | None = None
-    ) -> FlowResult:
-        """Manage the options."""
-        if user_input is not None:
-            return self.async_create_entry(title="", data=user_input)
-
-        return self.async_show_form(
-            step_id="init",
-            data_schema=vol.Schema(
-                {
-                    vol.Optional(
-                        "update_interval",
-                        default=self.config_entry.options.get("update_interval", 5),
-                    ): vol.All(vol.Coerce(int), vol.Range(min=1, max=60)),
-                }
-            ),
-        )
-
 
 class CannotConnect(HomeAssistantError):
     """Error to indicate we cannot connect."""

+ 8 - 2
const.py

@@ -6,14 +6,20 @@ DOMAIN = "solar_of_things"
 CONF_IOT_TOKEN = "iot_token"
 CONF_STATION_ID = "station_id"
 CONF_DEVICE_ID = "device_id"
+CONF_TIME_ZONE = "time_zone"
 
 # API endpoints
 API_BASE_URL = "https://solar.siseli.com"
-API_TIME_SERIES = "/api/attribute/keys/history/v1"
-API_MONTHLY_SUMMARY = "/api/stateAttributeSummary/category/yearly"
+# Endpoints based on the working reference client
+# https://github.com/Hyllesen/solar-of-things-solar-usage
+API_TIME_SERIES = "/apis/deviceState/simple/attribute/keys/history/v1"
+API_MONTHLY_SUMMARY = "/apis/stationOverView/stateAttributeSummary/category/yearly"
 API_SETTINGS_GET = "/api/device/settings/v1"
 API_SETTINGS_SET = "/api/device/settings/update/v1"
 
+# Station -> devices
+API_DEVICE_LIST = "/apis/device/list"
+
 # Sensor keys
 SENSOR_KEYS = [
     "pvInputPower",

+ 1 - 1
hacs.json

@@ -1,7 +1,7 @@
 {
   "name": "Solar of Things",
   "render_readme": true,
-  "domains": ["sensor"],
+  "domains": ["sensor", "number", "select", "switch"],
   "homeassistant": "2023.1.0",
   "iot_class": "Cloud Polling"
 }

+ 3 - 3
manifest.json

@@ -3,9 +3,9 @@
   "name": "Solar of Things",
   "codeowners": [],
   "config_flow": true,
-  "documentation": "https://github.com/yourusername/solar-of-things-ha",
+  "documentation": "https://github.com/conexocasa/solar-of-things-ha",
   "iot_class": "cloud_polling",
-  "issue_tracker": "https://github.com/yourusername/solar-of-things-ha/issues",
+  "issue_tracker": "https://github.com/conexocasa/solar-of-things-ha/issues",
   "requirements": [],
-  "version": "2.0.0"
+  "version": "2.1.0"
 }

+ 74 - 156
number.py

@@ -2,13 +2,8 @@
 from __future__ import annotations
 
 import logging
-from typing import Any
 
-from homeassistant.components.number import (
-    NumberEntity,
-    NumberDeviceClass,
-    NumberMode,
-)
+from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
 from homeassistant.config_entries import ConfigEntry
 from homeassistant.const import PERCENTAGE
 from homeassistant.core import HomeAssistant
@@ -25,101 +20,77 @@ async def async_setup_entry(
     entry: ConfigEntry,
     async_add_entities: AddEntitiesCallback,
 ) -> None:
-    """Set up Solar of Things number entities based on a config entry."""
-    coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
-    api = hass.data[DOMAIN][entry.entry_id]["api"]
-    
-    entities = [
-        SolarOfThingsBatteryChargeLimitNumber(
-            coordinator=coordinator,
-            entry=entry,
-            api=api,
-        ),
-        SolarOfThingsBatteryDischargeLimitNumber(
-            coordinator=coordinator,
-            entry=entry,
-            api=api,
-        ),
-        SolarOfThingsGridChargeLimitNumber(
-            coordinator=coordinator,
-            entry=entry,
-            api=api,
-        ),
-    ]
-    
-    async_add_entities(entities)
+    """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 SolarOfThingsBatteryChargeLimitNumber(CoordinatorEntity, NumberEntity):
-    """Number entity for battery charge limit."""
 
-    def __init__(
-        self,
-        coordinator,
-        entry: ConfigEntry,
-        api,
-    ) -> None:
-        """Initialize the number entity."""
+class _BaseNumber(CoordinatorEntity, NumberEntity):
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(coordinator)
-        
-        self._entry = entry
         self._api = api
-        self._attr_name = f"{entry.data.get('station_id', entry.data.get('device_id'))} Battery Charge Limit"
-        self._attr_unique_id = f"{entry.entry_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"
+        self._station_id = station_id
+        self._device_id = device_id
+        self._device_name = device_name
 
     @property
     def device_info(self):
-        """Return device information."""
         return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id', self._entry.data.get('device_id'))}",
+            "identifiers": {(DOMAIN, self._station_id, self._device_id)},
+            "name": self._device_name,
             "manufacturer": "Siseli",
-            "model": "Solar Inverter",
+            "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) -> float | None:
-        """Return the current value."""
-        if self.coordinator.data and "settings" in self.coordinator.data:
-            return self.coordinator.data["settings"].get("batteryChargeLimit")
-        return None
+    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:
-        """Set new value."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_battery_charge_limit,
-                int(value)
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error setting battery charge limit: %s", err)
-            raise
-
-
-class SolarOfThingsBatteryDischargeLimitNumber(CoordinatorEntity, NumberEntity):
-    """Number entity for battery discharge limit."""
-
-    def __init__(
-        self,
-        coordinator,
-        entry: ConfigEntry,
-        api,
-    ) -> None:
-        """Initialize the number entity."""
-        super().__init__(coordinator)
-        
-        self._entry = entry
-        self._api = api
-        self._attr_name = f"{entry.data.get('station_id', entry.data.get('device_id'))} Battery Discharge Limit"
-        self._attr_unique_id = f"{entry.entry_id}_battery_discharge_limit"
+        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
@@ -128,52 +99,21 @@ class SolarOfThingsBatteryDischargeLimitNumber(CoordinatorEntity, NumberEntity):
         self._attr_icon = "mdi:battery-arrow-down"
 
     @property
-    def device_info(self):
-        """Return device information."""
-        return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id', self._entry.data.get('device_id'))}",
-            "manufacturer": "Siseli",
-            "model": "Solar Inverter",
-        }
-
-    @property
-    def native_value(self) -> float | None:
-        """Return the current value."""
-        if self.coordinator.data and "settings" in self.coordinator.data:
-            return self.coordinator.data["settings"].get("batteryDischargeLimit")
-        return None
+    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:
-        """Set new value."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_battery_discharge_limit,
-                int(value)
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error setting battery discharge limit: %s", err)
-            raise
-
-
-class SolarOfThingsGridChargeLimitNumber(CoordinatorEntity, NumberEntity):
-    """Number entity for grid charge limit."""
-
-    def __init__(
-        self,
-        coordinator,
-        entry: ConfigEntry,
-        api,
-    ) -> None:
-        """Initialize the number entity."""
-        super().__init__(coordinator)
-        
-        self._entry = entry
-        self._api = api
-        self._attr_name = f"{entry.data.get('station_id', entry.data.get('device_id'))} Grid Charge Limit"
-        self._attr_unique_id = f"{entry.entry_id}_grid_charge_limit"
+        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
@@ -183,31 +123,9 @@ class SolarOfThingsGridChargeLimitNumber(CoordinatorEntity, NumberEntity):
         self._attr_icon = "mdi:transmission-tower-import"
 
     @property
-    def device_info(self):
-        """Return device information."""
-        return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id', self._entry.data.get('device_id'))}",
-            "manufacturer": "Siseli",
-            "model": "Solar Inverter",
-        }
-
-    @property
-    def native_value(self) -> float | None:
-        """Return the current value."""
-        if self.coordinator.data and "settings" in self.coordinator.data:
-            return self.coordinator.data["settings"].get("gridChargeLimit")
-        return None
+    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:
-        """Set new value."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_grid_charge_limit,
-                int(value)
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error setting grid charge limit: %s", err)
-            raise
+        await self.hass.async_add_executor_job(self._api.set_grid_charge_limit, self._device_id, int(value))
+        await self.coordinator.async_request_refresh()

+ 54 - 120
select.py

@@ -2,7 +2,6 @@
 from __future__ import annotations
 
 import logging
-from typing import Any
 
 from homeassistant.components.select import SelectEntity
 from homeassistant.config_entries import ConfigEntry
@@ -14,143 +13,78 @@ from .const import DOMAIN
 
 _LOGGER = logging.getLogger(__name__)
 
-# Operating modes available in the system
-OPERATING_MODES = [
-    "Self-Use",
-    "Time-of-Use",
-    "Backup",
-    "Grid-Tie",
-    "Off-Grid",
-]
-
-# Battery priority modes
-BATTERY_PRIORITY_MODES = [
-    "Solar First",
-    "Battery First",
-    "Grid First",
-]
-
-
-async def async_setup_entry(
-    hass: HomeAssistant,
-    entry: ConfigEntry,
-    async_add_entities: AddEntitiesCallback,
-) -> None:
-    """Set up Solar of Things select entities based on a config entry."""
-    coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
-    api = hass.data[DOMAIN][entry.entry_id]["api"]
-    
-    entities = [
-        SolarOfThingsOperatingModeSelect(
-            coordinator=coordinator,
-            entry=entry,
-            api=api,
-        ),
-        SolarOfThingsBatteryPrioritySelect(
-            coordinator=coordinator,
-            entry=entry,
-            api=api,
-        ),
-    ]
-    
-    async_add_entities(entities)
+OPERATING_MODES = ["Self-Use", "Time-of-Use", "Backup", "Grid-Tie", "Off-Grid"]
+BATTERY_PRIORITY_MODES = ["Solar First", "Battery First", "Grid First"]
+
+
+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 SolarOfThingsOperatingModeSelect(CoordinatorEntity, SelectEntity):
-    """Select entity for system operating mode."""
 
-    def __init__(
-        self,
-        coordinator,
-        entry: ConfigEntry,
-        api,
-    ) -> None:
-        """Initialize the select entity."""
+class _BaseSelect(CoordinatorEntity, SelectEntity):
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(coordinator)
-        
-        self._entry = entry
         self._api = api
-        self._attr_name = f"{entry.data.get('station_id', entry.data.get('device_id'))} Operating Mode"
-        self._attr_unique_id = f"{entry.entry_id}_operating_mode"
-        self._attr_options = OPERATING_MODES
-        self._attr_icon = "mdi:cog"
+        self._station_id = station_id
+        self._device_id = device_id
+        self._device_name = device_name
 
     @property
     def device_info(self):
-        """Return device information."""
         return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id', self._entry.data.get('device_id'))}",
+            "identifiers": {(DOMAIN, self._station_id, self._device_id)},
+            "name": self._device_name,
             "manufacturer": "Siseli",
-            "model": "Solar Inverter",
+            "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):
+    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} Operating Mode"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_operating_mode"
+        self._attr_options = OPERATING_MODES
+        self._attr_icon = "mdi:cog"
+
     @property
-    def current_option(self) -> str | None:
-        """Return the current selected option."""
-        if self.coordinator.data and "settings" in self.coordinator.data:
-            return self.coordinator.data["settings"].get("operatingMode")
-        return None
+    def current_option(self):
+        return ((self.coordinator.data or {}).get("settings") or {}).get("operatingMode")
 
     async def async_select_option(self, option: str) -> None:
-        """Change the selected option."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_operating_mode,
-                option
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error setting operating mode: %s", err)
-            raise
-
-
-class SolarOfThingsBatteryPrioritySelect(CoordinatorEntity, SelectEntity):
-    """Select entity for battery priority mode."""
-
-    def __init__(
-        self,
-        coordinator,
-        entry: ConfigEntry,
-        api,
-    ) -> None:
-        """Initialize the select entity."""
-        super().__init__(coordinator)
-        
-        self._entry = entry
-        self._api = api
-        self._attr_name = f"{entry.data.get('station_id', entry.data.get('device_id'))} Battery Priority"
-        self._attr_unique_id = f"{entry.entry_id}_battery_priority"
+        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):
+    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 Priority"
+        self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_battery_priority"
         self._attr_options = BATTERY_PRIORITY_MODES
         self._attr_icon = "mdi:battery-sync"
 
     @property
-    def device_info(self):
-        """Return device information."""
-        return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id', self._entry.data.get('device_id'))}",
-            "manufacturer": "Siseli",
-            "model": "Solar Inverter",
-        }
-
-    @property
-    def current_option(self) -> str | None:
-        """Return the current selected option."""
-        if self.coordinator.data and "settings" in self.coordinator.data:
-            return self.coordinator.data["settings"].get("batteryPriority")
-        return None
+    def current_option(self):
+        return ((self.coordinator.data or {}).get("settings") or {}).get("batteryPriority")
 
     async def async_select_option(self, option: str) -> None:
-        """Change the selected option."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_battery_priority,
-                option
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error setting battery priority: %s", err)
-            raise
+        await self.hass.async_add_executor_job(self._api.set_battery_priority, self._device_id, option)
+        await self.coordinator.async_request_refresh()

+ 84 - 93
sensor.py

@@ -1,21 +1,16 @@
 """Sensor platform for Solar of Things integration."""
 from __future__ import annotations
 
-from datetime import datetime
 import logging
 
-from homeassistant.components.sensor import (
-    SensorEntity,
-    SensorDeviceClass,
-    SensorStateClass,
-)
+from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
 from homeassistant.config_entries import ConfigEntry
 from homeassistant.const import (
-    UnitOfPower,
-    UnitOfEnergy,
+    PERCENTAGE,
     UnitOfElectricCurrent,
     UnitOfElectricPotential,
-    PERCENTAGE,
+    UnitOfEnergy,
+    UnitOfPower,
 )
 from homeassistant.core import HomeAssistant
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -31,63 +26,76 @@ async def async_setup_entry(
     entry: ConfigEntry,
     async_add_entities: AddEntitiesCallback,
 ) -> None:
-    """Set up Solar of Things sensor based on a config entry."""
-    coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
-    
-    entities = []
-    
-    # Create time-series sensors
-    for key, definition in SENSOR_DEFINITIONS.items():
-        if not key.startswith("monthly_"):
+    """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(
-                SolarOfThingsSensor(
+                SolarOfThingsDeviceSensor(
                     coordinator=coordinator,
-                    entry=entry,
+                    station_id=station_id,
+                    device_id=device_id,
+                    device_name=device_name,
                     sensor_key=key,
                     sensor_definition=definition,
                 )
             )
-    
-    # Create monthly sensors if station_id is available
-    if entry.data.get("station_id"):
+
+    # Station-level monthly sensors
+    if station_coordinator:
         for key, definition in SENSOR_DEFINITIONS.items():
-            if key.startswith("monthly_"):
-                entities.append(
-                    SolarOfThingsMonthlySensor(
-                        coordinator=coordinator,
-                        entry=entry,
-                        sensor_key=key,
-                        sensor_definition=definition,
-                    )
+            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 SolarOfThingsSensor(CoordinatorEntity, SensorEntity):
-    """Representation of a Solar of Things Sensor."""
+class SolarOfThingsDeviceSensor(CoordinatorEntity, SensorEntity):
+    """Per-device telemetry sensor."""
 
     def __init__(
         self,
         coordinator,
-        entry: ConfigEntry,
+        station_id: str,
+        device_id: str,
+        device_name: str,
         sensor_key: str,
         sensor_definition: dict,
     ) -> None:
-        """Initialize the sensor."""
         super().__init__(coordinator)
-        
-        self._entry = entry
+
+        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
-        
-        # Entity attributes
-        self._attr_name = f"{entry.data.get('station_id', entry.data.get('device_id'))} {sensor_definition['name']}"
-        self._attr_unique_id = f"{entry.entry_id}_{sensor_key}"
-        self._attr_native_unit_of_measurement = sensor_definition.get("unit")
+
+        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")
-        
-        # Set device class based on unit
+
         unit = sensor_definition.get("unit")
         if unit == "W":
             self._attr_device_class = SensorDeviceClass.POWER
@@ -113,56 +121,46 @@ class SolarOfThingsSensor(CoordinatorEntity, SensorEntity):
 
     @property
     def device_info(self):
-        """Return device information."""
         return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id', self._entry.data.get('device_id'))}",
+            "identifiers": {(DOMAIN, self._station_id, self._device_id)},
+            "name": self._device_name,
             "manufacturer": "Siseli",
-            "model": "Solar Inverter",
+            "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):
-        """Return the state of the sensor."""
-        if self.coordinator.data and "time_series" in self.coordinator.data:
-            value = self.coordinator.data["time_series"].get(self._sensor_key)
-            if value is not None:
-                try:
-                    return round(float(value), 2)
-                except (ValueError, TypeError):
-                    return None
-        return None
-
-    @property
-    def available(self) -> bool:
-        """Return if entity is available."""
-        return self.coordinator.last_update_success and self.native_value is not None
+        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 SolarOfThingsMonthlySensor(CoordinatorEntity, SensorEntity):
-    """Representation of a Solar of Things Monthly Sensor."""
+class SolarOfThingsStationMonthlySensor(CoordinatorEntity, SensorEntity):
+    """Station-level monthly summary sensor."""
 
     def __init__(
         self,
         coordinator,
-        entry: ConfigEntry,
+        station_id: str,
         sensor_key: str,
         sensor_definition: dict,
     ) -> None:
-        """Initialize the sensor."""
         super().__init__(coordinator)
-        
-        self._entry = entry
+
+        self._station_id = station_id
         self._sensor_key = sensor_key
         self._sensor_definition = sensor_definition
-        
-        # Entity attributes
-        self._attr_name = f"{entry.data.get('station_id')} {sensor_definition['name']}"
-        self._attr_unique_id = f"{entry.entry_id}_{sensor_key}"
-        self._attr_native_unit_of_measurement = sensor_definition.get("unit")
+
+        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")
-        
-        # Set device class
+
         unit = sensor_definition.get("unit")
         if unit == "kWh":
             self._attr_device_class = SensorDeviceClass.ENERGY
@@ -174,27 +172,20 @@ class SolarOfThingsMonthlySensor(CoordinatorEntity, SensorEntity):
 
     @property
     def device_info(self):
-        """Return device information."""
         return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id')}",
+            "identifiers": {(DOMAIN, self._station_id)},
+            "name": f"Solar Station {self._station_id}",
             "manufacturer": "Siseli",
-            "model": "Solar Inverter",
+            "model": "Station",
         }
 
     @property
     def native_value(self):
-        """Return the state of the sensor."""
-        if self.coordinator.data and "monthly" in self.coordinator.data:
-            value = self.coordinator.data["monthly"].get(self._sensor_key)
-            if value is not None:
-                try:
-                    return round(float(value), 2)
-                except (ValueError, TypeError):
-                    return None
-        return None
-
-    @property
-    def available(self) -> bool:
-        """Return if entity is available."""
-        return self.coordinator.last_update_success
+        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

+ 168 - 0
setup_github.sh

@@ -0,0 +1,168 @@
+#!/bin/bash
+
+# GitHub Repository Setup Script for Solar of Things Integration
+# This script will create a GitHub repository and push your integration
+
+echo "=================================================="
+echo "Solar of Things HA Integration - GitHub Setup"
+echo "=================================================="
+echo ""
+
+# Configuration
+REPO_NAME="solar-of-things-ha"
+GITHUB_USERNAME="conexocasa"
+GITHUB_EMAIL="bob@conexo.casa"
+
+echo "Repository Name: $REPO_NAME"
+echo "GitHub Username: $GITHUB_USERNAME"
+echo "GitHub Email: $GITHUB_EMAIL"
+echo ""
+
+# Check if we're in the right directory
+if [ ! -f "manifest.json" ]; then
+    echo "Error: Please run this script from the solar_of_things_integration directory"
+    exit 1
+fi
+
+# Check if git is configured
+if [ "$(git config user.email)" != "$GITHUB_EMAIL" ]; then
+    echo "Configuring git..."
+    git config --global user.email "$GITHUB_EMAIL"
+    git config --global user.name "$GITHUB_USERNAME"
+fi
+
+# Rename master to main (GitHub's default)
+echo "Renaming branch to main..."
+git branch -M main
+
+echo ""
+echo "=================================================="
+echo "STEP 1: Create GitHub Personal Access Token"
+echo "=================================================="
+echo ""
+echo "1. Go to: https://github.com/settings/tokens/new"
+echo "2. Token name: 'Solar of Things Integration'"
+echo "3. Expiration: Choose your preference"
+echo "4. Select scopes:"
+echo "   ☑ repo (all repo permissions)"
+echo "5. Click 'Generate token'"
+echo "6. COPY THE TOKEN (you won't see it again!)"
+echo ""
+echo -n "Paste your GitHub Personal Access Token here: "
+read -s GITHUB_TOKEN
+echo ""
+echo ""
+
+if [ -z "$GITHUB_TOKEN" ]; then
+    echo "Error: Token is required!"
+    exit 1
+fi
+
+echo "=================================================="
+echo "STEP 2: Create GitHub Repository"
+echo "=================================================="
+echo ""
+
+# Create repository using GitHub API
+echo "Creating repository on GitHub..."
+RESPONSE=$(curl -s -X POST \
+    -H "Authorization: token $GITHUB_TOKEN" \
+    -H "Accept: application/vnd.github.v3+json" \
+    https://api.github.com/user/repos \
+    -d "{
+        \"name\":\"$REPO_NAME\",
+        \"description\":\"Home Assistant integration for Solar of Things (Siseli) solar inverter systems with full monitoring and control capabilities\",
+        \"homepage\":\"https://github.com/$GITHUB_USERNAME/$REPO_NAME\",
+        \"private\":false,
+        \"has_issues\":true,
+        \"has_projects\":false,
+        \"has_wiki\":false
+    }")
+
+# Check if repository was created successfully
+if echo "$RESPONSE" | grep -q "\"full_name\""; then
+    echo "✓ Repository created successfully!"
+    REPO_URL=$(echo "$RESPONSE" | grep -o '"clone_url":"[^"]*"' | cut -d'"' -f4)
+    echo "  Repository URL: https://github.com/$GITHUB_USERNAME/$REPO_NAME"
+else
+    if echo "$RESPONSE" | grep -q "already exists"; then
+        echo "⚠ Repository already exists. Using existing repository."
+        REPO_URL="https://github.com/$GITHUB_USERNAME/$REPO_NAME.git"
+    else
+        echo "✗ Error creating repository:"
+        echo "$RESPONSE" | grep -o '"message":"[^"]*"' | cut -d'"' -f4
+        exit 1
+    fi
+fi
+
+echo ""
+echo "=================================================="
+echo "STEP 3: Push to GitHub"
+echo "=================================================="
+echo ""
+
+# Add remote with token authentication
+REPO_URL_WITH_TOKEN="https://$GITHUB_TOKEN@github.com/$GITHUB_USERNAME/$REPO_NAME.git"
+
+echo "Adding remote origin..."
+git remote remove origin 2>/dev/null
+git remote add origin "$REPO_URL_WITH_TOKEN"
+
+echo "Pushing to GitHub..."
+if git push -u origin main; then
+    echo "✓ Successfully pushed to GitHub!"
+else
+    echo "✗ Error pushing to GitHub"
+    exit 1
+fi
+
+echo ""
+echo "=================================================="
+echo "STEP 4: Create Release Tag"
+echo "=================================================="
+echo ""
+
+echo "Creating v2.0.0 tag..."
+git tag -a v2.0.0 -m "Version 2.0.0 - Full Control Features
+
+Major release with complete control capabilities:
+- 8 new control entities (Number, Select, Switch)
+- Battery charge/discharge limits
+- Operating mode selection
+- Grid charging control
+- Comprehensive documentation
+- 15+ automation examples"
+
+echo "Pushing tag to GitHub..."
+git push origin v2.0.0
+
+echo ""
+echo "=================================================="
+echo "SUCCESS! Repository Created"
+echo "=================================================="
+echo ""
+echo "Your repository is now available at:"
+echo "https://github.com/$GITHUB_USERNAME/$REPO_NAME"
+echo ""
+echo "Next steps:"
+echo "1. Visit your repository: https://github.com/$GITHUB_USERNAME/$REPO_NAME"
+echo "2. Edit the README if needed"
+echo "3. Add topics: homeassistant, solar, siseli, home-automation"
+echo "4. Submit to HACS (optional)"
+echo ""
+echo "To add to HACS default repository:"
+echo "1. Fork: https://github.com/hacs/default"
+echo "2. Add your repo to integration list"
+echo "3. Create pull request"
+echo ""
+echo "Repository details:"
+echo "- Name: $REPO_NAME"
+echo "- Version: 2.0.0"
+echo "- Branch: main"
+echo "- Files: 23 files committed"
+echo ""
+
+# Clean up token from git config (security)
+git remote set-url origin "https://github.com/$GITHUB_USERNAME/$REPO_NAME.git"
+
+echo "✓ Setup complete!"

+ 3 - 2
strings.json

@@ -6,8 +6,9 @@
         "description": "Configure your Solar of Things station. You can add multiple stations by configuring this integration multiple times.\n\nTo find your credentials:\n1. Log into https://solar.siseli.com\n2. Open browser developer tools (F12) → Network tab\n3. Refresh the page and look at API requests\n4. Copy the IOT-Token from request headers\n5. Find stationId and deviceId in the request URLs or payloads\n\nMore info: {docs_url}",
         "data": {
           "iot_token": "IOT Token",
-          "station_id": "Station ID (optional, for monthly stats)",
-          "device_id": "Device ID (required for real-time data)"
+          "station_id": "Station ID (required)",
+          "device_id": "Device ID (optional; leave blank to auto-discover all devices)",
+          "time_zone": "Time zone (IOT-Time-Zone header)"
         }
       },
       "add_another": {

+ 79 - 196
switch.py

@@ -2,9 +2,8 @@
 from __future__ import annotations
 
 import logging
-from typing import Any
 
-from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
+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
@@ -15,220 +14,104 @@ from .const import DOMAIN
 _LOGGER = logging.getLogger(__name__)
 
 
-async def async_setup_entry(
-    hass: HomeAssistant,
-    entry: ConfigEntry,
-    async_add_entities: AddEntitiesCallback,
-) -> None:
-    """Set up Solar of Things switch entities based on a config entry."""
-    coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
-    api = hass.data[DOMAIN][entry.entry_id]["api"]
-    
-    entities = [
-        SolarOfThingsGridChargingSwitch(
-            coordinator=coordinator,
-            entry=entry,
-            api=api,
-        ),
-        SolarOfThingsGridFeedInSwitch(
-            coordinator=coordinator,
-            entry=entry,
-            api=api,
-        ),
-        SolarOfThingsBackupModeSwitch(
-            coordinator=coordinator,
-            entry=entry,
-            api=api,
-        ),
-    ]
-    
-    async_add_entities(entities)
+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)
 
-class SolarOfThingsGridChargingSwitch(CoordinatorEntity, SwitchEntity):
-    """Switch entity for grid charging control."""
 
-    def __init__(
-        self,
-        coordinator,
-        entry: ConfigEntry,
-        api,
-    ) -> None:
-        """Initialize the switch entity."""
+class _BaseSwitch(CoordinatorEntity, SwitchEntity):
+    def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(coordinator)
-        
-        self._entry = entry
         self._api = api
-        self._attr_name = f"{entry.data.get('station_id', entry.data.get('device_id'))} Grid Charging"
-        self._attr_unique_id = f"{entry.entry_id}_grid_charging"
-        self._attr_device_class = SwitchDeviceClass.SWITCH
-        self._attr_icon = "mdi:transmission-tower"
+        self._station_id = station_id
+        self._device_id = device_id
+        self._device_name = device_name
 
     @property
     def device_info(self):
-        """Return device information."""
         return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id', self._entry.data.get('device_id'))}",
+            "identifiers": {(DOMAIN, self._station_id, self._device_id)},
+            "name": self._device_name,
             "manufacturer": "Siseli",
-            "model": "Solar Inverter",
+            "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):
+    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"
+        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:
-        """Return true if the switch is on."""
-        if self.coordinator.data and "settings" in self.coordinator.data:
-            return self.coordinator.data["settings"].get("gridChargingEnabled", False)
-        return None
-
-    async def async_turn_on(self, **kwargs: Any) -> None:
-        """Turn the switch on."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_grid_charging,
-                True
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error turning on grid charging: %s", err)
-            raise
-
-    async def async_turn_off(self, **kwargs: Any) -> None:
-        """Turn the switch off."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_grid_charging,
-                False
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error turning off grid charging: %s", err)
-            raise
-
-
-class SolarOfThingsGridFeedInSwitch(CoordinatorEntity, SwitchEntity):
-    """Switch entity for grid feed-in control."""
-
-    def __init__(
-        self,
-        coordinator,
-        entry: ConfigEntry,
-        api,
-    ) -> None:
-        """Initialize the switch entity."""
-        super().__init__(coordinator)
-        
-        self._entry = entry
-        self._api = api
-        self._attr_name = f"{entry.data.get('station_id', entry.data.get('device_id'))} Grid Feed-In"
-        self._attr_unique_id = f"{entry.entry_id}_grid_feed_in"
+    def is_on(self):
+        return bool(((self.coordinator.data or {}).get("settings") or {}).get("gridChargingEnabled", False))
+
+    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):
+    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 device_info(self):
-        """Return device information."""
-        return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id', self._entry.data.get('device_id'))}",
-            "manufacturer": "Siseli",
-            "model": "Solar Inverter",
-        }
+    def is_on(self):
+        return bool(((self.coordinator.data or {}).get("settings") or {}).get("gridFeedInEnabled", False))
 
-    @property
-    def is_on(self) -> bool | None:
-        """Return true if the switch is on."""
-        if self.coordinator.data and "settings" in self.coordinator.data:
-            return self.coordinator.data["settings"].get("gridFeedInEnabled", False)
-        return None
-
-    async def async_turn_on(self, **kwargs: Any) -> None:
-        """Turn the switch on."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_grid_feed_in,
-                True
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error turning on grid feed-in: %s", err)
-            raise
-
-    async def async_turn_off(self, **kwargs: Any) -> None:
-        """Turn the switch off."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_grid_feed_in,
-                False
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error turning off grid feed-in: %s", err)
-            raise
-
-
-class SolarOfThingsBackupModeSwitch(CoordinatorEntity, SwitchEntity):
-    """Switch entity for backup mode control."""
-
-    def __init__(
-        self,
-        coordinator,
-        entry: ConfigEntry,
-        api,
-    ) -> None:
-        """Initialize the switch entity."""
-        super().__init__(coordinator)
-        
-        self._entry = entry
-        self._api = api
-        self._attr_name = f"{entry.data.get('station_id', entry.data.get('device_id'))} Backup Mode"
-        self._attr_unique_id = f"{entry.entry_id}_backup_mode"
+    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):
+    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"
+        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 device_info(self):
-        """Return device information."""
-        return {
-            "identifiers": {(DOMAIN, self._entry.entry_id)},
-            "name": f"Solar Station {self._entry.data.get('station_id', self._entry.data.get('device_id'))}",
-            "manufacturer": "Siseli",
-            "model": "Solar Inverter",
-        }
+    def is_on(self):
+        return bool(((self.coordinator.data or {}).get("settings") or {}).get("backupModeEnabled", False))
 
-    @property
-    def is_on(self) -> bool | None:
-        """Return true if the switch is on."""
-        if self.coordinator.data and "settings" in self.coordinator.data:
-            return self.coordinator.data["settings"].get("backupModeEnabled", False)
-        return None
-
-    async def async_turn_on(self, **kwargs: Any) -> None:
-        """Turn the switch on."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_backup_mode,
-                True
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error turning on backup mode: %s", err)
-            raise
-
-    async def async_turn_off(self, **kwargs: Any) -> None:
-        """Turn the switch off."""
-        try:
-            await self.hass.async_add_executor_job(
-                self._api.set_backup_mode,
-                False
-            )
-            # Request immediate coordinator update
-            await self.coordinator.async_request_refresh()
-        except Exception as err:
-            _LOGGER.error("Error turning off backup mode: %s", err)
-            raise
+    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()

+ 5 - 3
tests/test_config_flow.py

@@ -28,7 +28,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
             context={"source": config_entries.SOURCE_USER},
             data={
                 "iot_token": "test_token",
-                "device_id": "123456789012345678",
+                "station_id": "876543210987654321",
             },
         )
 
@@ -47,8 +47,9 @@ async def test_form_success(hass: HomeAssistant) -> None:
             context={"source": config_entries.SOURCE_USER},
             data={
                 "iot_token": "test_token",
-                "device_id": "123456789012345678",
                 "station_id": "876543210987654321",
+                "device_id": "123456789012345678",
+                "time_zone": "Asia/Manila",
             },
         )
 
@@ -56,6 +57,7 @@ async def test_form_success(hass: HomeAssistant) -> None:
     assert result["title"] == "Solar Station 876543210987654321"
     assert result["data"] == {
         "iot_token": "test_token",
-        "device_id": "123456789012345678",
         "station_id": "876543210987654321",
+        "device_id": "123456789012345678",
+        "time_zone": "Asia/Manila",
     }

+ 3 - 2
translations/en.json

@@ -6,8 +6,9 @@
         "description": "Configure your Solar of Things station. You can add multiple stations by configuring this integration multiple times.\n\nTo find your credentials:\n1. Log into https://solar.siseli.com\n2. Open browser developer tools (F12) → Network tab\n3. Refresh the page and look at API requests\n4. Copy the IOT-Token from request headers\n5. Find stationId and deviceId in the request URLs or payloads\n\nMore info: {docs_url}",
         "data": {
           "iot_token": "IOT Token",
-          "station_id": "Station ID (optional, for monthly stats)",
-          "device_id": "Device ID (required for real-time data)"
+          "station_id": "Station ID (required)",
+          "device_id": "Device ID (optional; leave blank to auto-discover all devices)",
+          "time_zone": "Time zone (IOT-Time-Zone header)"
         }
       },
       "add_another": {