Fix Growatt V1 total output power (#176513)

This commit is contained in:
HansKDev
2026-08-29 20:13:54 +00:00
committed by Franck Nijhof
parent e177b2fb76
commit f011cd3874
3 changed files with 157 additions and 4 deletions
@@ -3,6 +3,7 @@
import datetime
import json
import logging
import math
from typing import TYPE_CHECKING, Any, override
import growattServer
@@ -38,10 +39,46 @@ if TYPE_CHECKING:
type GrowattConfigEntry = ConfigEntry[GrowattRuntimeData]
SCAN_INTERVAL = datetime.timedelta(minutes=5)
_MAX_POWER_READING_AGE = datetime.timedelta(minutes=10)
_LOGGER = logging.getLogger(__name__)
def _latest_power_value(
power_overview: dict[str, Any], now: datetime.datetime
) -> float | None:
"""Return the newest recent power value from a plant power overview."""
latest_time: datetime.datetime | None = None
latest_power: float | None = None
for reading in power_overview.get("powers", []):
power = reading.get("power")
timestamp = reading.get("time")
if power is None or not isinstance(timestamp, str):
continue
try:
reading_time = dt_util.parse_datetime(timestamp)
except ValueError:
continue
if reading_time is None:
continue
if reading_time.tzinfo is None:
reading_time = reading_time.replace(tzinfo=dt_util.get_default_time_zone())
if reading_time > now or now - reading_time > _MAX_POWER_READING_AGE:
continue
try:
power_value = float(power)
except TypeError, ValueError:
continue
if not math.isfinite(power_value):
continue
if latest_time is None or reading_time > latest_time:
latest_time = reading_time
latest_power = power_value
return latest_power
class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator to manage Growatt data fetching."""
@@ -205,8 +242,27 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
# V1 API returns current_power in kW, convert to W
total_info["invTodayPpv"] = total_info["current_power"] * 1000
# The plant power overview is authoritative for instantaneous power.
# Plant energy overview's current_power is only a fallback on error;
# it can report zero while production continues.
current_power = total_info["current_power"] * 1000
now = dt_util.now()
try:
power_overview = self.api.plant_power_overview(
self.plant_id, now.date()
)
except (growattServer.GrowattV1ApiError, RequestException) as err:
_LOGGER.debug(
"Failed to fetch plant power overview for %s: %s",
self.plant_id,
err,
)
else:
if (
latest_power := _latest_power_value(power_overview, now)
) is not None:
current_power = latest_power
total_info["invTodayPpv"] = current_power
else:
# Classic API: use plant_info as before.
# Copy the response to avoid mutating the dict returned by the library
@@ -29,6 +29,7 @@ def mock_growatt_v1_api():
Methods mocked for integration setup:
- device_list: Called during async_setup_entry to discover devices
- plant_energy_overview: Called by total coordinator during first refresh
- plant_power_overview: Called for the total coordinator's current power
Methods mocked for MIN device coordinator refresh:
- min_detail: Provides device state (e.g., acChargeEnable, chargePowerCommand)
@@ -145,6 +146,10 @@ def mock_growatt_v1_api():
"total_energy": 1250.0,
"current_power": 2.5,
}
mock_v1_api.plant_power_overview.return_value = {
"count": 0,
"powers": [],
}
# Called by switch/number entities during turn_on/turn_off/set_value
mock_v1_api.min_write_parameter.return_value = None
+94 -2
View File
@@ -1,7 +1,7 @@
"""Tests for the Growatt Server sensor platform."""
from datetime import timedelta
from unittest.mock import patch
from datetime import date, timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import growattServer
@@ -183,6 +183,98 @@ async def test_sensor_coordinator_updates(
assert state.state == "25.0"
@pytest.mark.parametrize(
("powers", "expected_state"),
[
pytest.param(
[
{"time": "2026-07-14 18:50", "power": 1000.0},
{"time": "2026-07-14 19:05", "power": 700.0},
{"time": "invalid", "power": 900.0},
{"time": "2026-07-14 18:55", "power": 880.6},
{"time": "2026-07-14 18:57", "power": None},
],
"880.6",
id="newest-valid-reading",
),
pytest.param(
[{"time": "2026-07-14 18:55", "power": 0.0}],
"0.0",
id="nighttime-zero",
),
pytest.param(
[{"time": "2026-07-14 18:45", "power": 900.0}],
"2500.0",
id="stale-reading",
),
pytest.param(
[
{"time": None, "power": 900.0},
{"time": "2026-02-30 12:00", "power": 900.0},
],
"2500.0",
id="invalid-timestamps",
),
pytest.param(
[
{"time": "2026-07-14 18:55", "power": "nan"},
{"time": "2026-07-14 18:56", "power": "inf"},
],
"2500.0",
id="non-finite-power",
),
pytest.param([], "2500.0", id="missing-readings"),
],
)
@pytest.mark.freeze_time("2026-07-14 17:00:00+00:00")
async def test_v1_total_output_power_uses_recent_plant_power(
hass: HomeAssistant,
mock_growatt_v1_api: MagicMock,
mock_config_entry: MockConfigEntry,
powers: list[dict[str, str | float | None]],
expected_state: str,
) -> None:
"""Test V1 total output power uses the newest recent plant power reading."""
await hass.config.async_set_time_zone("Europe/Amsterdam")
mock_growatt_v1_api.plant_power_overview.return_value = {
"count": len(powers),
"powers": powers,
}
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.test_plant_total_output_power")
assert state is not None
assert state.state == expected_state
mock_growatt_v1_api.plant_power_overview.assert_called_once_with(
"123456", date(2026, 7, 14)
)
@pytest.mark.freeze_time("2026-07-14 17:00:00+00:00")
async def test_v1_total_output_power_falls_back_on_power_api_error(
hass: HomeAssistant,
mock_growatt_v1_api: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test V1 total output power remains available when plant power fails."""
mock_growatt_v1_api.plant_power_overview.side_effect = (
growattServer.GrowattV1ApiError(
message="Rate limited",
error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED,
error_msg="Too many requests",
)
)
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.test_plant_total_output_power")
assert state is not None
assert state.state == "2500.0"
async def test_sensor_unavailable_on_coordinator_error(
hass: HomeAssistant,
mock_growatt_v1_api,