From 3e3824e68de89f4016bb0e2b8202bc1a4ee9b7a3 Mon Sep 17 00:00:00 2001 From: Xoffroad Date: Wed, 25 Mar 2026 11:36:52 +0100 Subject: [PATCH 1/4] adding active battery control for qcells and new field for overload security --- packages/modules/devices/qcells/qcells/bat.py | 80 ++++++++++++++++++- .../modules/devices/qcells/qcells/config.py | 9 ++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/modules/devices/qcells/qcells/bat.py b/packages/modules/devices/qcells/qcells/bat.py index f381a7894f..5d4459b4a5 100644 --- a/packages/modules/devices/qcells/qcells/bat.py +++ b/packages/modules/devices/qcells/qcells/bat.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -from typing import TypedDict, Any +import logging +from typing import TypedDict, Any, Optional from modules.common.abstract_device import AbstractBat from modules.common.component_state import BatState @@ -9,6 +10,25 @@ from modules.common.store import get_bat_value_store from modules.devices.qcells.qcells.config import QCellsBatSetup +log = logging.getLogger(__name__) + +# Solax/QCells Mode 8 Remote Control Registers (Holding Registers) +# Speichersteuerung via "Individual Setting - Duration Mode" +# Unterstuetzte Hardware: QCells Q.VOLT HYB-G3-3P (Solax Gen4), +# Solax Gen4/Gen5/Gen6 Hybrid und AC Wechselrichter. +REMOTE_CONTROL_MODE_REG = 0xA0 # U16: 0=Disabled, 8=Individual Duration +REMOTE_CONTROL_SET_TYPE_REG = 0xA1 # U16: 1=Set +REMOTE_CONTROL_PV_LIMIT_REG = 0xA2 # U32: PV Power Limit in Watt (keine Begrenzung = 30000) +REMOTE_CONTROL_PUSH_POWER_REG = 0xA4 # S32: Battery Push Power (+Entladung, -Ladung) +REMOTE_CONTROL_DURATION_REG = 0xA6 # U16: Dauer in Sekunden +REMOTE_CONTROL_TIMEOUT_REG = 0xA7 # U16: Timeout in Sekunden + +MODE_8_INDIVIDUAL_DURATION = 8 +SET_TYPE_SET = 1 +PV_LIMIT_NO_CURTAILMENT = 30000 +REMOTE_CONTROL_DURATION = 300 +REMOTE_CONTROL_TIMEOUT = 300 + class KwargsDict(TypedDict): modbus_id: int @@ -25,6 +45,7 @@ def initialize(self) -> None: self.client: ModbusTcpClient_ = self.kwargs['client'] self.store = get_bat_value_store(self.component_config.id) self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + self.last_mode: Optional[str] = 'Undefined' def update(self) -> None: power = self.client.read_input_registers(0x0016, ModbusDataType.INT_16, unit=self.__modbus_id) @@ -42,5 +63,62 @@ def update(self) -> None: ) self.store.set(bat_state) + def set_power_limit(self, power_limit: Optional[int]) -> None: + unit = self.__modbus_id + max_power = self.component_config.configuration.max_power + log.debug(f"QCells 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.client: + self.client.write_register( + REMOTE_CONTROL_MODE_REG, 0, data_type=ModbusDataType.UINT_16, unit=unit) + self.last_mode = None + elif power_limit == 0: + log.debug("Aktive Batteriesteuerung. Batterie wird gestoppt (kein Entladen)") + if self.last_mode != 'stop': + self._write_mode8(push_power=0, unit=unit) + self.last_mode = 'stop' + elif power_limit > 0: + charge_power = int(min(power_limit, max_power)) + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {charge_power} W geladen") + if self.last_mode != 'charge': + self.last_mode = 'charge' + # Solax Mode 8: negativer Push Power Wert = Ladung + self._write_mode8(push_power=-charge_power, unit=unit) + elif power_limit < 0: + discharge_power = int(min(abs(power_limit), max_power)) + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {discharge_power} W entladen") + if self.last_mode != 'discharge': + self.last_mode = 'discharge' + # Solax Mode 8: positiver Push Power Wert = Entladung + self._write_mode8(push_power=discharge_power, unit=unit) + + def _write_mode8(self, push_power: int, unit: int) -> None: + """Schreibt die Mode 8 Remote Control Register (0xA0-0xA7).""" + with self.client: + self.client.write_register( + REMOTE_CONTROL_MODE_REG, MODE_8_INDIVIDUAL_DURATION, + data_type=ModbusDataType.UINT_16, unit=unit) + self.client.write_register( + REMOTE_CONTROL_SET_TYPE_REG, SET_TYPE_SET, + data_type=ModbusDataType.UINT_16, unit=unit) + self.client.write_register( + REMOTE_CONTROL_PV_LIMIT_REG, PV_LIMIT_NO_CURTAILMENT, + data_type=ModbusDataType.UINT_32, unit=unit) + self.client.write_register( + REMOTE_CONTROL_PUSH_POWER_REG, push_power, + data_type=ModbusDataType.INT_32, unit=unit) + self.client.write_register( + REMOTE_CONTROL_DURATION_REG, REMOTE_CONTROL_DURATION, + data_type=ModbusDataType.UINT_16, unit=unit) + self.client.write_register( + REMOTE_CONTROL_TIMEOUT_REG, REMOTE_CONTROL_TIMEOUT, + data_type=ModbusDataType.UINT_16, unit=unit) + + def power_limit_controllable(self) -> bool: + return True + component_descriptor = ComponentDescriptor(configuration_factory=QCellsBatSetup) 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 845c0c655f3bafd1edd6039dc6209765aae4d193 Mon Sep 17 00:00:00 2001 From: Xoffroad Date: Thu, 26 Mar 2026 15:24:30 +0100 Subject: [PATCH 2/4] remove field for max_power and use max_charge_power and max_discharge_power instead --- packages/modules/devices/qcells/qcells/bat.py | 26 +++++++++++++------ .../modules/devices/qcells/qcells/config.py | 9 ++----- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/modules/devices/qcells/qcells/bat.py b/packages/modules/devices/qcells/qcells/bat.py index 5d4459b4a5..07aba194c1 100644 --- a/packages/modules/devices/qcells/qcells/bat.py +++ b/packages/modules/devices/qcells/qcells/bat.py @@ -2,6 +2,7 @@ import logging from typing import TypedDict, Any, Optional +from control import data from modules.common.abstract_device import AbstractBat from modules.common.component_state import BatState from modules.common.component_type import ComponentDescriptor @@ -65,8 +66,11 @@ def update(self) -> None: def set_power_limit(self, power_limit: Optional[int]) -> None: unit = self.__modbus_id - max_power = self.component_config.configuration.max_power - log.debug(f"QCells set_power_limit: power_limit={power_limit}, last_mode={self.last_mode}") + bat_get = data.data.bat_data[f"bat{self.component_config.id}"].data.get + max_charge = bat_get.max_charge_power + max_discharge = bat_get.max_discharge_power + log.debug(f"QCells set_power_limit: power_limit={power_limit}, " + f"max_charge={max_charge}W, max_discharge={max_discharge}W, last_mode={self.last_mode}") if power_limit is None: log.debug("Keine Batteriesteuerung, Selbstregelung durch Wechselrichter") @@ -81,19 +85,25 @@ def set_power_limit(self, power_limit: Optional[int]) -> None: self._write_mode8(push_power=0, unit=unit) self.last_mode = 'stop' elif power_limit > 0: - charge_power = int(min(power_limit, max_power)) - log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {charge_power} W geladen") + if max_charge <= 0: + log.warning("Maximale Ladeleistung ist nicht konfiguriert (0W). " + "Bitte unter Ladeeinstellungen > Speichersteuerung konfigurieren.") + clamped = int(min(power_limit, max_charge)) + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {clamped} W geladen") if self.last_mode != 'charge': self.last_mode = 'charge' # Solax Mode 8: negativer Push Power Wert = Ladung - self._write_mode8(push_power=-charge_power, unit=unit) + self._write_mode8(push_power=-clamped, unit=unit) elif power_limit < 0: - discharge_power = int(min(abs(power_limit), max_power)) - log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {discharge_power} W entladen") + if max_discharge <= 0: + log.warning("Maximale Entladeleistung ist nicht konfiguriert (0W). " + "Bitte unter Ladeeinstellungen > Speichersteuerung konfigurieren.") + clamped = int(min(abs(power_limit), max_discharge)) + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {clamped} W entladen") if self.last_mode != 'discharge': self.last_mode = 'discharge' # Solax Mode 8: positiver Push Power Wert = Entladung - self._write_mode8(push_power=discharge_power, unit=unit) + self._write_mode8(push_power=clamped, unit=unit) def _write_mode8(self, push_power: int, unit: int) -> None: """Schreibt die Mode 8 Remote Control Register (0xA0-0xA7).""" 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 1f7f1afdbeb3ac126c0e5849fac17a45caf58f94 Mon Sep 17 00:00:00 2001 From: Xoffroad <65235705+Xoffroad@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:53:25 +0100 Subject: [PATCH 3/4] Apply suggestion from @seaspotter Co-authored-by: SeaSpotter --- packages/modules/devices/qcells/qcells/bat.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/modules/devices/qcells/qcells/bat.py b/packages/modules/devices/qcells/qcells/bat.py index 07aba194c1..39ae1fea1a 100644 --- a/packages/modules/devices/qcells/qcells/bat.py +++ b/packages/modules/devices/qcells/qcells/bat.py @@ -2,7 +2,6 @@ import logging from typing import TypedDict, Any, Optional -from control import data from modules.common.abstract_device import AbstractBat from modules.common.component_state import BatState from modules.common.component_type import ComponentDescriptor From 37d96fe6ea21229ea3a36108c9e75b0a5a9e3487 Mon Sep 17 00:00:00 2001 From: Xoffroad <65235705+Xoffroad@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:55:42 +0100 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: SeaSpotter --- packages/modules/devices/qcells/qcells/bat.py | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/modules/devices/qcells/qcells/bat.py b/packages/modules/devices/qcells/qcells/bat.py index 39ae1fea1a..4def0b1182 100644 --- a/packages/modules/devices/qcells/qcells/bat.py +++ b/packages/modules/devices/qcells/qcells/bat.py @@ -65,11 +65,8 @@ def update(self) -> None: def set_power_limit(self, power_limit: Optional[int]) -> None: unit = self.__modbus_id - bat_get = data.data.bat_data[f"bat{self.component_config.id}"].data.get - max_charge = bat_get.max_charge_power - max_discharge = bat_get.max_discharge_power log.debug(f"QCells set_power_limit: power_limit={power_limit}, " - f"max_charge={max_charge}W, max_discharge={max_discharge}W, last_mode={self.last_mode}") + f"last_mode={self.last_mode}") if power_limit is None: log.debug("Keine Batteriesteuerung, Selbstregelung durch Wechselrichter") @@ -81,30 +78,24 @@ def set_power_limit(self, power_limit: Optional[int]) -> None: elif power_limit == 0: log.debug("Aktive Batteriesteuerung. Batterie wird gestoppt (kein Entladen)") if self.last_mode != 'stop': - self._write_mode8(push_power=0, unit=unit) + self._write_mode8(power_value=0, unit=unit) self.last_mode = 'stop' elif power_limit > 0: - if max_charge <= 0: - log.warning("Maximale Ladeleistung ist nicht konfiguriert (0W). " - "Bitte unter Ladeeinstellungen > Speichersteuerung konfigurieren.") - clamped = int(min(power_limit, max_charge)) - log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {clamped} W geladen") + power_value = int(power_limit) * -1 + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_value} W geladen") if self.last_mode != 'charge': self.last_mode = 'charge' # Solax Mode 8: negativer Push Power Wert = Ladung - self._write_mode8(push_power=-clamped, unit=unit) + self._write_mode8(power_value, unit=unit) elif power_limit < 0: - if max_discharge <= 0: - log.warning("Maximale Entladeleistung ist nicht konfiguriert (0W). " - "Bitte unter Ladeeinstellungen > Speichersteuerung konfigurieren.") - clamped = int(min(abs(power_limit), max_discharge)) - log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {clamped} W entladen") + power_value = int(power_limit) * -1 + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_value} W entladen") if self.last_mode != 'discharge': self.last_mode = 'discharge' # Solax Mode 8: positiver Push Power Wert = Entladung - self._write_mode8(push_power=clamped, unit=unit) + self._write_mode8(power_value, unit=unit) - def _write_mode8(self, push_power: int, unit: int) -> None: + def _write_mode8(self, power_value: int, unit: int) -> None: """Schreibt die Mode 8 Remote Control Register (0xA0-0xA7).""" with self.client: self.client.write_register( @@ -117,7 +108,7 @@ def _write_mode8(self, push_power: int, unit: int) -> None: REMOTE_CONTROL_PV_LIMIT_REG, PV_LIMIT_NO_CURTAILMENT, data_type=ModbusDataType.UINT_32, unit=unit) self.client.write_register( - REMOTE_CONTROL_PUSH_POWER_REG, push_power, + REMOTE_CONTROL_PUSH_POWER_REG, power_value, data_type=ModbusDataType.INT_32, unit=unit) self.client.write_register( REMOTE_CONTROL_DURATION_REG, REMOTE_CONTROL_DURATION,