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>
This commit is contained in:
Arie Catsman
2026-09-29 13:46:12 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 12e19d8219
commit c0ba77841a
3 changed files with 220 additions and 6 deletions
@@ -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
@@ -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):
+182 -1
View File
@@ -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"