mirror of
https://github.com/home-assistant/core.git
synced 2026-09-03 12:02:16 +01:00
Fix Growatt V1 total output power (#176513)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user