From 8a8ff60d2aa645dca0c50c35967352140ebda621 Mon Sep 17 00:00:00 2001 From: ChristophCaina Date: Fri, 22 May 2026 17:23:51 +0000 Subject: [PATCH 1/6] feat: add SunEnergyXT 500 Series battery storage module --- .../modules/devices/sunenergyxt/__init__.py | 0 .../sunenergyxt/sunenergyxt/__init__.py | 0 .../devices/sunenergyxt/sunenergyxt/bat.py | 87 +++++++++++++++++++ .../devices/sunenergyxt/sunenergyxt/config.py | 40 +++++++++ .../devices/sunenergyxt/sunenergyxt/device.py | 31 +++++++ .../sunenergyxt/sunenergyxt/device.vue | 56 ++++++++++++ .../modules/devices/sunenergyxt/vendor.py | 11 +++ 7 files changed, 225 insertions(+) create mode 100644 packages/modules/devices/sunenergyxt/__init__.py create mode 100644 packages/modules/devices/sunenergyxt/sunenergyxt/__init__.py create mode 100644 packages/modules/devices/sunenergyxt/sunenergyxt/bat.py create mode 100644 packages/modules/devices/sunenergyxt/sunenergyxt/config.py create mode 100644 packages/modules/devices/sunenergyxt/sunenergyxt/device.py create mode 100644 packages/modules/devices/sunenergyxt/sunenergyxt/device.vue create mode 100644 packages/modules/devices/sunenergyxt/vendor.py diff --git a/packages/modules/devices/sunenergyxt/__init__.py b/packages/modules/devices/sunenergyxt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/__init__.py b/packages/modules/devices/sunenergyxt/sunenergyxt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/bat.py b/packages/modules/devices/sunenergyxt/sunenergyxt/bat.py new file mode 100644 index 0000000000..01945f82a0 --- /dev/null +++ b/packages/modules/devices/sunenergyxt/sunenergyxt/bat.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""SunEnergyXT 500 Series – openWB Batteriespeicher-Modul.""" +import logging +from typing import Any, Optional +import requests +from modules.common.abstract_device import AbstractBat +from modules.common.component_state import BatState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.simcount import SimCounter +from modules.common.store import get_bat_value_store +from modules.devices.sunenergyxt.sunenergyxt.config import SunEnergyXT, SunEnergyXTBatSetup + +log = logging.getLogger(__name__) + + +class SunEnergyXTBat(AbstractBat): + def __init__(self, component_config: SunEnergyXTBatSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs = kwargs + + def initialize(self) -> None: + self.device_config: SunEnergyXT = self.kwargs['device_config'] + self.sim_counter = SimCounter(self.device_config.id, self.component_config.id, prefix="speicher") + self.store = get_bat_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + self._base_url = ( + f"http://{self.device_config.configuration.ip_address}" + f":{self.device_config.configuration.port}" + ) + self._timeout = self.device_config.configuration.timeout + + def _read(self) -> dict: + url = f"{self._base_url}/read" + resp = requests.get(url, timeout=self._timeout) + resp.raise_for_status() + return resp.json() + + def _write(self, **kwargs) -> None: + url = f"{self._base_url}/write" + payload = {"state": kwargs} + resp = requests.post(url, json=payload, timeout=self._timeout) + resp.raise_for_status() + log.debug("SunEnergyXT write %s → %s", kwargs, resp.text) + + def update(self) -> None: + data = self._read() + reported = data.get("state", {}).get("reported", data) + + soc = int(float(reported.get("SC", 0))) + power = float(reported.get("PB", 0)) + max_power = float(reported.get("IS", 0)) + + imported, exported = self.sim_counter.sim_count(power) + + bat_state = BatState( + power=power, + soc=soc, + imported=imported, + exported=exported, + ) + bat_state.max_charge_power = max_power + bat_state.max_discharge_power = max_power + self.store.set(bat_state) + log.debug("SunEnergyXT: SoC=%d%%, PB=%.0fW, IS=%.0fW", soc, power, max_power) + + def set_power_limit(self, power_limit: Optional[int]) -> None: + if power_limit is None: + log.debug("SunEnergyXT: Automatik (MM=1, GS=0)") + self._write(MM=1, GS=0) + elif power_limit == 0: + log.debug("SunEnergyXT: Entladung gesperrt (MM=0, GS=0)") + self._write(MM=0, GS=0) + elif power_limit > 0: + p = int(min(power_limit, 9999)) + log.debug("SunEnergyXT: Entladen mit %dW", p) + self._write(MM=0, GS=p) + else: + p = int(min(abs(power_limit), 9999)) + log.debug("SunEnergyXT: Laden mit %dW", p) + self._write(MM=0, GS=-p) + + def power_limit_controllable(self) -> bool: + return True + + +component_descriptor = ComponentDescriptor(configuration_factory=SunEnergyXTBatSetup) diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/config.py b/packages/modules/devices/sunenergyxt/sunenergyxt/config.py new file mode 100644 index 0000000000..a42ba8aace --- /dev/null +++ b/packages/modules/devices/sunenergyxt/sunenergyxt/config.py @@ -0,0 +1,40 @@ +from typing import Optional +from modules.common.component_setup import ComponentSetup +from ..vendor import vendor_descriptor + + +class SunEnergyXTConfiguration: + def __init__(self, + ip_address: Optional[str] = "192.168.1.100", + port: int = 80, + timeout: int = 5): + self.ip_address = ip_address + self.port = port + self.timeout = timeout + + +class SunEnergyXT: + def __init__(self, + name: str = "SunEnergyXT 500 Series", + type: str = "sunenergyxt", + id: int = 0, + configuration: SunEnergyXTConfiguration = None) -> None: + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or SunEnergyXTConfiguration() + + +class SunEnergyXTBatConfiguration: + def __init__(self): + pass + + +class SunEnergyXTBatSetup(ComponentSetup[SunEnergyXTBatConfiguration]): + def __init__(self, + name: str = "SunEnergyXT Speicher", + type: str = "bat", + id: int = 0, + configuration: SunEnergyXTBatConfiguration = None) -> None: + super().__init__(name, type, id, configuration or SunEnergyXTBatConfiguration()) diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/device.py b/packages/modules/devices/sunenergyxt/sunenergyxt/device.py new file mode 100644 index 0000000000..f0a69ddd1d --- /dev/null +++ b/packages/modules/devices/sunenergyxt/sunenergyxt/device.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.configurable_device import ComponentFactoryByType, ConfigurableDevice, MultiComponentUpdater +from modules.devices.sunenergyxt.sunenergyxt.bat import SunEnergyXTBat +from modules.devices.sunenergyxt.sunenergyxt.config import SunEnergyXT, SunEnergyXTBatSetup + +log = logging.getLogger(__name__) + + +def create_device(device_config: SunEnergyXT): + def create_bat_component(component_config: SunEnergyXTBatSetup): + return SunEnergyXTBat(component_config, device_config=device_config) + + def update_components(components: Iterable[SunEnergyXTBat]): + for component in components: + with SingleComponentUpdateContext(component.fault_state): + component.update() + + return ConfigurableDevice( + device_config=device_config, + component_factory=ComponentFactoryByType( + bat=create_bat_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=SunEnergyXT) diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/device.vue b/packages/modules/devices/sunenergyxt/sunenergyxt/device.vue new file mode 100644 index 0000000000..fa2bddeb5c --- /dev/null +++ b/packages/modules/devices/sunenergyxt/sunenergyxt/device.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/modules/devices/sunenergyxt/vendor.py b/packages/modules/devices/sunenergyxt/vendor.py new file mode 100644 index 0000000000..50df2f6d64 --- /dev/null +++ b/packages/modules/devices/sunenergyxt/vendor.py @@ -0,0 +1,11 @@ +from pathlib import Path +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "SunEnergyXT" + self.group = VendorGroup.VENDORS.value + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) From 728878378578f183db175c57a1948ff84cc2a45e Mon Sep 17 00:00:00 2001 From: Christoph Date: Thu, 28 May 2026 00:22:16 +0200 Subject: [PATCH 2/6] Delete packages/modules/devices/sunenergyxt/sunenergyxt/device.vue --- .../sunenergyxt/sunenergyxt/device.vue | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 packages/modules/devices/sunenergyxt/sunenergyxt/device.vue diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/device.vue b/packages/modules/devices/sunenergyxt/sunenergyxt/device.vue deleted file mode 100644 index fa2bddeb5c..0000000000 --- a/packages/modules/devices/sunenergyxt/sunenergyxt/device.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - From 6f78f04d88c594616abc9197351a2632aa044b0a Mon Sep 17 00:00:00 2001 From: Christoph Date: Thu, 28 May 2026 00:25:22 +0200 Subject: [PATCH 3/6] Refactor SunEnergyXTConfiguration constructor parameters Removed default values for ip_address, port, and timeout in SunEnergyXTConfiguration. --- packages/modules/devices/sunenergyxt/sunenergyxt/config.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/config.py b/packages/modules/devices/sunenergyxt/sunenergyxt/config.py index a42ba8aace..63cb10e683 100644 --- a/packages/modules/devices/sunenergyxt/sunenergyxt/config.py +++ b/packages/modules/devices/sunenergyxt/sunenergyxt/config.py @@ -5,12 +5,8 @@ class SunEnergyXTConfiguration: def __init__(self, - ip_address: Optional[str] = "192.168.1.100", - port: int = 80, - timeout: int = 5): + ip_address: Optional[str] = None): self.ip_address = ip_address - self.port = port - self.timeout = timeout class SunEnergyXT: From efb4c745583bea62490900424effd9f1f8af63c1 Mon Sep 17 00:00:00 2001 From: Christoph Date: Thu, 28 May 2026 00:31:46 +0200 Subject: [PATCH 4/6] Refactor HTTP requests to use common module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(sunenergyxt): apply Copilot review suggestions to bat.py - Replace raw `requests` with `req.get_http_session()` to align with openWB HTTP session conventions (timeout, error handling) - Remove `max_charge_power` / `max_discharge_power` assignments – BatState does not expose these fields - Replace magic constant `9999` with dynamic `self._gs_max`, derived from device field IS (max. inverter power) on each update(). IS reflects both model (500 / 500 Pro) and number of modules (BN), so no manual calculation needed. Falls back to 800W until first update. - Remove `self._timeout` – timeout is now a fixed inline value (5s) --- .../devices/sunenergyxt/sunenergyxt/bat.py | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/bat.py b/packages/modules/devices/sunenergyxt/sunenergyxt/bat.py index 01945f82a0..82f5e4e33b 100644 --- a/packages/modules/devices/sunenergyxt/sunenergyxt/bat.py +++ b/packages/modules/devices/sunenergyxt/sunenergyxt/bat.py @@ -2,7 +2,8 @@ """SunEnergyXT 500 Series – openWB Batteriespeicher-Modul.""" import logging from typing import Any, Optional -import requests + +from modules.common import req from modules.common.abstract_device import AbstractBat from modules.common.component_state import BatState from modules.common.component_type import ComponentDescriptor @@ -13,6 +14,9 @@ log = logging.getLogger(__name__) +# Fallback-Limit bis IS vom Gerät gelesen wurde (SunEnergyXT 500, 1 Modul) +_GS_MAX_FALLBACK: int = 800 + class SunEnergyXTBat(AbstractBat): def __init__(self, component_config: SunEnergyXTBatSetup, **kwargs: Any) -> None: @@ -24,23 +28,19 @@ def initialize(self) -> None: self.sim_counter = SimCounter(self.device_config.id, self.component_config.id, prefix="speicher") self.store = get_bat_value_store(self.component_config.id) self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) - self._base_url = ( - f"http://{self.device_config.configuration.ip_address}" - f":{self.device_config.configuration.port}" - ) - self._timeout = self.device_config.configuration.timeout + self._base_url = f"http://{self.device_config.configuration.ip_address}" + # Wird beim ersten update() aus IS (Max. Inverterleistung) gesetzt. + # IS berücksichtigt automatisch Modell (500 / 500 Pro) und Anzahl Module (BN). + self._gs_max: int = _GS_MAX_FALLBACK def _read(self) -> dict: url = f"{self._base_url}/read" - resp = requests.get(url, timeout=self._timeout) - resp.raise_for_status() - return resp.json() + return req.get_http_session().get(url, timeout=5).json() def _write(self, **kwargs) -> None: url = f"{self._base_url}/write" payload = {"state": kwargs} - resp = requests.post(url, json=payload, timeout=self._timeout) - resp.raise_for_status() + resp = req.get_http_session().post(url, json=payload, timeout=5) log.debug("SunEnergyXT write %s → %s", kwargs, resp.text) def update(self) -> None: @@ -49,7 +49,12 @@ def update(self) -> None: soc = int(float(reported.get("SC", 0))) power = float(reported.get("PB", 0)) - max_power = float(reported.get("IS", 0)) + + # IS = max. Inverterleistung: hängt von Modell (500/Pro) und Modulanzahl (BN) ab. + # Wird als dynamisches GS-Limit verwendet. + is_value = int(float(reported.get("IS", _GS_MAX_FALLBACK))) + if is_value > 0: + self._gs_max = is_value imported, exported = self.sim_counter.sim_count(power) @@ -59,10 +64,8 @@ def update(self) -> None: imported=imported, exported=exported, ) - bat_state.max_charge_power = max_power - bat_state.max_discharge_power = max_power self.store.set(bat_state) - log.debug("SunEnergyXT: SoC=%d%%, PB=%.0fW, IS=%.0fW", soc, power, max_power) + log.debug("SunEnergyXT: SoC=%d%%, PB=%.0fW, IS=%dW (gs_max)", soc, power, self._gs_max) def set_power_limit(self, power_limit: Optional[int]) -> None: if power_limit is None: @@ -72,12 +75,12 @@ def set_power_limit(self, power_limit: Optional[int]) -> None: log.debug("SunEnergyXT: Entladung gesperrt (MM=0, GS=0)") self._write(MM=0, GS=0) elif power_limit > 0: - p = int(min(power_limit, 9999)) - log.debug("SunEnergyXT: Entladen mit %dW", p) + p = int(min(power_limit, self._gs_max)) + log.debug("SunEnergyXT: Entladen mit %dW (gs_max=%dW)", p, self._gs_max) self._write(MM=0, GS=p) else: - p = int(min(abs(power_limit), 9999)) - log.debug("SunEnergyXT: Laden mit %dW", p) + p = int(min(abs(power_limit), self._gs_max)) + log.debug("SunEnergyXT: Laden mit %dW (gs_max=%dW)", p, self._gs_max) self._write(MM=0, GS=-p) def power_limit_controllable(self) -> bool: From 05f4a5e0ee8f7af19df57b52ba49be1d22ae4ee7 Mon Sep 17 00:00:00 2001 From: Christoph Date: Thu, 28 May 2026 00:32:19 +0200 Subject: [PATCH 5/6] Remove unused logging import in device.py --- packages/modules/devices/sunenergyxt/sunenergyxt/device.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/device.py b/packages/modules/devices/sunenergyxt/sunenergyxt/device.py index f0a69ddd1d..f7812b8135 100644 --- a/packages/modules/devices/sunenergyxt/sunenergyxt/device.py +++ b/packages/modules/devices/sunenergyxt/sunenergyxt/device.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -import logging from typing import Iterable from modules.common.abstract_device import DeviceDescriptor from modules.common.component_context import SingleComponentUpdateContext @@ -7,8 +6,6 @@ from modules.devices.sunenergyxt.sunenergyxt.bat import SunEnergyXTBat from modules.devices.sunenergyxt.sunenergyxt.config import SunEnergyXT, SunEnergyXTBatSetup -log = logging.getLogger(__name__) - def create_device(device_config: SunEnergyXT): def create_bat_component(component_config: SunEnergyXTBatSetup): From e80f52015ac718ff7cd23a683e0a84eca26b5fe7 Mon Sep 17 00:00:00 2001 From: Christoph Date: Thu, 28 May 2026 00:43:33 +0200 Subject: [PATCH 6/6] Add files via upload --- .../sunenergyxt/SunEnergyXT500_bat_test.py | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 packages/modules/devices/sunenergyxt/sunenergyxt/SunEnergyXT500_bat_test.py diff --git a/packages/modules/devices/sunenergyxt/sunenergyxt/SunEnergyXT500_bat_test.py b/packages/modules/devices/sunenergyxt/sunenergyxt/SunEnergyXT500_bat_test.py new file mode 100644 index 0000000000..a73879ef91 --- /dev/null +++ b/packages/modules/devices/sunenergyxt/sunenergyxt/SunEnergyXT500_bat_test.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Unit tests for SunEnergyXT 500 Series battery module.""" +import pytest +import requests_mock as req_mock + +from unittest.mock import MagicMock, patch +from modules.devices.sunenergyxt.sunenergyxt.bat import SunEnergyXTBat +from modules.devices.sunenergyxt.sunenergyxt.config import ( + SunEnergyXT, + SunEnergyXTBatSetup, + SunEnergyXTConfiguration, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +BASE_URL = "http://192.168.1.100" + + +def _make_bat() -> SunEnergyXTBat: + """Create a SunEnergyXTBat instance with mocked stores.""" + device_config = SunEnergyXT( + configuration=SunEnergyXTConfiguration(ip_address="192.168.1.100") + ) + component_config = SunEnergyXTBatSetup() + bat = SunEnergyXTBat(component_config, device_config=device_config) + + # Mock internal helpers so no real HA infrastructure is needed + bat.sim_counter = MagicMock() + bat.sim_counter.sim_count.return_value = (1000.0, 500.0) + bat.store = MagicMock() + bat.fault_state = MagicMock() + bat._base_url = BASE_URL + bat._gs_max = 800 # Fallback-Wert wie in initialize() + + return bat + + +# --------------------------------------------------------------------------- +# update() – Parsing SC / PB / IS +# --------------------------------------------------------------------------- + +class TestUpdate: + def test_update_parses_soc_and_power(self, requests_mock): + """update() liest SC (SoC) und PB (Batteriepower) korrekt aus.""" + bat = _make_bat() + requests_mock.get( + f"{BASE_URL}/read", + json={"state": {"reported": {"SC": 75, "PB": -500, "IS": 800}}} + ) + + bat.update() + + bat.store.set.assert_called_once() + bat_state = bat.store.set.call_args[0][0] + assert bat_state.soc == 75 + assert bat_state.power == -500.0 + + def test_update_sets_gs_max_from_is(self, requests_mock): + """update() setzt self._gs_max dynamisch aus IS.""" + bat = _make_bat() + requests_mock.get( + f"{BASE_URL}/read", + json={"state": {"reported": {"SC": 50, "PB": 0, "IS": 2400}}} + ) + + bat.update() + + assert bat._gs_max == 2400 + + def test_update_keeps_fallback_if_is_zero(self, requests_mock): + """update() behält Fallback-_gs_max wenn IS=0 geliefert wird.""" + bat = _make_bat() + requests_mock.get( + f"{BASE_URL}/read", + json={"state": {"reported": {"SC": 50, "PB": 0, "IS": 0}}} + ) + + bat.update() + + assert bat._gs_max == 800 # Fallback bleibt + + def test_update_handles_flat_json(self, requests_mock): + """update() akzeptiert auch flaches JSON ohne 'state'/'reported'.""" + bat = _make_bat() + requests_mock.get( + f"{BASE_URL}/read", + json={"SC": 42, "PB": 300, "IS": 800} + ) + + bat.update() + + bat_state = bat.store.set.call_args[0][0] + assert bat_state.soc == 42 + assert bat_state.power == 300.0 + + def test_update_uses_simcount(self, requests_mock): + """update() ruft sim_counter.sim_count() auf und übergibt Werte.""" + bat = _make_bat() + bat.sim_counter.sim_count.return_value = (2000.0, 1000.0) + requests_mock.get( + f"{BASE_URL}/read", + json={"state": {"reported": {"SC": 80, "PB": -1000, "IS": 800}}} + ) + + bat.update() + + bat.sim_counter.sim_count.assert_called_once_with(-1000.0) + bat_state = bat.store.set.call_args[0][0] + assert bat_state.imported == 2000.0 + assert bat_state.exported == 1000.0 + + +# --------------------------------------------------------------------------- +# set_power_limit() – MM/GS Steuerung +# --------------------------------------------------------------------------- + +class TestSetPowerLimit: + def test_none_sets_automatic_mode(self, requests_mock): + """power_limit=None → MM=1, GS=0 (Self-Consumption).""" + bat = _make_bat() + requests_mock.post(f"{BASE_URL}/write", json={}) + + bat.set_power_limit(None) + + assert requests_mock.last_request.json() == {"state": {"MM": 1, "GS": 0}} + + def test_zero_stops_discharge(self, requests_mock): + """power_limit=0 → MM=0, GS=0 (Entladung gesperrt).""" + bat = _make_bat() + requests_mock.post(f"{BASE_URL}/write", json={}) + + bat.set_power_limit(0) + + assert requests_mock.last_request.json() == {"state": {"MM": 0, "GS": 0}} + + def test_positive_discharges(self, requests_mock): + """power_limit>0 → MM=0, GS=+p (Entladen).""" + bat = _make_bat() + bat._gs_max = 800 + requests_mock.post(f"{BASE_URL}/write", json={}) + + bat.set_power_limit(500) + + assert requests_mock.last_request.json() == {"state": {"MM": 0, "GS": 500}} + + def test_negative_charges(self, requests_mock): + """power_limit<0 → MM=0, GS=-p (Laden).""" + bat = _make_bat() + bat._gs_max = 800 + requests_mock.post(f"{BASE_URL}/write", json={}) + + bat.set_power_limit(-600) + + assert requests_mock.last_request.json() == {"state": {"MM": 0, "GS": -600}} + + def test_discharge_capped_at_gs_max(self, requests_mock): + """Entladeleistung wird auf _gs_max (IS) begrenzt.""" + bat = _make_bat() + bat._gs_max = 800 + requests_mock.post(f"{BASE_URL}/write", json={}) + + bat.set_power_limit(9999) # Weit über Limit + + payload = requests_mock.last_request.json() + assert payload["state"]["GS"] == 800 + + def test_charge_capped_at_gs_max(self, requests_mock): + """Ladeleistung wird auf _gs_max (IS) begrenzt.""" + bat = _make_bat() + bat._gs_max = 2400 + requests_mock.post(f"{BASE_URL}/write", json={}) + + bat.set_power_limit(-9999) # Weit über Limit + + payload = requests_mock.last_request.json() + assert payload["state"]["GS"] == -2400 + + def test_gs_max_reflects_pro_model(self, requests_mock): + """Nach update() mit IS=2400 (Pro) wird 2400W als Limit genutzt.""" + bat = _make_bat() + bat._gs_max = 2400 # Wie nach update() mit IS=2400 + requests_mock.post(f"{BASE_URL}/write", json={}) + + bat.set_power_limit(-2400) + + payload = requests_mock.last_request.json() + assert payload["state"]["GS"] == -2400 + + +# --------------------------------------------------------------------------- +# power_limit_controllable() +# --------------------------------------------------------------------------- + +def test_power_limit_controllable(): + """power_limit_controllable() muss True zurückgeben.""" + bat = _make_bat() + assert bat.power_limit_controllable() is True