From c0ba77841a4d18bffbb6b60da16de03561c50962 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:46:12 +0200 Subject: [PATCH] remove 2^32 spikes from enphase_envoy daily production and consumption values (#183498) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/enphase_envoy/const.py | 5 + .../components/enphase_envoy/sensor.py | 38 +++- tests/components/enphase_envoy/test_sensor.py | 183 +++++++++++++++++- 3 files changed, 220 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 18bdd243af1c..8946106a7b8d 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -37,3 +37,8 @@ OPTION_DIAGNOSTICS_INCLUDE_FIXTURES_DEFAULT_VALUE = False OPTION_DISABLE_KEEP_ALIVE = "disable_keep_alive" OPTION_DISABLE_KEEP_ALIVE_DEFAULT_VALUE = False + +# The Envoy occasionally reports daily energy values of 2^32 Wh offset by the +# actual daily value (both above and below 2^32). Anything over 4 GWh for a +# single day is not physically plausible, so treat it as invalid. +DAILY_ENERGY_UPPER_LIMIT = 4_000_000_000 diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 676e8352b76e..d5d9d6518526 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -56,7 +56,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN +from .const import DAILY_ENERGY_UPPER_LIMIT, DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyACBAggregateEntity, EnvoyACBBatteryEntity, EnvoyBaseEntity @@ -208,6 +208,7 @@ class EnvoyProductionSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[EnvoySystemProduction], int] on_phase: str | None = None + upper_limit: int | None = None PRODUCTION_SENSORS = ( @@ -230,6 +231,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, value_fn=attrgetter("watt_hours_today"), + upper_limit=DAILY_ENERGY_UPPER_LIMIT, ), EnvoyProductionSensorEntityDescription( key="seven_days_production", @@ -275,6 +277,7 @@ class EnvoyConsumptionSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[EnvoySystemConsumption], int] on_phase: str | None = None + upper_limit: int | None = None CONSUMPTION_SENSORS = ( @@ -297,6 +300,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, value_fn=attrgetter("watt_hours_today"), + upper_limit=DAILY_ENERGY_UPPER_LIMIT, ), EnvoyConsumptionSensorEntityDescription( key="seven_days_consumption", @@ -1193,6 +1197,18 @@ class EnvoySystemSensorEntity(EnvoySensorBaseEntity): serial_number=self.envoy_serial_num, ) + def _apply_upper_limit(self, value: int, upper_limit: int | None) -> int | None: + """Return None for values above the upper limit.""" + if upper_limit is not None and value > upper_limit: + _LOGGER.debug( + "Value discarded as it exceeds the upper limit %s: %s > %s", + self.entity_id, + value, + upper_limit, + ) + return None + return value + class EnvoyProductionEntity(EnvoySystemSensorEntity): """Envoy production entity.""" @@ -1205,7 +1221,10 @@ class EnvoyProductionEntity(EnvoySystemSensorEntity): """Return the state of the sensor.""" if (system_production := self.data.system_production) is None: return None - return self.entity_description.value_fn(system_production) + return self._apply_upper_limit( + self.entity_description.value_fn(system_production), + self.entity_description.upper_limit, + ) class EnvoyConsumptionEntity(EnvoySystemSensorEntity): @@ -1219,7 +1238,10 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity): """Return the state of the sensor.""" if (system_consumption := self.data.system_consumption) is None: return None - return self.entity_description.value_fn(system_consumption) + return self._apply_upper_limit( + self.entity_description.value_fn(system_consumption), + self.entity_description.upper_limit, + ) class EnvoyNetConsumptionEntity(EnvoySystemSensorEntity): @@ -1258,7 +1280,10 @@ class EnvoyProductionPhaseEntity(EnvoySystemSensorEntity): ] ) is None: return None - return self.entity_description.value_fn(system_production) + return self._apply_upper_limit( + self.entity_description.value_fn(system_production), + self.entity_description.upper_limit, + ) class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity): @@ -1283,7 +1308,10 @@ class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity): ] ) is None: return None - return self.entity_description.value_fn(system_consumption) + return self._apply_upper_limit( + self.entity_description.value_fn(system_consumption), + self.entity_description.upper_limit, + ) class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity): diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index af0fdfdd7578..97fc9a10631f 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -13,7 +13,11 @@ from pyenphase.models.meters import CtType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.enphase_envoy.const import DOMAIN, Platform +from homeassistant.components.enphase_envoy.const import ( + DAILY_ENERGY_UPPER_LIMIT, + DOMAIN, + Platform, +) from homeassistant.components.enphase_envoy.coordinator import SCAN_INTERVAL from homeassistant.components.enphase_envoy.sensor import aggregate_acb_sleep_state from homeassistant.components.sensor import SensorStateClass @@ -1848,3 +1852,180 @@ async def test_acb_inventory_becomes_none( assert state.state == STATE_UNKNOWN assert (state := hass.states.get(aggregate)) assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_1p_metered", + "envoy_eu_batt", + "envoy_metered_batt_relay", + "envoy_nobatt_metered_3p", + "envoy_acb_batt", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_daily_production_consumption_upper_limit( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test production and consumption daily entities values limited by upper_limit.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.serial_number + ENTITY_BASE: str = f"{Platform.SENSOR}.envoy_{sn}" + + production_data = mock_envoy.data.system_production + consumption_data = mock_envoy.data.system_consumption + NAMES: tuple[str, ...] = ( + "energy_production_today", + "energy_consumption_today", + ) + + TARGETS: tuple[float, ...] = ( + production_data.watt_hours_today / 1000.0, + consumption_data.watt_hours_today / 1000.0, + ) + + for name, target in list(zip(NAMES, TARGETS, strict=False)): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert float(entity_state.state) == target + + # test upper limit is applied + mock_envoy.data.system_production.watt_hours_today = DAILY_ENERGY_UPPER_LIMIT + 1 + mock_envoy.data.system_consumption.watt_hours_today = DAILY_ENERGY_UPPER_LIMIT + 1 + mock_envoy.data.raw = {"change": 1} + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + for name in NAMES: + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert entity_state.state == "unknown" + + # test values are restored when not exceeding upper limit anymore + mock_envoy.data.system_production.watt_hours_today = DAILY_ENERGY_UPPER_LIMIT + mock_envoy.data.system_consumption.watt_hours_today = DAILY_ENERGY_UPPER_LIMIT + mock_envoy.data.raw = {"change": 2} + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + for name in NAMES: + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert float(entity_state.state) == DAILY_ENERGY_UPPER_LIMIT / 1000.0 + + # test upper limit is applied for reported issue value + mock_envoy.data.system_production.watt_hours_today = 2**32 - 669_000 + mock_envoy.data.system_consumption.watt_hours_today = 2**32 - 669_000 + mock_envoy.data.raw = {"change": 3} + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + for name in NAMES: + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert entity_state.state == "unknown" + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + "envoy_nobatt_metered_3p", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_daily_production_consumption_phase_upper_limit( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test production and consumption daily entities phase values limited by upper_limit.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.serial_number + ENTITY_BASE: str = f"{Platform.SENSOR}.envoy_{sn}" + + NAMES: tuple[str, ...] = ( + "energy_production_today", + "energy_consumption_today", + ) + + PHASE_NAMES: list[str] = [ + f"{name}_{phase.lower()}" for name in NAMES for phase in PHASENAMES + ] + PHASE_TARGETS = chain( + *[ + (phase_data.watt_hours_today / 1000.0,) + for phase_data in mock_envoy.data.system_production_phases.values() + ], + *[ + (phase_data.watt_hours_today / 1000.0,) + for phase_data in mock_envoy.data.system_consumption_phases.values() + ], + ) + for name, target in list(zip(PHASE_NAMES, PHASE_TARGETS, strict=False)): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert float(entity_state.state) == target + + # test upper limit is applied + for phase in PHASENAMES: + mock_envoy.data.system_production_phases[phase].watt_hours_today = ( + DAILY_ENERGY_UPPER_LIMIT + 1 + ) + mock_envoy.data.system_consumption_phases[phase].watt_hours_today = ( + DAILY_ENERGY_UPPER_LIMIT + 1 + ) + + mock_envoy.data.raw = {"change": 1} + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + for name in PHASE_NAMES: + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert entity_state.state == "unknown" + + # test values are restored when not exceeding upper limit anymore + for phase in PHASENAMES: + mock_envoy.data.system_production_phases[ + phase + ].watt_hours_today = DAILY_ENERGY_UPPER_LIMIT + mock_envoy.data.system_consumption_phases[ + phase + ].watt_hours_today = DAILY_ENERGY_UPPER_LIMIT + + mock_envoy.data.raw = {"change": 2} + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + for name in PHASE_NAMES: + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert float(entity_state.state) == DAILY_ENERGY_UPPER_LIMIT / 1000.0 + + # test upper limit is applied for reported issue value + mock_envoy.data.system_production.watt_hours_today = 2**32 - 669_000 + mock_envoy.data.system_consumption.watt_hours_today = 2**32 - 669_000 + + # test upper limit is applied for reported values + for phase in PHASENAMES: + mock_envoy.data.system_production_phases[phase].watt_hours_today = ( + 2**32 - 669_000 + ) + mock_envoy.data.system_consumption_phases[phase].watt_hours_today = ( + 2**32 - 669_000 + ) + + mock_envoy.data.raw = {"change": 3} + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + for name in PHASE_NAMES: + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert entity_state.state == "unknown"