Merge pull request #55969 from home-assistant/rc

This commit is contained in:
Paulus Schoutsen 2021-09-08 13:42:09 -07:00 committed by GitHub
commit 5cc54618c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 941 additions and 214 deletions

View File

@ -580,7 +580,7 @@ jobs:
python -m venv venv python -m venv venv
. venv/bin/activate . 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_all.txt
pip install -r requirements_test.txt pip install -r requirements_test.txt
pip install -e . pip install -e .

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable from typing import Callable, Final
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
STATE_CLASS_MEASUREMENT, STATE_CLASS_MEASUREMENT,
@ -24,6 +24,9 @@ from homeassistant.const import (
VOLUME_CUBIC_METERS, 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): def dsmr_transform(value):
"""Transform DSMR version value to right format.""" """Transform DSMR version value to right format."""
@ -301,31 +304,31 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1",
name="Low tariff delivered price", name="Low tariff delivered price",
icon="mdi:currency-eur", icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO, native_unit_of_measurement=PRICE_EUR_KWH,
), ),
DSMRReaderSensorEntityDescription( DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2",
name="High tariff delivered price", name="High tariff delivered price",
icon="mdi:currency-eur", icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO, native_unit_of_measurement=PRICE_EUR_KWH,
), ),
DSMRReaderSensorEntityDescription( DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1",
name="Low tariff returned price", name="Low tariff returned price",
icon="mdi:currency-eur", icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO, native_unit_of_measurement=PRICE_EUR_KWH,
), ),
DSMRReaderSensorEntityDescription( DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2",
name="High tariff returned price", name="High tariff returned price",
icon="mdi:currency-eur", icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO, native_unit_of_measurement=PRICE_EUR_KWH,
), ),
DSMRReaderSensorEntityDescription( DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/energy_supplier_price_gas", key="dsmr/day-consumption/energy_supplier_price_gas",
name="Gas price", name="Gas price",
icon="mdi:currency-eur", icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO, native_unit_of_measurement=PRICE_EUR_M3,
), ),
DSMRReaderSensorEntityDescription( DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/fixed_cost", key="dsmr/day-consumption/fixed_cost",

View File

@ -1,13 +1,16 @@
"""Helper sensor for calculating utility costs.""" """Helper sensor for calculating utility costs."""
from __future__ import annotations from __future__ import annotations
import copy
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import Any, Final, Literal, TypeVar, cast from typing import Any, Final, Literal, TypeVar, cast
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
ATTR_LAST_RESET,
ATTR_STATE_CLASS, ATTR_STATE_CLASS,
DEVICE_CLASS_MONETARY, DEVICE_CLASS_MONETARY,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING, STATE_CLASS_TOTAL_INCREASING,
SensorEntity, SensorEntity,
) )
@ -18,14 +21,19 @@ from homeassistant.const import (
ENERGY_WATT_HOUR, ENERGY_WATT_HOUR,
VOLUME_CUBIC_METERS, 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event 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 .const import DOMAIN
from .data import EnergyManager, async_get_manager from .data import EnergyManager, async_get_manager
SUPPORTED_STATE_CLASSES = [
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -206,15 +214,16 @@ class EnergyCostSensor(SensorEntity):
f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}"
) )
self._attr_device_class = DEVICE_CLASS_MONETARY 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._config = config
self._last_energy_sensor_state: StateType | None = None self._last_energy_sensor_state: State | None = None
self._cur_value = 0.0 self._cur_value = 0.0
def _reset(self, energy_state: StateType) -> None: def _reset(self, energy_state: State) -> None:
"""Reset the cost sensor.""" """Reset the cost sensor."""
self._attr_native_value = 0.0 self._attr_native_value = 0.0
self._cur_value = 0.0 self._cur_value = 0.0
self._attr_last_reset = dt_util.utcnow()
self._last_energy_sensor_state = energy_state self._last_energy_sensor_state = energy_state
self.async_write_ha_state() self.async_write_ha_state()
@ -228,9 +237,8 @@ class EnergyCostSensor(SensorEntity):
if energy_state is None: if energy_state is None:
return return
if ( state_class = energy_state.attributes.get(ATTR_STATE_CLASS)
state_class := energy_state.attributes.get(ATTR_STATE_CLASS) if state_class not in SUPPORTED_STATE_CLASSES:
) != STATE_CLASS_TOTAL_INCREASING:
if not self._wrong_state_class_reported: if not self._wrong_state_class_reported:
self._wrong_state_class_reported = True self._wrong_state_class_reported = True
_LOGGER.warning( _LOGGER.warning(
@ -240,6 +248,13 @@ class EnergyCostSensor(SensorEntity):
) )
return 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: try:
energy = float(energy_state.state) energy = float(energy_state.state)
except ValueError: except ValueError:
@ -273,7 +288,7 @@ class EnergyCostSensor(SensorEntity):
if self._last_energy_sensor_state is None: if self._last_energy_sensor_state is None:
# Initialize as it's the first time all required entities are in place. # Initialize as it's the first time all required entities are in place.
self._reset(energy_state.state) self._reset(energy_state)
return return
energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
@ -298,20 +313,29 @@ class EnergyCostSensor(SensorEntity):
) )
return 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, self.hass,
cast(str, self._config[self._adapter.entity_energy_key]), cast(str, self._config[self._adapter.entity_energy_key]),
energy, energy,
float(self._last_energy_sensor_state), float(self._last_energy_sensor_state.state),
): ):
# Energy meter was reset, reset cost sensor too # 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 # 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._cur_value += (energy - old_energy_value) * energy_price
self._attr_native_value = round(self._cur_value, 2) 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: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """Register callbacks."""

View File

@ -1,6 +1,7 @@
"""Validate the energy preferences provide valid data.""" """Validate the energy preferences provide valid data."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
import dataclasses import dataclasses
from typing import Any from typing import Any
@ -10,12 +11,24 @@ from homeassistant.const import (
ENERGY_WATT_HOUR, ENERGY_WATT_HOUR,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
VOLUME_CUBIC_FEET,
VOLUME_CUBIC_METERS,
) )
from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.core import HomeAssistant, callback, valid_entity_id
from . import data from . import data
from .const import DOMAIN 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 @dataclasses.dataclass
class ValidationIssue: class ValidationIssue:
@ -43,8 +56,12 @@ class EnergyPreferencesValidation:
@callback @callback
def _async_validate_energy_stat( def _async_validate_usage_stat(
hass: HomeAssistant, stat_value: str, result: list[ValidationIssue] hass: HomeAssistant,
stat_value: str,
allowed_units: Sequence[str],
unit_error: str,
result: list[ValidationIssue],
) -> None: ) -> None:
"""Validate a statistic.""" """Validate a statistic."""
has_entity_source = valid_entity_id(stat_value) has_entity_source = valid_entity_id(stat_value)
@ -91,14 +108,16 @@ def _async_validate_energy_stat(
unit = state.attributes.get("unit_of_measurement") unit = state.attributes.get("unit_of_measurement")
if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR): if unit not in allowed_units:
result.append( result.append(ValidationIssue(unit_error, stat_value, unit))
ValidationIssue("entity_unexpected_unit_energy", stat_value, unit)
)
state_class = state.attributes.get("state_class") 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( result.append(
ValidationIssue( ValidationIssue(
"entity_unexpected_state_class_total_increasing", "entity_unexpected_state_class_total_increasing",
@ -125,16 +144,13 @@ def _async_validate_price_entity(
return return
try: try:
value: float | None = float(state.state) float(state.state)
except ValueError: except ValueError:
result.append( result.append(
ValidationIssue("entity_state_non_numeric", entity_id, state.state) ValidationIssue("entity_state_non_numeric", entity_id, state.state)
) )
return 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") unit = state.attributes.get("unit_of_measurement")
if unit is None or not unit.endswith( if unit is None or not unit.endswith(
@ -188,7 +204,11 @@ def _async_validate_cost_entity(
state_class = state.attributes.get("state_class") 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( result.append(
ValidationIssue( ValidationIssue(
"entity_unexpected_state_class_total_increasing", entity_id, state_class "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": if source["type"] == "grid":
for flow in source["flow_from"]: for flow in source["flow_from"]:
_async_validate_energy_stat( _async_validate_usage_stat(
hass, flow["stat_energy_from"], source_result hass,
flow["stat_energy_from"],
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
) )
if flow.get("stat_cost") is not None: 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"]: 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: if flow.get("stat_compensation") is not None:
_async_validate_cost_stat( _async_validate_cost_stat(
@ -247,7 +277,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
) )
elif source["type"] == "gas": 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: if source.get("stat_cost") is not None:
_async_validate_cost_stat(hass, source["stat_cost"], source_result) _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": 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": elif source["type"] == "battery":
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result) _async_validate_usage_stat(
_async_validate_energy_stat(hass, source["stat_energy_to"], source_result) 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"]: for device in manager.data["device_consumption"]:
device_result: list[ValidationIssue] = [] device_result: list[ValidationIssue] = []
result.device_consumption.append(device_result) 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 return result

View File

@ -2311,16 +2311,12 @@ class SensorStateTrait(_Trait):
name = TRAIT_SENSOR_STATE name = TRAIT_SENSOR_STATE
commands = [] commands = []
@staticmethod @classmethod
def supported(domain, features, device_class, _): def supported(cls, domain, features, device_class, _):
"""Test if state is supported.""" """Test if state is supported."""
return domain == sensor.DOMAIN and device_class in ( return (
sensor.DEVICE_CLASS_AQI, domain == sensor.DOMAIN
sensor.DEVICE_CLASS_CO, and device_class in SensorStateTrait.sensor_types.keys()
sensor.DEVICE_CLASS_CO2,
sensor.DEVICE_CLASS_PM25,
sensor.DEVICE_CLASS_PM10,
sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
) )
def sync_attributes(self): def sync_attributes(self):

View File

@ -1,4 +1,5 @@
"""Support for Honeywell (US) Total Connect Comfort climate systems.""" """Support for Honeywell (US) Total Connect Comfort climate systems."""
import asyncio
from datetime import timedelta from datetime import timedelta
import somecomfort import somecomfort
@ -9,7 +10,8 @@ from homeassistant.util import Throttle
from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN 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"] PLATFORMS = ["climate"]
@ -42,7 +44,7 @@ async def async_setup_entry(hass, config):
return False return False
data = HoneywellData(hass, client, username, password, devices) data = HoneywellData(hass, client, username, password, devices)
await data.update() await data.async_update()
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config.entry_id] = data hass.data[DOMAIN][config.entry_id] = data
hass.config_entries.async_setup_platforms(config, PLATFORMS) hass.config_entries.async_setup_platforms(config, PLATFORMS)
@ -102,18 +104,19 @@ class HoneywellData:
self.devices = devices self.devices = devices
return True return True
def _refresh_devices(self): async def _refresh_devices(self):
"""Refresh each enabled device.""" """Refresh each enabled device."""
for device in self.devices: 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) @Throttle(MIN_TIME_BETWEEN_UPDATES)
async def update(self) -> None: async def async_update(self) -> None:
"""Update the state.""" """Update the state."""
retries = 3 retries = 3
while retries > 0: while retries > 0:
try: try:
await self._hass.async_add_executor_job(self._refresh_devices) await self._refresh_devices()
break break
except ( except (
somecomfort.client.APIRateLimited, somecomfort.client.APIRateLimited,
@ -124,7 +127,7 @@ class HoneywellData:
if retries == 0: if retries == 0:
raise exp raise exp
result = await self._hass.async_add_executor_job(self._retry()) result = await self._retry()
if not result: if not result:
raise exp raise exp

View File

@ -107,6 +107,8 @@ HW_FAN_MODE_TO_HA = {
"follow schedule": FAN_AUTO, "follow schedule": FAN_AUTO,
} }
PARALLEL_UPDATES = 1
async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): async def async_setup_entry(hass, config, async_add_entities, discovery_info=None):
"""Set up the Honeywell thermostat.""" """Set up the Honeywell thermostat."""
@ -384,4 +386,4 @@ class HoneywellUSThermostat(ClimateEntity):
async def async_update(self): async def async_update(self):
"""Get the latest state from the service.""" """Get the latest state from the service."""
await self._data.update() await self._data.async_update()

View File

@ -106,7 +106,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
"""Initialize the integration sensor.""" """Initialize the integration sensor."""
self._sensor_source_id = source_entity self._sensor_source_id = source_entity
self._round_digits = round_digits self._round_digits = round_digits
self._state = 0 self._state = STATE_UNAVAILABLE
self._method = integration_method self._method = integration_method
self._name = name if name is not None else f"{source_entity} integral" 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: except AssertionError as err:
_LOGGER.error("Could not calculate integral: %s", err) _LOGGER.error("Could not calculate integral: %s", err)
else: else:
self._state += integral if isinstance(self._state, Decimal):
self._state += integral
else:
self._state = integral
self.async_write_ha_state() self.async_write_ha_state()
async_track_state_change_event( async_track_state_change_event(
@ -202,7 +205,9 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
@property @property
def native_value(self): def native_value(self):
"""Return the state of the sensor.""" """Return the state of the sensor."""
return round(self._state, self._round_digits) if isinstance(self._state, Decimal):
return round(self._state, self._round_digits)
return self._state
@property @property
def native_unit_of_measurement(self): def native_unit_of_measurement(self):

View File

@ -4,10 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.significant_change import ( from homeassistant.helpers.significant_change import check_absolute_change
check_numeric_changed,
either_one_none,
)
from . import ( from . import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -37,24 +34,21 @@ def async_check_significant_change(
old_color = old_attrs.get(ATTR_HS_COLOR) old_color = old_attrs.get(ATTR_HS_COLOR)
new_color = new_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: if old_color and new_color:
# Range 0..360 # 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 return True
# Range 0..100 # 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 return True
if check_numeric_changed( if check_absolute_change(
old_attrs.get(ATTR_BRIGHTNESS), new_attrs.get(ATTR_BRIGHTNESS), 3 old_attrs.get(ATTR_BRIGHTNESS), new_attrs.get(ATTR_BRIGHTNESS), 3
): ):
return True return True
if check_numeric_changed( if check_absolute_change(
# Default range 153..500 # Default range 153..500
old_attrs.get(ATTR_COLOR_TEMP), old_attrs.get(ATTR_COLOR_TEMP),
new_attrs.get(ATTR_COLOR_TEMP), new_attrs.get(ATTR_COLOR_TEMP),
@ -62,7 +56,7 @@ def async_check_significant_change(
): ):
return True return True
if check_numeric_changed( if check_absolute_change(
# Range 0..255 # Range 0..255
old_attrs.get(ATTR_WHITE_VALUE), old_attrs.get(ATTR_WHITE_VALUE),
new_attrs.get(ATTR_WHITE_VALUE), new_attrs.get(ATTR_WHITE_VALUE),

View File

@ -8,6 +8,7 @@ import logging
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any, Callable
from sqlalchemy import bindparam from sqlalchemy import bindparam
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext import baked from sqlalchemy.ext import baked
from sqlalchemy.orm.scoping import scoped_session 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( metadata_id = _update_or_add_metadata(
instance.hass, session, entity_id, stat["meta"] instance.hass, session, entity_id, stat["meta"]
) )
session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) 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)) session.add(StatisticsRuns(start=start))
return True return True
@ -369,11 +377,11 @@ def statistics_during_period(
) )
if not stats: if not stats:
return {} 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( 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]]: ) -> dict[str, list[dict]]:
"""Return the last number_of_stats statistics for a statistic_id.""" """Return the last number_of_stats statistics for a statistic_id."""
statistic_ids = [statistic_id] statistic_ids = [statistic_id]
@ -403,7 +411,9 @@ def get_last_statistics(
if not stats: if not stats:
return {} 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( def _sorted_statistics_to_dict(
@ -411,11 +421,16 @@ def _sorted_statistics_to_dict(
stats: list, stats: list,
statistic_ids: list[str] | None, statistic_ids: list[str] | None,
metadata: dict[str, StatisticMetaData], metadata: dict[str, StatisticMetaData],
convert_units: bool,
) -> dict[str, list[dict]]: ) -> dict[str, list[dict]]:
"""Convert SQL results into JSON friendly data structure.""" """Convert SQL results into JSON friendly data structure."""
result: dict = defaultdict(list) result: dict = defaultdict(list)
units = hass.config.units 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 # Set all statistic IDs to empty lists in result set to maintain the order
if statistic_ids is not None: if statistic_ids is not None:
for stat_id in statistic_ids: 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 for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore
unit = metadata[meta_id]["unit_of_measurement"] unit = metadata[meta_id]["unit_of_measurement"]
statistic_id = metadata[meta_id]["statistic_id"] statistic_id = metadata[meta_id]["statistic_id"]
convert: Callable[[Any, Any], float | None] = UNIT_CONVERSIONS.get( convert: Callable[[Any, Any], float | None]
unit, lambda x, units: x # type: ignore 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 = result[meta_id]
ent_results.extend( ent_results.extend(
{ {

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import datetime import datetime
import itertools import itertools
import logging import logging
import math
from typing import Callable from typing import Callable
from homeassistant.components.recorder import history, statistics 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} 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( def _normalize_states(
hass: HomeAssistant, hass: HomeAssistant,
entity_history: list[State], entity_history: list[State],
@ -186,9 +195,10 @@ def _normalize_states(
fstates = [] fstates = []
for state in entity_history: for state in entity_history:
try: try:
fstates.append((float(state.state), state)) fstate = _parse_float(state.state)
except ValueError: except (ValueError, TypeError): # TypeError to guard for NULL state in DB
continue continue
fstates.append((fstate, state))
if fstates: if fstates:
all_units = _get_units(fstates) all_units = _get_units(fstates)
@ -218,20 +228,20 @@ def _normalize_states(
for state in entity_history: for state in entity_history:
try: try:
fstate = float(state.state) fstate = _parse_float(state.state)
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
# Exclude unsupported units from statistics
if unit not in UNIT_CONVERSIONS[device_class]:
if WARN_UNSUPPORTED_UNIT not in hass.data:
hass.data[WARN_UNSUPPORTED_UNIT] = set()
if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]:
hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id)
_LOGGER.warning("%s has unknown unit %s", entity_id, unit)
continue
fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state))
except ValueError: except ValueError:
continue continue
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
# Exclude unsupported units from statistics
if unit not in UNIT_CONVERSIONS[device_class]:
if WARN_UNSUPPORTED_UNIT not in hass.data:
hass.data[WARN_UNSUPPORTED_UNIT] = set()
if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]:
hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id)
_LOGGER.warning("%s has unknown unit %s", entity_id, unit)
continue
fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state))
return DEVICE_CLASS_UNITS[device_class], fstates return DEVICE_CLASS_UNITS[device_class], fstates
@ -380,7 +390,7 @@ def compile_statistics( # noqa: C901
last_reset = old_last_reset = None last_reset = old_last_reset = None
new_state = old_state = None new_state = old_state = None
_sum = 0 _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: if entity_id in last_stats:
# We have compiled history for this sensor before, use that as a starting point # 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"] last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"]

View File

@ -9,8 +9,33 @@ from homeassistant.const import (
TEMP_FAHRENHEIT, TEMP_FAHRENHEIT,
) )
from homeassistant.core import HomeAssistant, callback 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 @callback
@ -28,20 +53,35 @@ def async_check_significant_change(
if device_class is None: if device_class is None:
return None return None
absolute_change: float | None = None
percentage_change: float | None = None
if device_class == DEVICE_CLASS_TEMPERATURE: if device_class == DEVICE_CLASS_TEMPERATURE:
if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_FAHRENHEIT: if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_FAHRENHEIT:
change: float | int = 1 absolute_change = 1.0
else: else:
change = 0.5 absolute_change = 0.5
old_value = float(old_state)
new_value = float(new_state)
return abs(old_value - new_value) >= change
if device_class in (DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY): if device_class in (DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY):
old_value = float(old_state) absolute_change = 1.0
new_value = float(new_state)
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 return None

View File

@ -3,7 +3,7 @@
"name": "Switcher", "name": "Switcher",
"documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/",
"codeowners": ["@tomerfi","@thecode"], "codeowners": ["@tomerfi","@thecode"],
"requirements": ["aioswitcher==2.0.4"], "requirements": ["aioswitcher==2.0.5"],
"iot_class": "local_push", "iot_class": "local_push",
"config_flow": true "config_flow": true
} }

View File

@ -46,27 +46,27 @@ SELECT_SCHEMA = vol.Schema(
async def _async_create_entities( 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]: ) -> list[TemplateSelect]:
"""Create the Template select.""" """Create the Template select."""
for entity in entities: entities = []
unique_id = entity.get(CONF_UNIQUE_ID) for definition in definitions:
unique_id = definition.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix: if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}" unique_id = f"{unique_id_prefix}-{unique_id}"
entities.append(
return [
TemplateSelect( TemplateSelect(
hass, hass,
entity.get(CONF_NAME, DEFAULT_NAME), definition.get(CONF_NAME, DEFAULT_NAME),
entity[CONF_STATE], definition[CONF_STATE],
entity.get(CONF_AVAILABILITY), definition.get(CONF_AVAILABILITY),
entity[CONF_SELECT_OPTION], definition[CONF_SELECT_OPTION],
entity[ATTR_OPTIONS], definition[ATTR_OPTIONS],
entity.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), definition.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC),
unique_id, unique_id,
) )
] )
return entities
async def async_setup_platform( async def async_setup_platform(

View File

@ -301,7 +301,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity):
@property @property
def available(self): def available(self):
"""Return true when state is known.""" """Return true when state is known."""
return self._available return super().available and self._available
@property @property
def extra_state_attributes(self): def extra_state_attributes(self):

View File

@ -5,7 +5,7 @@ from typing import Final
MAJOR_VERSION: Final = 2021 MAJOR_VERSION: Final = 2021
MINOR_VERSION: Final = 9 MINOR_VERSION: Final = 9
PATCH_VERSION: Final = "4" PATCH_VERSION: Final = "5"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0)

View File

@ -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) 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, val1: int | float | None,
val2: int | float | None, val2: int | float | None,
change: int | float, change: int | float,
) -> bool: ) -> bool:
"""Check if two numeric values have changed.""" """Check if two numeric values have changed."""
if val1 is None and val2 is None: return _check_numeric_change(
return False val1, val2, change, lambda val1, val2: abs(val1 - val2)
)
if either_one_none(val1, val2):
return True
assert val1 is not None def check_percentage_change(
assert val2 is not None 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: def percentage_change(old_state: int | float, new_state: int | float) -> float:
return True 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: class SignificantlyChangedChecker:

View File

@ -243,7 +243,7 @@ aiorecollect==1.0.8
aioshelly==0.6.4 aioshelly==0.6.4
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==2.0.4 aioswitcher==2.0.5
# homeassistant.components.syncthing # homeassistant.components.syncthing
aiosyncthing==0.5.1 aiosyncthing==0.5.1

View File

@ -164,7 +164,7 @@ aiorecollect==1.0.8
aioshelly==0.6.4 aioshelly==0.6.4
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==2.0.4 aioswitcher==2.0.5
# homeassistant.components.syncthing # homeassistant.components.syncthing
aiosyncthing==0.5.1 aiosyncthing==0.5.1

View File

@ -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,
hass_storage, hass_storage,
hass_ws_client, hass_ws_client,
@ -89,7 +89,7 @@ async def test_cost_sensor_price_entity(
cost_sensor_entity_id, cost_sensor_entity_id,
flow_type, flow_type,
) -> None: ) -> None:
"""Test energy cost price from sensor entity.""" """Test energy cost price from total_increasing type sensor entity."""
def _compile_statistics(_): def _compile_statistics(_):
return compile_statistics(hass, now, now + timedelta(seconds=1)) return compile_statistics(hass, now, now + timedelta(seconds=1))
@ -136,6 +136,7 @@ async def test_cost_sensor_price_entity(
} }
now = dt_util.utcnow() now = dt_util.utcnow()
last_reset_cost_sensor = now.isoformat()
# Optionally initialize dependent entities # Optionally initialize dependent entities
if initial_energy is not None: 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) state = hass.states.get(cost_sensor_entity_id)
assert state.state == initial_cost assert state.state == initial_cost
assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY 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" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR"
# Optional late setup of dependent entities # 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) state = hass.states.get(cost_sensor_entity_id)
assert state.state == "0.0" assert state.state == "0.0"
assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY 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" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR"
# # Unique ID temp disabled # # Unique ID temp disabled
@ -185,6 +189,7 @@ async def test_cost_sensor_price_entity(
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get(cost_sensor_entity_id) 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.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 # Nothing happens when price changes
if price_entity is not None: if price_entity is not None:
@ -199,6 +204,7 @@ async def test_cost_sensor_price_entity(
assert msg["success"] assert msg["success"]
state = hass.states.get(cost_sensor_entity_id) 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.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 # Additional consumption is using the new price
hass.states.async_set( hass.states.async_set(
@ -209,6 +215,7 @@ async def test_cost_sensor_price_entity(
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get(cost_sensor_entity_id) 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.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 # Check generated statistics
await async_wait_recording_done_without_instance(hass) 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() await hass.async_block_till_done()
state = hass.states.get(cost_sensor_entity_id) 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.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 # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point
hass.states.async_set( hass.states.async_set(
@ -235,6 +243,8 @@ async def test_cost_sensor_price_entity(
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get(cost_sensor_entity_id) 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.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 # Energy use bumped to 10 kWh
hass.states.async_set( hass.states.async_set(
@ -245,6 +255,213 @@ async def test_cost_sensor_price_entity(
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get(cost_sensor_entity_id) 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.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 # Check generated statistics
await async_wait_recording_done_without_instance(hass) 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() now = dt_util.utcnow()
# Initial state: 10kWh
hass.states.async_set( hass.states.async_set(
"sensor.energy_consumption", "sensor.energy_consumption",
10000, 10000,
@ -296,7 +514,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None:
state = hass.states.get("sensor.energy_consumption_cost") state = hass.states.get("sensor.energy_consumption_cost")
assert state.state == "0.0" assert state.state == "0.0"
# Energy use bumped to 10 kWh # Energy use bumped by 10 kWh
hass.states.async_set( hass.states.async_set(
"sensor.energy_consumption", "sensor.energy_consumption",
20000, 20000,
@ -361,7 +579,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None:
async def test_cost_sensor_wrong_state_class( async def test_cost_sensor_wrong_state_class(
hass, hass_storage, caplog, state_class hass, hass_storage, caplog, state_class
) -> None: ) -> None:
"""Test energy sensor rejects wrong state_class.""" """Test energy sensor rejects state_class with wrong state_class."""
energy_attributes = { energy_attributes = {
ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR,
ATTR_STATE_CLASS: state_class, 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") state = hass.states.get("sensor.energy_consumption_cost")
assert state.state == STATE_UNKNOWN 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

View File

@ -382,15 +382,6 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager):
"value": "123,123.12", "value": "123,123.12",
}, },
), ),
(
"-100",
"$/kWh",
{
"type": "entity_negative_state",
"identifier": "sensor.grid_price_1",
"value": -100.0,
},
),
( (
"123", "123",
"$/Ws", "$/Ws",
@ -414,7 +405,7 @@ async def test_validation_grid_price_errors(
hass.states.async_set( hass.states.async_set(
"sensor.grid_price_1", "sensor.grid_price_1",
state, state,
{"unit_of_measurement": unit, "state_class": "total_increasing"}, {"unit_of_measurement": unit, "state_class": "measurement"},
) )
await mock_energy_manager.async_update( await mock_energy_manager.async_update(
{ {
@ -441,3 +432,59 @@ async def test_validation_grid_price_errors(
], ],
"device_consumption": [], "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": [],
}

View File

@ -1,11 +1,14 @@
"""Test honeywell setup process.""" """Test honeywell setup process."""
from unittest.mock import patch
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0)
async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry): async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry):
"""Initialize the config entry.""" """Initialize the config entry."""
config_entry.add_to_hass(hass) 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 assert hass.states.async_entity_ids_count() == 1
@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0)
async def test_setup_multiple_thermostats( async def test_setup_multiple_thermostats(
hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device
) -> None: ) -> None:

View File

@ -81,7 +81,6 @@ async def test_restore_state(hass: HomeAssistant) -> None:
"platform": "integration", "platform": "integration",
"name": "integration", "name": "integration",
"source": "sensor.power", "source": "sensor.power",
"unit": ENERGY_KILO_WATT_HOUR,
"round": 2, "round": 2,
} }
} }
@ -114,7 +113,6 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None:
"platform": "integration", "platform": "integration",
"name": "integration", "name": "integration",
"source": "sensor.power", "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") state = hass.states.get("sensor.integration")
assert state assert state
assert state.state == "0" assert state.state == "unavailable"
assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR assert state.attributes.get("unit_of_measurement") is None
assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING
assert "device_class" not in state.attributes assert "device_class" not in state.attributes

View File

@ -3,11 +3,15 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch, sentinel from unittest.mock import patch, sentinel
import pytest
from pytest import approx from pytest import approx
from homeassistant.components.recorder import history from homeassistant.components.recorder import history
from homeassistant.components.recorder.const import DATA_INSTANCE 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 ( from homeassistant.components.recorder.statistics import (
get_last_statistics, get_last_statistics,
statistics_during_period, statistics_during_period,
@ -32,7 +36,7 @@ def test_compile_hourly_statistics(hass_recorder):
for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}):
stats = statistics_during_period(hass, zero, **kwargs) stats = statistics_during_period(hass, zero, **kwargs)
assert stats == {} assert stats == {}
stats = get_last_statistics(hass, 0, "sensor.test1") stats = get_last_statistics(hass, 0, "sensor.test1", True)
assert stats == {} assert stats == {}
recorder.do_adhoc_statistics(period="hourly", start=zero) recorder.do_adhoc_statistics(period="hourly", start=zero)
@ -78,22 +82,121 @@ def test_compile_hourly_statistics(hass_recorder):
assert stats == {} assert stats == {}
# Test get_last_statistics # Test get_last_statistics
stats = get_last_statistics(hass, 0, "sensor.test1") stats = get_last_statistics(hass, 0, "sensor.test1", True)
assert stats == {} 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"}]} 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]} 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]} 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 == {} 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): def test_rename_entity(hass_recorder):
"""Test statistics is migrated when entity_id is changed.""" """Test statistics is migrated when entity_id is changed."""
hass = hass_recorder() hass = hass_recorder()
@ -116,7 +219,7 @@ def test_rename_entity(hass_recorder):
for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}):
stats = statistics_during_period(hass, zero, **kwargs) stats = statistics_during_period(hass, zero, **kwargs)
assert stats == {} assert stats == {}
stats = get_last_statistics(hass, 0, "sensor.test1") stats = get_last_statistics(hass, 0, "sensor.test1", True)
assert stats == {} assert stats == {}
recorder.do_adhoc_statistics(period="hourly", start=zero) recorder.do_adhoc_statistics(period="hourly", start=zero)

View File

@ -1,6 +1,7 @@
"""The tests for sensor recorder platform.""" """The tests for sensor recorder platform."""
# pylint: disable=protected-access,invalid-name # pylint: disable=protected-access,invalid-name
from datetime import timedelta from datetime import timedelta
import math
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -17,6 +18,7 @@ from homeassistant.components.recorder.statistics import (
from homeassistant.const import STATE_UNAVAILABLE from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.setup import setup_component from homeassistant.setup import setup_component
import homeassistant.util.dt as dt_util 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 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("state_class", ["measurement"])
@pytest.mark.parametrize( @pytest.mark.parametrize(
"device_class,unit,native_unit,factor", "units,device_class,unit,display_unit,factor",
[ [
("energy", "kWh", "kWh", 1), (IMPERIAL_SYSTEM, "energy", "kWh", "kWh", 1),
("energy", "Wh", "kWh", 1 / 1000), (IMPERIAL_SYSTEM, "energy", "Wh", "kWh", 1 / 1000),
("monetary", "EUR", "EUR", 1), (IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", 1),
("monetary", "SEK", "SEK", 1), (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", 1),
("gas", "", "", 1), (IMPERIAL_SYSTEM, "gas", "", "ft³", 35.314666711),
("gas", "ft³", "", 0.0283168466), (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", "", "", 1),
(METRIC_SYSTEM, "gas", "ft³", "", 0.0283168466),
], ],
) )
def test_compile_hourly_sum_statistics_amount( 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.""" """Test compiling hourly statistics."""
zero = dt_util.utcnow() zero = dt_util.utcnow()
hass = hass_recorder() hass = hass_recorder()
hass.config.units = units
recorder = hass.data[DATA_INSTANCE] recorder = hass.data[DATA_INSTANCE]
setup_component(hass, "sensor", {}) setup_component(hass, "sensor", {})
attributes = { attributes = {
@ -235,7 +244,7 @@ def test_compile_hourly_sum_statistics_amount(
wait_recording_done(hass) wait_recording_done(hass)
statistic_ids = list_statistic_ids(hass) statistic_ids = list_statistic_ids(hass)
assert statistic_ids == [ 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) stats = statistics_during_period(hass, zero)
assert stats == { 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 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( @pytest.mark.parametrize(
"device_class,unit,native_unit,factor", "device_class,unit,native_unit,factor",
[ [

View File

@ -1,5 +1,8 @@
"""Test the sensor significant change platform.""" """Test the sensor significant change platform."""
import pytest
from homeassistant.components.sensor.significant_change import ( from homeassistant.components.sensor.significant_change import (
DEVICE_CLASS_AQI,
DEVICE_CLASS_BATTERY, DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE,
@ -12,48 +15,54 @@ from homeassistant.const import (
TEMP_FAHRENHEIT, TEMP_FAHRENHEIT,
) )
AQI_ATTRS = {
ATTR_DEVICE_CLASS: DEVICE_CLASS_AQI,
}
async def test_significant_change_temperature(): 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,
}
TEMP_FREEDOM_ATTRS = {
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT,
}
@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.""" """Detect temperature significant changes."""
celsius_attrs = { assert (
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, async_check_significant_change(None, old_state, attrs, new_state, attrs)
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, is result
}
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 = {
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT,
}
assert async_check_significant_change(
None, "70", freedom_attrs, "71", freedom_attrs
)
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)

View File

@ -60,6 +60,38 @@ async def test_missing_optional_config(hass, calls):
_verify(hass, "a", ["a", "b"]) _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): async def test_missing_required_keys(hass, calls):
"""Test: missing required fields will fail.""" """Test: missing required fields will fail."""
with assert_setup_component(0, "template"): with assert_setup_component(0, "template"):
@ -250,9 +282,9 @@ async def test_trigger_select(hass):
assert events[0].event_type == "test_number_event" 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.""" """Verify select's state."""
state = hass.states.get(_TEST_SELECT) state = hass.states.get(entity_name)
attributes = state.attributes attributes = state.attributes
assert state.state == str(expected_current_option) assert state.state == str(expected_current_option)
assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options