Ver código fonte

fix: resolve per-firmware write-key aliases and gate unsupported controls (#18, #20) (#27)

lukaszkwapien's full writable-config dump for a FCHAO inverter (#18) confirmed
the same class of bug #13 fixed for sensor reads exists on the write path:
this integration always sent one hardcoded key name per control, but FCHAO
exposes Output Source Priority as setOutputSourcePriority (not the documented
outputSourcePrioritySetting), and doesn't expose Battery Charge/Discharge
Limit or Grid Charge Limit under any key name at all.

Adds SETTING_KEY_ALIASES + resolve_setting_key() (mirrors the read-path
ALIAS_GROUPS pattern), threaded through every set_* API method and the
select/switch/number entities' reads and availability. An entity whose
control isn't exposed under any known name now reports unavailable instead
of accepting a write guaranteed to fail with code=70134.

Also fixes a real, previously untested bug in number.py: native_value was
returning the whole settings object instead of its numeric "value" field, so
Battery Charge Limit / Battery Discharge Limit / Grid Charge Limit never
displayed correctly even on devices that fully support them.
Bob Ehlers 1 mês atrás
pai
commit
29e471f518

+ 33 - 0
CHANGELOG.md

@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 
 ---
 ---
 
 
+## [2.7.0] - 2026-09-15
+
+### Fixed
+- **Writing a control setting failed with `code=70134 "Config attribute not
+  exists"` on some firmware** — the integration always sent one hardcoded
+  key name per control (e.g. `outputSourcePrioritySetting`), but
+  lukaszkwapien's full writable-config dump for a FCHAO inverter (#18)
+  confirmed that firmware exposes the same control as
+  `setOutputSourcePriority` instead, and does not expose Battery Charge
+  Limit, Battery Discharge Limit, or Grid Charge Limit under **any** key
+  name at all. This is the same class of bug #13 fixed for sensor reads,
+  now fixed for control writes and their read-back too: each control now
+  resolves the real key name from the device's own writable-config listing
+  (already fetched every update cycle), and an entity whose control isn't
+  exposed under any known name now reports **unavailable** in Home
+  Assistant instead of accepting a click that is guaranteed to fail.
+- **Battery Charge Limit, Battery Discharge Limit, and Grid Charge Limit
+  number entities never displayed a value, even on devices that fully
+  support them.** `native_value` was returning the entire settings object
+  (`{"key", "value", "valueDisplay", ...}`) instead of extracting the numeric
+  `value` field — the same object every other read path already correctly
+  unwraps. Existing configured limits will now show correctly.
+
+### Notes
+- Every device that worked before this release keeps sending exactly the
+  same key names it always has — the alias table only adds alternates, and
+  falls back to the original documented key when nothing else is known.
+- If your inverter exposes a control under yet another key name, the
+  affected entity will now show as unavailable rather than fail silently or
+  loudly — please open an issue with your device's writable-config listing
+  (`GET /apis/remote/device/configs/cache/get?deviceId=<yours>`) so it can be
+  added.
+
 ## [2.6.1] - 2026-09-15
 ## [2.6.1] - 2026-09-15
 
 
 ### Fixed
 ### Fixed

+ 82 - 17
custom_components/solar_of_things/api.py

@@ -94,6 +94,7 @@ from .const import (
     API_ENERGY_FLOW,
     API_ENERGY_FLOW,
     ENERGY_FLOW_RULES,
     ENERGY_FLOW_RULES,
     REALTIME_PROBE_KEYS,
     REALTIME_PROBE_KEYS,
+    SETTING_KEY_ALIASES,
     IOT_APP_ID,
     IOT_APP_ID,
     IOT_APP_SECRET_ENC,
     IOT_APP_SECRET_ENC,
     TOKEN_REFRESH_LEAD_SECONDS,
     TOKEN_REFRESH_LEAD_SECONDS,
@@ -306,6 +307,29 @@ def map_energy_flow_fields(fields: Any) -> dict[str, float]:
     return mapped
     return mapped
 
 
 
 
+def resolve_setting_key(settings: Any, canonical: str) -> str | None:
+    """Return whichever alias of `canonical` is actually present in `settings`.
+
+    `settings` is the raw {settingKey: settingObject} dict from
+    get_device_settings(). Pure function: no network access, no instance
+    state, so read (current value) and write (which key to send) always agree
+    on the exact same resolution.
+
+    Returns None when the device's writable-config listing doesn't expose any
+    known alias of this setting at all (issue #18: a FCHAO inverter doesn't
+    expose batteryChargeLimit / batteryDischargeLimit / gridChargeLimit under
+    ANY known name) — callers use this to report the entity unavailable
+    rather than send a write that is guaranteed to fail with
+    code=70134 "Config attribute not exists".
+    """
+    if not isinstance(settings, dict):
+        return None
+    for candidate in SETTING_KEY_ALIASES.get(canonical, (canonical,)):
+        if candidate in settings:
+            return candidate
+    return None
+
+
 def has_realtime_values(values: Any) -> bool:
 def has_realtime_values(values: Any) -> bool:
     """Return True if any canonical realtime key carries a usable number."""
     """Return True if any canonical realtime key carries a usable number."""
     if not isinstance(values, dict):
     if not isinstance(values, dict):
@@ -1048,47 +1072,88 @@ class SolarOfThingsAPI:
     }
     }
     _CHARGER_PRIORITY_REVERSE: dict[int, str] = {v: k for k, v in _CHARGER_PRIORITY_MAP.items()}
     _CHARGER_PRIORITY_REVERSE: dict[int, str] = {v: k for k, v in _CHARGER_PRIORITY_MAP.items()}
 
 
-    def set_operating_mode(self, device_id: str, mode: str) -> None:
-        """Set Output Source Priority.  mode is one of _OUTPUT_MODE_MAP keys."""
+    def _resolve_write_key(self, canonical: str, settings: dict[str, Any] | None) -> str:
+        """Return the actual writable-config key name to send for `canonical`.
+
+        Falls back to the canonical name itself when `settings` wasn't passed
+        or resolves to nothing — this keeps every device that already worked
+        before #18 sending exactly the same key it always has.
+        """
+        if settings is None:
+            return canonical
+        return resolve_setting_key(settings, canonical) or canonical
+
+    def set_operating_mode(
+        self, device_id: str, mode: str, settings: dict[str, Any] | None = None
+    ) -> None:
+        """Set Output Source Priority.  mode is one of _OUTPUT_MODE_MAP keys.
+
+        `settings` is the coordinator's cached get_device_settings() result;
+        pass it so a device whose firmware exposes this control under a
+        different key name (e.g. FCHAO's `setOutputSourcePriority`, #18) gets
+        the working key instead of the documented one.
+        """
         value = self._OUTPUT_MODE_MAP.get(mode)
         value = self._OUTPUT_MODE_MAP.get(mode)
         if value is None:
         if value is None:
             raise ValueError(f"Unknown operating mode: {mode!r}. "
             raise ValueError(f"Unknown operating mode: {mode!r}. "
                              f"Valid options: {list(self._OUTPUT_MODE_MAP)!r}")
                              f"Valid options: {list(self._OUTPUT_MODE_MAP)!r}")
-        self._write_setting(device_id, "outputSourcePrioritySetting", value)
+        key = self._resolve_write_key("outputSourcePrioritySetting", settings)
+        self._write_setting(device_id, key, value)
 
 
-    def set_battery_priority(self, device_id: str, mode: str) -> None:
+    def set_battery_priority(
+        self, device_id: str, mode: str, settings: dict[str, Any] | None = None
+    ) -> None:
         """Set Charger Source Priority.  mode is one of _CHARGER_PRIORITY_MAP keys."""
         """Set Charger Source Priority.  mode is one of _CHARGER_PRIORITY_MAP keys."""
         value = self._CHARGER_PRIORITY_MAP.get(mode)
         value = self._CHARGER_PRIORITY_MAP.get(mode)
         if value is None:
         if value is None:
             raise ValueError(f"Unknown battery priority: {mode!r}. "
             raise ValueError(f"Unknown battery priority: {mode!r}. "
                              f"Valid options: {list(self._CHARGER_PRIORITY_MAP)!r}")
                              f"Valid options: {list(self._CHARGER_PRIORITY_MAP)!r}")
-        self._write_setting(device_id, "chargerSourcePrioritySetting", value)
+        key = self._resolve_write_key("chargerSourcePrioritySetting", settings)
+        self._write_setting(device_id, key, value)
 
 
-    def set_grid_charging(self, device_id: str, enabled: bool) -> None:
+    def set_grid_charging(
+        self, device_id: str, enabled: bool, settings: dict[str, Any] | None = None
+    ) -> None:
         """Set AC Input Range: Appliance (0, grid charging allowed) / UPS (1, bypass)."""
         """Set AC Input Range: Appliance (0, grid charging allowed) / UPS (1, bypass)."""
-        self._write_setting(device_id, "acInputRangeSetting", 0 if enabled else 1)
+        key = self._resolve_write_key("acInputRangeSetting", settings)
+        self._write_setting(device_id, key, 0 if enabled else 1)
 
 
-    def set_grid_feed_in(self, device_id: str, enabled: bool) -> None:
+    def set_grid_feed_in(
+        self, device_id: str, enabled: bool, settings: dict[str, Any] | None = None
+    ) -> None:
         """Enable or disable the GRID grid switch (batteryPowerLimitingSetting)."""
         """Enable or disable the GRID grid switch (batteryPowerLimitingSetting)."""
-        self._write_setting(device_id, "batteryPowerLimitingSetting", 1 if enabled else 0)
+        key = self._resolve_write_key("batteryPowerLimitingSetting", settings)
+        self._write_setting(device_id, key, 1 if enabled else 0)
 
 
-    def set_backup_mode(self, device_id: str, enabled: bool) -> None:
+    def set_backup_mode(
+        self, device_id: str, enabled: bool, settings: dict[str, Any] | None = None
+    ) -> None:
         """Set Output Source Priority to SBU (backup/off-grid priority) when True,
         """Set Output Source Priority to SBU (backup/off-grid priority) when True,
         or SUB (solar-first, grid-supplemented) when False."""
         or SUB (solar-first, grid-supplemented) when False."""
         value = 2 if enabled else 1   # SBU=2 (battery before grid), SUB=1
         value = 2 if enabled else 1   # SBU=2 (battery before grid), SUB=1
-        self._write_setting(device_id, "outputSourcePrioritySetting", value)
+        key = self._resolve_write_key("outputSourcePrioritySetting", settings)
+        self._write_setting(device_id, key, value)
 
 
-    def set_battery_charge_limit(self, device_id: str, percent: int) -> None:
+    def set_battery_charge_limit(
+        self, device_id: str, percent: int, settings: dict[str, Any] | None = None
+    ) -> None:
         """Set battery charge limit (0–100 %)."""
         """Set battery charge limit (0–100 %)."""
-        self._write_setting(device_id, "batteryChargeLimit", percent)
+        key = self._resolve_write_key("batteryChargeLimit", settings)
+        self._write_setting(device_id, key, percent)
 
 
-    def set_battery_discharge_limit(self, device_id: str, percent: int) -> None:
+    def set_battery_discharge_limit(
+        self, device_id: str, percent: int, settings: dict[str, Any] | None = None
+    ) -> None:
         """Set battery discharge limit / minimum SOC (0–100 %)."""
         """Set battery discharge limit / minimum SOC (0–100 %)."""
-        self._write_setting(device_id, "batteryDischargeLimit", percent)
+        key = self._resolve_write_key("batteryDischargeLimit", settings)
+        self._write_setting(device_id, key, percent)
 
 
-    def set_grid_charge_limit(self, device_id: str, watts: int) -> None:
+    def set_grid_charge_limit(
+        self, device_id: str, watts: int, settings: dict[str, Any] | None = None
+    ) -> None:
         """Set maximum grid charge power (0–5000 W)."""
         """Set maximum grid charge power (0–5000 W)."""
-        self._write_setting(device_id, "gridChargeLimit", watts)
+        key = self._resolve_write_key("gridChargeLimit", settings)
+        self._write_setting(device_id, key, watts)
 
 
     def test_connection(self, station_id: str) -> bool:
     def test_connection(self, station_id: str) -> bool:
         """Return True if we can reach the device-list endpoint successfully."""
         """Return True if we can reach the device-list endpoint successfully."""

+ 25 - 0
custom_components/solar_of_things/const.py

@@ -279,3 +279,28 @@ SENSOR_DEFINITIONS = {
         "icon": "mdi:percent",
         "icon": "mdi:percent",
     },
     },
 }
 }
+
+# ─── Device-setting key aliases (write path) ───────────────────────────────────
+# Several inverter firmwares expose the same logical control under a different
+# writable-config key name than the one this integration was written against —
+# the same class of bug #13 already fixed for sensor *reads*, but here for
+# control *writes* (and their matching read-back for current state). Confirmed
+# by lukaszkwapien's full writable-config dump for a FCHAO inverter (#18):
+#   Output Source Priority: the documented `outputSourcePrioritySetting` fails
+#   with `code=70134 "Config attribute not exists"` on this firmware, which
+#   exposes the same control as `setOutputSourcePriority` instead.
+# Every other control below has no confirmed alternate name yet — its tuple is
+# just itself. The same dump also confirmed FCHAO does not expose
+# batteryChargeLimit / batteryDischargeLimit / gridChargeLimit under ANY name;
+# for those, resolve_setting_key() in api.py returning None is used to mark
+# the entity unavailable rather than let the user trigger a write that is
+# guaranteed to fail with the same portal error.
+SETTING_KEY_ALIASES: dict[str, tuple[str, ...]] = {
+    "outputSourcePrioritySetting": ("outputSourcePrioritySetting", "setOutputSourcePriority"),
+    "chargerSourcePrioritySetting": ("chargerSourcePrioritySetting",),
+    "acInputRangeSetting": ("acInputRangeSetting",),
+    "batteryPowerLimitingSetting": ("batteryPowerLimitingSetting",),
+    "batteryChargeLimit": ("batteryChargeLimit",),
+    "batteryDischargeLimit": ("batteryDischargeLimit",),
+    "gridChargeLimit": ("gridChargeLimit",),
+}

+ 1 - 1
custom_components/solar_of_things/manifest.json

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

+ 46 - 15
custom_components/solar_of_things/number.py

@@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
 
+from .api import resolve_setting_key
 from .const import DOMAIN
 from .const import DOMAIN
 
 
 _LOGGER = logging.getLogger(__name__)
 _LOGGER = logging.getLogger(__name__)
@@ -43,6 +44,13 @@ async def async_setup_entry(
 
 
 
 
 class _BaseNumber(CoordinatorEntity, NumberEntity):
 class _BaseNumber(CoordinatorEntity, NumberEntity):
+    # Canonical setting key this entity controls (set by subclasses via
+    # _setting_key). See resolve_setting_key() in api.py: lets a firmware
+    # that exposes this control under a different key name still work, and a
+    # firmware that doesn't expose it under any known name report
+    # unavailable instead of offering a control guaranteed to fail.
+    _setting_key: str = ""
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(coordinator)
         super().__init__(coordinator)
         self._api = api
         self._api = api
@@ -60,6 +68,32 @@ class _BaseNumber(CoordinatorEntity, NumberEntity):
             "via_device": (DOMAIN, self._station_id),
             "via_device": (DOMAIN, self._station_id),
         }
         }
 
 
+    @property
+    def available(self) -> bool:
+        if not super().available:
+            return False
+        settings = (self.coordinator.data or {}).get("settings")
+        return resolve_setting_key(settings, self._setting_key) is not None
+
+    @property
+    def native_value(self):
+        """Return the setting's numeric value.
+
+        get_device_settings() returns {key: {"key", "value", "valueDisplay",
+        ...}} per key, not a bare number — this was previously returning that
+        whole object, which HA's number platform cannot render as a value.
+        """
+        settings = ((self.coordinator.data or {}).get("settings") or {})
+        key = resolve_setting_key(settings, self._setting_key)
+        entry = settings.get(key) if key else None
+        if entry is None:
+            return None
+        raw = entry.get("value") if isinstance(entry, dict) else entry
+        try:
+            return float(raw)
+        except (TypeError, ValueError):
+            return None
+
 
 
 class SolarOfThingsBatteryChargeLimitNumber(_BaseNumber):
 class SolarOfThingsBatteryChargeLimitNumber(_BaseNumber):
     _setting_key = "batteryChargeLimit"
     _setting_key = "batteryChargeLimit"
@@ -75,12 +109,11 @@ class SolarOfThingsBatteryChargeLimitNumber(_BaseNumber):
         self._attr_mode = NumberMode.SLIDER
         self._attr_mode = NumberMode.SLIDER
         self._attr_icon = "mdi:battery-arrow-up"
         self._attr_icon = "mdi:battery-arrow-up"
 
 
-    @property
-    def native_value(self):
-        return ((self.coordinator.data or {}).get("settings") or {}).get(self._setting_key)
-
     async def async_set_native_value(self, value: float) -> None:
     async def async_set_native_value(self, value: float) -> None:
-        await self.hass.async_add_executor_job(self._api.set_battery_charge_limit, self._device_id, int(value))
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(
+            self._api.set_battery_charge_limit, self._device_id, int(value), settings
+        )
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()
 
 
 
 
@@ -98,12 +131,11 @@ class SolarOfThingsBatteryDischargeLimitNumber(_BaseNumber):
         self._attr_mode = NumberMode.SLIDER
         self._attr_mode = NumberMode.SLIDER
         self._attr_icon = "mdi:battery-arrow-down"
         self._attr_icon = "mdi:battery-arrow-down"
 
 
-    @property
-    def native_value(self):
-        return ((self.coordinator.data or {}).get("settings") or {}).get(self._setting_key)
-
     async def async_set_native_value(self, value: float) -> None:
     async def async_set_native_value(self, value: float) -> None:
-        await self.hass.async_add_executor_job(self._api.set_battery_discharge_limit, self._device_id, int(value))
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(
+            self._api.set_battery_discharge_limit, self._device_id, int(value), settings
+        )
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()
 
 
 
 
@@ -122,10 +154,9 @@ class SolarOfThingsGridChargeLimitNumber(_BaseNumber):
         self._attr_device_class = NumberDeviceClass.POWER
         self._attr_device_class = NumberDeviceClass.POWER
         self._attr_icon = "mdi:transmission-tower-import"
         self._attr_icon = "mdi:transmission-tower-import"
 
 
-    @property
-    def native_value(self):
-        return ((self.coordinator.data or {}).get("settings") or {}).get(self._setting_key)
-
     async def async_set_native_value(self, value: float) -> None:
     async def async_set_native_value(self, value: float) -> None:
-        await self.hass.async_add_executor_job(self._api.set_grid_charge_limit, self._device_id, int(value))
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(
+            self._api.set_grid_charge_limit, self._device_id, int(value), settings
+        )
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()

+ 31 - 4
custom_components/solar_of_things/select.py

@@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
 
+from .api import resolve_setting_key
 from .const import DOMAIN
 from .const import DOMAIN
 
 
 _LOGGER = logging.getLogger(__name__)
 _LOGGER = logging.getLogger(__name__)
@@ -57,6 +58,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
 
 
 
 
 class _BaseSelect(CoordinatorEntity, SelectEntity):
 class _BaseSelect(CoordinatorEntity, SelectEntity):
+    # Canonical setting key this entity controls (set by subclasses). Used
+    # with resolve_setting_key() so a firmware that exposes the same control
+    # under a different key name (#18) still reads and writes correctly, and
+    # a firmware that doesn't expose it under any known name reports
+    # unavailable instead of offering a control guaranteed to fail.
+    _canonical_key: str = ""
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(coordinator)
         super().__init__(coordinator)
         self._api = api
         self._api = api
@@ -74,6 +82,13 @@ class _BaseSelect(CoordinatorEntity, SelectEntity):
             "via_device": (DOMAIN, self._station_id),
             "via_device": (DOMAIN, self._station_id),
         }
         }
 
 
+    @property
+    def available(self) -> bool:
+        if not super().available:
+            return False
+        settings = (self.coordinator.data or {}).get("settings")
+        return resolve_setting_key(settings, self._canonical_key) is not None
+
 
 
 class SolarOfThingsOperatingModeSelect(_BaseSelect):
 class SolarOfThingsOperatingModeSelect(_BaseSelect):
     """Select entity for Output Source Priority (outputSourcePrioritySetting).
     """Select entity for Output Source Priority (outputSourcePrioritySetting).
@@ -81,6 +96,8 @@ class SolarOfThingsOperatingModeSelect(_BaseSelect):
     Reflects the real device API key.  Values 0/1/2 map to USO/SUB/SBU.
     Reflects the real device API key.  Values 0/1/2 map to USO/SUB/SBU.
     """
     """
 
 
+    _canonical_key = "outputSourcePrioritySetting"
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
     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)
         super().__init__(api, coordinator, station_id, device_id, device_name)
         self._attr_name = f"{device_name} Output Source Priority"
         self._attr_name = f"{device_name} Output Source Priority"
@@ -91,7 +108,8 @@ class SolarOfThingsOperatingModeSelect(_BaseSelect):
     @property
     @property
     def current_option(self) -> str | None:
     def current_option(self) -> str | None:
         settings = ((self.coordinator.data or {}).get("settings") or {})
         settings = ((self.coordinator.data or {}).get("settings") or {})
-        entry = settings.get("outputSourcePrioritySetting")
+        key = resolve_setting_key(settings, self._canonical_key)
+        entry = settings.get(key) if key else None
         if entry is None:
         if entry is None:
             return None
             return None
         raw = entry.get("value") if isinstance(entry, dict) else entry
         raw = entry.get("value") if isinstance(entry, dict) else entry
@@ -101,7 +119,10 @@ class SolarOfThingsOperatingModeSelect(_BaseSelect):
             return None
             return None
 
 
     async def async_select_option(self, option: str) -> 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)
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(
+            self._api.set_operating_mode, self._device_id, option, settings
+        )
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()
 
 
 
 
@@ -111,6 +132,8 @@ class SolarOfThingsBatteryPrioritySelect(_BaseSelect):
     Reflects the real device API key.  Values 0/1/2 map to CSO/SNU/OSO.
     Reflects the real device API key.  Values 0/1/2 map to CSO/SNU/OSO.
     """
     """
 
 
+    _canonical_key = "chargerSourcePrioritySetting"
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
     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)
         super().__init__(api, coordinator, station_id, device_id, device_name)
         self._attr_name = f"{device_name} Charger Source Priority"
         self._attr_name = f"{device_name} Charger Source Priority"
@@ -121,7 +144,8 @@ class SolarOfThingsBatteryPrioritySelect(_BaseSelect):
     @property
     @property
     def current_option(self) -> str | None:
     def current_option(self) -> str | None:
         settings = ((self.coordinator.data or {}).get("settings") or {})
         settings = ((self.coordinator.data or {}).get("settings") or {})
-        entry = settings.get("chargerSourcePrioritySetting")
+        key = resolve_setting_key(settings, self._canonical_key)
+        entry = settings.get(key) if key else None
         if entry is None:
         if entry is None:
             return None
             return None
         raw = entry.get("value") if isinstance(entry, dict) else entry
         raw = entry.get("value") if isinstance(entry, dict) else entry
@@ -131,5 +155,8 @@ class SolarOfThingsBatteryPrioritySelect(_BaseSelect):
             return None
             return None
 
 
     async def async_select_option(self, option: str) -> 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)
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(
+            self._api.set_battery_priority, self._device_id, option, settings
+        )
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()

+ 45 - 12
custom_components/solar_of_things/switch.py

@@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
 
+from .api import resolve_setting_key
 from .const import DOMAIN
 from .const import DOMAIN
 
 
 _LOGGER = logging.getLogger(__name__)
 _LOGGER = logging.getLogger(__name__)
@@ -35,10 +36,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
     async_add_entities(entities)
     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."""
+def _setting_value(coordinator_data: dict | None, canonical_key: str) -> int | None:
+    """Extract the integer value for a canonical setting from coordinator data.
+
+    Resolves through resolve_setting_key() so a firmware that exposes this
+    control under a different key name (#18) still reads its real state
+    instead of showing unknown.
+    """
     settings = ((coordinator_data or {}).get("settings") or {})
     settings = ((coordinator_data or {}).get("settings") or {})
-    entry = settings.get(key)
+    key = resolve_setting_key(settings, canonical_key)
+    entry = settings.get(key) if key else None
     if entry is None:
     if entry is None:
         return None
         return None
     raw = entry.get("value") if isinstance(entry, dict) else entry
     raw = entry.get("value") if isinstance(entry, dict) else entry
@@ -49,6 +56,13 @@ def _setting_value(coordinator_data: dict | None, key: str) -> int | None:
 
 
 
 
 class _BaseSwitch(CoordinatorEntity, SwitchEntity):
 class _BaseSwitch(CoordinatorEntity, SwitchEntity):
+    # Canonical setting key this entity controls (set by subclasses). See
+    # resolve_setting_key() in api.py: lets a firmware that exposes this
+    # control under a different key name still work, and a firmware that
+    # doesn't expose it under any known name report unavailable instead of
+    # offering a control guaranteed to fail.
+    _canonical_key: str = ""
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
         super().__init__(coordinator)
         super().__init__(coordinator)
         self._api = api
         self._api = api
@@ -66,6 +80,13 @@ class _BaseSwitch(CoordinatorEntity, SwitchEntity):
             "via_device": (DOMAIN, self._station_id),
             "via_device": (DOMAIN, self._station_id),
         }
         }
 
 
+    @property
+    def available(self) -> bool:
+        if not super().available:
+            return False
+        settings = (self.coordinator.data or {}).get("settings")
+        return resolve_setting_key(settings, self._canonical_key) is not None
+
 
 
 class SolarOfThingsGridChargingSwitch(_BaseSwitch):
 class SolarOfThingsGridChargingSwitch(_BaseSwitch):
     """Switch for AC Input Range setting (acInputRangeSetting).
     """Switch for AC Input Range setting (acInputRangeSetting).
@@ -75,6 +96,8 @@ class SolarOfThingsGridChargingSwitch(_BaseSwitch):
     The switch reports ON when the inverter is in Appliance (grid-charging) mode.
     The switch reports ON when the inverter is in Appliance (grid-charging) mode.
     """
     """
 
 
+    _canonical_key = "acInputRangeSetting"
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
     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)
         super().__init__(api, coordinator, station_id, device_id, device_name)
         self._attr_name = f"{device_name} Grid Charging (AC Input Range)"
         self._attr_name = f"{device_name} Grid Charging (AC Input Range)"
@@ -84,17 +107,19 @@ class SolarOfThingsGridChargingSwitch(_BaseSwitch):
 
 
     @property
     @property
     def is_on(self) -> bool | None:
     def is_on(self) -> bool | None:
-        val = _setting_value(self.coordinator.data, "acInputRangeSetting")
+        val = _setting_value(self.coordinator.data, self._canonical_key)
         if val is None:
         if val is None:
             return None
             return None
         return val == 0  # 0=Appliance (charging OK), 1=UPS (bypass)
         return val == 0  # 0=Appliance (charging OK), 1=UPS (bypass)
 
 
     async def async_turn_on(self, **kwargs):
     async def async_turn_on(self, **kwargs):
-        await self.hass.async_add_executor_job(self._api.set_grid_charging, self._device_id, True)
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(self._api.set_grid_charging, self._device_id, True, settings)
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()
 
 
     async def async_turn_off(self, **kwargs):
     async def async_turn_off(self, **kwargs):
-        await self.hass.async_add_executor_job(self._api.set_grid_charging, self._device_id, False)
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(self._api.set_grid_charging, self._device_id, False, settings)
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()
 
 
 
 
@@ -105,6 +130,8 @@ class SolarOfThingsGridFeedInSwitch(_BaseSwitch):
     1 = ON  (grid switch enabled / feed-in on).
     1 = ON  (grid switch enabled / feed-in on).
     """
     """
 
 
+    _canonical_key = "batteryPowerLimitingSetting"
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
     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)
         super().__init__(api, coordinator, station_id, device_id, device_name)
         self._attr_name = f"{device_name} Grid Feed-In"
         self._attr_name = f"{device_name} Grid Feed-In"
@@ -114,17 +141,19 @@ class SolarOfThingsGridFeedInSwitch(_BaseSwitch):
 
 
     @property
     @property
     def is_on(self) -> bool | None:
     def is_on(self) -> bool | None:
-        val = _setting_value(self.coordinator.data, "batteryPowerLimitingSetting")
+        val = _setting_value(self.coordinator.data, self._canonical_key)
         if val is None:
         if val is None:
             return None
             return None
         return val == 1  # 1=ON
         return val == 1  # 1=ON
 
 
     async def async_turn_on(self, **kwargs):
     async def async_turn_on(self, **kwargs):
-        await self.hass.async_add_executor_job(self._api.set_grid_feed_in, self._device_id, True)
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(self._api.set_grid_feed_in, self._device_id, True, settings)
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()
 
 
     async def async_turn_off(self, **kwargs):
     async def async_turn_off(self, **kwargs):
-        await self.hass.async_add_executor_job(self._api.set_grid_feed_in, self._device_id, False)
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(self._api.set_grid_feed_in, self._device_id, False, settings)
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()
 
 
 
 
@@ -138,6 +167,8 @@ class SolarOfThingsBackupModeSwitch(_BaseSwitch):
     to 'Solar+Battery First (SBU)', which is the expected behaviour.
     to 'Solar+Battery First (SBU)', which is the expected behaviour.
     """
     """
 
 
+    _canonical_key = "outputSourcePrioritySetting"
+
     def __init__(self, api, coordinator, station_id: str, device_id: str, device_name: str) -> None:
     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)
         super().__init__(api, coordinator, station_id, device_id, device_name)
         self._attr_name = f"{device_name} Backup Mode (SBU Priority)"
         self._attr_name = f"{device_name} Backup Mode (SBU Priority)"
@@ -147,15 +178,17 @@ class SolarOfThingsBackupModeSwitch(_BaseSwitch):
 
 
     @property
     @property
     def is_on(self) -> bool | None:
     def is_on(self) -> bool | None:
-        val = _setting_value(self.coordinator.data, "outputSourcePrioritySetting")
+        val = _setting_value(self.coordinator.data, self._canonical_key)
         if val is None:
         if val is None:
             return None
             return None
         return val == 2  # SBU
         return val == 2  # SBU
 
 
     async def async_turn_on(self, **kwargs):
     async def async_turn_on(self, **kwargs):
-        await self.hass.async_add_executor_job(self._api.set_backup_mode, self._device_id, True)
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(self._api.set_backup_mode, self._device_id, True, settings)
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()
 
 
     async def async_turn_off(self, **kwargs):
     async def async_turn_off(self, **kwargs):
-        await self.hass.async_add_executor_job(self._api.set_backup_mode, self._device_id, False)
+        settings = (self.coordinator.data or {}).get("settings")
+        await self.hass.async_add_executor_job(self._api.set_backup_mode, self._device_id, False, settings)
         await self.coordinator.async_request_refresh()
         await self.coordinator.async_request_refresh()

+ 129 - 0
tests/test_setting_key_aliases.py

@@ -0,0 +1,129 @@
+"""Tests for the write-path device-setting key aliases (issue #18 / #20).
+
+Several inverter firmwares expose the same logical control under a different
+writable-config key name than the one this integration was written against —
+the same class of bug #13 fixed for sensor reads, here for control writes.
+Confirmed by lukaszkwapien's full writable-config dump for a FCHAO inverter
+(#18): Output Source Priority is `setOutputSourcePriority` on that firmware,
+not the documented `outputSourcePrioritySetting`, and several other controls
+(battery charge/discharge limit, grid charge limit) aren't exposed under any
+key name at all on that device.
+"""
+from __future__ import annotations
+
+import pytest
+
+from custom_components.solar_of_things.api import (
+    SolarOfThingsAPI,
+    resolve_setting_key,
+)
+from custom_components.solar_of_things.const import SETTING_KEY_ALIASES
+
+# Trimmed from lukaszkwapien's writable-config dump for a FCHAO inverter
+# (#18) — a real device that exposes Output Source Priority under the
+# alternate key and does not expose the three limit settings at all.
+FCHAO_SETTINGS = {
+    "setOutputSourcePriority": {"key": "setOutputSourcePriority", "value": 0, "valueDisplay": "Utilty"},
+    "chargeSourcePrioirty": {"key": "chargeSourcePrioirty", "value": 0, "valueDisplay": "Solar And Utility"},
+    "flotingChargingVoltageSetting": {"value": 54.0},
+}
+
+# A device that uses every documented key name as-is.
+DOCUMENTED_SETTINGS = {
+    "outputSourcePrioritySetting": {"key": "outputSourcePrioritySetting", "value": 1},
+    "chargerSourcePrioritySetting": {"key": "chargerSourcePrioritySetting", "value": 0},
+    "acInputRangeSetting": {"key": "acInputRangeSetting", "value": 0},
+    "batteryPowerLimitingSetting": {"key": "batteryPowerLimitingSetting", "value": 1},
+    "batteryChargeLimit": {"key": "batteryChargeLimit", "value": 80},
+    "batteryDischargeLimit": {"key": "batteryDischargeLimit", "value": 20},
+    "gridChargeLimit": {"key": "gridChargeLimit", "value": 3000},
+}
+
+
+# ─── resolve_setting_key ────────────────────────────────────────────────────
+
+def test_resolves_the_documented_key_when_present():
+    assert resolve_setting_key(DOCUMENTED_SETTINGS, "outputSourcePrioritySetting") == "outputSourcePrioritySetting"
+
+
+def test_resolves_the_fchao_alternate_key_for_output_source_priority():
+    assert resolve_setting_key(FCHAO_SETTINGS, "outputSourcePrioritySetting") == "setOutputSourcePriority"
+
+
+def test_documented_key_wins_when_a_device_has_both():
+    """The canonical name is tried first in SETTING_KEY_ALIASES — if a device
+    somehow exposed both, prefer the documented one rather than the alias."""
+    both = {**DOCUMENTED_SETTINGS, "setOutputSourcePriority": {"value": 9}}
+    assert resolve_setting_key(both, "outputSourcePrioritySetting") == "outputSourcePrioritySetting"
+
+
+@pytest.mark.parametrize(
+    "canonical",
+    ["batteryChargeLimit", "batteryDischargeLimit", "gridChargeLimit"],
+)
+def test_returns_none_when_a_device_exposes_neither_name(canonical):
+    """FCHAO's dump does not contain these keys under any known spelling —
+    must resolve to None so the entity can report unavailable, rather than
+    fall back to a guess that would send a write guaranteed to fail with
+    code=70134 "Config attribute not exists"."""
+    assert resolve_setting_key(FCHAO_SETTINGS, canonical) is None
+
+
+@pytest.mark.parametrize("settings", [None, {}, [], "text", 0])
+def test_hostile_input_resolves_to_none(settings):
+    assert resolve_setting_key(settings, "outputSourcePrioritySetting") is None
+
+
+def test_every_alias_group_lists_the_canonical_name_first():
+    """SETTING_KEY_ALIASES documents preference order; the canonical
+    (documented) key must always be tried before any alternate."""
+    for canonical, aliases in SETTING_KEY_ALIASES.items():
+        assert aliases[0] == canonical, canonical
+
+
+# ─── API write methods pick up the resolved key ────────────────────────────
+
+@pytest.fixture
+def api_stub():
+    """An API instance with _write_setting stubbed to record what it was
+    called with, instead of making a network request."""
+    api = SolarOfThingsAPI(iot_token="test-token")
+    calls = []
+    api._write_setting = lambda device_id, key, value: calls.append((device_id, key, value))
+    return api, calls
+
+
+def test_set_operating_mode_uses_the_fchao_alternate_key(api_stub):
+    api, calls = api_stub
+    api.set_operating_mode("device-1", "Utility First (USO)", settings=FCHAO_SETTINGS)
+    assert calls == [("device-1", "setOutputSourcePriority", 0)]
+
+
+def test_set_operating_mode_uses_the_documented_key_by_default(api_stub):
+    api, calls = api_stub
+    api.set_operating_mode("device-2", "Utility First (USO)", settings=DOCUMENTED_SETTINGS)
+    assert calls == [("device-2", "outputSourcePrioritySetting", 0)]
+
+
+def test_set_operating_mode_falls_back_to_the_documented_key_when_settings_is_none(api_stub):
+    """No settings passed (e.g. an older caller) -> unchanged pre-#18 behavior."""
+    api, calls = api_stub
+    api.set_operating_mode("device-3", "Solar First (SUB)", settings=None)
+    assert calls == [("device-3", "outputSourcePrioritySetting", 1)]
+
+
+def test_set_backup_mode_uses_the_fchao_alternate_key_too(api_stub):
+    """set_backup_mode controls the same underlying setting as
+    set_operating_mode and must resolve through the same alias."""
+    api, calls = api_stub
+    api.set_backup_mode("device-4", True, settings=FCHAO_SETTINGS)
+    assert calls == [("device-4", "setOutputSourcePriority", 2)]
+
+
+def test_set_battery_charge_limit_falls_back_when_key_is_confirmed_absent(api_stub):
+    """No known alternate exists for this key on FCHAO — _resolve_write_key
+    falls back to the documented name rather than raising, since preventing
+    the doomed call is the entity's `available=False` job, not this one's."""
+    api, calls = api_stub
+    api.set_battery_charge_limit("device-5", 80, settings=FCHAO_SETTINGS)
+    assert calls == [("device-5", "batteryChargeLimit", 80)]