From b91c07b2afaac7009d4cb9575edabfde8e6087cc Mon Sep 17 00:00:00 2001 From: johanzander Date: Thu, 19 Feb 2026 08:07:52 +0100 Subject: [PATCH] Fix midnight bounce suppression for Growatt today sensors (#163106) Co-authored-by: Claude Opus 4.6 Co-authored-by: Petar Petrov --- .../components/growatt_server/coordinator.py | 36 +++ .../components/growatt_server/test_sensor.py | 295 ++++++++++++++++++ 2 files changed, 331 insertions(+) diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index 68297e9c1c7..8a939a89439 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -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 diff --git a/tests/components/growatt_server/test_sensor.py b/tests/components/growatt_server/test_sensor.py index f7d520a524c..666ad9e6c61 100644 --- a/tests/components/growatt_server/test_sensor.py +++ b/tests/components/growatt_server/test_sensor.py @@ -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,