mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 14:29:21 -04:00
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:
co-authored by
Copilot Autofix powered by AI
parent
12e19d8219
commit
c0ba77841a
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user