diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6dba415e0e4fe1..7c3eb49efb335c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 1 + CACHE_VERSION: 2 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2025.12" diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 442aedf23b09e7..19aa3cd8491c00 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -5,7 +5,7 @@ import asyncio from collections import Counter from collections.abc import Awaitable, Callable -from typing import Literal, TypedDict +from typing import Literal, NotRequired, TypedDict import voluptuous as vol @@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager: class FlowFromGridSourceType(TypedDict): """Dictionary describing the 'from' stat for the grid source.""" - # statistic_id of a an energy meter (kWh) + # statistic_id of an energy meter (kWh) stat_energy_from: str # statistic_id of costs ($) incurred from the energy meter @@ -58,6 +58,14 @@ class FlowToGridSourceType(TypedDict): number_energy_price: float | None # Price for energy ($/kWh) +class GridPowerSourceType(TypedDict): + """Dictionary holding the source of grid power consumption.""" + + # statistic_id of a power meter (kW) + # negative values indicate grid return + stat_rate: str + + class GridSourceType(TypedDict): """Dictionary holding the source of grid energy consumption.""" @@ -65,6 +73,7 @@ class GridSourceType(TypedDict): flow_from: list[FlowFromGridSourceType] flow_to: list[FlowToGridSourceType] + power: NotRequired[list[GridPowerSourceType]] cost_adjustment_day: float @@ -75,6 +84,7 @@ class SolarSourceType(TypedDict): type: Literal["solar"] stat_energy_from: str + stat_rate: NotRequired[str] config_entry_solar_forecast: list[str] | None @@ -85,6 +95,8 @@ class BatterySourceType(TypedDict): stat_energy_from: str stat_energy_to: str + # positive when discharging, negative when charging + stat_rate: NotRequired[str] class GasSourceType(TypedDict): @@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict): # This is an ever increasing value stat_consumption: str + # Instantaneous rate of flow: W, L/min or m³/h + stat_rate: NotRequired[str] + # An optional custom name for display in energy graphs name: str | None # An optional statistic_id identifying a device # that includes this device's consumption in its total - included_in_stat: str | None + included_in_stat: NotRequired[str] class EnergyPreferences(TypedDict): @@ -194,6 +209,12 @@ def _flow_from_ensure_single_price( } ) +GRID_POWER_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("stat_rate"): str, + } +) + def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]: """Generate a validator that ensures a value is only used once.""" @@ -224,6 +245,10 @@ def validate_uniqueness( [FLOW_TO_GRID_SOURCE_SCHEMA], _generate_unique_value_validator("stat_energy_to"), ), + vol.Optional("power"): vol.All( + [GRID_POWER_SOURCE_SCHEMA], + _generate_unique_value_validator("stat_rate"), + ), vol.Required("cost_adjustment_day"): vol.Coerce(float), } ) @@ -231,6 +256,7 @@ def validate_uniqueness( { vol.Required("type"): "solar", vol.Required("stat_energy_from"): str, + vol.Optional("stat_rate"): str, vol.Optional("config_entry_solar_forecast"): vol.Any([str], None), } ) @@ -239,6 +265,7 @@ def validate_uniqueness( vol.Required("type"): "battery", vol.Required("stat_energy_from"): str, vol.Required("stat_energy_to"): str, + vol.Optional("stat_rate"): str, } ) GAS_SOURCE_SCHEMA = vol.Schema( @@ -294,6 +321,7 @@ def check_type_limits(value: list[SourceType]) -> list[SourceType]: DEVICE_CONSUMPTION_SCHEMA = vol.Schema( { vol.Required("stat_consumption"): str, + vol.Optional("stat_rate"): str, vol.Optional("name"): str, vol.Optional("included_in_stat"): str, } diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 6c11c2b068c79a..62ff7beac5e8ff 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -12,6 +12,7 @@ STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfEnergy, + UnitOfPower, UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback, valid_entity_id @@ -23,12 +24,17 @@ ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = { sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy) } +POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,) +POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = { + sensor.SensorDeviceClass.POWER: tuple(UnitOfPower) +} ENERGY_PRICE_UNITS = tuple( f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units ) ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price" +POWER_UNIT_ERROR = "entity_unexpected_unit_power" GAS_USAGE_DEVICE_CLASSES = ( sensor.SensorDeviceClass.ENERGY, sensor.SensorDeviceClass.GAS, @@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] | f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS ), } + if issue_type == POWER_UNIT_ERROR: + return { + "power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]), + } if issue_type == GAS_UNIT_ERROR: return { "energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]), @@ -159,7 +169,7 @@ def as_dict(self) -> dict: @callback -def _async_validate_usage_stat( +def _async_validate_stat_common( hass: HomeAssistant, metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]], stat_id: str, @@ -167,37 +177,41 @@ def _async_validate_usage_stat( allowed_units: Mapping[str, Sequence[str]], unit_error: str, issues: ValidationIssues, -) -> None: - """Validate a statistic.""" + check_negative: bool = False, +) -> str | None: + """Validate common aspects of a statistic. + + Returns the entity_id if validation succeeds, None otherwise. + """ if stat_id not in metadata: issues.add_issue(hass, "statistics_not_defined", stat_id) has_entity_source = valid_entity_id(stat_id) if not has_entity_source: - return + return None entity_id = stat_id if not recorder.is_entity_recorded(hass, entity_id): issues.add_issue(hass, "recorder_untracked", entity_id) - return + return None if (state := hass.states.get(entity_id)) is None: issues.add_issue(hass, "entity_not_defined", entity_id) - return + return None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): issues.add_issue(hass, "entity_unavailable", entity_id, state.state) - return + return None try: current_value: float | None = float(state.state) except ValueError: issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state) - return + return None - if current_value is not None and current_value < 0: + if check_negative and current_value is not None and current_value < 0: issues.add_issue(hass, "entity_negative_state", entity_id, current_value) device_class = state.attributes.get(ATTR_DEVICE_CLASS) @@ -211,6 +225,36 @@ def _async_validate_usage_stat( if device_class and unit not in allowed_units.get(device_class, []): issues.add_issue(hass, unit_error, entity_id, unit) + return entity_id + + +@callback +def _async_validate_usage_stat( + hass: HomeAssistant, + metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]], + stat_id: str, + allowed_device_classes: Sequence[str], + allowed_units: Mapping[str, Sequence[str]], + unit_error: str, + issues: ValidationIssues, +) -> None: + """Validate a statistic.""" + entity_id = _async_validate_stat_common( + hass, + metadata, + stat_id, + allowed_device_classes, + allowed_units, + unit_error, + issues, + check_negative=True, + ) + + if entity_id is None: + return + + state = hass.states.get(entity_id) + assert state is not None state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) allowed_state_classes = [ @@ -255,6 +299,39 @@ def _async_validate_price_entity( issues.add_issue(hass, unit_error, entity_id, unit) +@callback +def _async_validate_power_stat( + hass: HomeAssistant, + metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]], + stat_id: str, + allowed_device_classes: Sequence[str], + allowed_units: Mapping[str, Sequence[str]], + unit_error: str, + issues: ValidationIssues, +) -> None: + """Validate a power statistic.""" + entity_id = _async_validate_stat_common( + hass, + metadata, + stat_id, + allowed_device_classes, + allowed_units, + unit_error, + issues, + check_negative=False, + ) + + if entity_id is None: + return + + state = hass.states.get(entity_id) + assert state is not None + state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) + + if state_class != sensor.SensorStateClass.MEASUREMENT: + issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class) + + @callback def _async_validate_cost_stat( hass: HomeAssistant, @@ -434,6 +511,21 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) ) + for power_stat in source.get("power", []): + wanted_statistics_metadata.add(power_stat["stat_rate"]) + validate_calls.append( + functools.partial( + _async_validate_power_stat, + hass, + statistics_metadata, + power_stat["stat_rate"], + POWER_USAGE_DEVICE_CLASSES, + POWER_USAGE_UNITS, + POWER_UNIT_ERROR, + source_result, + ) + ) + elif source["type"] == "gas": wanted_statistics_metadata.add(source["stat_energy_from"]) validate_calls.append( diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 537545ac9d1d02..db9caf41239495 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: sidebar_title="climate", sidebar_default_visible=False, ) + async_register_built_in_panel( + hass, + "home", + sidebar_icon="mdi:home", + sidebar_title="home", + sidebar_default_visible=False, + ) async_register_built_in_panel(hass, "profile") diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 40051aeb1e608f..6d33c18b3b4157 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.14.0", "mill-local==0.3.0"] + "requirements": ["millheater==0.14.1", "mill-local==0.3.0"] } diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 697d15486388a9..48b68852a122b5 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -109,7 +109,7 @@ "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" }, "oauth2_implementation_unavailable": { - "message": "OAuth2 implementation unavailable, will retry" + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" }, "update_failed": { "message": "Failed to update drive state" diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json index bd2ced43bb8d4c..538d90d4a05c80 100644 --- a/homeassistant/components/point/strings.json +++ b/homeassistant/components/point/strings.json @@ -36,7 +36,7 @@ }, "exceptions": { "oauth2_implementation_unavailable": { - "message": "OAuth2 implementation unavailable, will retry" + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } } } diff --git a/homeassistant/components/senz/__init__.py b/homeassistant/components/senz/__init__.py index 7e7b3101861553..83f619d21da852 100644 --- a/homeassistant/components/senz/__init__.py +++ b/homeassistant/components/senz/__init__.py @@ -32,9 +32,10 @@ PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]] +type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool: """Set up SENZ from a config entry.""" try: implementation = await async_get_config_entry_implementation(hass, entry) @@ -71,16 +72,13 @@ async def update_thermostats() -> dict[str, Thermostat]: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index f303f4b147c1b3..5f532378beecd4 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -12,24 +12,23 @@ HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SENZDataUpdateCoordinator +from . import SENZConfigEntry, SENZDataUpdateCoordinator from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SENZConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SENZ climate entities from a config entry.""" - coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values() ) diff --git a/homeassistant/components/senz/diagnostics.py b/homeassistant/components/senz/diagnostics.py index d9b024a3a5d8f9..bed15a7091a6ce 100644 --- a/homeassistant/components/senz/diagnostics.py +++ b/homeassistant/components/senz/diagnostics.py @@ -3,10 +3,9 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from . import SENZConfigEntry TO_REDACT = [ "access_token", @@ -15,13 +14,11 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SENZConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - raw_data = ( - [device.raw_data for device in hass.data[DOMAIN][entry.entry_id].data.values()], - ) + raw_data = ([device.raw_data for device in entry.runtime_data.data.values()],) return { "entry_data": async_redact_data(entry.data, TO_REDACT), diff --git a/homeassistant/components/senz/sensor.py b/homeassistant/components/senz/sensor.py index 430c959b047718..444b787d39af4b 100644 --- a/homeassistant/components/senz/sensor.py +++ b/homeassistant/components/senz/sensor.py @@ -13,14 +13,13 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SENZDataUpdateCoordinator +from . import SENZConfigEntry, SENZDataUpdateCoordinator from .const import DOMAIN @@ -45,11 +44,11 @@ class SenzSensorDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SENZConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SENZ sensor entities from a config entry.""" - coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SENZSensor(thermostat, coordinator, description) for description in SENSORS diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 48af6df7bb7bb6..9ca1c5fe6e1989 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -663,7 +663,7 @@ }, "exceptions": { "oauth2_implementation_unavailable": { - "message": "OAuth2 implementation unavailable, will retry" + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } }, "issues": { diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index a63af62309bce2..9a2f9069d76033 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -34,7 +34,7 @@ }, "exceptions": { "oauth2_implementation_unavailable": { - "message": "OAuth2 implementation unavailable, will retry" + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } }, "system_health": { diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 7e0b727ba79042..f8b220638fb25e 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -237,7 +237,7 @@ def _async_update_progress(self) -> None: if self._download_percentage > 1 and self._download_percentage < 100: self._attr_in_progress = True self._attr_update_percentage = self._download_percentage - elif self._install_percentage > 1: + elif self._install_percentage > 10: self._attr_in_progress = True self._attr_update_percentage = self._install_percentage else: diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json index 0bde7b83558d1b..4a3566325a50d8 100644 --- a/homeassistant/components/twitch/strings.json +++ b/homeassistant/components/twitch/strings.json @@ -61,7 +61,7 @@ }, "exceptions": { "oauth2_implementation_unavailable": { - "message": "OAuth2 implementation unavailable, will retry" + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } } } diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index a00e62ab725fef..8687d6760ec43f 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -5,7 +5,12 @@ from pyvlx import PyVLX, PyVLXException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, issue_registry as ir @@ -30,6 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo entry.runtime_data = pyvlx + connections = None + if (mac := entry.data.get(CONF_MAC)) is not None: + connections = {(dr.CONNECTION_NETWORK_MAC, mac)} + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -43,6 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo sw_version=( str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None ), + connections=connections, ) async def on_hass_stop(event): diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 99e7fff2a2eaf7..8f8a1106f73aad 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.54.0"] + "requirements": ["PyViCare==2.55.0"] } diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 65baec78e81f45..61d3fc89ced5d6 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -363,7 +363,7 @@ "message": "Unable to retrieve vehicle details." }, "oauth2_implementation_unavailable": { - "message": "OAuth2 implementation unavailable, will retry" + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" }, "unauthorized": { "message": "Authentication failed. {message}" diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index a8559b72172734..fdb141dea814a1 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -334,7 +334,7 @@ }, "exceptions": { "oauth2_implementation_unavailable": { - "message": "OAuth2 implementation unavailable, will retry" + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } } } diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 1ed8abc86fdc7b..2672f249de8712 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -43,7 +43,7 @@ }, "exceptions": { "oauth2_implementation_unavailable": { - "message": "OAuth2 implementation unavailable, will retry" + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } }, "options": { diff --git a/requirements_all.txt b/requirements_all.txt index bcbf36669f6763..232d1a7b793805 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -99,7 +99,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.54.0 +PyViCare==2.55.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -1456,7 +1456,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.14.0 +millheater==0.14.1 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49608e835a1caf..8ea343b8a35ec7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -93,7 +93,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.54.0 +PyViCare==2.55.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -1251,7 +1251,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.14.0 +millheater==0.14.1 # homeassistant.components.minio minio==7.1.12 diff --git a/script/licenses.py b/script/licenses.py index 15d10643fec35f..3c77618f4219e4 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -203,6 +203,7 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 "ujson", # https://github.com/ultrajson/ultrajson/blob/main/LICENSE.txt + "wsproto", # https://github.com/python-hyper/wsproto/pull/194 } # fmt: off diff --git a/tests/components/energy/conftest.py b/tests/components/energy/conftest.py new file mode 100644 index 00000000000000..dae67af413c891 --- /dev/null +++ b/tests/components/energy/conftest.py @@ -0,0 +1,56 @@ +"""Fixtures for energy component tests.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.energy import async_get_manager +from homeassistant.components.energy.data import EnergyManager +from homeassistant.components.recorder import Recorder +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.fixture +def mock_is_entity_recorded(): + """Mock recorder.is_entity_recorded.""" + mocks = {} + + with patch( + "homeassistant.components.recorder.is_entity_recorded", + side_effect=lambda hass, entity_id: mocks.get(entity_id, True), + ): + yield mocks + + +@pytest.fixture +def mock_get_metadata(): + """Mock recorder.statistics.get_metadata.""" + mocks = {} + + def _get_metadata(_hass, *, statistic_ids): + result = {} + for statistic_id in statistic_ids: + if statistic_id in mocks: + if mocks[statistic_id] is not None: + result[statistic_id] = mocks[statistic_id] + else: + result[statistic_id] = (1, {}) + return result + + with patch( + "homeassistant.components.recorder.statistics.get_metadata", + wraps=_get_metadata, + ): + yield mocks + + +@pytest.fixture +async def mock_energy_manager( + recorder_mock: Recorder, hass: HomeAssistant +) -> EnergyManager: + """Set up energy.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + return manager diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 9addf6c10015b1..cf46ac9dc4c7e2 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -1,65 +1,24 @@ """Test that validation works.""" -from unittest.mock import patch - import pytest -from homeassistant.components.energy import async_get_manager, validate +from homeassistant.components.energy import validate from homeassistant.components.energy.data import EnergyManager -from homeassistant.components.recorder import Recorder from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSON_DUMP -from homeassistant.setup import async_setup_component ENERGY_UNITS_STRING = ", ".join(tuple(UnitOfEnergy)) ENERGY_PRICE_UNITS_STRING = ", ".join(f"EUR/{unit}" for unit in tuple(UnitOfEnergy)) -@pytest.fixture -def mock_is_entity_recorded(): - """Mock recorder.is_entity_recorded.""" - mocks = {} - - with patch( - "homeassistant.components.recorder.is_entity_recorded", - side_effect=lambda hass, entity_id: mocks.get(entity_id, True), - ): - yield mocks - - -@pytest.fixture -def mock_get_metadata(): - """Mock recorder.statistics.get_metadata.""" - mocks = {} - - def _get_metadata(_hass, *, statistic_ids): - result = {} - for statistic_id in statistic_ids: - if statistic_id in mocks: - if mocks[statistic_id] is not None: - result[statistic_id] = mocks[statistic_id] - else: - result[statistic_id] = (1, {}) - return result - - with patch( - "homeassistant.components.recorder.statistics.get_metadata", - wraps=_get_metadata, - ): - yield mocks - - @pytest.fixture(autouse=True) -async def mock_energy_manager( - recorder_mock: Recorder, hass: HomeAssistant +async def setup_energy_for_validation( + mock_energy_manager: EnergyManager, ) -> EnergyManager: - """Set up energy.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) - manager = await async_get_manager(hass) - manager.data = manager.default_preferences() - return manager + """Ensure energy manager is set up for validation tests.""" + return mock_energy_manager async def test_validation_empty_config(hass: HomeAssistant) -> None: @@ -413,6 +372,7 @@ async def test_validation_grid( "stat_compensation": "sensor.grid_compensation_1", } ], + "power": [], } ] } @@ -504,6 +464,7 @@ async def test_validation_grid_external_cost_compensation( "stat_compensation": "external:grid_compensation_1", } ], + "power": [], } ] } @@ -742,6 +703,7 @@ async def test_validation_grid_price_errors( } ], "flow_to": [], + "power": [], } ] } @@ -947,6 +909,7 @@ async def test_validation_grid_no_costs_tracking( "number_energy_price": None, }, ], + "power": [], "cost_adjustment_day": 0.0, } ] diff --git a/tests/components/energy/test_validate_power.py b/tests/components/energy/test_validate_power.py new file mode 100644 index 00000000000000..4a5c114503c5ca --- /dev/null +++ b/tests/components/energy/test_validate_power.py @@ -0,0 +1,450 @@ +"""Test power stat validation.""" + +import pytest + +from homeassistant.components.energy import validate +from homeassistant.components.energy.data import EnergyManager +from homeassistant.const import UnitOfPower +from homeassistant.core import HomeAssistant + +POWER_UNITS_STRING = ", ".join(tuple(UnitOfPower)) + + +@pytest.fixture(autouse=True) +async def setup_energy_for_validation( + mock_energy_manager: EnergyManager, +) -> EnergyManager: + """Ensure energy manager is set up for validation tests.""" + return mock_energy_manager + + +async def test_validation_grid_power_valid( + hass: HomeAssistant, mock_energy_manager, mock_get_metadata +) -> None: + """Test validating grid with valid power sensor.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "sensor.grid_power", + } + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + hass.states.async_set( + "sensor.grid_power", + "1.5", + { + "device_class": "power", + "unit_of_measurement": UnitOfPower.KILO_WATT, + "state_class": "measurement", + }, + ) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [[]], + "device_consumption": [], + } + + +async def test_validation_grid_power_wrong_unit( + hass: HomeAssistant, mock_energy_manager, mock_get_metadata +) -> None: + """Test validating grid with power sensor having wrong unit.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "sensor.grid_power", + } + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + hass.states.async_set( + "sensor.grid_power", + "1.5", + { + "device_class": "power", + "unit_of_measurement": "beers", + "state_class": "measurement", + }, + ) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_power", + "affected_entities": {("sensor.grid_power", "beers")}, + "translation_placeholders": {"power_units": POWER_UNITS_STRING}, + } + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_power_wrong_state_class( + hass: HomeAssistant, mock_energy_manager, mock_get_metadata +) -> None: + """Test validating grid with power sensor having wrong state class.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "sensor.grid_power", + } + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + hass.states.async_set( + "sensor.grid_power", + "1.5", + { + "device_class": "power", + "unit_of_measurement": UnitOfPower.KILO_WATT, + "state_class": "total_increasing", + }, + ) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_state_class", + "affected_entities": {("sensor.grid_power", "total_increasing")}, + "translation_placeholders": None, + } + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_power_entity_missing( + hass: HomeAssistant, mock_energy_manager +) -> None: + """Test validating grid with missing power sensor.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "sensor.missing_power", + } + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [ + [ + { + "type": "statistics_not_defined", + "affected_entities": {("sensor.missing_power", None)}, + "translation_placeholders": None, + }, + { + "type": "entity_not_defined", + "affected_entities": {("sensor.missing_power", None)}, + "translation_placeholders": None, + }, + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_power_entity_unavailable( + hass: HomeAssistant, mock_energy_manager, mock_get_metadata +) -> None: + """Test validating grid with unavailable power sensor.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "sensor.unavailable_power", + } + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + hass.states.async_set("sensor.unavailable_power", "unavailable", {}) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unavailable", + "affected_entities": {("sensor.unavailable_power", "unavailable")}, + "translation_placeholders": None, + } + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_power_entity_non_numeric( + hass: HomeAssistant, mock_energy_manager, mock_get_metadata +) -> None: + """Test validating grid with non-numeric power sensor.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "sensor.non_numeric_power", + } + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + hass.states.async_set( + "sensor.non_numeric_power", + "not_a_number", + { + "device_class": "power", + "unit_of_measurement": UnitOfPower.KILO_WATT, + "state_class": "measurement", + }, + ) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_state_non_numeric", + "affected_entities": {("sensor.non_numeric_power", "not_a_number")}, + "translation_placeholders": None, + } + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_power_wrong_device_class( + hass: HomeAssistant, mock_energy_manager, mock_get_metadata +) -> None: + """Test validating grid with power sensor having wrong device class.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "sensor.wrong_device_class_power", + } + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + hass.states.async_set( + "sensor.wrong_device_class_power", + "1.5", + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "measurement", + }, + ) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_device_class", + "affected_entities": { + ("sensor.wrong_device_class_power", "energy") + }, + "translation_placeholders": None, + } + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_power_different_units( + hass: HomeAssistant, mock_energy_manager, mock_get_metadata +) -> None: + """Test validating grid with power sensors using different valid units.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "sensor.power_watt", + }, + { + "stat_rate": "sensor.power_milliwatt", + }, + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + hass.states.async_set( + "sensor.power_watt", + "1500", + { + "device_class": "power", + "unit_of_measurement": UnitOfPower.WATT, + "state_class": "measurement", + }, + ) + hass.states.async_set( + "sensor.power_milliwatt", + "1500000", + { + "device_class": "power", + "unit_of_measurement": UnitOfPower.MILLIWATT, + "state_class": "measurement", + }, + ) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [[]], + "device_consumption": [], + } + + +async def test_validation_grid_power_external_statistics( + hass: HomeAssistant, mock_energy_manager, mock_get_metadata +) -> None: + """Test validating grid with external power statistics (non-entity).""" + mock_get_metadata["external:power_stat"] = None + + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "external:power_stat", + } + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [ + [ + { + "type": "statistics_not_defined", + "affected_entities": {("external:power_stat", None)}, + "translation_placeholders": None, + } + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_power_recorder_untracked( + hass: HomeAssistant, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata +) -> None: + """Test validating grid with power sensor not tracked by recorder.""" + mock_is_entity_recorded["sensor.untracked_power"] = False + + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "stat_rate": "sensor.untracked_power", + } + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + + result = await validate.async_validate(hass) + assert result.as_dict() == { + "energy_sources": [ + [ + { + "type": "recorder_untracked", + "affected_entities": {("sensor.untracked_power", None)}, + "translation_placeholders": None, + } + ] + ], + "device_consumption": [], + } diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index da518e78ef9aec..7f05fcfc346bea 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -137,17 +137,24 @@ async def test_save_preferences( "number_energy_price": 0.20, }, ], + "power": [ + { + "stat_rate": "sensor.grid_power", + } + ], "cost_adjustment_day": 1.2, }, { "type": "solar", "stat_energy_from": "my_solar_production", + "stat_rate": "my_solar_power", "config_entry_solar_forecast": ["predicted_config_entry"], }, { "type": "battery", "stat_energy_from": "my_battery_draining", "stat_energy_to": "my_battery_charging", + "stat_rate": "my_battery_power", }, ], "device_consumption": [ @@ -155,6 +162,7 @@ async def test_save_preferences( "stat_consumption": "some_device_usage", "name": "My Device", "included_in_stat": "sensor.some_other_device", + "stat_rate": "sensor.some_device_power", } ], } @@ -253,6 +261,7 @@ async def test_handle_duplicate_from_stat( }, ], "flow_to": [], + "power": [], "cost_adjustment_day": 0, }, ], diff --git a/tests/components/tado/conftest.py b/tests/components/tado/conftest.py index 1aa62b218a2c9e..1e666d5c815aaa 100644 --- a/tests/components/tado/conftest.py +++ b/tests/components/tado/conftest.py @@ -5,10 +5,12 @@ from PyTado.http import DeviceActivationStatus import pytest +import requests_mock from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN +from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, async_load_fixture, load_json_object_fixture @pytest.fixture @@ -48,3 +50,196 @@ def mock_config_entry() -> MockConfigEntry: unique_id="1", version=2, ) + + +@pytest.fixture +async def init_integration(hass: HomeAssistant): + """Set up the tado integration in Home Assistant.""" + + token_fixture = "token.json" + devices_fixture = "devices.json" + mobile_devices_fixture = "mobile_devices.json" + me_fixture = "me.json" + weather_fixture = "weather.json" + home_fixture = "home.json" + home_state_fixture = "home_state.json" + zones_fixture = "zones.json" + zone_states_fixture = "zone_states.json" + + # WR1 Device + device_wr1_fixture = "device_wr1.json" + + # Smart AC with fanLevel, Vertical and Horizontal swings + zone_6_state_fixture = "smartac4.with_fanlevel.json" + zone_6_capabilities_fixture = "zone_with_fanlevel_horizontal_vertical_swing.json" + + # Smart AC with Swing + zone_5_state_fixture = "smartac3.with_swing.json" + zone_5_capabilities_fixture = "zone_with_swing_capabilities.json" + + # Water Heater 2 + zone_4_state_fixture = "tadov2.water_heater.heating.json" + zone_4_capabilities_fixture = "water_heater_zone_capabilities.json" + + # Smart AC + zone_3_state_fixture = "smartac3.cool_mode.json" + zone_3_capabilities_fixture = "zone_capabilities.json" + + # Water Heater + zone_2_state_fixture = "tadov2.water_heater.auto_mode.json" + zone_2_capabilities_fixture = "water_heater_zone_capabilities.json" + + # Tado V2 with manual heating + zone_1_state_fixture = "tadov2.heating.manual_mode.json" + zone_1_capabilities_fixture = "tadov2.zone_capabilities.json" + + # Device Temp Offset + device_temp_offset = "device_temp_offset.json" + + # Zone Default Overlay + zone_def_overlay = "zone_default_overlay.json" + + with requests_mock.mock() as m: + m.post( + "https://auth.tado.com/oauth/token", + text=await async_load_fixture(hass, token_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/me", + text=await async_load_fixture(hass, me_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/", + text=await async_load_fixture(hass, home_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/weather", + text=await async_load_fixture(hass, weather_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/state", + text=await async_load_fixture(hass, home_state_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/devices", + text=await async_load_fixture(hass, devices_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/mobileDevices", + text=await async_load_fixture(hass, mobile_devices_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/devices/WR1/", + text=await async_load_fixture(hass, device_wr1_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/devices/WR1/temperatureOffset", + text=await async_load_fixture(hass, device_temp_offset, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/devices/WR4/temperatureOffset", + text=await async_load_fixture(hass, device_temp_offset, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones", + text=await async_load_fixture(hass, zones_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zoneStates", + text=await async_load_fixture(hass, zone_states_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/6/capabilities", + text=await async_load_fixture(hass, zone_6_capabilities_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/5/capabilities", + text=await async_load_fixture(hass, zone_5_capabilities_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/4/capabilities", + text=await async_load_fixture(hass, zone_4_capabilities_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/3/capabilities", + text=await async_load_fixture(hass, zone_3_capabilities_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/2/capabilities", + text=await async_load_fixture(hass, zone_2_capabilities_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/1/capabilities", + text=await async_load_fixture(hass, zone_1_capabilities_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/1/defaultOverlay", + text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/2/defaultOverlay", + text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/3/defaultOverlay", + text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/4/defaultOverlay", + text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/5/defaultOverlay", + text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/6/defaultOverlay", + text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/6/state", + text=await async_load_fixture(hass, zone_6_state_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/5/state", + text=await async_load_fixture(hass, zone_5_state_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/4/state", + text=await async_load_fixture(hass, zone_4_state_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/3/state", + text=await async_load_fixture(hass, zone_3_state_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/2/state", + text=await async_load_fixture(hass, zone_2_state_fixture, DOMAIN), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/1/state", + text=await async_load_fixture(hass, zone_1_state_fixture, DOMAIN), + ) + m.post( + "https://login.tado.com/oauth2/token", + text=await async_load_fixture(hass, token_fixture, DOMAIN), + ) + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + data={ + CONF_REFRESH_TOKEN: "mock-token", + }, + options={"fallback": "NEXT_TIME_BLOCK"}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # For a first refresh + await entry.runtime_data.coordinator.async_refresh() + await entry.runtime_data.mobile_coordinator.async_refresh() + await hass.async_block_till_done() + + yield diff --git a/tests/components/tado/test_binary_sensor.py b/tests/components/tado/test_binary_sensor.py index 9a0b94883fa09c..4157d43f09202f 100644 --- a/tests/components/tado/test_binary_sensor.py +++ b/tests/components/tado/test_binary_sensor.py @@ -1,6 +1,6 @@ """The binary sensor tests for the tado platform.""" -from collections.abc import AsyncGenerator +from collections.abc import Generator from unittest.mock import patch import pytest @@ -11,25 +11,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import async_init_integration - from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) -def setup_platforms() -> AsyncGenerator[None]: +def setup_platforms() -> Generator[None]: """Set up the platforms for the tests.""" with patch("homeassistant.components.tado.PLATFORMS", [Platform.BINARY_SENSOR]): yield +@pytest.mark.usefixtures("init_integration") async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test creation of binary sensor.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index 71ee0471e5fd9c..79f8246fd98d2f 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -1,6 +1,6 @@ """The climate tests for the tado platform.""" -from collections.abc import AsyncGenerator +from collections.abc import Generator from unittest.mock import patch from PyTado.interface.api.my_tado import TadoZone @@ -19,45 +19,37 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import async_init_integration - from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) -def setup_platforms() -> AsyncGenerator[None]: +def setup_platforms() -> Generator[None]: """Set up the platforms for the tests.""" with patch("homeassistant.components.tado.PLATFORMS", [Platform.CLIMATE]): yield +@pytest.mark.usefixtures("init_integration") async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test creation of climate entities.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.usefixtures("init_integration") async def test_heater_set_temperature( hass: HomeAssistant, snapshot: SnapshotAssertion ) -> None: """Test the set temperature of the heater.""" - await async_init_integration(hass) - with ( patch( "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay" ) as mock_set_state, - patch( - "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_state", - return_value={"setting": {"temperature": {"celsius": 22.0}}}, - ), ): await hass.services.async_call( CLIMATE_DOMAIN, @@ -80,6 +72,7 @@ async def test_heater_set_temperature( (HVACMode.OFF, "OFF"), ], ) +@pytest.mark.usefixtures("init_integration") async def test_aircon_set_hvac_mode( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -88,14 +81,12 @@ async def test_aircon_set_hvac_mode( ) -> None: """Test the set hvac mode of the air conditioning.""" - await async_init_integration(hass) - with ( patch( - "homeassistant.components.tado.__init__.PyTado.interface.api.Tado.set_zone_overlay" + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay" ) as mock_set_state, patch( - "homeassistant.components.tado.__init__.PyTado.interface.api.Tado.get_zone_state", + "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_state", return_value=TadoZone( zone_id=1, current_temp=18.7, diff --git a/tests/components/tado/test_diagnostics.py b/tests/components/tado/test_diagnostics.py index 36d136d5d779d5..df0dad145a1b42 100644 --- a/tests/components/tado/test_diagnostics.py +++ b/tests/components/tado/test_diagnostics.py @@ -1,26 +1,24 @@ """Test the Tado component diagnostics.""" +import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.tado.const import DOMAIN from homeassistant.core import HomeAssistant -from .util import async_init_integration - from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("init_integration") async def test_get_config_entry_diagnostics( hass: HomeAssistant, snapshot: SnapshotAssertion, hass_client: ClientSessionGenerator, ) -> None: """Test if get_config_entry_diagnostics returns the correct data.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] diagnostics = await get_diagnostics_for_config_entry( hass, hass_client, config_entry diff --git a/tests/components/tado/test_sensor.py b/tests/components/tado/test_sensor.py index 8445683d11d89c..10d40cef2fbd4b 100644 --- a/tests/components/tado/test_sensor.py +++ b/tests/components/tado/test_sensor.py @@ -1,6 +1,6 @@ """The sensor tests for the tado platform.""" -from collections.abc import AsyncGenerator +from collections.abc import Generator from unittest.mock import patch import pytest @@ -11,25 +11,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import async_init_integration - from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) -def setup_platforms() -> AsyncGenerator[None]: +def setup_platforms() -> Generator[None]: """Set up the platforms for the tests.""" with patch("homeassistant.components.tado.PLATFORMS", [Platform.SENSOR]): yield +@pytest.mark.usefixtures("init_integration") async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test creation of sensor entities.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/tado/test_services.py b/tests/components/tado/test_services.py index 0bbde9de76d3af..9830a0d4563864 100644 --- a/tests/components/tado/test_services.py +++ b/tests/components/tado/test_services.py @@ -15,28 +15,24 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .util import async_init_integration - from tests.common import MockConfigEntry, async_load_fixture +@pytest.mark.usefixtures("init_integration") async def test_has_services( hass: HomeAssistant, ) -> None: """Test the existence of the Tado Service.""" - await async_init_integration(hass) - assert hass.services.has_service(DOMAIN, SERVICE_ADD_METER_READING) +@pytest.mark.usefixtures("init_integration") async def test_add_meter_readings( hass: HomeAssistant, ) -> None: """Test the add_meter_readings service.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] fixture: str = await async_load_fixture(hass, "add_readings_success.json", DOMAIN) with patch( @@ -55,13 +51,12 @@ async def test_add_meter_readings( assert response is None +@pytest.mark.usefixtures("init_integration") async def test_add_meter_readings_exception( hass: HomeAssistant, ) -> None: """Test the add_meter_readings service with a RequestException.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] with ( patch( @@ -83,13 +78,12 @@ async def test_add_meter_readings_exception( assert "Error setting Tado meter reading: Error" in str(exc.value) +@pytest.mark.usefixtures("init_integration") async def test_add_meter_readings_invalid( hass: HomeAssistant, ) -> None: """Test the add_meter_readings service with an invalid_meter_reading response.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] fixture: str = await async_load_fixture( hass, "add_readings_invalid_meter_reading.json", DOMAIN @@ -114,13 +108,12 @@ async def test_add_meter_readings_invalid( assert "invalid new reading" in str(exc) +@pytest.mark.usefixtures("init_integration") async def test_add_meter_readings_duplicate( hass: HomeAssistant, ) -> None: """Test the add_meter_readings service with a duplicated_meter_reading response.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] fixture: str = await async_load_fixture( hass, "add_readings_duplicated_meter_reading.json", DOMAIN diff --git a/tests/components/tado/test_switch.py b/tests/components/tado/test_switch.py index 6bfdf1283d176c..b5b7ad32c7e977 100644 --- a/tests/components/tado/test_switch.py +++ b/tests/components/tado/test_switch.py @@ -1,6 +1,6 @@ """The switch tests for the tado platform.""" -from collections.abc import AsyncGenerator +from collections.abc import Generator from unittest.mock import patch import pytest @@ -16,27 +16,24 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import async_init_integration - from tests.common import MockConfigEntry, snapshot_platform CHILD_LOCK_SWITCH_ENTITY = "switch.baseboard_heater_child_lock" @pytest.fixture(autouse=True) -def setup_platforms() -> AsyncGenerator[None]: +def setup_platforms() -> Generator[None]: """Set up the platforms for the tests.""" with patch("homeassistant.components.tado.PLATFORMS", [Platform.SWITCH]): yield +@pytest.mark.usefixtures("init_integration") async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test creation of switch entities.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @@ -45,11 +42,10 @@ async def test_entities( @pytest.mark.parametrize( ("method", "expected"), [(SERVICE_TURN_ON, True), (SERVICE_TURN_OFF, False)] ) +@pytest.mark.usefixtures("init_integration") async def test_set_child_lock(hass: HomeAssistant, method, expected) -> None: """Test enable child lock on switch.""" - await async_init_integration(hass) - with patch( "homeassistant.components.tado.PyTado.interface.api.Tado.set_child_lock" ) as mock_set_state: diff --git a/tests/components/tado/test_water_heater.py b/tests/components/tado/test_water_heater.py index 7c13ba1604ec98..f9ec99f16c74d4 100644 --- a/tests/components/tado/test_water_heater.py +++ b/tests/components/tado/test_water_heater.py @@ -1,6 +1,6 @@ """The water heater tests for the tado platform.""" -from collections.abc import AsyncGenerator +from collections.abc import Generator from unittest.mock import patch import pytest @@ -11,25 +11,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import async_init_integration - from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) -def setup_platforms() -> AsyncGenerator[None]: +def setup_platforms() -> Generator[None]: """Set up the platforms for the tests.""" with patch("homeassistant.components.tado.PLATFORMS", [Platform.WATER_HEATER]): yield +@pytest.mark.usefixtures("init_integration") async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test creation of water heater.""" - await async_init_integration(hass) - config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py deleted file mode 100644 index 8ee7209acb269b..00000000000000 --- a/tests/components/tado/util.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Tests for the tado integration.""" - -import requests_mock - -from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry, async_load_fixture - - -async def async_init_integration( - hass: HomeAssistant, - skip_setup: bool = False, -): - """Set up the tado integration in Home Assistant.""" - - token_fixture = "token.json" - devices_fixture = "devices.json" - mobile_devices_fixture = "mobile_devices.json" - me_fixture = "me.json" - weather_fixture = "weather.json" - home_fixture = "home.json" - home_state_fixture = "home_state.json" - zones_fixture = "zones.json" - zone_states_fixture = "zone_states.json" - - # WR1 Device - device_wr1_fixture = "device_wr1.json" - - # Smart AC with fanLevel, Vertical and Horizontal swings - zone_6_state_fixture = "smartac4.with_fanlevel.json" - zone_6_capabilities_fixture = "zone_with_fanlevel_horizontal_vertical_swing.json" - - # Smart AC with Swing - zone_5_state_fixture = "smartac3.with_swing.json" - zone_5_capabilities_fixture = "zone_with_swing_capabilities.json" - - # Water Heater 2 - zone_4_state_fixture = "tadov2.water_heater.heating.json" - zone_4_capabilities_fixture = "water_heater_zone_capabilities.json" - - # Smart AC - zone_3_state_fixture = "smartac3.cool_mode.json" - zone_3_capabilities_fixture = "zone_capabilities.json" - - # Water Heater - zone_2_state_fixture = "tadov2.water_heater.auto_mode.json" - zone_2_capabilities_fixture = "water_heater_zone_capabilities.json" - - # Tado V2 with manual heating - zone_1_state_fixture = "tadov2.heating.manual_mode.json" - zone_1_capabilities_fixture = "tadov2.zone_capabilities.json" - - # Device Temp Offset - device_temp_offset = "device_temp_offset.json" - - # Zone Default Overlay - zone_def_overlay = "zone_default_overlay.json" - - with requests_mock.mock() as m: - m.post( - "https://auth.tado.com/oauth/token", - text=await async_load_fixture(hass, token_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/me", - text=await async_load_fixture(hass, me_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/", - text=await async_load_fixture(hass, home_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/weather", - text=await async_load_fixture(hass, weather_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/state", - text=await async_load_fixture(hass, home_state_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/devices", - text=await async_load_fixture(hass, devices_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/mobileDevices", - text=await async_load_fixture(hass, mobile_devices_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/devices/WR1/", - text=await async_load_fixture(hass, device_wr1_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/devices/WR1/temperatureOffset", - text=await async_load_fixture(hass, device_temp_offset, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/devices/WR4/temperatureOffset", - text=await async_load_fixture(hass, device_temp_offset, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones", - text=await async_load_fixture(hass, zones_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zoneStates", - text=await async_load_fixture(hass, zone_states_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/6/capabilities", - text=await async_load_fixture(hass, zone_6_capabilities_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/5/capabilities", - text=await async_load_fixture(hass, zone_5_capabilities_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/4/capabilities", - text=await async_load_fixture(hass, zone_4_capabilities_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/3/capabilities", - text=await async_load_fixture(hass, zone_3_capabilities_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/2/capabilities", - text=await async_load_fixture(hass, zone_2_capabilities_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/1/capabilities", - text=await async_load_fixture(hass, zone_1_capabilities_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/1/defaultOverlay", - text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/2/defaultOverlay", - text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/3/defaultOverlay", - text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/4/defaultOverlay", - text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/5/defaultOverlay", - text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/6/defaultOverlay", - text=await async_load_fixture(hass, zone_def_overlay, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/6/state", - text=await async_load_fixture(hass, zone_6_state_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/5/state", - text=await async_load_fixture(hass, zone_5_state_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/4/state", - text=await async_load_fixture(hass, zone_4_state_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/3/state", - text=await async_load_fixture(hass, zone_3_state_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/2/state", - text=await async_load_fixture(hass, zone_2_state_fixture, DOMAIN), - ) - m.get( - "https://my.tado.com/api/v2/homes/1/zones/1/state", - text=await async_load_fixture(hass, zone_1_state_fixture, DOMAIN), - ) - m.post( - "https://login.tado.com/oauth2/token", - text=await async_load_fixture(hass, token_fixture, DOMAIN), - ) - entry = MockConfigEntry( - domain=DOMAIN, - version=2, - data={ - CONF_REFRESH_TOKEN: "mock-token", - }, - options={"fallback": "NEXT_TIME_BLOCK"}, - ) - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # For a first refresh - await entry.runtime_data.coordinator.async_refresh() - await entry.runtime_data.mobile_coordinator.async_refresh() - await hass.async_block_till_done()