Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 69 additions & 20 deletions custom_components/zero_grid_controller/control_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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",
Expand Down
115 changes: 115 additions & 0 deletions tests/test_control_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from unittest.mock import AsyncMock, patch

import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry

Expand Down Expand Up @@ -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
Loading