mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 00:03:16 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user