فهرست منبع

v2.3.3: fix 404 settings endpoint + map real API device keys

conexocasa 7 ماه پیش
والد
کامیت
110e287205
6فایلهای تغییر یافته به همراه207 افزوده شده و 41 حذف شده
  1. 22 0
      CHANGELOG.md
  2. 72 20
      api.py
  3. 5 2
      const.py
  4. 1 1
      manifest.json
  5. 55 10
      select.py
  6. 52 8
      switch.py

+ 22 - 0
CHANGELOG.md

@@ -1,3 +1,25 @@
+## [2.3.3] - 2026-03-07
+
+### Fixed
+- **404 Not Found on device settings**: The settings endpoints `/api/device/settings/v1`
+  and `/api/device/settings/update/v1` do not exist on `solar.siseli.com`. Replaced with the
+  correct remote-config API endpoints discovered from the live portal JS bundle:
+  - **Read**: `POST /apis/remote/device/configs/cache/get?deviceId=<id>` (plain `IOT-Token` header)
+  - **Write**: `POST /apis/remote/device/config/write?deviceId=<id>` (body `{deviceId, key, value}`)
+
+### Changed
+- `select.py` — **Operating Mode** select now uses real API key `outputSourcePrioritySetting`
+  with options `Utility First (USO)` / `Solar First (SUB)` / `Solar+Battery First (SBU)`.
+- `select.py` — **Battery Priority** select now uses real API key `chargerSourcePrioritySetting`
+  with options `Solar + Utility (CSO)` / `Solar First (SNU)` / `Solar Only (OSO)`.
+- `switch.py` — **Grid Feed-In** switch maps to `batteryPowerLimitingSetting` (0=OFF, 1=ON).
+- `switch.py` — **Grid Charging** switch maps to `acInputRangeSetting` (0=Appliance/ON, 1=UPS/OFF).
+- `switch.py` — **Backup Mode** switch maps to `outputSourcePrioritySetting` SBU=2 (ON) / SUB=1 (OFF).
+- `api.py` — `get_device_settings` / `fetch_settings` now POST to the correct URL with query
+  parameter and plain `IOT-Token` header (no IOT-Open signing required for this endpoint).
+- `api.py` — `_write_setting` helper added; `set_*` control methods updated with correct
+  API key names and integer value mappings (no longer sending arbitrary string names).
+
 # Changelog
 
 All notable changes to this project will be documented in this file.

+ 72 - 20
api.py

@@ -708,10 +708,36 @@ class SolarOfThingsAPI:
         return monthly
 
     # ─── Device settings ───────────────────────────────────────────────────────
+    # The remote config endpoints require only a plain IOT-Token header (which the
+    # session already carries) and pass deviceId as a URL query parameter rather
+    # than in the JSON body.
+
+    def _write_setting(self, device_id: str, key: str, value: Any) -> None:
+        """Write a single device setting key=value via the remote config write API."""
+        self._ensure_token_valid()
+        url = f"{API_BASE_URL}{API_SETTINGS_SET}?deviceId={device_id}"
+        payload = {"deviceId": device_id, "key": key, "value": value}
+        resp = self.session.post(url, json=payload, timeout=30)
+        resp.raise_for_status()
+        data = resp.json()
+        if data.get("code") not in (0, None):
+            raise RuntimeError(
+                f"Settings write error code={data.get('code')} "
+                f"message={data.get('message')} (key={key})"
+            )
 
     def get_device_settings(self, device_id: str) -> dict[str, Any]:
-        """Fetch current device settings."""
-        data = self._post(API_SETTINGS_GET, {"deviceId": device_id})
+        """Fetch the cached device settings from the remote config API.
+
+        Returns a flat dict of {settingKey: settingObject} where each value
+        contains at least 'key', 'value', and 'valueDisplay' fields.
+        The endpoint accepts a plain IOT-Token header (no IOT-Open-Sign).
+        """
+        self._ensure_token_valid()
+        url = f"{API_BASE_URL}{API_SETTINGS_GET}?deviceId={device_id}"
+        resp = self.session.post(url, json={}, timeout=30)
+        resp.raise_for_status()
+        data = resp.json()
         if data.get("code") not in (0, None):
             raise RuntimeError(
                 f"Settings fetch error code={data.get('code')} "
@@ -723,36 +749,62 @@ class SolarOfThingsAPI:
     fetch_settings = get_device_settings
 
     def update_device_settings(self, device_id: str, settings: dict[str, Any]) -> None:
-        """Update one or more device settings."""
-        payload = {"deviceId": device_id, **settings}
-        data = self._post(API_SETTINGS_SET, payload)
-        if data.get("code") not in (0, None):
-            raise RuntimeError(
-                f"Settings update error code={data.get('code')} "
-                f"message={data.get('message')}"
-            )
+        """Write multiple settings (one API call per key)."""
+        for key, value in settings.items():
+            self._write_setting(device_id, key, value)
 
     # ─── Convenience control helpers (called by select.py / switch.py) ─────────
+    # Key names are the real device attribute keys returned by get_device_settings.
+    # Output Source Priority:   USO=0, SUB=1, SBU=2
+    # Charger Source Priority:  CSO=0, SNU=1, OSO=2
+    # batteryPowerLimitingSetting: 0=OFF, 1=ON  (GRID switch)
+    # acInputRangeSetting:         0=Appliance, 1=UPS
+
+    # Operating-mode select maps HA option strings to integer values
+    _OUTPUT_MODE_MAP: dict[str, int] = {
+        "Utility First (USO)": 0,
+        "Solar First (SUB)": 1,
+        "Solar+Battery First (SBU)": 2,
+    }
+    _OUTPUT_MODE_REVERSE: dict[int, str] = {v: k for k, v in _OUTPUT_MODE_MAP.items()}
+
+    # Charger-priority select
+    _CHARGER_PRIORITY_MAP: dict[str, int] = {
+        "Solar + Utility (CSO)": 0,
+        "Solar First (SNU)": 1,
+        "Solar Only (OSO)": 2,
+    }
+    _CHARGER_PRIORITY_REVERSE: dict[int, str] = {v: k for k, v in _CHARGER_PRIORITY_MAP.items()}
 
     def set_operating_mode(self, device_id: str, mode: str) -> None:
-        """Set the inverter operating mode (e.g. 'Self-Use', 'Backup')."""
-        self.update_device_settings(device_id, {"operatingMode": mode})
+        """Set Output Source Priority.  mode is one of _OUTPUT_MODE_MAP keys."""
+        value = self._OUTPUT_MODE_MAP.get(mode)
+        if value is None:
+            raise ValueError(f"Unknown operating mode: {mode!r}. "
+                             f"Valid options: {list(self._OUTPUT_MODE_MAP)!r}")
+        self._write_setting(device_id, "outputSourcePrioritySetting", value)
 
     def set_battery_priority(self, device_id: str, mode: str) -> None:
-        """Set battery charging priority (e.g. 'Solar First', 'Grid First')."""
-        self.update_device_settings(device_id, {"batteryPriority": mode})
+        """Set Charger Source Priority.  mode is one of _CHARGER_PRIORITY_MAP keys."""
+        value = self._CHARGER_PRIORITY_MAP.get(mode)
+        if value is None:
+            raise ValueError(f"Unknown battery priority: {mode!r}. "
+                             f"Valid options: {list(self._CHARGER_PRIORITY_MAP)!r}")
+        self._write_setting(device_id, "chargerSourcePrioritySetting", value)
 
     def set_grid_charging(self, device_id: str, enabled: bool) -> None:
-        """Enable or disable grid charging."""
-        self.update_device_settings(device_id, {"gridChargingEnabled": enabled})
+        """Set AC Input Range: Appliance (0, grid charging allowed) / UPS (1, bypass)."""
+        self._write_setting(device_id, "acInputRangeSetting", 0 if enabled else 1)
 
     def set_grid_feed_in(self, device_id: str, enabled: bool) -> None:
-        """Enable or disable grid feed-in (export)."""
-        self.update_device_settings(device_id, {"gridFeedInEnabled": enabled})
+        """Enable or disable the GRID grid switch (batteryPowerLimitingSetting)."""
+        self._write_setting(device_id, "batteryPowerLimitingSetting", 1 if enabled else 0)
 
     def set_backup_mode(self, device_id: str, enabled: bool) -> None:
-        """Enable or disable backup (reserve) mode."""
-        self.update_device_settings(device_id, {"backupModeEnabled": enabled})
+        """Set Output Source Priority to SBU (backup/off-grid priority) when True,
+        or SUB (solar-first, grid-supplemented) when False."""
+        value = 2 if enabled else 1   # SBU=2 (battery before grid), SUB=1
+        self._write_setting(device_id, "outputSourcePrioritySetting", value)
 
     def test_connection(self, station_id: str) -> bool:
         """Return True if we can reach the device-list endpoint successfully."""

+ 5 - 2
const.py

@@ -38,8 +38,11 @@ IOT_APP_SECRET_ENC  = "I4D0KRr2339z3pQ/at91V9BpFAOe54DaTafwSm6suIQ="
 # ─── Data endpoints ────────────────────────────────────────────────────────────
 API_TIME_SERIES    = "/apis/deviceState/simple/attribute/keys/history/v1"
 API_MONTHLY_SUMMARY = "/apis/stationOverView/stateAttributeSummary/category/yearly"
-API_SETTINGS_GET   = "/api/device/settings/v1"
-API_SETTINGS_SET   = "/api/device/settings/update/v1"
+# Remote device config endpoints (discovered 2026-03-07 from live API testing).
+# These accept a plain IOT-Token header (no IOT-Open-Sign) and use the device ID
+# as a query parameter.  Write sends one setting key+value per call.
+API_SETTINGS_GET   = "/apis/remote/device/configs/cache/get"  # ?deviceId=<id>
+API_SETTINGS_SET   = "/apis/remote/device/config/write"       # ?deviceId=<id>
 API_DEVICE_LIST    = "/apis/device/list"
 
 # ─── Token refresh window ──────────────────────────────────────────────────────

+ 1 - 1
manifest.json

@@ -9,5 +9,5 @@
   "requirements": [
     "pycryptodome>=3.9.0"
   ],
-  "version": "2.3.2"
+  "version": "2.3.3"
 }

+ 55 - 10
select.py

@@ -13,8 +13,27 @@ from .const import DOMAIN
 
 _LOGGER = logging.getLogger(__name__)
 
-OPERATING_MODES = ["Self-Use", "Time-of-Use", "Backup", "Grid-Tie", "Off-Grid"]
-BATTERY_PRIORITY_MODES = ["Solar First", "Battery First", "Grid First"]
+# Real API key: outputSourcePrioritySetting  (integer value)
+# 0 = USO  Utility first
+# 1 = SUB  Solar first (grid supplement)
+# 2 = SBU  Solar + Battery first (grid only last resort)
+OUTPUT_MODE_BY_VALUE: dict[int, str] = {
+    0: "Utility First (USO)",
+    1: "Solar First (SUB)",
+    2: "Solar+Battery First (SBU)",
+}
+OUTPUT_MODES = list(OUTPUT_MODE_BY_VALUE.values())
+
+# Real API key: chargerSourcePrioritySetting  (integer value)
+# 0 = CSO  Solar + Utility charging (utility has priority)
+# 1 = SNU  Solar First for charging, utility as fallback
+# 2 = OSO  Solar Only charging
+CHARGER_PRIORITY_BY_VALUE: dict[int, str] = {
+    0: "Solar + Utility (CSO)",
+    1: "Solar First (SNU)",
+    2: "Solar Only (OSO)",
+}
+CHARGER_PRIORITIES = list(CHARGER_PRIORITY_BY_VALUE.values())
 
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
@@ -57,16 +76,29 @@ class _BaseSelect(CoordinatorEntity, SelectEntity):
 
 
 class SolarOfThingsOperatingModeSelect(_BaseSelect):
+    """Select entity for Output Source Priority (outputSourcePrioritySetting).
+
+    Reflects the real device API key.  Values 0/1/2 map to USO/SUB/SBU.
+    """
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(api, coordinator, station_id, device_id, device_name)
-        self._attr_name = f"{device_name} Operating Mode"
+        self._attr_name = f"{device_name} Output Source Priority"
         self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_operating_mode"
-        self._attr_options = OPERATING_MODES
+        self._attr_options = OUTPUT_MODES
         self._attr_icon = "mdi:cog"
 
     @property
-    def current_option(self):
-        return ((self.coordinator.data or {}).get("settings") or {}).get("operatingMode")
+    def current_option(self) -> str | None:
+        settings = ((self.coordinator.data or {}).get("settings") or {})
+        entry = settings.get("outputSourcePrioritySetting")
+        if entry is None:
+            return None
+        raw = entry.get("value") if isinstance(entry, dict) else entry
+        try:
+            return OUTPUT_MODE_BY_VALUE.get(int(raw))
+        except (TypeError, ValueError):
+            return None
 
     async def async_select_option(self, option: str) -> None:
         await self.hass.async_add_executor_job(self._api.set_operating_mode, self._device_id, option)
@@ -74,16 +106,29 @@ class SolarOfThingsOperatingModeSelect(_BaseSelect):
 
 
 class SolarOfThingsBatteryPrioritySelect(_BaseSelect):
+    """Select entity for Charger Source Priority (chargerSourcePrioritySetting).
+
+    Reflects the real device API key.  Values 0/1/2 map to CSO/SNU/OSO.
+    """
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(api, coordinator, station_id, device_id, device_name)
-        self._attr_name = f"{device_name} Battery Priority"
+        self._attr_name = f"{device_name} Charger Source Priority"
         self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_battery_priority"
-        self._attr_options = BATTERY_PRIORITY_MODES
+        self._attr_options = CHARGER_PRIORITIES
         self._attr_icon = "mdi:battery-sync"
 
     @property
-    def current_option(self):
-        return ((self.coordinator.data or {}).get("settings") or {}).get("batteryPriority")
+    def current_option(self) -> str | None:
+        settings = ((self.coordinator.data or {}).get("settings") or {})
+        entry = settings.get("chargerSourcePrioritySetting")
+        if entry is None:
+            return None
+        raw = entry.get("value") if isinstance(entry, dict) else entry
+        try:
+            return CHARGER_PRIORITY_BY_VALUE.get(int(raw))
+        except (TypeError, ValueError):
+            return None
 
     async def async_select_option(self, option: str) -> None:
         await self.hass.async_add_executor_job(self._api.set_battery_priority, self._device_id, option)

+ 52 - 8
switch.py

@@ -35,6 +35,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
     async_add_entities(entities)
 
 
+def _setting_value(coordinator_data: dict | None, key: str) -> int | None:
+    """Extract the integer value for a device setting key from coordinator data."""
+    settings = ((coordinator_data or {}).get("settings") or {})
+    entry = settings.get(key)
+    if entry is None:
+        return None
+    raw = entry.get("value") if isinstance(entry, dict) else entry
+    try:
+        return int(raw)
+    except (TypeError, ValueError):
+        return None
+
+
 class _BaseSwitch(CoordinatorEntity, SwitchEntity):
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(coordinator)
@@ -55,16 +68,26 @@ class _BaseSwitch(CoordinatorEntity, SwitchEntity):
 
 
 class SolarOfThingsGridChargingSwitch(_BaseSwitch):
+    """Switch for AC Input Range setting (acInputRangeSetting).
+
+    0 = Appliance mode – wide input voltage range, grid charging allowed.
+    1 = UPS mode – narrow voltage range, stricter bypass behaviour.
+    The switch reports ON when the inverter is in Appliance (grid-charging) mode.
+    """
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(api, coordinator, station_id, device_id, device_name)
-        self._attr_name = f"{device_name} Grid Charging"
+        self._attr_name = f"{device_name} Grid Charging (AC Input Range)"
         self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_grid_charging"
         self._attr_device_class = SwitchDeviceClass.SWITCH
         self._attr_icon = "mdi:transmission-tower"
 
     @property
-    def is_on(self):
-        return bool(((self.coordinator.data or {}).get("settings") or {}).get("gridChargingEnabled", False))
+    def is_on(self) -> bool | None:
+        val = _setting_value(self.coordinator.data, "acInputRangeSetting")
+        if val is None:
+            return None
+        return val == 0  # 0=Appliance (charging OK), 1=UPS (bypass)
 
     async def async_turn_on(self, **kwargs):
         await self.hass.async_add_executor_job(self._api.set_grid_charging, self._device_id, True)
@@ -76,6 +99,12 @@ class SolarOfThingsGridChargingSwitch(_BaseSwitch):
 
 
 class SolarOfThingsGridFeedInSwitch(_BaseSwitch):
+    """Switch for GRID grid switch (batteryPowerLimitingSetting).
+
+    0 = OFF (grid switch disabled / feed-in off).
+    1 = ON  (grid switch enabled / feed-in on).
+    """
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(api, coordinator, station_id, device_id, device_name)
         self._attr_name = f"{device_name} Grid Feed-In"
@@ -84,8 +113,11 @@ class SolarOfThingsGridFeedInSwitch(_BaseSwitch):
         self._attr_icon = "mdi:transmission-tower-export"
 
     @property
-    def is_on(self):
-        return bool(((self.coordinator.data or {}).get("settings") or {}).get("gridFeedInEnabled", False))
+    def is_on(self) -> bool | None:
+        val = _setting_value(self.coordinator.data, "batteryPowerLimitingSetting")
+        if val is None:
+            return None
+        return val == 1  # 1=ON
 
     async def async_turn_on(self, **kwargs):
         await self.hass.async_add_executor_job(self._api.set_grid_feed_in, self._device_id, True)
@@ -97,16 +129,28 @@ class SolarOfThingsGridFeedInSwitch(_BaseSwitch):
 
 
 class SolarOfThingsBackupModeSwitch(_BaseSwitch):
+    """Switch that maps to Output Source Priority SBU (backup/off-grid biased).
+
+    ON  → outputSourcePrioritySetting = 2 (SBU: Solar+Battery first, grid last).
+    OFF → outputSourcePrioritySetting = 1 (SUB: Solar first, grid as supplement).
+
+    Note: turning this switch ON will also change the Operating Mode select entity
+    to 'Solar+Battery First (SBU)', which is the expected behaviour.
+    """
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(api, coordinator, station_id, device_id, device_name)
-        self._attr_name = f"{device_name} Backup Mode"
+        self._attr_name = f"{device_name} Backup Mode (SBU Priority)"
         self._attr_unique_id = f"{DOMAIN}_{station_id}_{device_id}_backup_mode"
         self._attr_device_class = SwitchDeviceClass.SWITCH
         self._attr_icon = "mdi:battery-lock"
 
     @property
-    def is_on(self):
-        return bool(((self.coordinator.data or {}).get("settings") or {}).get("backupModeEnabled", False))
+    def is_on(self) -> bool | None:
+        val = _setting_value(self.coordinator.data, "outputSourcePrioritySetting")
+        if val is None:
+            return None
+        return val == 2  # SBU
 
     async def async_turn_on(self, **kwargs):
         await self.hass.async_add_executor_job(self._api.set_backup_mode, self._device_id, True)