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
. 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 .

View File

@ -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",

View File

@ -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."""

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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()

View File

@ -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:
self._state += integral
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."""
return round(self._state, self._round_digits)
if isinstance(self._state, Decimal):
return round(self._state, self._round_digits)
return self._state
@property
def native_unit_of_measurement(self):

View File

@ -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),

View File

@ -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"]
)
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))
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(
{

View File

@ -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,20 +228,20 @@ def _normalize_states(
for state in entity_history:
try:
fstate = 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))
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]:
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
@ -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"]

View File

@ -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

View File

@ -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
}

View File

@ -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(

View File

@ -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):

View File

@ -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)

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)
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:

View File

@ -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

View File

@ -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

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_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

View File

@ -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": [],
}

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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", "", "", 1),
("gas", "ft³", "", 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", "", "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", "", "", 1),
(METRIC_SYSTEM, "gas", "ft³", "", 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",
[

View File

@ -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():
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."""
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, old_state, attrs, new_state, attrs)
is result
)
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"])
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