From dd17ccbf2eaa6dfcca6077bb1e8b16c1996d97d9 Mon Sep 17 00:00:00 2001 From: Xoffroad Date: Wed, 25 Mar 2026 11:36:52 +0100 Subject: [PATCH 1/3] adding active battery control for qcells and new field for overload security --- packages/modules/devices/qcells/qcells/config.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/modules/devices/qcells/qcells/config.py b/packages/modules/devices/qcells/qcells/config.py index 445e253b07..0b28e97f7d 100644 --- a/packages/modules/devices/qcells/qcells/config.py +++ b/packages/modules/devices/qcells/qcells/config.py @@ -25,8 +25,13 @@ def __init__(self, class QCellsBatConfiguration: - def __init__(self): - pass + def __init__(self, max_power: int = 4000): + # Maximale Lade-/Entladeleistung des Speichers in Watt. + # Speichersteuerung via Solax Remote Control Mode 8 (Modbus). + # Unterstuetzte Hardware: QCells Q.VOLT HYB-G3-3P (Solax Gen4), + # Solax Gen4/Gen5/Gen6 Hybrid und AC Wechselrichter. + # Gen2/Gen3 werden nicht unterstuetzt (kein Remote Control). + self.max_power = max_power class QCellsBatSetup(ComponentSetup[QCellsBatConfiguration]): From 39a1d5ec1b206f9181daf1b727b9b5bf7fa93a57 Mon Sep 17 00:00:00 2001 From: Xoffroad Date: Thu, 26 Mar 2026 15:24:30 +0100 Subject: [PATCH 2/3] remove field for max_power and use max_charge_power and max_discharge_power instead --- packages/modules/devices/qcells/qcells/config.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/modules/devices/qcells/qcells/config.py b/packages/modules/devices/qcells/qcells/config.py index 0b28e97f7d..445e253b07 100644 --- a/packages/modules/devices/qcells/qcells/config.py +++ b/packages/modules/devices/qcells/qcells/config.py @@ -25,13 +25,8 @@ def __init__(self, class QCellsBatConfiguration: - def __init__(self, max_power: int = 4000): - # Maximale Lade-/Entladeleistung des Speichers in Watt. - # Speichersteuerung via Solax Remote Control Mode 8 (Modbus). - # Unterstuetzte Hardware: QCells Q.VOLT HYB-G3-3P (Solax Gen4), - # Solax Gen4/Gen5/Gen6 Hybrid und AC Wechselrichter. - # Gen2/Gen3 werden nicht unterstuetzt (kein Remote Control). - self.max_power = max_power + def __init__(self): + pass class QCellsBatSetup(ComponentSetup[QCellsBatConfiguration]): From 3c6a9feb4f8753139d8d1faa3cf22cc55e91e0fa Mon Sep 17 00:00:00 2001 From: Xoffroad Date: Thu, 7 May 2026 10:37:08 +0200 Subject: [PATCH 3/3] fix(solax): enable mode4 battery control for g3 hybrids only Implement active battery control via SolaX Remote Control Mode 4 in the SolaX battery module. Gate controllability to version g3 (Gen3/Gen4 Hybrid) and keep g2/g4 non-controllable; add unit tests for sign mapping and version gating. --- packages/modules/devices/solax/solax/bat.py | 96 ++++++++++++++++++- .../modules/devices/solax/solax/bat_test.py | 41 ++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 packages/modules/devices/solax/solax/bat_test.py diff --git a/packages/modules/devices/solax/solax/bat.py b/packages/modules/devices/solax/solax/bat.py index c1cd62a371..d2aa4b023b 100644 --- a/packages/modules/devices/solax/solax/bat.py +++ b/packages/modules/devices/solax/solax/bat.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 -from typing import Any, TypedDict +import logging +from typing import Any, Optional, TypedDict + +from pymodbus.constants import Endian +from pymodbus.payload import BinaryPayloadBuilder from modules.common import modbus from modules.common.abstract_device import AbstractBat @@ -10,9 +14,20 @@ from modules.common.simcount import SimCounter from modules.common.store import get_bat_value_store from modules.devices.solax.solax.config import SolaxBatSetup, Solax +from modules.devices.solax.solax.version import SolaxVersion from modules.common.utils.peak_filter import PeakFilter from modules.common.component_type import ComponentType +log = logging.getLogger(__name__) + +# Solax Remote Control Registers (Holding Registers) +REMOTE_CONTROL_MODE_REG = 0x7C +MODE_DISABLED = 0 +MODE_4_PUSH_POWER = 4 +SET_TYPE_SET = 1 +MODE_4_TIMEOUT_DISABLED = 0 +MODE4_BLOCK_REG_COUNT = 15 + class KwargsDict(TypedDict): client: modbus.ModbusTcpClient_ @@ -31,11 +46,12 @@ def initialize(self) -> None: self.store = get_bat_value_store(self.component_config.id) self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) self.peak_filter = PeakFilter(ComponentType.BAT, self.component_config.id, self.fault_state) + self.last_mode: Optional[str] = 'Undefined' def update(self) -> None: unit = self.device_config.configuration.modbus_id - # kein Speicher für Versionen G2 und G4 + # Basiswerte aus dem Batterie-Registersatz lesen power = self.__tcp_client.read_input_registers(0x0016, ModbusDataType.INT_16, unit=unit) soc = self.__tcp_client.read_input_registers(0x001C, ModbusDataType.UINT_16, unit=unit) self.peak_filter.check_values(power) @@ -48,5 +64,81 @@ def update(self) -> None: ) self.store.set(bat_state) + def set_power_limit(self, power_limit: Optional[int]) -> None: + if self.power_limit_controllable() is False: + log.debug("SolaX set_power_limit: aktive Speichersteuerung für diese Version nicht unterstützt") + return + + unit = self.device_config.configuration.modbus_id + log.debug(f"SolaX set_power_limit: power_limit={power_limit}, last_mode={self.last_mode}") + + if power_limit is None: + log.debug("Keine Batteriesteuerung, Selbstregelung durch Wechselrichter") + if self.last_mode is not None: + with self.__tcp_client: + self.__tcp_client.write_register( + REMOTE_CONTROL_MODE_REG, + MODE_DISABLED, + data_type=ModbusDataType.UINT_16, + unit=unit, + ) + self.last_mode = None + return + + if power_limit < 0: + self.last_mode = 'discharge' + elif power_limit > 0: + self.last_mode = 'charge' + else: + self.last_mode = 'stop' + + push_power = self._get_mode4_push_power(int(power_limit)) + self._write_mode4(push_power, unit) + + def _get_mode4_push_power(self, power_limit: int) -> int: + # openWB power_limit semantics: + # <0 discharge, 0 stop, >0 charge + # Mode 4 push_power semantics: + # >0 discharge, 0 stop, <0 charge + push_power = int(power_limit * -1) + log.debug(f"SolaX Mode4 target: power_limit={power_limit}W -> push_power={push_power}W") + return push_power + + def _write_mode4(self, push_power: int, unit: int) -> None: + log.debug( + ( + f"SolaX Mode4 write: mode={MODE_4_PUSH_POWER}, set_type={SET_TYPE_SET}, " + f"timeout={MODE_4_TIMEOUT_DISABLED}s, push_power={push_power}W" + ) + ) + builder = BinaryPayloadBuilder(byteorder=Endian.Big, wordorder=Endian.Little) + builder.add_16bit_uint(MODE_4_PUSH_POWER) + builder.add_16bit_uint(SET_TYPE_SET) + builder.add_32bit_int(0) + builder.add_32bit_int(0) + builder.add_16bit_uint(0) + builder.add_16bit_uint(0) + builder.add_32bit_uint(0) + builder.add_32bit_int(0) + builder.add_16bit_uint(MODE_4_TIMEOUT_DISABLED) + builder.add_32bit_int(push_power) + payload = builder.to_registers() + if len(payload) != MODE4_BLOCK_REG_COUNT: + raise RuntimeError( + f"Unexpected mode4 payload size {len(payload)}, expected {MODE4_BLOCK_REG_COUNT}" + ) + + with self.__tcp_client: + self.__tcp_client.write_register(REMOTE_CONTROL_MODE_REG, payload, unit=unit) + + def power_limit_controllable(self) -> bool: + device_config = getattr(self, 'device_config', self.kwargs.get('device_config')) + if device_config is None: + return False + try: + return SolaxVersion(device_config.configuration.version) == SolaxVersion.G3 + except ValueError: + return False + component_descriptor = ComponentDescriptor(configuration_factory=SolaxBatSetup) diff --git a/packages/modules/devices/solax/solax/bat_test.py b/packages/modules/devices/solax/solax/bat_test.py new file mode 100644 index 0000000000..fdfc6d2d41 --- /dev/null +++ b/packages/modules/devices/solax/solax/bat_test.py @@ -0,0 +1,41 @@ +from types import SimpleNamespace + +from modules.devices.solax.solax import bat +from modules.devices.solax.solax.config import Solax, SolaxBatSetup, SolaxConfiguration +from modules.devices.solax.solax.version import SolaxVersion + + +def _create_solax_bat(version: SolaxVersion) -> bat.SolaxBat: + config = SolaxConfiguration(version=version) + device_config = Solax(configuration=config) + return bat.SolaxBat(SolaxBatSetup(), device_config=device_config, client=SimpleNamespace()) + + +def test_get_mode4_push_power_stop_is_zero() -> None: + solax_bat = _create_solax_bat(SolaxVersion.G3) + assert solax_bat._get_mode4_push_power(0) == 0 + + +def test_get_mode4_push_power_discharge_is_positive() -> None: + solax_bat = _create_solax_bat(SolaxVersion.G3) + assert solax_bat._get_mode4_push_power(-700) == 700 + + +def test_get_mode4_push_power_charge_is_negative() -> None: + solax_bat = _create_solax_bat(SolaxVersion.G3) + assert solax_bat._get_mode4_push_power(1000) == -1000 + + +def test_power_limit_controllable_true_for_g3() -> None: + solax_bat = _create_solax_bat(SolaxVersion.G3) + assert solax_bat.power_limit_controllable() is True + + +def test_power_limit_controllable_false_for_g2() -> None: + solax_bat = _create_solax_bat(SolaxVersion.G2) + assert solax_bat.power_limit_controllable() is False + + +def test_power_limit_controllable_false_for_g4() -> None: + solax_bat = _create_solax_bat(SolaxVersion.G4) + assert solax_bat.power_limit_controllable() is False