Fix midnight bounce suppression for Growatt today sensors (#163106)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
This commit is contained in:
johanzander
2026-02-19 08:07:52 +01:00
committed by GitHub
parent 37f0f1869f
commit b91c07b2af
2 changed files with 331 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any
import growattServer
from homeassistant.components.sensor import SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
@@ -54,6 +55,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.device_type = device_type
self.plant_id = plant_id
self.previous_values: dict[str, Any] = {}
self._pre_reset_values: dict[str, float] = {}
if self.api_version == "v1":
self.username = None
@@ -251,6 +253,40 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
return_value = previous_value
# Suppress midnight bounce for TOTAL_INCREASING "today" sensors.
# The Growatt API sometimes delivers stale yesterday values after a midnight
# reset (0 → stale → 0), causing TOTAL_INCREASING double-counting.
if (
entity_description.state_class is SensorStateClass.TOTAL_INCREASING
and not entity_description.never_resets
and return_value is not None
and previous_value is not None
):
current_val = float(return_value)
prev_val = float(previous_value)
if prev_val > 0 and current_val == 0:
# Value dropped to 0 from a positive level — track it.
self._pre_reset_values[variable] = prev_val
elif variable in self._pre_reset_values:
pre_reset = self._pre_reset_values[variable]
if current_val == pre_reset:
# Value equals yesterday's final value — the API is
# serving a stale cached response (bounce)
_LOGGER.debug(
"Suppressing midnight bounce for %s: stale value %s matches "
"pre-reset value, keeping %s",
variable,
current_val,
previous_value,
)
return_value = previous_value
elif current_val > 0:
# Genuine new-day production — clear tracking
del self._pre_reset_values[variable]
# Note: previous_values stores the *output* value (after suppression),
# not the raw API value. This is intentional — after a suppressed bounce,
# previous_value will be 0, which is what downstream comparisons need.
self.previous_values[variable] = return_value
return return_value

View File

@@ -138,6 +138,301 @@ async def test_sensor_unavailable_on_coordinator_error(
assert state.state == STATE_UNAVAILABLE
async def test_midnight_bounce_suppression(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that stale yesterday values after midnight reset are suppressed.
The Growatt API sometimes delivers stale yesterday values after a midnight
reset (9.5 → 0 → 9.5 → 0), causing TOTAL_INCREASING double-counting.
"""
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
entity_id = "sensor.test_plant_total_energy_today"
# Initial state: 12.5 kWh produced today
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "12.5"
# Step 1: Midnight reset — API returns 0 (legitimate reset)
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "0"
# Step 2: Stale bounce — API returns yesterday's value (12.5) after reset
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 12.5,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Bounce should be suppressed — state stays at 0
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "0"
# Step 3: Another reset arrives — still 0 (no double-counting)
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "0"
# Step 4: Genuine new production — small value passes through
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0.1,
"total_energy": 1250.1,
"current_power": 500,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "0.1"
async def test_normal_reset_no_bounce(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that normal midnight reset without bounce passes through correctly."""
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
entity_id = "sensor.test_plant_total_energy_today"
# Initial state: 9.5 kWh
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 9.5,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "9.5"
# Midnight reset — API returns 0
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "0"
# No bounce — genuine new production starts
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0.1,
"total_energy": 1250.1,
"current_power": 500,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "0.1"
# Production continues normally
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 1.5,
"total_energy": 1251.5,
"current_power": 2000,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "1.5"
async def test_midnight_bounce_repeated(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test multiple consecutive stale bounces are all suppressed."""
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
entity_id = "sensor.test_plant_total_energy_today"
# Set up a known pre-reset value
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 8.0,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == "8.0"
# Midnight reset
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == "0"
# First stale bounce — suppressed
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 8.0,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == "0"
# Back to 0
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == "0"
# Second stale bounce — also suppressed
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 8.0,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == "0"
# Back to 0 again
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0,
"total_energy": 1250.0,
"current_power": 0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == "0"
# Finally, genuine new production passes through
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0.2,
"total_energy": 1250.2,
"current_power": 1000,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == "0.2"
async def test_non_total_increasing_sensor_unaffected_by_bounce_suppression(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that non-TOTAL_INCREASING sensors are not affected by bounce suppression.
The total_energy_output sensor (totalEnergy) has state_class=TOTAL,
so bounce suppression (which only targets TOTAL_INCREASING) should not apply.
"""
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
# total_energy_output uses state_class=TOTAL (not TOTAL_INCREASING)
entity_id = "sensor.test_plant_total_lifetime_energy_output"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "1250.0"
# Simulate API returning 0 — no bounce suppression on TOTAL sensors
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 12.5,
"total_energy": 0,
"current_power": 2500,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "0"
# Value recovers — passes through without suppression
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 12.5,
"total_energy": 1250.0,
"current_power": 2500,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "1250.0"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_total_sensors_classic_api(
hass: HomeAssistant,