Skip to content

Commit 2178c86

Browse files
authored
Merge pull request #2163 from TCWORLD/vpp-min-soc
Respect battery minimum SoC in SolaX remote control
2 parents c6f259f + 34361e5 commit 2178c86

3 files changed

Lines changed: 69 additions & 30 deletions

File tree

custom_components/solax_modbus/plugin_solax.py

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@
117117
PM = 0x20000
118118
ALL_PM_GROUP = PM
119119

120+
MPPT3 = 0x40000
121+
MPPT4 = 0x80000
122+
MPPT5 = 0x100000
123+
MPPT6 = 0x200000
124+
MPPT8 = 0x400000
125+
MPPT10 = 0x800000
126+
ALL_MPPT_GROUP = MPPT3 | MPPT4 | MPPT5 | MPPT6 | MPPT8 | MPPT10
127+
128+
ALLDEFAULT = 0 # should be equivalent to AC | HYBRID | GEN2 | GEN3 | GEN4 | GEN5 | X1 | X3
129+
120130
# ============================================================================
121131
# Plugin-Level Register Validation
122132
# ============================================================================
@@ -181,16 +191,6 @@ def validate_register_data(descr: Any, value: Any, datadict: dict[str, Any]) ->
181191
return value
182192

183193

184-
MPPT3 = 0x40000
185-
MPPT4 = 0x80000
186-
MPPT5 = 0x100000
187-
MPPT6 = 0x200000
188-
MPPT8 = 0x400000
189-
MPPT10 = 0x800000
190-
ALL_MPPT_GROUP = MPPT3 | MPPT4 | MPPT5 | MPPT6 | MPPT8 | MPPT10
191-
192-
ALLDEFAULT = 0 # should be equivalent to AC | HYBRID | GEN2 | GEN3 | GEN4 | GEN5 | X1 | X3
193-
194194
# ======================= end of bitmask handling code =============================================
195195

196196
SENSOR_TYPES: Sequence["SolaXModbusSensorEntityDescription"] = []
@@ -382,6 +382,7 @@ def autorepeat_function_remotecontrol_recompute(initval: int, descr: Any, datadi
382382
# Get power measurements
383383
measured_power = datadict.get("measured_power", 0) # Grid power (positive = import, negative = export)
384384
battery_capacity = datadict.get("battery_capacity", 0)
385+
battery_min_soc = datadict.get("selfuse_discharge_min_soc", 10)
385386

386387
# Parallel mode support: Use PM power if in parallel mode and we're the Master
387388
parallel_setting = datadict.get("parallel_setting", "Free")
@@ -470,9 +471,11 @@ def autorepeat_function_remotecontrol_recompute(initval: int, descr: Any, datadi
470471
ap_target = 0 - min(house_load, pv_power)
471472
else:
472473
# When the battery is fully charged (allowing a tolerance to prevent
473-
# older inverters shutting down PV), then we emulate self-use mode
474-
# by simply pushing all PV power through the grid connected port
475-
ap_target = 0 - pv_power
474+
# older inverters shutting down PV), then we push only the PV power
475+
# through to the grid connected port. We add a little headroom so
476+
# that PV output can grow after e.g. clouds. This will also trickle
477+
# discharge the battery, keeping the SoC close to 98%.
478+
ap_target = 0 - pv_power - 150
476479
power_control = "Enabled Power Control"
477480

478481
else:
@@ -617,6 +620,21 @@ def autorepeat_function_remotecontrol_recompute(initval: int, descr: Any, datadi
617620
f"adjusted_by={old_ap_target - ap_target}W"
618621
)
619622

623+
# Battery minimum SoC protection
624+
old_ap_target = ap_target
625+
if battery_capacity <= battery_min_soc:
626+
# Battery is at or below minimum SoC, so limit target to
627+
# allow pushes of no more than current PV power to prevent
628+
# further discharge of the battery (or at the very least
629+
# try to reduce it to no more than a trickle).
630+
ap_target = max(ap_target, 0 - pv_power)
631+
if old_ap_target != ap_target:
632+
_LOGGER.debug(
633+
"[REMOTE_CONTROL] Battery SoC checking: "
634+
f"initial_ap_target={old_ap_target}W final_ap_target={ap_target}W "
635+
f"adjusted_by={old_ap_target - ap_target}W"
636+
)
637+
620638
# Prepare result data
621639
res = [
622640
("remotecontrol_power_control", power_control),
@@ -711,6 +729,7 @@ def autorepeat_function_powercontrolmode8_recompute(initval: int, descr: Any, da
711729
setpvlimit = datadict.get("remotecontrol_pv_power_limit", 10000)
712730
pushmode_power = datadict.get("remotecontrol_push_mode_power_8_9", 0)
713731
target_soc = datadict.get("remotecontrol_target_soc_8_9", 95)
732+
min_discharge_soc = max(datadict.get("remotecontrol_minimum_soc_8_9", 10), datadict.get("selfuse_discharge_min_soc", 10))
714733
# rc_duration = datadict.get("remotecontrol_duration", 20)
715734
import_limit = datadict.get("remotecontrol_import_limit", 20000)
716735
battery_capacity = datadict.get("battery_capacity", 0)
@@ -742,7 +761,6 @@ def autorepeat_function_powercontrolmode8_recompute(initval: int, descr: Any, da
742761
hl = max(0, int(houseload_alt))
743762

744763
# SOC bounds
745-
min_discharge_soc = datadict.get("selfuse_discharge_min_soc", 10)
746764
max_charge_soc = min(target_soc, datadict.get("battery_charge_upper_soc", 100))
747765
charge_soc_hysteresis = datadict.get("negative_injection_battery_hysteresis", 2)
748766
# bias towards import
@@ -923,7 +941,6 @@ def autorepeat_function_powercontrolmode8_recompute(initval: int, descr: Any, da
923941
# Final debug and state
924942
net_flow = min(pvlimit, pv) - houseload + pushmode_power
925943
_LOGGER.debug(f"[Mode8 No-Discharge] result: push={pushmode_power}W pvlimit={pvlimit}W net_flow={net_flow}W (>0 export, <0 import)")
926-
927944
elif power_control == "Export-First Battery Limit":
928945
# --- Export-First Battery Limit (Mode 8 custom) ---
929946
# Controller goals (no PV limit adjustments in this mode):
@@ -937,7 +954,6 @@ def autorepeat_function_powercontrolmode8_recompute(initval: int, descr: Any, da
937954
hl = max(0, int(houseload_alt))
938955

939956
# SOC bounds
940-
min_discharge_soc = datadict.get("selfuse_discharge_min_soc", 10)
941957
max_charge_soc = min(target_soc, datadict.get("battery_charge_upper_soc", 100))
942958
# Keep a small gap below the inverter's own export cap so our loop does not
943959
# constantly fight the inverter's internal export limiter.
@@ -1038,13 +1054,18 @@ def autorepeat_function_powercontrolmode8_recompute(initval: int, descr: Any, da
10381054
power_control = "Disabled"
10391055
pvlimit = setpvlimit
10401056
# limit import to max import (capacity tarif in some countries)
1041-
old_pushmode_power = pushmode_power
10421057
excess_import = houseload - pv - pushmode_power - import_limit
10431058
if excess_import > 0:
1059+
old_pushmode_power = pushmode_power
10441060
pushmode_power = pushmode_power + excess_import # reduce import
1061+
_LOGGER.debug(f"import shaving: old_pushmode_power:{old_pushmode_power}W new pushmode_power:{pushmode_power}W")
1062+
1063+
# If commanding a discharge, but the battery capacity is at minimum
1064+
if pushmode_power > 0 and battery_capacity <= min_discharge_soc:
1065+
# Then stop pushing.
1066+
_LOGGER.debug(f"minimum soc limiter: requested pushmode_power:{pushmode_power}W but clamping to 0W")
1067+
pushmode_power = 0
10451068

1046-
if old_pushmode_power != pushmode_power:
1047-
_LOGGER.debug(f"import shaving: old_pushmode_power:{old_pushmode_power} new pushmode_power:{pushmode_power}")
10481069
# res sequence only valid for mode 8 and submodes of mode 8
10491070
res = [
10501071
(
@@ -1076,13 +1097,13 @@ def autorepeat_function_powercontrolmode8_recompute(initval: int, descr: Any, da
10761097
timeout_motion,
10771098
),
10781099
]
1079-
datadict["remotecontrol_current_pushmode_power"] = pushmode_power
1080-
datadict["remotecontrol_current_pv_power_limit"] = pvlimit
10811100
if initval != BUTTONREPEAT_FIRST and curmode != "Individual Setting - Duration Mode":
10821101
_LOGGER.warning(f"autorepeat mode 8 changed curmode: {curmode}; battery: {battery_capacity}; mode: {power_control}")
10831102
if power_control == "Disabled":
10841103
autorepeat_stop(datadict, descr.key)
10851104
_LOGGER.info("Stopping mode 8 loop by disabling mode 8")
1105+
datadict["remotecontrol_current_pushmode_power"] = None
1106+
datadict["remotecontrol_current_pv_power_limit"] = None
10861107
return {
10871108
"action": WRITE_MULTI_MODBUS,
10881109
"register": 0xA0,
@@ -1092,9 +1113,10 @@ def autorepeat_function_powercontrolmode8_recompute(initval: int, descr: Any, da
10921113
"Disabled",
10931114
),
10941115
],
1095-
} # was 0x7C
1096-
# datadict["remotecontrol_power_control"] = "Disabled" # disable the remotecontrol Mode 1 loop
1097-
# autorepeat_stop_with_postaction(datadict,"remotecontrol_trigger") # trigger the remotecontrol mode 1 button for a single BUTTONREPEAT_POST action
1116+
}
1117+
# Save current control values to allow loop filtering
1118+
datadict["remotecontrol_current_pushmode_power"] = pushmode_power
1119+
datadict["remotecontrol_current_pv_power_limit"] = pvlimit
10981120
_LOGGER.debug(f"Evaluated remotecontrol_mode8_trigger: corrected/clamped values: {res}")
10991121
return {"action": WRITE_MULTI_MODBUS, "data": res}
11001122

@@ -1873,7 +1895,7 @@ def value_function_bms_2_max_charge(initval: int, descr: Any, datadict: dict[str
18731895
suggested_display_precision=0,
18741896
),
18751897
SolaxModbusNumberEntityDescription(
1876-
name="Export-First Battery Charge Limit (mode 8/9)",
1898+
name="Remotecontrol Battery Charge Limit (mode 8/9)",
18771899
key="export_first_battery_charge_limit_8_9",
18781900
allowedtypes=AC | HYBRID | GEN4 | GEN5 | GEN6,
18791901
native_min_value=0,
@@ -1890,12 +1912,26 @@ def value_function_bms_2_max_charge(initval: int, descr: Any, datadict: dict[str
18901912
name="Remotecontrol Target SOC (mode 8/9)",
18911913
key="remotecontrol_target_soc_8_9",
18921914
allowedtypes=AC | HYBRID | GEN4 | GEN5 | GEN6,
1893-
native_min_value=-0,
1915+
native_min_value=10,
18941916
native_max_value=100,
18951917
native_step=1,
18961918
native_unit_of_measurement=PERCENTAGE,
18971919
initvalue=95,
1898-
register_data_type=REGISTER_U16, #
1920+
register_data_type=REGISTER_U16,
1921+
write_method=WRITE_DATA_LOCAL,
1922+
fmt="i",
1923+
suggested_display_precision=0,
1924+
),
1925+
SolaxModbusNumberEntityDescription(
1926+
name="Remotecontrol Minimum SOC (mode 8/9)",
1927+
key="remotecontrol_minimum_soc_8_9",
1928+
allowedtypes=AC | HYBRID | GEN4 | GEN5 | GEN6,
1929+
native_min_value=10,
1930+
native_max_value=100,
1931+
native_step=1,
1932+
native_unit_of_measurement=PERCENTAGE,
1933+
initvalue=10,
1934+
register_data_type=REGISTER_U16,
18991935
write_method=WRITE_DATA_LOCAL,
19001936
fmt="i",
19011937
suggested_display_precision=0,

docs/solax-mode1-modbus-power-control.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ With these parameters a power control dashboard card can be created:
8888

8989
Before issuing a `remotecontrol_trigger` button activation, an automation should make sure that the other remotecontrol_* parameters have the correct values. This also applies to manual interactions with the `remotecontrol_trigger` button.
9090

91+
When using the autorepeat function, checks are in place to ensure that you cannot request the battery discharge below the minimum SoC limit set by the `selfuse_discharge_min_soc` entity. Once this SoC level is reached, any modes which would normally discharge the battery will be prevented from further discharging the battery.
92+
9193
### Related read-only sensors
9294
The state of the modbus power control can be monitored by examining following sensors:
9395
- `modbus_power_control`

docs/solax-mode8-modbus-power-control.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,15 @@ The integration hides this complexity and implements one button to trigger a sin
4444
* **Mode 8 - PV and BAT control - Duration**: this is the manual mode that uses fixed `remeotecontrol_pv_power_limit` and fixed `remotecontrol_push_mode_8_9` settings. The same behavior can probably be achieved with the _direct version of the entities (without autorepeat loop)
4545
* **Negative Injection Price**: in this submode, PV will charge the battery and feed the house load, but once the battery is fully charged, PV will be reduced to house load so that no export takes place. The autorepeat mechanism is essential to adapt to the varying PV power or house load. If there is still some remaining export (probably because there is a limit on the possible charging power), reduce the `remotecontrol_pv_power_limit` parameter. In this submode, `remotecontrol_pv_power_limit` will be interpreted as maximum battery charge power, and `remotecontrol_target_soc_8_9` sets the upper SoC limit on charging the battery to allow leaving some spare capacity if desired.
4646
* **Negative Injection and Consumption Price**: In this submode, PV will be limited to zero and battery will be charged from the grid (house load also from grid)
47-
* **Export-First Battery Limit**: This submode prioritises feeding excess energy to the grid up to the defined export limit. Surplus power beyond that is used to charge the battery, with charging power adjustable via the Export-First Battery Charge Limit (mode 8/9) parameter. It enables Home Assistant to run logic similar to the SolaX Datahub — using solar forecasts to prioritise grid feed-in when high solar production is expected, while allowing slower charging to protect battery health.
47+
* **Export-First Battery Limit**: This submode prioritises feeding excess energy to the grid up to the defined export limit. Surplus power beyond that is used to charge the battery, with charging power adjustable via the Export-First Battery Charge Limit (mode 8/9) parameter. It enables Home Assistant to run logic similar to the SolaX Datahub — using solar forecasts to prioritise grid feed-in when high solar production is expected, while allowing slower charging to protect battery health. The `remotecontrol_target_soc_8_9` parameter sets the upper SoC limit when the battery is being charging with excess solar.
4848
* **Enabled Grid Control**: In this submode, the `push_mode_power_8_9` parameter represents the target at the grid interface: positive means export, negative means import
49-
* **Enabled Feedin Priority**: In this mode, solar power will serve the house load and the excess power will go to the grid. Battery will not be charged and will only discharge if there is insufficient solar power
49+
* **Enabled Feedin Priority**: In this mode, solar power will serve the house load and the excess power will go to the grid. Battery will not be charged and will only discharge if there is insufficient solar power and the battery is above the minimum SoC limits.
5050
* **Enabled No Discharge**: With this submode, the goal is to stop the battery from being discharged. The `push_mode_power_8_9` is ignored. House load will be provided by PV, and any surplus will be used to charge the battery. When PV is insufficient, the deficit is provided from the Grid to ensure the battery holds its SoC.
5151
* **remotecontrol_pv_power_limit**: maximum PV power; is automatically recomputed in the Negative Price submodes. An initial max value is needed however
5252
* **remotecontrol_push_mode_power_8_9**: positive numbers are discharge, negative numbers charge; is automatically computed in the Negative Price submodes
5353
* **remotecontrol_import_limit**: can be used to limit the maximum import from grid. This limitation is active in all submodes, so make sure to set this parameter to a proper value. Please note that the import limitation will only work as long as the battery is not empty. In regions where the maximum import has a financial impact (e.g. part of Belgium), the autorepeat approach may have an advantage over the _direct alternative.
54-
* **remotecontrol_target_soc_8_9**: Used in some submodes to limit the maximum SoC of the battery while charging where noted above
54+
* **remotecontrol_target_soc_8_9**: Used in some submodes to limit the maximum SoC of the battery while charging where noted above.
55+
* **remotecontrol_minimum_soc_8_9**: Used to set the limit for minimum SoC of the battery while discharging in remote control mode. Mode 8 will not allow the battery to discharge below this SoC level when running. Please note the minimum SoC will be the higher value of this entity and the `selfuse_discharge_min_soc` entity. In other words this option allows increasing the minimum SoC, not forcing it below the existing work mode limits.
5556
* **remotecontrol_duration**: we recommend keeping the default value of 20s
5657
* **remotecontrol_autorepeat_duration**: typically a couple of hours - e.g. duration of the negative price
5758
* **remotecontrol_timeout**: can be left 0 or set to a few seconds more than the polling interval; unclear how to use this

0 commit comments

Comments
 (0)