mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 20:27:08 +00:00
Merge pull request #55969 from home-assistant/rc
This commit is contained in:
commit
5cc54618c5
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@ -580,7 +580,7 @@ jobs:
|
||||
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -U "pip<20.3" setuptools wheel
|
||||
pip install -U "pip<20.3" "setuptools<58" wheel
|
||||
pip install -r requirements_all.txt
|
||||
pip install -r requirements_test.txt
|
||||
pip install -e .
|
||||
|
@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
from typing import Callable, Final
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
@ -24,6 +24,9 @@ from homeassistant.const import (
|
||||
VOLUME_CUBIC_METERS,
|
||||
)
|
||||
|
||||
PRICE_EUR_KWH: Final = f"EUR/{ENERGY_KILO_WATT_HOUR}"
|
||||
PRICE_EUR_M3: Final = f"EUR/{VOLUME_CUBIC_METERS}"
|
||||
|
||||
|
||||
def dsmr_transform(value):
|
||||
"""Transform DSMR version value to right format."""
|
||||
@ -301,31 +304,31 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1",
|
||||
name="Low tariff delivered price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_KWH,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2",
|
||||
name="High tariff delivered price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_KWH,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1",
|
||||
name="Low tariff returned price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_KWH,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2",
|
||||
name="High tariff returned price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_KWH,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/energy_supplier_price_gas",
|
||||
name="Gas price",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=CURRENCY_EURO,
|
||||
native_unit_of_measurement=PRICE_EUR_M3,
|
||||
),
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/day-consumption/fixed_cost",
|
||||
|
@ -1,13 +1,16 @@
|
||||
"""Helper sensor for calculating utility costs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Final, Literal, TypeVar, cast
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
ATTR_LAST_RESET,
|
||||
ATTR_STATE_CLASS,
|
||||
DEVICE_CLASS_MONETARY,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
SensorEntity,
|
||||
)
|
||||
@ -18,14 +21,19 @@ from homeassistant.const import (
|
||||
ENERGY_WATT_HOUR,
|
||||
VOLUME_CUBIC_METERS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import EnergyManager, async_get_manager
|
||||
|
||||
SUPPORTED_STATE_CLASSES = [
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -206,15 +214,16 @@ class EnergyCostSensor(SensorEntity):
|
||||
f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}"
|
||||
)
|
||||
self._attr_device_class = DEVICE_CLASS_MONETARY
|
||||
self._attr_state_class = STATE_CLASS_TOTAL_INCREASING
|
||||
self._attr_state_class = STATE_CLASS_MEASUREMENT
|
||||
self._config = config
|
||||
self._last_energy_sensor_state: StateType | None = None
|
||||
self._last_energy_sensor_state: State | None = None
|
||||
self._cur_value = 0.0
|
||||
|
||||
def _reset(self, energy_state: StateType) -> None:
|
||||
def _reset(self, energy_state: State) -> None:
|
||||
"""Reset the cost sensor."""
|
||||
self._attr_native_value = 0.0
|
||||
self._cur_value = 0.0
|
||||
self._attr_last_reset = dt_util.utcnow()
|
||||
self._last_energy_sensor_state = energy_state
|
||||
self.async_write_ha_state()
|
||||
|
||||
@ -228,9 +237,8 @@ class EnergyCostSensor(SensorEntity):
|
||||
if energy_state is None:
|
||||
return
|
||||
|
||||
if (
|
||||
state_class := energy_state.attributes.get(ATTR_STATE_CLASS)
|
||||
) != STATE_CLASS_TOTAL_INCREASING:
|
||||
state_class = energy_state.attributes.get(ATTR_STATE_CLASS)
|
||||
if state_class not in SUPPORTED_STATE_CLASSES:
|
||||
if not self._wrong_state_class_reported:
|
||||
self._wrong_state_class_reported = True
|
||||
_LOGGER.warning(
|
||||
@ -240,6 +248,13 @@ class EnergyCostSensor(SensorEntity):
|
||||
)
|
||||
return
|
||||
|
||||
# last_reset must be set if the sensor is STATE_CLASS_MEASUREMENT
|
||||
if (
|
||||
state_class == STATE_CLASS_MEASUREMENT
|
||||
and ATTR_LAST_RESET not in energy_state.attributes
|
||||
):
|
||||
return
|
||||
|
||||
try:
|
||||
energy = float(energy_state.state)
|
||||
except ValueError:
|
||||
@ -273,7 +288,7 @@ class EnergyCostSensor(SensorEntity):
|
||||
|
||||
if self._last_energy_sensor_state is None:
|
||||
# Initialize as it's the first time all required entities are in place.
|
||||
self._reset(energy_state.state)
|
||||
self._reset(energy_state)
|
||||
return
|
||||
|
||||
energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
@ -298,20 +313,29 @@ class EnergyCostSensor(SensorEntity):
|
||||
)
|
||||
return
|
||||
|
||||
if reset_detected(
|
||||
if state_class != STATE_CLASS_TOTAL_INCREASING and energy_state.attributes.get(
|
||||
ATTR_LAST_RESET
|
||||
) != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET):
|
||||
# Energy meter was reset, reset cost sensor too
|
||||
energy_state_copy = copy.copy(energy_state)
|
||||
energy_state_copy.state = "0.0"
|
||||
self._reset(energy_state_copy)
|
||||
elif state_class == STATE_CLASS_TOTAL_INCREASING and reset_detected(
|
||||
self.hass,
|
||||
cast(str, self._config[self._adapter.entity_energy_key]),
|
||||
energy,
|
||||
float(self._last_energy_sensor_state),
|
||||
float(self._last_energy_sensor_state.state),
|
||||
):
|
||||
# Energy meter was reset, reset cost sensor too
|
||||
self._reset(0)
|
||||
energy_state_copy = copy.copy(energy_state)
|
||||
energy_state_copy.state = "0.0"
|
||||
self._reset(energy_state_copy)
|
||||
# Update with newly incurred cost
|
||||
old_energy_value = float(self._last_energy_sensor_state)
|
||||
old_energy_value = float(self._last_energy_sensor_state.state)
|
||||
self._cur_value += (energy - old_energy_value) * energy_price
|
||||
self._attr_native_value = round(self._cur_value, 2)
|
||||
|
||||
self._last_energy_sensor_state = energy_state.state
|
||||
self._last_energy_sensor_state = energy_state
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Validate the energy preferences provide valid data."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
@ -10,12 +11,24 @@ from homeassistant.const import (
|
||||
ENERGY_WATT_HOUR,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
VOLUME_CUBIC_FEET,
|
||||
VOLUME_CUBIC_METERS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||
|
||||
from . import data
|
||||
from .const import DOMAIN
|
||||
|
||||
ENERGY_USAGE_UNITS = (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR)
|
||||
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
|
||||
GAS_USAGE_UNITS = (
|
||||
ENERGY_WATT_HOUR,
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
VOLUME_CUBIC_METERS,
|
||||
VOLUME_CUBIC_FEET,
|
||||
)
|
||||
GAS_UNIT_ERROR = "entity_unexpected_unit_gas"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ValidationIssue:
|
||||
@ -43,8 +56,12 @@ class EnergyPreferencesValidation:
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_energy_stat(
|
||||
hass: HomeAssistant, stat_value: str, result: list[ValidationIssue]
|
||||
def _async_validate_usage_stat(
|
||||
hass: HomeAssistant,
|
||||
stat_value: str,
|
||||
allowed_units: Sequence[str],
|
||||
unit_error: str,
|
||||
result: list[ValidationIssue],
|
||||
) -> None:
|
||||
"""Validate a statistic."""
|
||||
has_entity_source = valid_entity_id(stat_value)
|
||||
@ -91,14 +108,16 @@ def _async_validate_energy_stat(
|
||||
|
||||
unit = state.attributes.get("unit_of_measurement")
|
||||
|
||||
if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR):
|
||||
result.append(
|
||||
ValidationIssue("entity_unexpected_unit_energy", stat_value, unit)
|
||||
)
|
||||
if unit not in allowed_units:
|
||||
result.append(ValidationIssue(unit_error, stat_value, unit))
|
||||
|
||||
state_class = state.attributes.get("state_class")
|
||||
|
||||
if state_class != sensor.STATE_CLASS_TOTAL_INCREASING:
|
||||
supported_state_classes = [
|
||||
sensor.STATE_CLASS_MEASUREMENT,
|
||||
sensor.STATE_CLASS_TOTAL_INCREASING,
|
||||
]
|
||||
if state_class not in supported_state_classes:
|
||||
result.append(
|
||||
ValidationIssue(
|
||||
"entity_unexpected_state_class_total_increasing",
|
||||
@ -125,16 +144,13 @@ def _async_validate_price_entity(
|
||||
return
|
||||
|
||||
try:
|
||||
value: float | None = float(state.state)
|
||||
float(state.state)
|
||||
except ValueError:
|
||||
result.append(
|
||||
ValidationIssue("entity_state_non_numeric", entity_id, state.state)
|
||||
)
|
||||
return
|
||||
|
||||
if value is not None and value < 0:
|
||||
result.append(ValidationIssue("entity_negative_state", entity_id, value))
|
||||
|
||||
unit = state.attributes.get("unit_of_measurement")
|
||||
|
||||
if unit is None or not unit.endswith(
|
||||
@ -188,7 +204,11 @@ def _async_validate_cost_entity(
|
||||
|
||||
state_class = state.attributes.get("state_class")
|
||||
|
||||
if state_class != sensor.STATE_CLASS_TOTAL_INCREASING:
|
||||
supported_state_classes = [
|
||||
sensor.STATE_CLASS_MEASUREMENT,
|
||||
sensor.STATE_CLASS_TOTAL_INCREASING,
|
||||
]
|
||||
if state_class not in supported_state_classes:
|
||||
result.append(
|
||||
ValidationIssue(
|
||||
"entity_unexpected_state_class_total_increasing", entity_id, state_class
|
||||
@ -211,8 +231,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
|
||||
if source["type"] == "grid":
|
||||
for flow in source["flow_from"]:
|
||||
_async_validate_energy_stat(
|
||||
hass, flow["stat_energy_from"], source_result
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
flow["stat_energy_from"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
if flow.get("stat_cost") is not None:
|
||||
@ -229,7 +253,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
)
|
||||
|
||||
for flow in source["flow_to"]:
|
||||
_async_validate_energy_stat(hass, flow["stat_energy_to"], source_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
flow["stat_energy_to"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
if flow.get("stat_compensation") is not None:
|
||||
_async_validate_cost_stat(
|
||||
@ -247,7 +277,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
)
|
||||
|
||||
elif source["type"] == "gas":
|
||||
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
GAS_USAGE_UNITS,
|
||||
GAS_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
if source.get("stat_cost") is not None:
|
||||
_async_validate_cost_stat(hass, source["stat_cost"], source_result)
|
||||
@ -263,15 +299,39 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
)
|
||||
|
||||
elif source["type"] == "solar":
|
||||
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
elif source["type"] == "battery":
|
||||
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
|
||||
_async_validate_energy_stat(hass, source["stat_energy_to"], source_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
source["stat_energy_to"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
|
||||
for device in manager.data["device_consumption"]:
|
||||
device_result: list[ValidationIssue] = []
|
||||
result.device_consumption.append(device_result)
|
||||
_async_validate_energy_stat(hass, device["stat_consumption"], device_result)
|
||||
_async_validate_usage_stat(
|
||||
hass,
|
||||
device["stat_consumption"],
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
device_result,
|
||||
)
|
||||
|
||||
return result
|
||||
|
@ -2311,16 +2311,12 @@ class SensorStateTrait(_Trait):
|
||||
name = TRAIT_SENSOR_STATE
|
||||
commands = []
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features, device_class, _):
|
||||
@classmethod
|
||||
def supported(cls, domain, features, device_class, _):
|
||||
"""Test if state is supported."""
|
||||
return domain == sensor.DOMAIN and device_class in (
|
||||
sensor.DEVICE_CLASS_AQI,
|
||||
sensor.DEVICE_CLASS_CO,
|
||||
sensor.DEVICE_CLASS_CO2,
|
||||
sensor.DEVICE_CLASS_PM25,
|
||||
sensor.DEVICE_CLASS_PM10,
|
||||
sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
|
||||
return (
|
||||
domain == sensor.DOMAIN
|
||||
and device_class in SensorStateTrait.sensor_types.keys()
|
||||
)
|
||||
|
||||
def sync_attributes(self):
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Support for Honeywell (US) Total Connect Comfort climate systems."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
import somecomfort
|
||||
@ -9,7 +10,8 @@ from homeassistant.util import Throttle
|
||||
|
||||
from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180)
|
||||
UPDATE_LOOP_SLEEP_TIME = 5
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300)
|
||||
PLATFORMS = ["climate"]
|
||||
|
||||
|
||||
@ -42,7 +44,7 @@ async def async_setup_entry(hass, config):
|
||||
return False
|
||||
|
||||
data = HoneywellData(hass, client, username, password, devices)
|
||||
await data.update()
|
||||
await data.async_update()
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][config.entry_id] = data
|
||||
hass.config_entries.async_setup_platforms(config, PLATFORMS)
|
||||
@ -102,18 +104,19 @@ class HoneywellData:
|
||||
self.devices = devices
|
||||
return True
|
||||
|
||||
def _refresh_devices(self):
|
||||
async def _refresh_devices(self):
|
||||
"""Refresh each enabled device."""
|
||||
for device in self.devices:
|
||||
device.refresh()
|
||||
await self._hass.async_add_executor_job(device.refresh)
|
||||
await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
async def update(self) -> None:
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state."""
|
||||
retries = 3
|
||||
while retries > 0:
|
||||
try:
|
||||
await self._hass.async_add_executor_job(self._refresh_devices)
|
||||
await self._refresh_devices()
|
||||
break
|
||||
except (
|
||||
somecomfort.client.APIRateLimited,
|
||||
@ -124,7 +127,7 @@ class HoneywellData:
|
||||
if retries == 0:
|
||||
raise exp
|
||||
|
||||
result = await self._hass.async_add_executor_job(self._retry())
|
||||
result = await self._retry()
|
||||
|
||||
if not result:
|
||||
raise exp
|
||||
|
@ -107,6 +107,8 @@ HW_FAN_MODE_TO_HA = {
|
||||
"follow schedule": FAN_AUTO,
|
||||
}
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the Honeywell thermostat."""
|
||||
@ -384,4 +386,4 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
|
||||
async def async_update(self):
|
||||
"""Get the latest state from the service."""
|
||||
await self._data.update()
|
||||
await self._data.async_update()
|
||||
|
@ -106,7 +106,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
|
||||
"""Initialize the integration sensor."""
|
||||
self._sensor_source_id = source_entity
|
||||
self._round_digits = round_digits
|
||||
self._state = 0
|
||||
self._state = STATE_UNAVAILABLE
|
||||
self._method = integration_method
|
||||
|
||||
self._name = name if name is not None else f"{source_entity} integral"
|
||||
@ -187,7 +187,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
|
||||
except AssertionError as err:
|
||||
_LOGGER.error("Could not calculate integral: %s", err)
|
||||
else:
|
||||
if isinstance(self._state, Decimal):
|
||||
self._state += integral
|
||||
else:
|
||||
self._state = integral
|
||||
self.async_write_ha_state()
|
||||
|
||||
async_track_state_change_event(
|
||||
@ -202,7 +205,9 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
if isinstance(self._state, Decimal):
|
||||
return round(self._state, self._round_digits)
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
|
@ -4,10 +4,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.significant_change import (
|
||||
check_numeric_changed,
|
||||
either_one_none,
|
||||
)
|
||||
from homeassistant.helpers.significant_change import check_absolute_change
|
||||
|
||||
from . import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@ -37,24 +34,21 @@ def async_check_significant_change(
|
||||
old_color = old_attrs.get(ATTR_HS_COLOR)
|
||||
new_color = new_attrs.get(ATTR_HS_COLOR)
|
||||
|
||||
if either_one_none(old_color, new_color):
|
||||
return True
|
||||
|
||||
if old_color and new_color:
|
||||
# Range 0..360
|
||||
if check_numeric_changed(old_color[0], new_color[0], 5):
|
||||
if check_absolute_change(old_color[0], new_color[0], 5):
|
||||
return True
|
||||
|
||||
# Range 0..100
|
||||
if check_numeric_changed(old_color[1], new_color[1], 3):
|
||||
if check_absolute_change(old_color[1], new_color[1], 3):
|
||||
return True
|
||||
|
||||
if check_numeric_changed(
|
||||
if check_absolute_change(
|
||||
old_attrs.get(ATTR_BRIGHTNESS), new_attrs.get(ATTR_BRIGHTNESS), 3
|
||||
):
|
||||
return True
|
||||
|
||||
if check_numeric_changed(
|
||||
if check_absolute_change(
|
||||
# Default range 153..500
|
||||
old_attrs.get(ATTR_COLOR_TEMP),
|
||||
new_attrs.get(ATTR_COLOR_TEMP),
|
||||
@ -62,7 +56,7 @@ def async_check_significant_change(
|
||||
):
|
||||
return True
|
||||
|
||||
if check_numeric_changed(
|
||||
if check_absolute_change(
|
||||
# Range 0..255
|
||||
old_attrs.get(ATTR_WHITE_VALUE),
|
||||
new_attrs.get(ATTR_WHITE_VALUE),
|
||||
|
@ -8,6 +8,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from sqlalchemy import bindparam
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext import baked
|
||||
from sqlalchemy.orm.scoping import scoped_session
|
||||
|
||||
@ -215,7 +216,14 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool:
|
||||
metadata_id = _update_or_add_metadata(
|
||||
instance.hass, session, entity_id, stat["meta"]
|
||||
)
|
||||
try:
|
||||
session.add(Statistics.from_stats(metadata_id, start, stat["stat"]))
|
||||
except SQLAlchemyError:
|
||||
_LOGGER.exception(
|
||||
"Unexpected exception when inserting statistics %s:%s ",
|
||||
metadata_id,
|
||||
stat,
|
||||
)
|
||||
session.add(StatisticsRuns(start=start))
|
||||
|
||||
return True
|
||||
@ -369,11 +377,11 @@ def statistics_during_period(
|
||||
)
|
||||
if not stats:
|
||||
return {}
|
||||
return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata)
|
||||
return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata, True)
|
||||
|
||||
|
||||
def get_last_statistics(
|
||||
hass: HomeAssistant, number_of_stats: int, statistic_id: str
|
||||
hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Return the last number_of_stats statistics for a statistic_id."""
|
||||
statistic_ids = [statistic_id]
|
||||
@ -403,7 +411,9 @@ def get_last_statistics(
|
||||
if not stats:
|
||||
return {}
|
||||
|
||||
return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata)
|
||||
return _sorted_statistics_to_dict(
|
||||
hass, stats, statistic_ids, metadata, convert_units
|
||||
)
|
||||
|
||||
|
||||
def _sorted_statistics_to_dict(
|
||||
@ -411,11 +421,16 @@ def _sorted_statistics_to_dict(
|
||||
stats: list,
|
||||
statistic_ids: list[str] | None,
|
||||
metadata: dict[str, StatisticMetaData],
|
||||
convert_units: bool,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Convert SQL results into JSON friendly data structure."""
|
||||
result: dict = defaultdict(list)
|
||||
units = hass.config.units
|
||||
|
||||
def no_conversion(val: Any, _: Any) -> float | None:
|
||||
"""Return x."""
|
||||
return val # type: ignore
|
||||
|
||||
# Set all statistic IDs to empty lists in result set to maintain the order
|
||||
if statistic_ids is not None:
|
||||
for stat_id in statistic_ids:
|
||||
@ -428,9 +443,11 @@ def _sorted_statistics_to_dict(
|
||||
for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore
|
||||
unit = metadata[meta_id]["unit_of_measurement"]
|
||||
statistic_id = metadata[meta_id]["statistic_id"]
|
||||
convert: Callable[[Any, Any], float | None] = UNIT_CONVERSIONS.get(
|
||||
unit, lambda x, units: x # type: ignore
|
||||
)
|
||||
convert: Callable[[Any, Any], float | None]
|
||||
if convert_units:
|
||||
convert = UNIT_CONVERSIONS.get(unit, lambda x, units: x) # type: ignore
|
||||
else:
|
||||
convert = no_conversion
|
||||
ent_results = result[meta_id]
|
||||
ent_results.extend(
|
||||
{
|
||||
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
from typing import Callable
|
||||
|
||||
from homeassistant.components.recorder import history, statistics
|
||||
@ -172,6 +173,14 @@ def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]:
|
||||
return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates}
|
||||
|
||||
|
||||
def _parse_float(state: str) -> float:
|
||||
"""Parse a float string, throw on inf or nan."""
|
||||
fstate = float(state)
|
||||
if math.isnan(fstate) or math.isinf(fstate):
|
||||
raise ValueError
|
||||
return fstate
|
||||
|
||||
|
||||
def _normalize_states(
|
||||
hass: HomeAssistant,
|
||||
entity_history: list[State],
|
||||
@ -186,9 +195,10 @@ def _normalize_states(
|
||||
fstates = []
|
||||
for state in entity_history:
|
||||
try:
|
||||
fstates.append((float(state.state), state))
|
||||
except ValueError:
|
||||
fstate = _parse_float(state.state)
|
||||
except (ValueError, TypeError): # TypeError to guard for NULL state in DB
|
||||
continue
|
||||
fstates.append((fstate, state))
|
||||
|
||||
if fstates:
|
||||
all_units = _get_units(fstates)
|
||||
@ -218,7 +228,9 @@ def _normalize_states(
|
||||
|
||||
for state in entity_history:
|
||||
try:
|
||||
fstate = float(state.state)
|
||||
fstate = _parse_float(state.state)
|
||||
except ValueError:
|
||||
continue
|
||||
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
# Exclude unsupported units from statistics
|
||||
if unit not in UNIT_CONVERSIONS[device_class]:
|
||||
@ -230,8 +242,6 @@ def _normalize_states(
|
||||
continue
|
||||
|
||||
fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return DEVICE_CLASS_UNITS[device_class], fstates
|
||||
|
||||
@ -380,7 +390,7 @@ def compile_statistics( # noqa: C901
|
||||
last_reset = old_last_reset = None
|
||||
new_state = old_state = None
|
||||
_sum = 0
|
||||
last_stats = statistics.get_last_statistics(hass, 1, entity_id)
|
||||
last_stats = statistics.get_last_statistics(hass, 1, entity_id, False)
|
||||
if entity_id in last_stats:
|
||||
# We have compiled history for this sensor before, use that as a starting point
|
||||
last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"]
|
||||
|
@ -9,8 +9,33 @@ from homeassistant.const import (
|
||||
TEMP_FAHRENHEIT,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.significant_change import (
|
||||
check_absolute_change,
|
||||
check_percentage_change,
|
||||
)
|
||||
|
||||
from . import DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE
|
||||
from . import (
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_CO,
|
||||
DEVICE_CLASS_CO2,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PM10,
|
||||
DEVICE_CLASS_PM25,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
|
||||
)
|
||||
|
||||
|
||||
def _absolute_and_relative_change(
|
||||
old_state: int | float | None,
|
||||
new_state: int | float | None,
|
||||
absolute_change: int | float,
|
||||
percentage_change: int | float,
|
||||
) -> bool:
|
||||
return check_absolute_change(
|
||||
old_state, new_state, absolute_change
|
||||
) and check_percentage_change(old_state, new_state, percentage_change)
|
||||
|
||||
|
||||
@callback
|
||||
@ -28,20 +53,35 @@ def async_check_significant_change(
|
||||
if device_class is None:
|
||||
return None
|
||||
|
||||
absolute_change: float | None = None
|
||||
percentage_change: float | None = None
|
||||
if device_class == DEVICE_CLASS_TEMPERATURE:
|
||||
if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_FAHRENHEIT:
|
||||
change: float | int = 1
|
||||
absolute_change = 1.0
|
||||
else:
|
||||
change = 0.5
|
||||
|
||||
old_value = float(old_state)
|
||||
new_value = float(new_state)
|
||||
return abs(old_value - new_value) >= change
|
||||
absolute_change = 0.5
|
||||
|
||||
if device_class in (DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY):
|
||||
old_value = float(old_state)
|
||||
new_value = float(new_state)
|
||||
absolute_change = 1.0
|
||||
|
||||
return abs(old_value - new_value) >= 1
|
||||
if device_class in (
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_CO,
|
||||
DEVICE_CLASS_CO2,
|
||||
DEVICE_CLASS_PM25,
|
||||
DEVICE_CLASS_PM10,
|
||||
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
|
||||
):
|
||||
absolute_change = 1.0
|
||||
percentage_change = 2.0
|
||||
|
||||
if absolute_change is not None and percentage_change is not None:
|
||||
return _absolute_and_relative_change(
|
||||
float(old_state), float(new_state), absolute_change, percentage_change
|
||||
)
|
||||
if absolute_change is not None:
|
||||
return check_absolute_change(
|
||||
float(old_state), float(new_state), absolute_change
|
||||
)
|
||||
|
||||
return None
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "Switcher",
|
||||
"documentation": "https://www.home-assistant.io/integrations/switcher_kis/",
|
||||
"codeowners": ["@tomerfi","@thecode"],
|
||||
"requirements": ["aioswitcher==2.0.4"],
|
||||
"requirements": ["aioswitcher==2.0.5"],
|
||||
"iot_class": "local_push",
|
||||
"config_flow": true
|
||||
}
|
||||
|
@ -46,27 +46,27 @@ SELECT_SCHEMA = vol.Schema(
|
||||
|
||||
|
||||
async def _async_create_entities(
|
||||
hass: HomeAssistant, entities: list[dict[str, Any]], unique_id_prefix: str | None
|
||||
hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None
|
||||
) -> list[TemplateSelect]:
|
||||
"""Create the Template select."""
|
||||
for entity in entities:
|
||||
unique_id = entity.get(CONF_UNIQUE_ID)
|
||||
|
||||
entities = []
|
||||
for definition in definitions:
|
||||
unique_id = definition.get(CONF_UNIQUE_ID)
|
||||
if unique_id and unique_id_prefix:
|
||||
unique_id = f"{unique_id_prefix}-{unique_id}"
|
||||
|
||||
return [
|
||||
entities.append(
|
||||
TemplateSelect(
|
||||
hass,
|
||||
entity.get(CONF_NAME, DEFAULT_NAME),
|
||||
entity[CONF_STATE],
|
||||
entity.get(CONF_AVAILABILITY),
|
||||
entity[CONF_SELECT_OPTION],
|
||||
entity[ATTR_OPTIONS],
|
||||
entity.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC),
|
||||
definition.get(CONF_NAME, DEFAULT_NAME),
|
||||
definition[CONF_STATE],
|
||||
definition.get(CONF_AVAILABILITY),
|
||||
definition[CONF_SELECT_OPTION],
|
||||
definition[ATTR_OPTIONS],
|
||||
definition.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC),
|
||||
unique_id,
|
||||
)
|
||||
]
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
|
@ -301,7 +301,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity):
|
||||
@property
|
||||
def available(self):
|
||||
"""Return true when state is known."""
|
||||
return self._available
|
||||
return super().available and self._available
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
|
@ -5,7 +5,7 @@ from typing import Final
|
||||
|
||||
MAJOR_VERSION: Final = 2021
|
||||
MINOR_VERSION: Final = 9
|
||||
PATCH_VERSION: Final = "4"
|
||||
PATCH_VERSION: Final = "5"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0)
|
||||
|
@ -95,25 +95,55 @@ def either_one_none(val1: Any | None, val2: Any | None) -> bool:
|
||||
return (val1 is None and val2 is not None) or (val1 is not None and val2 is None)
|
||||
|
||||
|
||||
def check_numeric_changed(
|
||||
def _check_numeric_change(
|
||||
old_state: int | float | None,
|
||||
new_state: int | float | None,
|
||||
change: int | float,
|
||||
metric: Callable[[int | float, int | float], int | float],
|
||||
) -> bool:
|
||||
"""Check if two numeric values have changed."""
|
||||
if old_state is None and new_state is None:
|
||||
return False
|
||||
|
||||
if either_one_none(old_state, new_state):
|
||||
return True
|
||||
|
||||
assert old_state is not None
|
||||
assert new_state is not None
|
||||
|
||||
if metric(old_state, new_state) >= change:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_absolute_change(
|
||||
val1: int | float | None,
|
||||
val2: int | float | None,
|
||||
change: int | float,
|
||||
) -> bool:
|
||||
"""Check if two numeric values have changed."""
|
||||
if val1 is None and val2 is None:
|
||||
return False
|
||||
return _check_numeric_change(
|
||||
val1, val2, change, lambda val1, val2: abs(val1 - val2)
|
||||
)
|
||||
|
||||
if either_one_none(val1, val2):
|
||||
return True
|
||||
|
||||
assert val1 is not None
|
||||
assert val2 is not None
|
||||
def check_percentage_change(
|
||||
old_state: int | float | None,
|
||||
new_state: int | float | None,
|
||||
change: int | float,
|
||||
) -> bool:
|
||||
"""Check if two numeric values have changed."""
|
||||
|
||||
if abs(val1 - val2) >= change:
|
||||
return True
|
||||
def percentage_change(old_state: int | float, new_state: int | float) -> float:
|
||||
if old_state == new_state:
|
||||
return 0
|
||||
try:
|
||||
return (abs(new_state - old_state) / old_state) * 100.0
|
||||
except ZeroDivisionError:
|
||||
return float("inf")
|
||||
|
||||
return False
|
||||
return _check_numeric_change(old_state, new_state, change, percentage_change)
|
||||
|
||||
|
||||
class SignificantlyChangedChecker:
|
||||
|
@ -243,7 +243,7 @@ aiorecollect==1.0.8
|
||||
aioshelly==0.6.4
|
||||
|
||||
# homeassistant.components.switcher_kis
|
||||
aioswitcher==2.0.4
|
||||
aioswitcher==2.0.5
|
||||
|
||||
# homeassistant.components.syncthing
|
||||
aiosyncthing==0.5.1
|
||||
|
@ -164,7 +164,7 @@ aiorecollect==1.0.8
|
||||
aioshelly==0.6.4
|
||||
|
||||
# homeassistant.components.switcher_kis
|
||||
aioswitcher==2.0.4
|
||||
aioswitcher==2.0.5
|
||||
|
||||
# homeassistant.components.syncthing
|
||||
aiosyncthing==0.5.1
|
||||
|
@ -77,7 +77,7 @@ async def test_cost_sensor_no_states(hass, hass_storage) -> None:
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_cost_sensor_price_entity(
|
||||
async def test_cost_sensor_price_entity_total_increasing(
|
||||
hass,
|
||||
hass_storage,
|
||||
hass_ws_client,
|
||||
@ -89,7 +89,7 @@ async def test_cost_sensor_price_entity(
|
||||
cost_sensor_entity_id,
|
||||
flow_type,
|
||||
) -> None:
|
||||
"""Test energy cost price from sensor entity."""
|
||||
"""Test energy cost price from total_increasing type sensor entity."""
|
||||
|
||||
def _compile_statistics(_):
|
||||
return compile_statistics(hass, now, now + timedelta(seconds=1))
|
||||
@ -136,6 +136,7 @@ async def test_cost_sensor_price_entity(
|
||||
}
|
||||
|
||||
now = dt_util.utcnow()
|
||||
last_reset_cost_sensor = now.isoformat()
|
||||
|
||||
# Optionally initialize dependent entities
|
||||
if initial_energy is not None:
|
||||
@ -152,7 +153,9 @@ async def test_cost_sensor_price_entity(
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == initial_cost
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY
|
||||
assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
|
||||
if initial_cost != "unknown":
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
assert state.attributes[ATTR_STATE_CLASS] == "measurement"
|
||||
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR"
|
||||
|
||||
# Optional late setup of dependent entities
|
||||
@ -168,7 +171,8 @@ async def test_cost_sensor_price_entity(
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "0.0"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY
|
||||
assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
assert state.attributes[ATTR_STATE_CLASS] == "measurement"
|
||||
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR"
|
||||
|
||||
# # Unique ID temp disabled
|
||||
@ -185,6 +189,7 @@ async def test_cost_sensor_price_entity(
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Nothing happens when price changes
|
||||
if price_entity is not None:
|
||||
@ -199,6 +204,7 @@ async def test_cost_sensor_price_entity(
|
||||
assert msg["success"]
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Additional consumption is using the new price
|
||||
hass.states.async_set(
|
||||
@ -209,6 +215,7 @@ async def test_cost_sensor_price_entity(
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Check generated statistics
|
||||
await async_wait_recording_done_without_instance(hass)
|
||||
@ -225,6 +232,7 @@ async def test_cost_sensor_price_entity(
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point
|
||||
hass.states.async_set(
|
||||
@ -235,6 +243,8 @@ async def test_cost_sensor_price_entity(
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR
|
||||
assert state.attributes["last_reset"] != last_reset_cost_sensor
|
||||
last_reset_cost_sensor = state.attributes["last_reset"]
|
||||
|
||||
# Energy use bumped to 10 kWh
|
||||
hass.states.async_set(
|
||||
@ -245,6 +255,213 @@ async def test_cost_sensor_price_entity(
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Check generated statistics
|
||||
await async_wait_recording_done_without_instance(hass)
|
||||
statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass)
|
||||
assert cost_sensor_entity_id in statistics
|
||||
assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")])
|
||||
@pytest.mark.parametrize(
|
||||
"price_entity,fixed_price", [("sensor.energy_price", None), (None, 1)]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"usage_sensor_entity_id,cost_sensor_entity_id,flow_type",
|
||||
[
|
||||
("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"),
|
||||
(
|
||||
"sensor.energy_production",
|
||||
"sensor.energy_production_compensation",
|
||||
"flow_to",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("energy_state_class", ["measurement"])
|
||||
async def test_cost_sensor_price_entity_total(
|
||||
hass,
|
||||
hass_storage,
|
||||
hass_ws_client,
|
||||
initial_energy,
|
||||
initial_cost,
|
||||
price_entity,
|
||||
fixed_price,
|
||||
usage_sensor_entity_id,
|
||||
cost_sensor_entity_id,
|
||||
flow_type,
|
||||
energy_state_class,
|
||||
) -> None:
|
||||
"""Test energy cost price from total type sensor entity."""
|
||||
|
||||
def _compile_statistics(_):
|
||||
return compile_statistics(hass, now, now + timedelta(seconds=1))
|
||||
|
||||
energy_attributes = {
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_STATE_CLASS: energy_state_class,
|
||||
}
|
||||
|
||||
await async_init_recorder_component(hass)
|
||||
energy_data = data.EnergyManager.default_preferences()
|
||||
energy_data["energy_sources"].append(
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [
|
||||
{
|
||||
"stat_energy_from": "sensor.energy_consumption",
|
||||
"entity_energy_from": "sensor.energy_consumption",
|
||||
"stat_cost": None,
|
||||
"entity_energy_price": price_entity,
|
||||
"number_energy_price": fixed_price,
|
||||
}
|
||||
]
|
||||
if flow_type == "flow_from"
|
||||
else [],
|
||||
"flow_to": [
|
||||
{
|
||||
"stat_energy_to": "sensor.energy_production",
|
||||
"entity_energy_to": "sensor.energy_production",
|
||||
"stat_compensation": None,
|
||||
"entity_energy_price": price_entity,
|
||||
"number_energy_price": fixed_price,
|
||||
}
|
||||
]
|
||||
if flow_type == "flow_to"
|
||||
else [],
|
||||
"cost_adjustment_day": 0,
|
||||
}
|
||||
)
|
||||
|
||||
hass_storage[data.STORAGE_KEY] = {
|
||||
"version": 1,
|
||||
"data": energy_data,
|
||||
}
|
||||
|
||||
now = dt_util.utcnow()
|
||||
last_reset = dt_util.utc_from_timestamp(0).isoformat()
|
||||
last_reset_cost_sensor = now.isoformat()
|
||||
|
||||
# Optionally initialize dependent entities
|
||||
if initial_energy is not None:
|
||||
hass.states.async_set(
|
||||
usage_sensor_entity_id,
|
||||
initial_energy,
|
||||
{**energy_attributes, **{"last_reset": last_reset}},
|
||||
)
|
||||
hass.states.async_set("sensor.energy_price", "1")
|
||||
|
||||
with patch("homeassistant.util.dt.utcnow", return_value=now):
|
||||
await setup_integration(hass)
|
||||
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == initial_cost
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY
|
||||
if initial_cost != "unknown":
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
assert state.attributes[ATTR_STATE_CLASS] == "measurement"
|
||||
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR"
|
||||
|
||||
# Optional late setup of dependent entities
|
||||
if initial_energy is None:
|
||||
with patch("homeassistant.util.dt.utcnow", return_value=now):
|
||||
hass.states.async_set(
|
||||
usage_sensor_entity_id,
|
||||
"0",
|
||||
{**energy_attributes, **{"last_reset": last_reset}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "0.0"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
assert state.attributes[ATTR_STATE_CLASS] == "measurement"
|
||||
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR"
|
||||
|
||||
# # Unique ID temp disabled
|
||||
# # entity_registry = er.async_get(hass)
|
||||
# # entry = entity_registry.async_get(cost_sensor_entity_id)
|
||||
# # assert entry.unique_id == "energy_energy_consumption cost"
|
||||
|
||||
# Energy use bumped to 10 kWh
|
||||
hass.states.async_set(
|
||||
usage_sensor_entity_id,
|
||||
"10",
|
||||
{**energy_attributes, **{"last_reset": last_reset}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Nothing happens when price changes
|
||||
if price_entity is not None:
|
||||
hass.states.async_set(price_entity, "2")
|
||||
await hass.async_block_till_done()
|
||||
else:
|
||||
energy_data = copy.deepcopy(energy_data)
|
||||
energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data})
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Additional consumption is using the new price
|
||||
hass.states.async_set(
|
||||
usage_sensor_entity_id,
|
||||
"14.5",
|
||||
{**energy_attributes, **{"last_reset": last_reset}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Check generated statistics
|
||||
await async_wait_recording_done_without_instance(hass)
|
||||
statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass)
|
||||
assert cost_sensor_entity_id in statistics
|
||||
assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0
|
||||
|
||||
# Energy sensor has a small dip
|
||||
hass.states.async_set(
|
||||
usage_sensor_entity_id,
|
||||
"14",
|
||||
{**energy_attributes, **{"last_reset": last_reset}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point
|
||||
last_reset = (now + timedelta(seconds=1)).isoformat()
|
||||
hass.states.async_set(
|
||||
usage_sensor_entity_id,
|
||||
"4",
|
||||
{**energy_attributes, **{"last_reset": last_reset}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR
|
||||
assert state.attributes["last_reset"] != last_reset_cost_sensor
|
||||
last_reset_cost_sensor = state.attributes["last_reset"]
|
||||
|
||||
# Energy use bumped to 10 kWh
|
||||
hass.states.async_set(
|
||||
usage_sensor_entity_id,
|
||||
"10",
|
||||
{**energy_attributes, **{"last_reset": last_reset}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(cost_sensor_entity_id)
|
||||
assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR
|
||||
assert state.attributes["last_reset"] == last_reset_cost_sensor
|
||||
|
||||
# Check generated statistics
|
||||
await async_wait_recording_done_without_instance(hass)
|
||||
@ -284,6 +501,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None:
|
||||
|
||||
now = dt_util.utcnow()
|
||||
|
||||
# Initial state: 10kWh
|
||||
hass.states.async_set(
|
||||
"sensor.energy_consumption",
|
||||
10000,
|
||||
@ -296,7 +514,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None:
|
||||
state = hass.states.get("sensor.energy_consumption_cost")
|
||||
assert state.state == "0.0"
|
||||
|
||||
# Energy use bumped to 10 kWh
|
||||
# Energy use bumped by 10 kWh
|
||||
hass.states.async_set(
|
||||
"sensor.energy_consumption",
|
||||
20000,
|
||||
@ -361,7 +579,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None:
|
||||
async def test_cost_sensor_wrong_state_class(
|
||||
hass, hass_storage, caplog, state_class
|
||||
) -> None:
|
||||
"""Test energy sensor rejects wrong state_class."""
|
||||
"""Test energy sensor rejects state_class with wrong state_class."""
|
||||
energy_attributes = {
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_STATE_CLASS: state_class,
|
||||
@ -417,3 +635,61 @@ async def test_cost_sensor_wrong_state_class(
|
||||
|
||||
state = hass.states.get("sensor.energy_consumption_cost")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state_class", ["measurement"])
|
||||
async def test_cost_sensor_state_class_measurement_no_reset(
|
||||
hass, hass_storage, caplog, state_class
|
||||
) -> None:
|
||||
"""Test energy sensor rejects state_class with no last_reset."""
|
||||
energy_attributes = {
|
||||
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
|
||||
ATTR_STATE_CLASS: state_class,
|
||||
}
|
||||
energy_data = data.EnergyManager.default_preferences()
|
||||
energy_data["energy_sources"].append(
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [
|
||||
{
|
||||
"stat_energy_from": "sensor.energy_consumption",
|
||||
"entity_energy_from": "sensor.energy_consumption",
|
||||
"stat_cost": None,
|
||||
"entity_energy_price": None,
|
||||
"number_energy_price": 0.5,
|
||||
}
|
||||
],
|
||||
"flow_to": [],
|
||||
"cost_adjustment_day": 0,
|
||||
}
|
||||
)
|
||||
|
||||
hass_storage[data.STORAGE_KEY] = {
|
||||
"version": 1,
|
||||
"data": energy_data,
|
||||
}
|
||||
|
||||
now = dt_util.utcnow()
|
||||
|
||||
hass.states.async_set(
|
||||
"sensor.energy_consumption",
|
||||
10000,
|
||||
energy_attributes,
|
||||
)
|
||||
|
||||
with patch("homeassistant.util.dt.utcnow", return_value=now):
|
||||
await setup_integration(hass)
|
||||
|
||||
state = hass.states.get("sensor.energy_consumption_cost")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
# Energy use bumped to 10 kWh
|
||||
hass.states.async_set(
|
||||
"sensor.energy_consumption",
|
||||
20000,
|
||||
energy_attributes,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.energy_consumption_cost")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
@ -382,15 +382,6 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager):
|
||||
"value": "123,123.12",
|
||||
},
|
||||
),
|
||||
(
|
||||
"-100",
|
||||
"$/kWh",
|
||||
{
|
||||
"type": "entity_negative_state",
|
||||
"identifier": "sensor.grid_price_1",
|
||||
"value": -100.0,
|
||||
},
|
||||
),
|
||||
(
|
||||
"123",
|
||||
"$/Ws",
|
||||
@ -414,7 +405,7 @@ async def test_validation_grid_price_errors(
|
||||
hass.states.async_set(
|
||||
"sensor.grid_price_1",
|
||||
state,
|
||||
{"unit_of_measurement": unit, "state_class": "total_increasing"},
|
||||
{"unit_of_measurement": unit, "state_class": "measurement"},
|
||||
)
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
@ -441,3 +432,59 @@ async def test_validation_grid_price_errors(
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded):
|
||||
"""Test validating gas with sensors for energy and cost/compensation."""
|
||||
mock_is_entity_recorded["sensor.gas_cost_1"] = False
|
||||
mock_is_entity_recorded["sensor.gas_compensation_1"] = False
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "gas",
|
||||
"stat_energy_from": "sensor.gas_consumption_1",
|
||||
"stat_cost": "sensor.gas_cost_1",
|
||||
},
|
||||
{
|
||||
"type": "gas",
|
||||
"stat_energy_from": "sensor.gas_consumption_2",
|
||||
"stat_cost": "sensor.gas_cost_2",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.gas_consumption_1",
|
||||
"10.10",
|
||||
{"unit_of_measurement": "beers", "state_class": "total_increasing"},
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.gas_consumption_2",
|
||||
"10.10",
|
||||
{"unit_of_measurement": "kWh", "state_class": "total_increasing"},
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.gas_cost_2",
|
||||
"10.10",
|
||||
{"unit_of_measurement": "EUR/kWh", "state_class": "total_increasing"},
|
||||
)
|
||||
|
||||
assert (await validate.async_validate(hass)).as_dict() == {
|
||||
"energy_sources": [
|
||||
[
|
||||
{
|
||||
"type": "entity_unexpected_unit_gas",
|
||||
"identifier": "sensor.gas_consumption_1",
|
||||
"value": "beers",
|
||||
},
|
||||
{
|
||||
"type": "recorder_untracked",
|
||||
"identifier": "sensor.gas_cost_1",
|
||||
"value": None,
|
||||
},
|
||||
],
|
||||
[],
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
@ -1,11 +1,14 @@
|
||||
"""Test honeywell setup process."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0)
|
||||
async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry):
|
||||
"""Initialize the config entry."""
|
||||
config_entry.add_to_hass(hass)
|
||||
@ -15,6 +18,7 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry):
|
||||
assert hass.states.async_entity_ids_count() == 1
|
||||
|
||||
|
||||
@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0)
|
||||
async def test_setup_multiple_thermostats(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device
|
||||
) -> None:
|
||||
|
@ -81,7 +81,6 @@ async def test_restore_state(hass: HomeAssistant) -> None:
|
||||
"platform": "integration",
|
||||
"name": "integration",
|
||||
"source": "sensor.power",
|
||||
"unit": ENERGY_KILO_WATT_HOUR,
|
||||
"round": 2,
|
||||
}
|
||||
}
|
||||
@ -114,7 +113,6 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None:
|
||||
"platform": "integration",
|
||||
"name": "integration",
|
||||
"source": "sensor.power",
|
||||
"unit": ENERGY_KILO_WATT_HOUR,
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,9 +121,10 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None:
|
||||
|
||||
state = hass.states.get("sensor.integration")
|
||||
assert state
|
||||
assert state.state == "0"
|
||||
assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR
|
||||
assert state.state == "unavailable"
|
||||
assert state.attributes.get("unit_of_measurement") is None
|
||||
assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING
|
||||
|
||||
assert "device_class" not in state.attributes
|
||||
|
||||
|
||||
|
@ -3,11 +3,15 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch, sentinel
|
||||
|
||||
import pytest
|
||||
from pytest import approx
|
||||
|
||||
from homeassistant.components.recorder import history
|
||||
from homeassistant.components.recorder.const import DATA_INSTANCE
|
||||
from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat
|
||||
from homeassistant.components.recorder.models import (
|
||||
Statistics,
|
||||
process_timestamp_to_utc_isoformat,
|
||||
)
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
get_last_statistics,
|
||||
statistics_during_period,
|
||||
@ -32,7 +36,7 @@ def test_compile_hourly_statistics(hass_recorder):
|
||||
for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}):
|
||||
stats = statistics_during_period(hass, zero, **kwargs)
|
||||
assert stats == {}
|
||||
stats = get_last_statistics(hass, 0, "sensor.test1")
|
||||
stats = get_last_statistics(hass, 0, "sensor.test1", True)
|
||||
assert stats == {}
|
||||
|
||||
recorder.do_adhoc_statistics(period="hourly", start=zero)
|
||||
@ -78,22 +82,121 @@ def test_compile_hourly_statistics(hass_recorder):
|
||||
assert stats == {}
|
||||
|
||||
# Test get_last_statistics
|
||||
stats = get_last_statistics(hass, 0, "sensor.test1")
|
||||
stats = get_last_statistics(hass, 0, "sensor.test1", True)
|
||||
assert stats == {}
|
||||
|
||||
stats = get_last_statistics(hass, 1, "sensor.test1")
|
||||
stats = get_last_statistics(hass, 1, "sensor.test1", True)
|
||||
assert stats == {"sensor.test1": [{**expected_2, "statistic_id": "sensor.test1"}]}
|
||||
|
||||
stats = get_last_statistics(hass, 2, "sensor.test1")
|
||||
stats = get_last_statistics(hass, 2, "sensor.test1", True)
|
||||
assert stats == {"sensor.test1": expected_stats1[::-1]}
|
||||
|
||||
stats = get_last_statistics(hass, 3, "sensor.test1")
|
||||
stats = get_last_statistics(hass, 3, "sensor.test1", True)
|
||||
assert stats == {"sensor.test1": expected_stats1[::-1]}
|
||||
|
||||
stats = get_last_statistics(hass, 1, "sensor.test3")
|
||||
stats = get_last_statistics(hass, 1, "sensor.test3", True)
|
||||
assert stats == {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sensor_statistics():
|
||||
"""Generate some fake statistics."""
|
||||
sensor_stats = {
|
||||
"meta": {"unit_of_measurement": "dogs", "has_mean": True, "has_sum": False},
|
||||
"stat": {},
|
||||
}
|
||||
|
||||
def get_fake_stats():
|
||||
return {
|
||||
"sensor.test1": sensor_stats,
|
||||
"sensor.test2": sensor_stats,
|
||||
"sensor.test3": sensor_stats,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.sensor.recorder.compile_statistics",
|
||||
return_value=get_fake_stats(),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_from_stats():
|
||||
"""Mock out Statistics.from_stats."""
|
||||
counter = 0
|
||||
real_from_stats = Statistics.from_stats
|
||||
|
||||
def from_stats(metadata_id, start, stats):
|
||||
nonlocal counter
|
||||
if counter == 0 and metadata_id == 2:
|
||||
counter += 1
|
||||
return None
|
||||
return real_from_stats(metadata_id, start, stats)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.recorder.statistics.Statistics.from_stats",
|
||||
side_effect=from_stats,
|
||||
autospec=True,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def test_compile_hourly_statistics_exception(
|
||||
hass_recorder, mock_sensor_statistics, mock_from_stats
|
||||
):
|
||||
"""Test exception handling when compiling hourly statistics."""
|
||||
|
||||
def mock_from_stats():
|
||||
raise ValueError
|
||||
|
||||
hass = hass_recorder()
|
||||
recorder = hass.data[DATA_INSTANCE]
|
||||
setup_component(hass, "sensor", {})
|
||||
|
||||
now = dt_util.utcnow()
|
||||
recorder.do_adhoc_statistics(period="hourly", start=now)
|
||||
recorder.do_adhoc_statistics(period="hourly", start=now + timedelta(hours=1))
|
||||
wait_recording_done(hass)
|
||||
expected_1 = {
|
||||
"statistic_id": "sensor.test1",
|
||||
"start": process_timestamp_to_utc_isoformat(now),
|
||||
"mean": None,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"last_reset": None,
|
||||
"state": None,
|
||||
"sum": None,
|
||||
}
|
||||
expected_2 = {
|
||||
"statistic_id": "sensor.test1",
|
||||
"start": process_timestamp_to_utc_isoformat(now + timedelta(hours=1)),
|
||||
"mean": None,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"last_reset": None,
|
||||
"state": None,
|
||||
"sum": None,
|
||||
}
|
||||
expected_stats1 = [
|
||||
{**expected_1, "statistic_id": "sensor.test1"},
|
||||
{**expected_2, "statistic_id": "sensor.test1"},
|
||||
]
|
||||
expected_stats2 = [
|
||||
{**expected_2, "statistic_id": "sensor.test2"},
|
||||
]
|
||||
expected_stats3 = [
|
||||
{**expected_1, "statistic_id": "sensor.test3"},
|
||||
{**expected_2, "statistic_id": "sensor.test3"},
|
||||
]
|
||||
|
||||
stats = statistics_during_period(hass, now)
|
||||
assert stats == {
|
||||
"sensor.test1": expected_stats1,
|
||||
"sensor.test2": expected_stats2,
|
||||
"sensor.test3": expected_stats3,
|
||||
}
|
||||
|
||||
|
||||
def test_rename_entity(hass_recorder):
|
||||
"""Test statistics is migrated when entity_id is changed."""
|
||||
hass = hass_recorder()
|
||||
@ -116,7 +219,7 @@ def test_rename_entity(hass_recorder):
|
||||
for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}):
|
||||
stats = statistics_during_period(hass, zero, **kwargs)
|
||||
assert stats == {}
|
||||
stats = get_last_statistics(hass, 0, "sensor.test1")
|
||||
stats = get_last_statistics(hass, 0, "sensor.test1", True)
|
||||
assert stats == {}
|
||||
|
||||
recorder.do_adhoc_statistics(period="hourly", start=zero)
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""The tests for sensor recorder platform."""
|
||||
# pylint: disable=protected-access,invalid-name
|
||||
from datetime import timedelta
|
||||
import math
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@ -17,6 +18,7 @@ from homeassistant.components.recorder.statistics import (
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.setup import setup_component
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM
|
||||
|
||||
from tests.components.recorder.common import wait_recording_done
|
||||
|
||||
@ -193,22 +195,29 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes
|
||||
|
||||
@pytest.mark.parametrize("state_class", ["measurement"])
|
||||
@pytest.mark.parametrize(
|
||||
"device_class,unit,native_unit,factor",
|
||||
"units,device_class,unit,display_unit,factor",
|
||||
[
|
||||
("energy", "kWh", "kWh", 1),
|
||||
("energy", "Wh", "kWh", 1 / 1000),
|
||||
("monetary", "EUR", "EUR", 1),
|
||||
("monetary", "SEK", "SEK", 1),
|
||||
("gas", "m³", "m³", 1),
|
||||
("gas", "ft³", "m³", 0.0283168466),
|
||||
(IMPERIAL_SYSTEM, "energy", "kWh", "kWh", 1),
|
||||
(IMPERIAL_SYSTEM, "energy", "Wh", "kWh", 1 / 1000),
|
||||
(IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", 1),
|
||||
(IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", 1),
|
||||
(IMPERIAL_SYSTEM, "gas", "m³", "ft³", 35.314666711),
|
||||
(IMPERIAL_SYSTEM, "gas", "ft³", "ft³", 1),
|
||||
(METRIC_SYSTEM, "energy", "kWh", "kWh", 1),
|
||||
(METRIC_SYSTEM, "energy", "Wh", "kWh", 1 / 1000),
|
||||
(METRIC_SYSTEM, "monetary", "EUR", "EUR", 1),
|
||||
(METRIC_SYSTEM, "monetary", "SEK", "SEK", 1),
|
||||
(METRIC_SYSTEM, "gas", "m³", "m³", 1),
|
||||
(METRIC_SYSTEM, "gas", "ft³", "m³", 0.0283168466),
|
||||
],
|
||||
)
|
||||
def test_compile_hourly_sum_statistics_amount(
|
||||
hass_recorder, caplog, state_class, device_class, unit, native_unit, factor
|
||||
hass_recorder, caplog, units, state_class, device_class, unit, display_unit, factor
|
||||
):
|
||||
"""Test compiling hourly statistics."""
|
||||
zero = dt_util.utcnow()
|
||||
hass = hass_recorder()
|
||||
hass.config.units = units
|
||||
recorder = hass.data[DATA_INSTANCE]
|
||||
setup_component(hass, "sensor", {})
|
||||
attributes = {
|
||||
@ -235,7 +244,7 @@ def test_compile_hourly_sum_statistics_amount(
|
||||
wait_recording_done(hass)
|
||||
statistic_ids = list_statistic_ids(hass)
|
||||
assert statistic_ids == [
|
||||
{"statistic_id": "sensor.test1", "unit_of_measurement": native_unit}
|
||||
{"statistic_id": "sensor.test1", "unit_of_measurement": display_unit}
|
||||
]
|
||||
stats = statistics_during_period(hass, zero)
|
||||
assert stats == {
|
||||
@ -349,6 +358,70 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change(
|
||||
assert "Error while processing event StatisticsTask" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state_class", ["measurement"])
|
||||
@pytest.mark.parametrize(
|
||||
"device_class,unit,native_unit,factor",
|
||||
[
|
||||
("energy", "kWh", "kWh", 1),
|
||||
],
|
||||
)
|
||||
def test_compile_hourly_sum_statistics_nan_inf_state(
|
||||
hass_recorder, caplog, state_class, device_class, unit, native_unit, factor
|
||||
):
|
||||
"""Test compiling hourly statistics with nan and inf states."""
|
||||
zero = dt_util.utcnow()
|
||||
hass = hass_recorder()
|
||||
recorder = hass.data[DATA_INSTANCE]
|
||||
setup_component(hass, "sensor", {})
|
||||
attributes = {
|
||||
"device_class": device_class,
|
||||
"state_class": state_class,
|
||||
"unit_of_measurement": unit,
|
||||
"last_reset": None,
|
||||
}
|
||||
seq = [10, math.nan, 15, 15, 20, math.inf, 20, 10]
|
||||
|
||||
states = {"sensor.test1": []}
|
||||
one = zero
|
||||
for i in range(len(seq)):
|
||||
one = one + timedelta(minutes=1)
|
||||
_states = record_meter_state(
|
||||
hass, one, "sensor.test1", attributes, seq[i : i + 1]
|
||||
)
|
||||
states["sensor.test1"].extend(_states["sensor.test1"])
|
||||
|
||||
hist = history.get_significant_states(
|
||||
hass,
|
||||
zero - timedelta.resolution,
|
||||
one + timedelta.resolution,
|
||||
significant_changes_only=False,
|
||||
)
|
||||
assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"]
|
||||
|
||||
recorder.do_adhoc_statistics(period="hourly", start=zero)
|
||||
wait_recording_done(hass)
|
||||
statistic_ids = list_statistic_ids(hass)
|
||||
assert statistic_ids == [
|
||||
{"statistic_id": "sensor.test1", "unit_of_measurement": native_unit}
|
||||
]
|
||||
stats = statistics_during_period(hass, zero)
|
||||
assert stats == {
|
||||
"sensor.test1": [
|
||||
{
|
||||
"statistic_id": "sensor.test1",
|
||||
"start": process_timestamp_to_utc_isoformat(zero),
|
||||
"max": None,
|
||||
"mean": None,
|
||||
"min": None,
|
||||
"last_reset": process_timestamp_to_utc_isoformat(one),
|
||||
"state": approx(factor * seq[7]),
|
||||
"sum": approx(factor * (seq[2] + seq[3] + seq[4] + seq[6] + seq[7])),
|
||||
},
|
||||
]
|
||||
}
|
||||
assert "Error while processing event StatisticsTask" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device_class,unit,native_unit,factor",
|
||||
[
|
||||
|
@ -1,5 +1,8 @@
|
||||
"""Test the sensor significant change platform."""
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.sensor.significant_change import (
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
@ -12,48 +15,54 @@ from homeassistant.const import (
|
||||
TEMP_FAHRENHEIT,
|
||||
)
|
||||
|
||||
AQI_ATTRS = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_AQI,
|
||||
}
|
||||
|
||||
async def test_significant_change_temperature():
|
||||
"""Detect temperature significant changes."""
|
||||
celsius_attrs = {
|
||||
BATTERY_ATTRS = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY,
|
||||
}
|
||||
|
||||
HUMIDITY_ATTRS = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
|
||||
}
|
||||
|
||||
TEMP_CELSIUS_ATTRS = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
|
||||
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
|
||||
}
|
||||
assert not async_check_significant_change(
|
||||
None, "12", celsius_attrs, "12", celsius_attrs
|
||||
)
|
||||
assert async_check_significant_change(
|
||||
None, "12", celsius_attrs, "13", celsius_attrs
|
||||
)
|
||||
assert not async_check_significant_change(
|
||||
None, "12.1", celsius_attrs, "12.2", celsius_attrs
|
||||
)
|
||||
}
|
||||
|
||||
freedom_attrs = {
|
||||
TEMP_FREEDOM_ATTRS = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
|
||||
ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT,
|
||||
}
|
||||
assert async_check_significant_change(
|
||||
None, "70", freedom_attrs, "71", freedom_attrs
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"old_state,new_state,attrs,result",
|
||||
[
|
||||
("0", "1", AQI_ATTRS, True),
|
||||
("1", "0", AQI_ATTRS, True),
|
||||
("0.1", "0.5", AQI_ATTRS, False),
|
||||
("0.5", "0.1", AQI_ATTRS, False),
|
||||
("99", "100", AQI_ATTRS, False),
|
||||
("100", "99", AQI_ATTRS, False),
|
||||
("101", "99", AQI_ATTRS, False),
|
||||
("99", "101", AQI_ATTRS, True),
|
||||
("100", "100", BATTERY_ATTRS, False),
|
||||
("100", "99", BATTERY_ATTRS, True),
|
||||
("100", "100", HUMIDITY_ATTRS, False),
|
||||
("100", "99", HUMIDITY_ATTRS, True),
|
||||
("12", "12", TEMP_CELSIUS_ATTRS, False),
|
||||
("12", "13", TEMP_CELSIUS_ATTRS, True),
|
||||
("12.1", "12.2", TEMP_CELSIUS_ATTRS, False),
|
||||
("70", "71", TEMP_FREEDOM_ATTRS, True),
|
||||
("70", "70.5", TEMP_FREEDOM_ATTRS, False),
|
||||
],
|
||||
)
|
||||
async def test_significant_change_temperature(old_state, new_state, attrs, result):
|
||||
"""Detect temperature significant changes."""
|
||||
assert (
|
||||
async_check_significant_change(None, old_state, attrs, new_state, attrs)
|
||||
is result
|
||||
)
|
||||
assert not async_check_significant_change(
|
||||
None, "70", freedom_attrs, "70.5", freedom_attrs
|
||||
)
|
||||
|
||||
|
||||
async def test_significant_change_battery():
|
||||
"""Detect battery significant changes."""
|
||||
attrs = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY,
|
||||
}
|
||||
assert not async_check_significant_change(None, "100", attrs, "100", attrs)
|
||||
assert async_check_significant_change(None, "100", attrs, "99", attrs)
|
||||
|
||||
|
||||
async def test_significant_change_humidity():
|
||||
"""Detect humidity significant changes."""
|
||||
attrs = {
|
||||
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
|
||||
}
|
||||
assert not async_check_significant_change(None, "100", attrs, "100", attrs)
|
||||
assert async_check_significant_change(None, "100", attrs, "99", attrs)
|
||||
|
@ -60,6 +60,38 @@ async def test_missing_optional_config(hass, calls):
|
||||
_verify(hass, "a", ["a", "b"])
|
||||
|
||||
|
||||
async def test_multiple_configs(hass, calls):
|
||||
"""Test: multiple select entities get created."""
|
||||
with assert_setup_component(1, "template"):
|
||||
assert await setup.async_setup_component(
|
||||
hass,
|
||||
"template",
|
||||
{
|
||||
"template": {
|
||||
"select": [
|
||||
{
|
||||
"state": "{{ 'a' }}",
|
||||
"select_option": {"service": "script.select_option"},
|
||||
"options": "{{ ['a', 'b'] }}",
|
||||
},
|
||||
{
|
||||
"state": "{{ 'a' }}",
|
||||
"select_option": {"service": "script.select_option"},
|
||||
"options": "{{ ['a', 'b'] }}",
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
_verify(hass, "a", ["a", "b"])
|
||||
_verify(hass, "a", ["a", "b"], f"{_TEST_SELECT}_2")
|
||||
|
||||
|
||||
async def test_missing_required_keys(hass, calls):
|
||||
"""Test: missing required fields will fail."""
|
||||
with assert_setup_component(0, "template"):
|
||||
@ -250,9 +282,9 @@ async def test_trigger_select(hass):
|
||||
assert events[0].event_type == "test_number_event"
|
||||
|
||||
|
||||
def _verify(hass, expected_current_option, expected_options):
|
||||
def _verify(hass, expected_current_option, expected_options, entity_name=_TEST_SELECT):
|
||||
"""Verify select's state."""
|
||||
state = hass.states.get(_TEST_SELECT)
|
||||
state = hass.states.get(entity_name)
|
||||
attributes = state.attributes
|
||||
assert state.state == str(expected_current_option)
|
||||
assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options
|
||||
|
Loading…
x
Reference in New Issue
Block a user