mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 14:17:45 +00:00
Add time based integration trigger to Riemann sum integral helper sensor (#110685)
* Schedule max dt for Riemann Integral sensor * Simplify validation. Dont integrate on change if either old or new state is not numeric. * Add validation to integration methods. Rollback requirement for both states to be always numeric. * Use 0 max_dt for disabling time based updates. * Use docstring instead of pass keyword in abstract methods. * Use time_period config validation for max_dt * Use new_state for scheduling max_dt. Only schedule if new state is numeric. * Use default 0 (None) for max_dt. * Rename max_dt to max_age. * Rollback accidental renaming of different file * Remove unnecessary and nonsensical max value. * Improve new config description * Use DurationSelector in config flow * Rename new config to max_sub_interval * Simplify by checking once for the integration strategy * Use positive time period validation of sub interval in platform schema Co-authored-by: Erik Montnemery <erik@montnemery.com> * Remove return keyword Co-authored-by: Erik Montnemery <erik@montnemery.com> * Simplify scheduling of interval exceeded callback Co-authored-by: Erik Montnemery <erik@montnemery.com> * Improve documentation * Be more clear about when time based integration is disabled. * Update homeassistant/components/integration/config_flow.py --------- Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
parent
4d27dd0fb0
commit
43c69c71c2
@ -26,6 +26,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
CONF_MAX_SUB_INTERVAL,
|
||||||
CONF_ROUND_DIGITS,
|
CONF_ROUND_DIGITS,
|
||||||
CONF_SOURCE_SENSOR,
|
CONF_SOURCE_SENSOR,
|
||||||
CONF_UNIT_PREFIX,
|
CONF_UNIT_PREFIX,
|
||||||
@ -100,6 +101,9 @@ async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict:
|
|||||||
min=0, max=6, mode=selector.NumberSelectorMode.BOX
|
min=0, max=6, mode=selector.NumberSelectorMode.BOX
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
vol.Optional(CONF_MAX_SUB_INTERVAL): selector.DurationSelector(
|
||||||
|
selector.DurationSelectorConfig(allow_negative=False)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ CONF_SOURCE_SENSOR = "source"
|
|||||||
CONF_UNIT_OF_MEASUREMENT = "unit"
|
CONF_UNIT_OF_MEASUREMENT = "unit"
|
||||||
CONF_UNIT_PREFIX = "unit_prefix"
|
CONF_UNIT_PREFIX = "unit_prefix"
|
||||||
CONF_UNIT_TIME = "unit_time"
|
CONF_UNIT_TIME = "unit_time"
|
||||||
|
CONF_MAX_SUB_INTERVAL = "max_sub_interval"
|
||||||
|
|
||||||
METHOD_TRAPEZOIDAL = "trapezoidal"
|
METHOD_TRAPEZOIDAL = "trapezoidal"
|
||||||
METHOD_LEFT = "left"
|
METHOD_LEFT = "left"
|
||||||
|
@ -4,7 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from decimal import Decimal, DecimalException, InvalidOperation
|
from decimal import Decimal, DecimalException, InvalidOperation
|
||||||
|
from enum import Enum
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Final, Self
|
from typing import Any, Final, Self
|
||||||
|
|
||||||
@ -29,6 +31,7 @@ from homeassistant.const import (
|
|||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
)
|
)
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
|
CALLBACK_TYPE,
|
||||||
Event,
|
Event,
|
||||||
EventStateChangedData,
|
EventStateChangedData,
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
@ -42,10 +45,11 @@ from homeassistant.helpers import (
|
|||||||
)
|
)
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_track_state_change_event
|
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
CONF_MAX_SUB_INTERVAL,
|
||||||
CONF_ROUND_DIGITS,
|
CONF_ROUND_DIGITS,
|
||||||
CONF_SOURCE_SENSOR,
|
CONF_SOURCE_SENSOR,
|
||||||
CONF_UNIT_OF_MEASUREMENT,
|
CONF_UNIT_OF_MEASUREMENT,
|
||||||
@ -87,6 +91,7 @@ PLATFORM_SCHEMA = vol.All(
|
|||||||
vol.Optional(CONF_UNIT_PREFIX): vol.In(UNIT_PREFIXES),
|
vol.Optional(CONF_UNIT_PREFIX): vol.In(UNIT_PREFIXES),
|
||||||
vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME),
|
vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME),
|
||||||
vol.Remove(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
vol.Remove(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
||||||
|
vol.Optional(CONF_MAX_SUB_INTERVAL): cv.positive_time_period,
|
||||||
vol.Optional(CONF_METHOD, default=METHOD_TRAPEZOIDAL): vol.In(
|
vol.Optional(CONF_METHOD, default=METHOD_TRAPEZOIDAL): vol.In(
|
||||||
INTEGRATION_METHODS
|
INTEGRATION_METHODS
|
||||||
),
|
),
|
||||||
@ -176,6 +181,11 @@ _NAME_TO_INTEGRATION_METHOD: dict[str, type[_IntegrationMethod]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _IntegrationTrigger(Enum):
|
||||||
|
StateChange = "state_change"
|
||||||
|
TimeElapsed = "time_elapsed"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class IntegrationSensorExtraStoredData(SensorExtraStoredData):
|
class IntegrationSensorExtraStoredData(SensorExtraStoredData):
|
||||||
"""Object to hold extra stored data."""
|
"""Object to hold extra stored data."""
|
||||||
@ -261,6 +271,11 @@ async def async_setup_entry(
|
|||||||
# Before we had support for optional selectors, "none" was used for selecting nothing
|
# Before we had support for optional selectors, "none" was used for selecting nothing
|
||||||
unit_prefix = None
|
unit_prefix = None
|
||||||
|
|
||||||
|
if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None):
|
||||||
|
max_sub_interval = cv.time_period(max_sub_interval_dict)
|
||||||
|
else:
|
||||||
|
max_sub_interval = None
|
||||||
|
|
||||||
round_digits = config_entry.options.get(CONF_ROUND_DIGITS)
|
round_digits = config_entry.options.get(CONF_ROUND_DIGITS)
|
||||||
if round_digits:
|
if round_digits:
|
||||||
round_digits = int(round_digits)
|
round_digits = int(round_digits)
|
||||||
@ -274,6 +289,7 @@ async def async_setup_entry(
|
|||||||
unit_prefix=unit_prefix,
|
unit_prefix=unit_prefix,
|
||||||
unit_time=config_entry.options[CONF_UNIT_TIME],
|
unit_time=config_entry.options[CONF_UNIT_TIME],
|
||||||
device_info=device_info,
|
device_info=device_info,
|
||||||
|
max_sub_interval=max_sub_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
async_add_entities([integral])
|
async_add_entities([integral])
|
||||||
@ -294,6 +310,7 @@ async def async_setup_platform(
|
|||||||
unique_id=config.get(CONF_UNIQUE_ID),
|
unique_id=config.get(CONF_UNIQUE_ID),
|
||||||
unit_prefix=config.get(CONF_UNIT_PREFIX),
|
unit_prefix=config.get(CONF_UNIT_PREFIX),
|
||||||
unit_time=config[CONF_UNIT_TIME],
|
unit_time=config[CONF_UNIT_TIME],
|
||||||
|
max_sub_interval=config.get(CONF_MAX_SUB_INTERVAL),
|
||||||
)
|
)
|
||||||
|
|
||||||
async_add_entities([integral])
|
async_add_entities([integral])
|
||||||
@ -315,6 +332,7 @@ class IntegrationSensor(RestoreSensor):
|
|||||||
unique_id: str | None,
|
unique_id: str | None,
|
||||||
unit_prefix: str | None,
|
unit_prefix: str | None,
|
||||||
unit_time: UnitOfTime,
|
unit_time: UnitOfTime,
|
||||||
|
max_sub_interval: timedelta | None,
|
||||||
device_info: DeviceInfo | None = None,
|
device_info: DeviceInfo | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the integration sensor."""
|
"""Initialize the integration sensor."""
|
||||||
@ -334,6 +352,14 @@ class IntegrationSensor(RestoreSensor):
|
|||||||
self._source_entity: str = source_entity
|
self._source_entity: str = source_entity
|
||||||
self._last_valid_state: Decimal | None = None
|
self._last_valid_state: Decimal | None = None
|
||||||
self._attr_device_info = device_info
|
self._attr_device_info = device_info
|
||||||
|
self._max_sub_interval: timedelta | None = (
|
||||||
|
None # disable time based integration
|
||||||
|
if max_sub_interval is None or max_sub_interval.total_seconds() == 0
|
||||||
|
else max_sub_interval
|
||||||
|
)
|
||||||
|
self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None
|
||||||
|
self._last_integration_time: datetime = datetime.now(tz=UTC)
|
||||||
|
self._last_integration_trigger = _IntegrationTrigger.StateChange
|
||||||
self._attr_suggested_display_precision = round_digits or 2
|
self._attr_suggested_display_precision = round_digits or 2
|
||||||
|
|
||||||
def _calculate_unit(self, source_unit: str) -> str:
|
def _calculate_unit(self, source_unit: str) -> str:
|
||||||
@ -421,19 +447,55 @@ class IntegrationSensor(RestoreSensor):
|
|||||||
self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||||
self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||||
|
|
||||||
|
if self._max_sub_interval is not None:
|
||||||
|
source_state = self.hass.states.get(self._sensor_source_id)
|
||||||
|
self._schedule_max_sub_interval_exceeded_if_state_is_numeric(source_state)
|
||||||
|
self.async_on_remove(self._cancel_max_sub_interval_exceeded_callback)
|
||||||
|
handle_state_change = self._integrate_on_state_change_and_max_sub_interval
|
||||||
|
else:
|
||||||
|
handle_state_change = self._integrate_on_state_change_callback
|
||||||
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_track_state_change_event(
|
async_track_state_change_event(
|
||||||
self.hass,
|
self.hass,
|
||||||
[self._sensor_source_id],
|
[self._sensor_source_id],
|
||||||
self._handle_state_change,
|
handle_state_change,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_state_change(self, event: Event[EventStateChangedData]) -> None:
|
def _integrate_on_state_change_and_max_sub_interval(
|
||||||
|
self, event: Event[EventStateChangedData]
|
||||||
|
) -> None:
|
||||||
|
"""Integrate based on state change and time.
|
||||||
|
|
||||||
|
Next to doing the integration based on state change this method cancels and
|
||||||
|
reschedules time based integration.
|
||||||
|
"""
|
||||||
|
self._cancel_max_sub_interval_exceeded_callback()
|
||||||
old_state = event.data["old_state"]
|
old_state = event.data["old_state"]
|
||||||
new_state = event.data["new_state"]
|
new_state = event.data["new_state"]
|
||||||
|
try:
|
||||||
|
self._integrate_on_state_change(old_state, new_state)
|
||||||
|
self._last_integration_trigger = _IntegrationTrigger.StateChange
|
||||||
|
self._last_integration_time = datetime.now(tz=UTC)
|
||||||
|
finally:
|
||||||
|
# When max_sub_interval exceeds without state change the source is assumed
|
||||||
|
# constant with the last known state (new_state).
|
||||||
|
self._schedule_max_sub_interval_exceeded_if_state_is_numeric(new_state)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _integrate_on_state_change_callback(
|
||||||
|
self, event: Event[EventStateChangedData]
|
||||||
|
) -> None:
|
||||||
|
"""Handle the sensor state changes."""
|
||||||
|
old_state = event.data["old_state"]
|
||||||
|
new_state = event.data["new_state"]
|
||||||
|
return self._integrate_on_state_change(old_state, new_state)
|
||||||
|
|
||||||
|
def _integrate_on_state_change(
|
||||||
|
self, old_state: State | None, new_state: State | None
|
||||||
|
) -> None:
|
||||||
if old_state is None or new_state is None:
|
if old_state is None or new_state is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -451,6 +513,8 @@ class IntegrationSensor(RestoreSensor):
|
|||||||
|
|
||||||
elapsed_seconds = Decimal(
|
elapsed_seconds = Decimal(
|
||||||
(new_state.last_updated - old_state.last_updated).total_seconds()
|
(new_state.last_updated - old_state.last_updated).total_seconds()
|
||||||
|
if self._last_integration_trigger == _IntegrationTrigger.StateChange
|
||||||
|
else (new_state.last_updated - self._last_integration_time).total_seconds()
|
||||||
)
|
)
|
||||||
|
|
||||||
area = self._method.calculate_area_with_two_states(elapsed_seconds, *states)
|
area = self._method.calculate_area_with_two_states(elapsed_seconds, *states)
|
||||||
@ -458,6 +522,52 @@ class IntegrationSensor(RestoreSensor):
|
|||||||
self._update_integral(area)
|
self._update_integral(area)
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
def _schedule_max_sub_interval_exceeded_if_state_is_numeric(
|
||||||
|
self, source_state: State | None
|
||||||
|
) -> None:
|
||||||
|
"""Schedule possible integration using the source state and max_sub_interval.
|
||||||
|
|
||||||
|
The callback reference is stored for possible cancellation if the source state
|
||||||
|
reports a change before max_sub_interval has passed.
|
||||||
|
|
||||||
|
If the callback is executed, meaning there was no state change reported, the
|
||||||
|
source_state is assumed constant and integration is done using its value.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
self._max_sub_interval is not None
|
||||||
|
and source_state is not None
|
||||||
|
and (source_state_dec := _decimal_state(source_state.state))
|
||||||
|
):
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _integrate_on_max_sub_interval_exceeded_callback(now: datetime) -> None:
|
||||||
|
"""Integrate based on time and reschedule."""
|
||||||
|
elapsed_seconds = Decimal(
|
||||||
|
(now - self._last_integration_time).total_seconds()
|
||||||
|
)
|
||||||
|
self._derive_and_set_attributes_from_state(source_state)
|
||||||
|
area = self._method.calculate_area_with_one_state(
|
||||||
|
elapsed_seconds, source_state_dec
|
||||||
|
)
|
||||||
|
self._update_integral(area)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
self._last_integration_time = datetime.now(tz=UTC)
|
||||||
|
self._last_integration_trigger = _IntegrationTrigger.TimeElapsed
|
||||||
|
|
||||||
|
self._schedule_max_sub_interval_exceeded_if_state_is_numeric(
|
||||||
|
source_state
|
||||||
|
)
|
||||||
|
|
||||||
|
self._max_sub_interval_exceeded_callback = async_call_later(
|
||||||
|
self.hass,
|
||||||
|
self._max_sub_interval,
|
||||||
|
_integrate_on_max_sub_interval_exceeded_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cancel_max_sub_interval_exceeded_callback(self) -> None:
|
||||||
|
self._max_sub_interval_exceeded_callback()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> Decimal | None:
|
def native_value(self) -> Decimal | None:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
|
@ -11,12 +11,14 @@
|
|||||||
"round": "Precision",
|
"round": "Precision",
|
||||||
"source": "Input sensor",
|
"source": "Input sensor",
|
||||||
"unit_prefix": "Metric prefix",
|
"unit_prefix": "Metric prefix",
|
||||||
"unit_time": "Time unit"
|
"unit_time": "Time unit",
|
||||||
|
"max_sub_interval": "Max sub-interval"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"round": "Controls the number of decimal digits in the output.",
|
"round": "Controls the number of decimal digits in the output.",
|
||||||
"unit_prefix": "The output will be scaled according to the selected metric prefix.",
|
"unit_prefix": "The output will be scaled according to the selected metric prefix.",
|
||||||
"unit_time": "The output will be scaled according to the selected time unit."
|
"unit_time": "The output will be scaled according to the selected time unit.",
|
||||||
|
"max_sub_interval": "Applies time based integration if the source did not change for this duration. Use 0 for no time based updates."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None:
|
|||||||
"round": 1,
|
"round": 1,
|
||||||
"source": input_sensor_entity_id,
|
"source": input_sensor_entity_id,
|
||||||
"unit_time": "min",
|
"unit_time": "min",
|
||||||
|
"max_sub_interval": {"seconds": 0},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
@ -49,6 +50,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None:
|
|||||||
"round": 1.0,
|
"round": 1.0,
|
||||||
"source": "sensor.input",
|
"source": "sensor.input",
|
||||||
"unit_time": "min",
|
"unit_time": "min",
|
||||||
|
"max_sub_interval": {"seconds": 0},
|
||||||
}
|
}
|
||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
@ -60,6 +62,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None:
|
|||||||
"round": 1.0,
|
"round": 1.0,
|
||||||
"source": "sensor.input",
|
"source": "sensor.input",
|
||||||
"unit_time": "min",
|
"unit_time": "min",
|
||||||
|
"max_sub_interval": {"seconds": 0},
|
||||||
}
|
}
|
||||||
assert config_entry.title == "My integration"
|
assert config_entry.title == "My integration"
|
||||||
|
|
||||||
@ -89,6 +92,7 @@ async def test_options(hass: HomeAssistant, platform) -> None:
|
|||||||
"source": "sensor.input",
|
"source": "sensor.input",
|
||||||
"unit_prefix": "k",
|
"unit_prefix": "k",
|
||||||
"unit_time": "min",
|
"unit_time": "min",
|
||||||
|
"max_sub_interval": {"minutes": 1},
|
||||||
},
|
},
|
||||||
title="My integration",
|
title="My integration",
|
||||||
)
|
)
|
||||||
@ -119,6 +123,7 @@ async def test_options(hass: HomeAssistant, platform) -> None:
|
|||||||
"method": "right",
|
"method": "right",
|
||||||
"round": 2.0,
|
"round": 2.0,
|
||||||
"source": "sensor.input",
|
"source": "sensor.input",
|
||||||
|
"max_sub_interval": {"minutes": 1},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
@ -129,6 +134,7 @@ async def test_options(hass: HomeAssistant, platform) -> None:
|
|||||||
"source": "sensor.input",
|
"source": "sensor.input",
|
||||||
"unit_prefix": "k",
|
"unit_prefix": "k",
|
||||||
"unit_time": "min",
|
"unit_time": "min",
|
||||||
|
"max_sub_interval": {"minutes": 1},
|
||||||
}
|
}
|
||||||
assert config_entry.data == {}
|
assert config_entry.data == {}
|
||||||
assert config_entry.options == {
|
assert config_entry.options == {
|
||||||
@ -138,6 +144,7 @@ async def test_options(hass: HomeAssistant, platform) -> None:
|
|||||||
"source": "sensor.input",
|
"source": "sensor.input",
|
||||||
"unit_prefix": "k",
|
"unit_prefix": "k",
|
||||||
"unit_time": "min",
|
"unit_time": "min",
|
||||||
|
"max_sub_interval": {"minutes": 1},
|
||||||
}
|
}
|
||||||
assert config_entry.title == "My integration"
|
assert config_entry.title == "My integration"
|
||||||
|
|
||||||
|
@ -30,6 +30,7 @@ async def test_setup_and_remove_config_entry(
|
|||||||
"source": "sensor.input",
|
"source": "sensor.input",
|
||||||
"unit_prefix": "k",
|
"unit_prefix": "k",
|
||||||
"unit_time": "min",
|
"unit_time": "min",
|
||||||
|
"max_sub_interval": {"minutes": 1},
|
||||||
},
|
},
|
||||||
title="My integration",
|
title="My integration",
|
||||||
)
|
)
|
||||||
|
@ -18,12 +18,17 @@ from homeassistant.const import (
|
|||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, State
|
from homeassistant.core import HomeAssistant, State
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import (
|
||||||
|
condition,
|
||||||
|
device_registry as dr,
|
||||||
|
entity_registry as er,
|
||||||
|
)
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from tests.common import (
|
from tests.common import (
|
||||||
MockConfigEntry,
|
MockConfigEntry,
|
||||||
|
async_fire_time_changed,
|
||||||
mock_restore_cache,
|
mock_restore_cache,
|
||||||
mock_restore_cache_with_extra_data,
|
mock_restore_cache_with_extra_data,
|
||||||
)
|
)
|
||||||
@ -745,3 +750,146 @@ async def test_device_id(
|
|||||||
integration_entity = entity_registry.async_get("sensor.integration")
|
integration_entity = entity_registry.async_get("sensor.integration")
|
||||||
assert integration_entity is not None
|
assert integration_entity is not None
|
||||||
assert integration_entity.device_id == source_entity.device_id
|
assert integration_entity.device_id == source_entity.device_id
|
||||||
|
|
||||||
|
|
||||||
|
def _integral_sensor_config(max_sub_interval: dict[str, int] | None = {"minutes": 1}):
|
||||||
|
sensor = {
|
||||||
|
"platform": "integration",
|
||||||
|
"name": "integration",
|
||||||
|
"source": "sensor.power",
|
||||||
|
"method": "right",
|
||||||
|
}
|
||||||
|
if max_sub_interval is not None:
|
||||||
|
sensor["max_sub_interval"] = max_sub_interval
|
||||||
|
return {"sensor": sensor}
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_integral_sensor(
|
||||||
|
hass: HomeAssistant, max_sub_interval: dict[str, int] | None = {"minutes": 1}
|
||||||
|
) -> None:
|
||||||
|
await async_setup_component(
|
||||||
|
hass, "sensor", _integral_sensor_config(max_sub_interval=max_sub_interval)
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_source_sensor(hass: HomeAssistant, value: int | str) -> None:
|
||||||
|
hass.states.async_set(
|
||||||
|
_integral_sensor_config()["sensor"]["source"],
|
||||||
|
value,
|
||||||
|
{ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT},
|
||||||
|
force_update=True,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_valid_source_expect_update_on_time(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> None:
|
||||||
|
"""Test whether time based integration updates the integral on a valid source."""
|
||||||
|
start_time = dt_util.utcnow()
|
||||||
|
|
||||||
|
with freeze_time(start_time) as freezer:
|
||||||
|
await _setup_integral_sensor(hass)
|
||||||
|
await _update_source_sensor(hass, 100)
|
||||||
|
state_before_max_sub_interval_exceeded = hass.states.get("sensor.integration")
|
||||||
|
|
||||||
|
freezer.tick(61)
|
||||||
|
async_fire_time_changed(hass, dt_util.now())
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.integration")
|
||||||
|
assert (
|
||||||
|
condition.async_numeric_state(hass, state_before_max_sub_interval_exceeded)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert state_before_max_sub_interval_exceeded.state != state.state
|
||||||
|
assert condition.async_numeric_state(hass, state) is True
|
||||||
|
assert float(state.state) > 1.69 # approximately 100 * 61 / 3600
|
||||||
|
assert float(state.state) < 1.8
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_unvailable_source_expect_no_update_on_time(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> None:
|
||||||
|
"""Test whether time based integration handles unavailability of the source properly."""
|
||||||
|
|
||||||
|
start_time = dt_util.utcnow()
|
||||||
|
with freeze_time(start_time) as freezer:
|
||||||
|
await _setup_integral_sensor(hass)
|
||||||
|
await _update_source_sensor(hass, 100)
|
||||||
|
freezer.tick(61)
|
||||||
|
async_fire_time_changed(hass, dt_util.now())
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.integration")
|
||||||
|
assert condition.async_numeric_state(hass, state) is True
|
||||||
|
|
||||||
|
await _update_source_sensor(hass, STATE_UNAVAILABLE)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
freezer.tick(61)
|
||||||
|
async_fire_time_changed(hass, dt_util.now())
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.integration")
|
||||||
|
assert condition.state(hass, state, STATE_UNAVAILABLE) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_statechanges_source_expect_no_update_on_time(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> None:
|
||||||
|
"""Test whether state changes cancel time based integration."""
|
||||||
|
|
||||||
|
start_time = dt_util.utcnow()
|
||||||
|
with freeze_time(start_time) as freezer:
|
||||||
|
await _setup_integral_sensor(hass)
|
||||||
|
await _update_source_sensor(hass, 100)
|
||||||
|
|
||||||
|
freezer.tick(30)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await _update_source_sensor(hass, 101)
|
||||||
|
|
||||||
|
state_after_30s = hass.states.get("sensor.integration")
|
||||||
|
assert condition.async_numeric_state(hass, state_after_30s) is True
|
||||||
|
|
||||||
|
freezer.tick(35)
|
||||||
|
async_fire_time_changed(hass, dt_util.now())
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
state_after_65s = hass.states.get("sensor.integration")
|
||||||
|
assert (dt_util.now() - start_time).total_seconds() > 60
|
||||||
|
# No state change because the timer was cancelled because of an update after 30s
|
||||||
|
assert state_after_65s == state_after_30s
|
||||||
|
|
||||||
|
freezer.tick(35)
|
||||||
|
async_fire_time_changed(hass, dt_util.now())
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
state_after_105s = hass.states.get("sensor.integration")
|
||||||
|
# Update based on time
|
||||||
|
assert float(state_after_105s.state) > float(state_after_65s.state)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_no_max_sub_interval_expect_no_timebased_updates(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> None:
|
||||||
|
"""Test whether integratal is not updated by time when max_sub_interval is not configured."""
|
||||||
|
|
||||||
|
start_time = dt_util.utcnow()
|
||||||
|
with freeze_time(start_time) as freezer:
|
||||||
|
await _setup_integral_sensor(hass, max_sub_interval=None)
|
||||||
|
await _update_source_sensor(hass, 100)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await _update_source_sensor(hass, 101)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state_after_last_state_change = hass.states.get("sensor.integration")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
condition.async_numeric_state(hass, state_after_last_state_change) is True
|
||||||
|
)
|
||||||
|
|
||||||
|
freezer.tick(100)
|
||||||
|
async_fire_time_changed(hass, dt_util.now())
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
state_after_100s = hass.states.get("sensor.integration")
|
||||||
|
assert state_after_100s == state_after_last_state_change
|
||||||
|
Loading…
x
Reference in New Issue
Block a user