diff --git a/custom_components/zero_grid_controller/control_engine.py b/custom_components/zero_grid_controller/control_engine.py index e4ff504..d608fe5 100644 --- a/custom_components/zero_grid_controller/control_engine.py +++ b/custom_components/zero_grid_controller/control_engine.py @@ -976,17 +976,59 @@ async def _apply_switch_hysteresis( return residual + def _battery_soc(self, battery: BatteryConfig) -> float | None: + """Return the battery SoC in %, or None when no sensor is configured.""" + if not battery.soc_sensor_entity: + return None + return self.read_sensor_safe(battery.soc_sensor_entity) + + async def _write_array_if_changed(self, array: ArrayConfig, target: float) -> bool: + """Write an array setpoint only when the entity does not match yet. + + Comparing against the actual entity state (not our recorded value) + keeps the maximize modes enforcing their position against external + changes without re-writing every 5 s cycle. + """ + if array.is_switch: + desired = "on" if target > array.setpoint_min else "off" + if self.entity_state(array.setpoint_entity) == desired: + return True + else: + current = self.read_sensor_safe(array.setpoint_entity) + if current is not None and abs(current - target) < 1.0: + return True + await self._actuators.write_setpoint(array, target) + return True + + async def _write_numeric_if_changed(self, entity_id: str, target: float) -> None: + """Write a numeric entity only when its state does not match yet.""" + current = self.read_sensor_safe(entity_id) + if current is not None and abs(current - target) < BATTERY_WRITE_THRESHOLD_W: + return + await self._actuators.write_numeric_entity(entity_id, target) + + async def _write_switch_if_changed(self, entity_id: str, turn_on: bool) -> None: + """Write a switch entity only when its state does not match yet.""" + if self.entity_state(entity_id) == ("on" if turn_on else "off"): + return + await self._actuators.write_switch_entity(entity_id, turn_on) + async def _set_maximize_export( self, arrays: list[ArrayConfig], loads: list[LoadConfig], batteries: list[BatteryConfig], ) -> None: - """Maximize export: arrays → max, loads → off, batteries → discharge max.""" + """Maximize export: arrays → max, loads → off, batteries → discharge max. + + SoC limits are respected: a battery at or below min_soc is set to 0 + instead of full discharge. Writes are skipped when the entity already + matches the target. + """ for array in arrays: new_sp = array.setpoint_max try: - await self._actuators.write_setpoint(array, new_sp) + await self._write_array_if_changed(array, new_sp) except Exception: _LOGGER.exception( "Failed to set array %s to max for maximize_export", array.name @@ -996,12 +1038,10 @@ async def _set_maximize_export( for load in loads: try: if load.is_switch: - await self._actuators.write_switch_entity( - load.setpoint_entity, False - ) + await self._write_switch_if_changed(load.setpoint_entity, False) self._current_load_setpoints[load.name] = 0.0 else: - await self._actuators.write_numeric_entity( + await self._write_numeric_if_changed( load.setpoint_entity, load.setpoint_min ) self._current_load_setpoints[load.name] = load.setpoint_min @@ -1010,11 +1050,14 @@ async def _set_maximize_export( "Failed to set load %s to min for maximize_export", load.name ) for battery in batteries: - target = battery.max_discharge_w + soc = self._battery_soc(battery) + target = ( + 0.0 + if soc is not None and soc <= battery.min_soc + else battery.max_discharge_w + ) try: - await self._actuators.write_numeric_entity( - battery.setpoint_entity, target - ) + await self._write_numeric_if_changed(battery.setpoint_entity, target) except Exception: _LOGGER.exception( "Failed to set battery %s to discharge for maximize_export", @@ -1029,11 +1072,16 @@ async def _set_maximize_import( loads: list[LoadConfig], batteries: list[BatteryConfig], ) -> None: - """Maximize import: arrays → off, loads → max, batteries → charge max.""" + """Maximize import: arrays → off, loads → max, batteries → charge max. + + SoC limits are respected: a battery at or above max_soc is set to 0 + instead of full charge. Writes are skipped when the entity already + matches the target. + """ for array in arrays: new_sp = array.setpoint_min try: - await self._actuators.write_setpoint(array, new_sp) + await self._write_array_if_changed(array, new_sp) except Exception: _LOGGER.exception( "Failed to set array %s to min for maximize_import", array.name @@ -1043,12 +1091,10 @@ async def _set_maximize_import( for load in loads: try: if load.is_switch: - await self._actuators.write_switch_entity( - load.setpoint_entity, True - ) + await self._write_switch_if_changed(load.setpoint_entity, True) self._current_load_setpoints[load.name] = 1.0 else: - await self._actuators.write_numeric_entity( + await self._write_numeric_if_changed( load.setpoint_entity, load.setpoint_max ) self._current_load_setpoints[load.name] = load.setpoint_max @@ -1057,11 +1103,14 @@ async def _set_maximize_import( "Failed to set load %s to max for maximize_import", load.name ) for battery in batteries: - target = -battery.max_charge_w + soc = self._battery_soc(battery) + target = ( + 0.0 + if soc is not None and soc >= battery.max_soc + else -battery.max_charge_w + ) try: - await self._actuators.write_numeric_entity( - battery.setpoint_entity, target - ) + await self._write_numeric_if_changed(battery.setpoint_entity, target) except Exception: _LOGGER.exception( "Failed to set battery %s to charge for maximize_import", diff --git a/tests/test_control_modes.py b/tests/test_control_modes.py index 4a3290c..dbbb73e 100644 --- a/tests/test_control_modes.py +++ b/tests/test_control_modes.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest.mock import AsyncMock, patch + import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -667,3 +669,116 @@ async def _mock_write_setpoint(array_cfg, value: float) -> None: result = await coordinator._async_update_data() assert result.status == ControllerStatus.IDLE_IMPORT_OK assert written == [], f"No array writes expected in idle, got: {written}" + + +# --------------------------------------------------------------------------- +# Maximize modes: SoC limits and write-if-changed +# --------------------------------------------------------------------------- + + +def _battery_subentry(**extra): + return { + "data": {**_BATTERY_DATA, **extra}, + "subentry_type": BATTERY_SUBENTRY_TYPE, + "subentry_id": "sub_bat", + "title": "Battery", + "unique_id": None, + } + + +def _array_subentry(): + return { + "data": _ARRAY_DATA, + "subentry_type": ARRAY_SUBENTRY_TYPE, + "subentry_id": "sub_arr", + "title": "Roof", + "unique_id": None, + } + + +async def test_maximize_export_respects_min_soc(hass): + """An empty battery is not discharged in maximize_export mode.""" + entry = _make_entry( + control_mode="maximize_export", + subentries_data=(_battery_subentry(battery_soc_sensor="sensor.battery_soc"),), + ) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + _set_state(hass, "sensor.battery_soc", 8) # below default min_soc 10 + _set_grid(hass, 0, 0) + + with patch.object(coordinator._actuators, "write_numeric_entity", new=AsyncMock()): + result = await coordinator._async_update_data() + + assert result.battery_setpoints.get("Battery") == 0.0, ( + "maximize_export must not discharge a battery at/below min SoC" + ) + + +async def test_maximize_import_respects_max_soc(hass): + """A full battery is not charged in maximize_import mode.""" + entry = _make_entry( + control_mode="maximize_import", + subentries_data=(_battery_subentry(battery_soc_sensor="sensor.battery_soc"),), + ) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + _set_state(hass, "sensor.battery_soc", 97) # above default max_soc 95 + _set_grid(hass, 0, 0) + + with patch.object(coordinator._actuators, "write_numeric_entity", new=AsyncMock()): + result = await coordinator._async_update_data() + + assert result.battery_setpoints.get("Battery") == 0.0, ( + "maximize_import must not charge a battery at/above max SoC" + ) + + +async def test_maximize_export_skips_writes_when_entities_match(hass): + """maximize_export does not rewrite actuators that already match the target.""" + entry = _make_entry( + control_mode="maximize_export", + subentries_data=(_array_subentry(), _battery_subentry()), + ) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + + # Entities already at the maximize targets + _set_state(hass, "number.inverter_limit", 100) + _set_state(hass, "number.battery_setpoint", 2000) + _set_grid(hass, 0, 0) + + with ( + patch.object( + coordinator._actuators, "write_setpoint", new=AsyncMock() + ) as mock_sp, + patch.object( + coordinator._actuators, "write_numeric_entity", new=AsyncMock() + ) as mock_num, + ): + await coordinator._async_update_data() + + mock_sp.assert_not_awaited() + mock_num.assert_not_awaited() + + +async def test_maximize_export_reasserts_external_changes(hass): + """maximize_export re-writes an actuator that was changed externally.""" + entry = _make_entry( + control_mode="maximize_export", + subentries_data=(_array_subentry(),), + ) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + + # Someone moved the limit down outside the controller + _set_state(hass, "number.inverter_limit", 40) + _set_grid(hass, 0, 0) + + with patch.object( + coordinator._actuators, "write_setpoint", new=AsyncMock() + ) as mock_sp: + await coordinator._async_update_data() + + mock_sp.assert_awaited() + assert mock_sp.call_args[0][1] == 100.0