diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 98f4fb04e34..7bfebcd1f07 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 11 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.1" + HA_SHORT_VERSION: "2025.2" DEFAULT_PYTHON: "3.12" ALL_PYTHON_VERSIONS: "['3.12', '3.13']" # 10.3 is the oldest supported version diff --git a/CODEOWNERS b/CODEOWNERS index 8ab0994cdac..546177dfec4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1103,8 +1103,8 @@ build.json @home-assistant/supervisor /tests/components/otbr/ @home-assistant/core /homeassistant/components/ourgroceries/ @OnFreund /tests/components/ourgroceries/ @OnFreund -/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14 -/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14 +/homeassistant/components/overkiz/ @imicknl +/tests/components/overkiz/ @imicknl /homeassistant/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001 /homeassistant/components/p1_monitor/ @klaasnicolaas @@ -1135,8 +1135,8 @@ build.json @home-assistant/supervisor /tests/components/plaato/ @JohNan /homeassistant/components/plex/ @jjlawren /tests/components/plex/ @jjlawren -/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck -/tests/components/plugwise/ @CoMPaTech @bouwew @frenck +/homeassistant/components/plugwise/ @CoMPaTech @bouwew +/tests/components/plugwise/ @CoMPaTech @bouwew /homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa /tests/components/plum_lightpad/ @ColinHarrington @prystupa /homeassistant/components/point/ @fredrike @@ -1573,8 +1573,8 @@ build.json @home-assistant/supervisor /tests/components/triggercmd/ @rvmey /homeassistant/components/tts/ @home-assistant/core /tests/components/tts/ @home-assistant/core -/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck -/tests/components/tuya/ @Tuya @zlinoliver @frenck +/homeassistant/components/tuya/ @Tuya @zlinoliver +/tests/components/tuya/ @Tuya @zlinoliver /homeassistant/components/twentemilieu/ @frenck /tests/components/twentemilieu/ @frenck /homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 79dc3cc55ce..4bd9dd03eea 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -13,7 +13,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.storage import STORAGE_DIR -from .const import CONF_STATION_UPDATES, DOMAIN, PLATFORMS +from .const import CONF_RADAR_UPDATES, CONF_STATION_UPDATES, DOMAIN, PLATFORMS from .coordinator import AemetConfigEntry, AemetData, WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -26,6 +26,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] update_features: int = UpdateFeature.FORECAST + if entry.options.get(CONF_RADAR_UPDATES, False): + update_features |= UpdateFeature.RADAR if entry.options.get(CONF_STATION_UPDATES, True): update_features |= UpdateFeature.STATION diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index e2b0b436c8c..80b5c07e6bd 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -17,10 +17,11 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaOptionsFlowHandler, ) -from .const import CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN +from .const import CONF_RADAR_UPDATES, CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN OPTIONS_SCHEMA = vol.Schema( { + vol.Required(CONF_RADAR_UPDATES, default=False): bool, vol.Required(CONF_STATION_UPDATES, default=True): bool, } ) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 665075c4093..b79a94d209d 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -51,8 +51,9 @@ from homeassistant.components.weather import ( from homeassistant.const import Platform ATTRIBUTION = "Powered by AEMET OpenData" +CONF_RADAR_UPDATES = "radar_updates" CONF_STATION_UPDATES = "station_updates" -PLATFORMS = [Platform.SENSOR, Platform.WEATHER] +PLATFORMS = [Platform.IMAGE, Platform.SENSOR, Platform.WEATHER] DEFAULT_NAME = "AEMET" DOMAIN = "aemet" diff --git a/homeassistant/components/aemet/diagnostics.py b/homeassistant/components/aemet/diagnostics.py index bc366fc6d44..b072309d4b8 100644 --- a/homeassistant/components/aemet/diagnostics.py +++ b/homeassistant/components/aemet/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from aemet_opendata.const import AOD_COORDS +from aemet_opendata.const import AOD_COORDS, AOD_IMG_BYTES from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import ( @@ -26,6 +26,7 @@ TO_REDACT_CONFIG = [ TO_REDACT_COORD = [ AOD_COORDS, + AOD_IMG_BYTES, ] diff --git a/homeassistant/components/aemet/image.py b/homeassistant/components/aemet/image.py new file mode 100644 index 00000000000..ffc53022e4c --- /dev/null +++ b/homeassistant/components/aemet/image.py @@ -0,0 +1,86 @@ +"""Support for the AEMET OpenData images.""" + +from __future__ import annotations + +from typing import Final + +from aemet_opendata.const import AOD_DATETIME, AOD_IMG_BYTES, AOD_IMG_TYPE, AOD_RADAR +from aemet_opendata.helpers import dict_nested_value + +from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator +from .entity import AemetEntity + +AEMET_IMAGES: Final[tuple[ImageEntityDescription, ...]] = ( + ImageEntityDescription( + key=AOD_RADAR, + translation_key="weather_radar", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AemetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AEMET OpenData image entities based on a config entry.""" + domain_data = config_entry.runtime_data + name = domain_data.name + coordinator = domain_data.coordinator + + unique_id = config_entry.unique_id + assert unique_id is not None + + async_add_entities( + AemetImage( + hass, + name, + coordinator, + description, + unique_id, + ) + for description in AEMET_IMAGES + if dict_nested_value(coordinator.data["lib"], [description.key]) is not None + ) + + +class AemetImage(AemetEntity, ImageEntity): + """Implementation of an AEMET OpenData image.""" + + entity_description: ImageEntityDescription + + def __init__( + self, + hass: HomeAssistant, + name: str, + coordinator: WeatherUpdateCoordinator, + description: ImageEntityDescription, + unique_id: str, + ) -> None: + """Initialize the image.""" + super().__init__(coordinator, name, unique_id) + ImageEntity.__init__(self, hass) + self.entity_description = description + self._attr_unique_id = f"{unique_id}-{description.key}" + + self._async_update_attrs() + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update image attributes.""" + image_data = self.get_aemet_value([self.entity_description.key]) + self._cached_image = Image( + content_type=image_data.get(AOD_IMG_TYPE), + content=image_data.get(AOD_IMG_BYTES), + ) + self._attr_image_last_updated = image_data.get(AOD_DATETIME) diff --git a/homeassistant/components/aemet/strings.json b/homeassistant/components/aemet/strings.json index 75c810978ad..d65c546b050 100644 --- a/homeassistant/components/aemet/strings.json +++ b/homeassistant/components/aemet/strings.json @@ -18,10 +18,18 @@ } } }, + "entity": { + "image": { + "weather_radar": { + "name": "Weather radar" + } + } + }, "options": { "step": { "init": { "data": { + "radar_updates": "Gather data from AEMET weather radar", "station_updates": "Gather data from AEMET weather stations" } } diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 4f3c4d7ef4e..ebe27d42471 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["apprise"], "quality_scale": "legacy", - "requirements": ["apprise==1.9.0"] + "requirements": ["apprise==1.9.1"] } diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py index 6463d10f3e8..b5ed60a7754 100644 --- a/homeassistant/components/apsystems/number.py +++ b/homeassistant/components/apsystems/number.py @@ -2,6 +2,8 @@ from __future__ import annotations +from aiohttp import ClientConnectorError + from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant @@ -45,7 +47,13 @@ class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity): async def async_update(self) -> None: """Set the state with the value fetched from the inverter.""" - self._attr_native_value = await self._api.get_max_power() + try: + status = await self._api.get_max_power() + except (TimeoutError, ClientConnectorError): + self._attr_available = False + else: + self._attr_available = True + self._attr_native_value = status async def async_set_native_value(self, value: float) -> None: """Set the desired output power.""" diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index f1a6f3be196..ab324a44e3b 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -5,6 +5,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType +# Pre-import backup to avoid it being imported +# later when the import executor is busy and delaying +# startup +from . import backup # noqa: F401 from .agent import ( BackupAgent, BackupAgentError, diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 80c02571d24..80b00237fd3 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -36,7 +36,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.signal_type import SignalType -from . import account_link, http_api +# Pre-import backup to avoid it being imported +# later when the import executor is busy and delaying +# startup +from . import ( + account_link, + backup, # noqa: F401 + http_api, +) from .client import CloudClient from .const import ( CONF_ACCOUNT_LINK_SERVER, diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py index 5d76b38bed8..2759ca972df 100644 --- a/homeassistant/components/ecovacs/button.py +++ b/homeassistant/components/ecovacs/button.py @@ -2,7 +2,12 @@ from dataclasses import dataclass -from deebot_client.capabilities import CapabilityExecute, CapabilityLifeSpan +from deebot_client.capabilities import ( + CapabilityExecute, + CapabilityExecuteTypes, + CapabilityLifeSpan, +) +from deebot_client.commands import StationAction from deebot_client.events import LifeSpan from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -11,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EcovacsConfigEntry -from .const import SUPPORTED_LIFESPANS +from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS from .entity import ( EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, @@ -35,6 +40,13 @@ class EcovacsLifespanButtonEntityDescription(ButtonEntityDescription): component: LifeSpan +@dataclass(kw_only=True, frozen=True) +class EcovacsStationActionButtonEntityDescription(ButtonEntityDescription): + """Ecovacs station action button entity description.""" + + action: StationAction + + ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = ( EcovacsButtonEntityDescription( capability_fn=lambda caps: caps.map.relocation if caps.map else None, @@ -44,6 +56,16 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = ( ), ) +STATION_ENTITY_DESCRIPTIONS = tuple( + EcovacsStationActionButtonEntityDescription( + action=action, + key=f"station_action_{action.name.lower()}", + translation_key=f"station_action_{action.name.lower()}", + ) + for action in SUPPORTED_STATION_ACTIONS +) + + LIFESPAN_ENTITY_DESCRIPTIONS = tuple( EcovacsLifespanButtonEntityDescription( component=component, @@ -74,6 +96,15 @@ async def async_setup_entry( for description in LIFESPAN_ENTITY_DESCRIPTIONS if description.component in device.capabilities.life_span.types ) + entities.extend( + EcovacsStationActionButtonEntity( + device, device.capabilities.station.action, description + ) + for device in controller.devices + if device.capabilities.station + for description in STATION_ENTITY_DESCRIPTIONS + if description.action in device.capabilities.station.action.types + ) async_add_entities(entities) @@ -103,3 +134,18 @@ class EcovacsResetLifespanButtonEntity( await self._device.execute_command( self._capability.reset(self.entity_description.component) ) + + +class EcovacsStationActionButtonEntity( + EcovacsDescriptionEntity[CapabilityExecuteTypes[StationAction]], + ButtonEntity, +): + """Ecovacs station action button entity.""" + + entity_description: EcovacsStationActionButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + await self._device.execute_command( + self._capability.execute(self.entity_description.action) + ) diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py index ac7a268f1bd..0bfe9cfd544 100644 --- a/homeassistant/components/ecovacs/const.py +++ b/homeassistant/components/ecovacs/const.py @@ -2,6 +2,7 @@ from enum import StrEnum +from deebot_client.commands import StationAction from deebot_client.events import LifeSpan DOMAIN = "ecovacs" @@ -19,8 +20,11 @@ SUPPORTED_LIFESPANS = ( LifeSpan.SIDE_BRUSH, LifeSpan.UNIT_CARE, LifeSpan.ROUND_MOP, + LifeSpan.STATION_FILTER, ) +SUPPORTED_STATION_ACTIONS = (StationAction.EMPTY_DUSTBIN,) + LEGACY_SUPPORTED_LIFESPANS = ( "main_brush", "side_brush", diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 6097f43a4e4..b0e2a0595bf 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -27,11 +27,17 @@ "reset_lifespan_side_brush": { "default": "mdi:broom" }, + "reset_lifespan_station_filter": { + "default": "mdi:air-filter" + }, "reset_lifespan_unit_care": { "default": "mdi:robot-vacuum" }, "reset_lifespan_round_mop": { "default": "mdi:broom" + }, + "station_action_empty_dustbin": { + "default": "mdi:delete-restore" } }, "event": { @@ -72,6 +78,9 @@ "lifespan_side_brush": { "default": "mdi:broom" }, + "lifespan_station_filter": { + "default": "mdi:air-filter" + }, "lifespan_unit_care": { "default": "mdi:robot-vacuum" }, @@ -87,6 +96,9 @@ "network_ssid": { "default": "mdi:wifi" }, + "station_state": { + "default": "mdi:home" + }, "stats_area": { "default": "mdi:floor-plan" }, diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 7c190d27775..0e906c6cb16 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -16,6 +16,7 @@ from deebot_client.events import ( NetworkInfoEvent, StatsEvent, TotalStatsEvent, + station, ) from sucks import VacBot @@ -46,7 +47,7 @@ from .entity import ( EcovacsLegacyEntity, EventT, ) -from .util import get_supported_entitites +from .util import get_name_key, get_options, get_supported_entitites @dataclass(kw_only=True, frozen=True) @@ -136,6 +137,15 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), + # Station + EcovacsSensorEntityDescription[station.StationEvent]( + capability_fn=lambda caps: caps.station.state if caps.station else None, + value_fn=lambda e: get_name_key(e.state), + key="station_state", + translation_key="station_state", + device_class=SensorDeviceClass.ENUM, + options=get_options(station.State), + ), ) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index c9de461ad5b..723bdef17f8 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -46,6 +46,9 @@ "relocate": { "name": "Relocate" }, + "reset_lifespan_base_station_filter": { + "name": "Reset station filter lifespan" + }, "reset_lifespan_blade": { "name": "Reset blade lifespan" }, @@ -66,6 +69,9 @@ }, "reset_lifespan_side_brush": { "name": "Reset side brush lifespan" + }, + "station_action_empty_dustbin": { + "name": "Empty dustbin" } }, "event": { @@ -107,6 +113,9 @@ } } }, + "lifespan_base_station_filter": { + "name": "Station filter lifespan" + }, "lifespan_blade": { "name": "Blade lifespan" }, @@ -140,6 +149,13 @@ "network_ssid": { "name": "Wi-Fi SSID" }, + "station_state": { + "name": "Station state", + "state": { + "idle": "[%key:common::state::idle%]", + "emptying_dustbin": "Emptying dustbin" + } + }, "stats_area": { "name": "Area cleaned" }, diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index a4894de8968..0cfbf1e8f91 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -7,6 +7,8 @@ import random import string from typing import TYPE_CHECKING +from deebot_client.events.station import State + from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify @@ -47,4 +49,13 @@ def get_supported_entitites( @callback def get_name_key(enum: Enum) -> str: """Return the lower case name of the enum.""" + if enum is State.EMPTYING: + # Will be fixed in the next major release of deebot-client + return "emptying_dustbin" return enum.name.lower() + + +@callback +def get_options(enum: type[Enum]) -> list[str]: + """Return the options for the enum.""" + return [get_name_key(option) for option in enum] diff --git a/homeassistant/components/elevenlabs/quality_scale.yaml b/homeassistant/components/elevenlabs/quality_scale.yaml index ecd2092492c..94c395310c5 100644 --- a/homeassistant/components/elevenlabs/quality_scale.yaml +++ b/homeassistant/components/elevenlabs/quality_scale.yaml @@ -13,7 +13,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: > diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 52fa3ba1a12..a6a30ffdc6a 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -27,6 +27,7 @@ class FritzboxCoordinatorData: devices: dict[str, FritzhomeDevice] templates: dict[str, FritzhomeTemplate] + supported_color_properties: dict[str, tuple[dict, list]] class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorData]): @@ -49,7 +50,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.new_devices: set[str] = set() self.new_templates: set[str] = set() - self.data = FritzboxCoordinatorData({}, {}) + self.data = FritzboxCoordinatorData({}, {}, {}) async def async_setup(self) -> None: """Set up the coordinator.""" @@ -120,6 +121,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat devices = self.fritz.get_devices() device_data = {} + supported_color_properties = self.data.supported_color_properties for device in devices: # assume device as unavailable, see #55799 if ( @@ -136,6 +138,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat device_data[device.ain] = device + # pre-load supported colors and color temps for new devices + if device.has_color and device.ain not in supported_color_properties: + supported_color_properties[device.ain] = ( + device.get_colors(), + device.get_color_temps(), + ) + template_data = {} if self.has_templates: templates = self.fritz.get_templates() @@ -145,7 +154,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.new_devices = device_data.keys() - self.data.devices.keys() self.new_templates = template_data.keys() - self.data.templates.keys() - return FritzboxCoordinatorData(devices=device_data, templates=template_data) + return FritzboxCoordinatorData( + devices=device_data, + templates=template_data, + supported_color_properties=supported_color_properties, + ) async def _async_update_data(self) -> FritzboxCoordinatorData: """Fetch all device data.""" diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index d347f6898c0..36cb7dc8cff 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -57,7 +57,6 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): ) -> None: """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - self._supported_hs: dict[int, list[int]] = {} self._attr_supported_color_modes = {ColorMode.ONOFF} if self.data.has_color: @@ -65,6 +64,26 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): elif self.data.has_level: self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + (supported_colors, supported_color_temps) = ( + coordinator.data.supported_color_properties.get(self.data.ain, ({}, [])) + ) + + # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. + # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup + self._supported_hs: dict[int, list[int]] = {} + for values in supported_colors.values(): + hue = int(values[0][0]) + self._supported_hs[hue] = [ + int(values[0][1]), + int(values[1][1]), + int(values[2][1]), + ] + + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + @property def is_on(self) -> bool: """If the light is currently on or off.""" @@ -148,30 +167,3 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Turn the light off.""" await self.hass.async_add_executor_job(self.data.set_state_off) await self.coordinator.async_refresh() - - async def async_added_to_hass(self) -> None: - """Get light attributes from device after entity is added to hass.""" - await super().async_added_to_hass() - - def _get_color_data() -> tuple[dict, list]: - return (self.data.get_colors(), self.data.get_color_temps()) - - ( - supported_colors, - supported_color_temps, - ) = await self.hass.async_add_executor_job(_get_color_data) - - if supported_color_temps: - # only available for color bulbs - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) - - # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. - # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup - for values in supported_colors.values(): - hue = int(values[0][0]) - self._supported_hs[hue] = [ - int(values[0][1]), - int(values[1][1]), - int(values[2][1]), - ] diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 227234f9937..94d0f90b0bd 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -11,5 +11,6 @@ "documentation": "https://www.home-assistant.io/integrations/fronius", "iot_class": "local_polling", "loggers": ["pyfronius"], + "quality_scale": "gold", "requirements": ["PyFronius==0.7.3"] } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 1f9988dff38..2d3604330f6 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241127.8"] + "requirements": ["home-assistant-frontend==20241223.1"] } diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index b3ecadacba5..45841e6255f 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -77,7 +77,7 @@ }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", - "unknown_with_details": "[%key:common::config_flow::error::unknown_with_details]", + "unknown_with_details": "[%key:component::generic::config::error::unknown_with_details%]", "already_exists": "[%key:component::generic::config::error::already_exists%]", "unable_still_load": "[%key:component::generic::config::error::unable_still_load%]", "unable_still_load_auth": "[%key:component::generic::config::error::unable_still_load_auth%]", diff --git a/homeassistant/components/harvey/__init__.py b/homeassistant/components/harvey/__init__.py new file mode 100644 index 00000000000..e40d1799a64 --- /dev/null +++ b/homeassistant/components/harvey/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Harvey.""" diff --git a/homeassistant/components/harvey/manifest.json b/homeassistant/components/harvey/manifest.json new file mode 100644 index 00000000000..3cb2a1b9aff --- /dev/null +++ b/homeassistant/components/harvey/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "harvey", + "name": "Harvey", + "integration_type": "virtual", + "supported_by": "aquacell" +} diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index f9b79d74cb4..83528b73f6f 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -118,9 +118,7 @@ class HistoryStats: <= current_period_end_timestamp ): self._history_current_period.append( - HistoryState( - new_state.state, new_state.last_changed.timestamp() - ) + HistoryState(new_state.state, new_state.last_changed_timestamp) ) new_data = True if not new_data and current_period_end_timestamp < now_timestamp: @@ -131,6 +129,16 @@ class HistoryStats: await self._async_history_from_db( current_period_start_timestamp, current_period_end_timestamp ) + if event and (new_state := event.data["new_state"]) is not None: + if ( + current_period_start_timestamp + <= floored_timestamp(new_state.last_changed) + <= current_period_end_timestamp + ): + self._history_current_period.append( + HistoryState(new_state.state, new_state.last_changed_timestamp) + ) + self._previous_run_before_start = False seconds_matched, match_count = self._async_compute_seconds_and_changes( diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index d14d98bcf50..d2938896f92 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -113,12 +113,17 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): await self.hive.session.updateData(self.device) self.device = await self.hive.sensor.getSensor(self.device) self.attributes = self.device.get("attributes", {}) - self._attr_is_on = self.device["status"]["state"] + if self.device["hiveType"] != "Connectivity": - self._attr_available = self.device["deviceData"].get("online") + self._attr_available = ( + self.device["deviceData"].get("online") and "status" in self.device + ) else: self._attr_available = True + if self._attr_available: + self._attr_is_on = self.device["status"].get("state") + class HiveSensorEntity(HiveEntity, BinarySensorEntity): """Hive Sensor Entity.""" diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 7f44f8bbf44..9e83347f098 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -12,5 +12,6 @@ "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "integration_type": "device", "iot_class": "local_push", + "quality_scale": "bronze", "requirements": ["idasen-ha==2.6.3"] } diff --git a/homeassistant/components/idasen_desk/quality_scale.yaml b/homeassistant/components/idasen_desk/quality_scale.yaml index 9aca846e32c..34bf97d9496 100644 --- a/homeassistant/components/idasen_desk/quality_scale.yaml +++ b/homeassistant/components/idasen_desk/quality_scale.yaml @@ -17,9 +17,9 @@ rules: status: exempt comment: | This integration does not provide additional actions. - docs-high-level-description: todo + docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: done diff --git a/homeassistant/components/keba/manifest.json b/homeassistant/components/keba/manifest.json index d86ce053187..6427a30f000 100644 --- a/homeassistant/components/keba/manifest.json +++ b/homeassistant/components/keba/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["keba_kecontact"], "quality_scale": "legacy", - "requirements": ["keba-kecontact==1.1.0"] + "requirements": ["keba-kecontact==1.3.0"] } diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 48f8c726836..bbd2ba5c02d 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -46,9 +46,13 @@ from homeassistant.const import ( CONF_TYPE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, + SERVICE_RELOAD, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.reload import async_integration_yaml_config +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from .const import ( @@ -451,18 +455,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Modbus component.""" if DOMAIN not in config: return True + + async def _reload_config(call: Event | ServiceCall) -> None: + """Reload Modbus.""" + if DOMAIN not in hass.data: + _LOGGER.error("Modbus cannot reload, because it was never loaded") + return + hubs = hass.data[DOMAIN] + for name in hubs: + await hubs[name].async_close() + reset_platforms = async_get_platforms(hass, DOMAIN) + for reset_platform in reset_platforms: + _LOGGER.debug("Reload modbus resetting platform: %s", reset_platform.domain) + await reset_platform.async_reset() + reload_config = await async_integration_yaml_config(hass, DOMAIN) + if not reload_config: + _LOGGER.debug("Modbus not present anymore") + return + _LOGGER.debug("Modbus reloading") + await async_modbus_setup(hass, reload_config) + + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) + return await async_modbus_setup( hass, config, ) - - -async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> None: - """Release modbus resources.""" - if DOMAIN not in hass.data: - _LOGGER.error("Modbus cannot reload, because it was never loaded") - return - _LOGGER.debug("Modbus reloading") - hubs = hass.data[DOMAIN] - for name in hubs: - await hubs[name].async_close() diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index efce44d7979..8c8a879ead6 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -34,7 +34,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType from .const import ( @@ -125,8 +124,6 @@ async def async_modbus_setup( ) -> bool: """Set up Modbus component.""" - await async_setup_reload_service(hass, DOMAIN, [DOMAIN]) - if config[DOMAIN]: config[DOMAIN] = check_config(hass, config[DOMAIN]) if not config[DOMAIN]: diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py index 0bc1b117a70..ae4e8986816 100644 --- a/homeassistant/components/niko_home_control/__init__.py +++ b/homeassistant/components/niko_home_control/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers import entity_registry as er from .const import _LOGGER -PLATFORMS: list[Platform] = [Platform.LIGHT] +PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] type NikoHomeControlConfigEntry = ConfigEntry[NHCController] diff --git a/homeassistant/components/niko_home_control/cover.py b/homeassistant/components/niko_home_control/cover.py new file mode 100644 index 00000000000..51e2a8a702d --- /dev/null +++ b/homeassistant/components/niko_home_control/cover.py @@ -0,0 +1,54 @@ +"""Cover Platform for Niko Home Control.""" + +from __future__ import annotations + +from typing import Any + +from nhc.cover import NHCCover + +from homeassistant.components.cover import CoverEntity, CoverEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import NikoHomeControlConfigEntry +from .entity import NikoHomeControlEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NikoHomeControlConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Niko Home Control cover entry.""" + controller = entry.runtime_data + + async_add_entities( + NikoHomeControlCover(cover, controller, entry.entry_id) + for cover in controller.covers + ) + + +class NikoHomeControlCover(NikoHomeControlEntity, CoverEntity): + """Representation of a Niko Cover.""" + + _attr_name = None + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + _action: NHCCover + + def open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + self._action.open() + + def close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + self._action.close() + + def stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + self._action.stop() + + def update_state(self): + """Update HA state.""" + self._attr_is_closed = self._action.state == 0 diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 3b093eb06ac..eda39821d5c 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -1,14 +1,7 @@ { "domain": "overkiz", "name": "Overkiz", - "codeowners": [ - "@imicknl", - "@vlebourl", - "@tetienne", - "@nyroDev", - "@tronix117", - "@alexfp14" - ], + "codeowners": ["@imicknl"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 398788f1f9f..058f2aefb3b 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -16,6 +16,7 @@ from peblar import ( PeblarEVInterface, PeblarMeter, PeblarSystem, + PeblarSystemInformation, PeblarUserConfiguration, PeblarVersions, ) @@ -24,7 +25,6 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from tests.components.peblar.conftest import PeblarSystemInformation from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index a6fa3acf457..f6a228ca236 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -20,7 +20,7 @@ "data_description": { "password": "[%key:component::peblar::config::step::user::data_description::password%]" }, - "description": "Reauthenticate with your Peblar RV charger.\n\nTo do so, you will need to enter your new password you use to log into Peblar's device web interface." + "description": "Reauthenticate with your Peblar EV charger.\n\nTo do so, you will need to enter your new password you use to log into Peblar EV charger' web interface." }, "reconfigure": { "data": { @@ -31,7 +31,7 @@ "host": "[%key:component::peblar::config::step::user::data_description::host%]", "password": "[%key:component::peblar::config::step::user::data_description::password%]" }, - "description": "Reconfigure your Peblar EV charger.\n\nThis allows you to change the IP address of your Peblar charger and the password you use to log into the Peblar device' web interface." + "description": "Reconfigure your Peblar EV charger.\n\nThis allows you to change the IP address of your Peblar EV charger and the password you use to log into its web interface." }, "user": { "data": { @@ -39,10 +39,10 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Peblar charger on your home network.", - "password": "The same password as you use to log in to the Peblar device' local web interface." + "host": "The hostname or IP address of your Peblar EV charger on your home network.", + "password": "The same password as you use to log in to the Peblar EV charger' local web interface." }, - "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need to get the IP address of your Peblar charger and the password you use to log into the Peblar device' web interface.\n\nHome Assistant will automatically configure your Peblar charger for use with Home Assistant." + "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need to get the IP address of your Peblar EV charger and the password you use to log into its web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." }, "zeroconf_confirm": { "data": { @@ -51,7 +51,7 @@ "data_description": { "password": "[%key:component::peblar::config::step::user::data_description::password%]" }, - "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need the password you use to log into the Peblar device' web interface.\n\nHome Assistant will automatically configure your Peblar charger for use with Home Assistant." + "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need the password you use to log into the Peblar EV charger' web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." } } }, @@ -164,13 +164,13 @@ }, "exceptions": { "authentication_error": { - "message": "An authentication failure occurred while communicating with the Peblar device." + "message": "An authentication failure occurred while communicating with the Peblar EV charger." }, "communication_error": { - "message": "An error occurred while communicating with the Peblar device: {error}" + "message": "An error occurred while communicating with the Peblar EV charger: {error}" }, "unknown_error": { - "message": "An unknown error occurred while communicating with the Peblar device: {error}" + "message": "An unknown error occurred while communicating with the Peblar EV charger: {error}" } } } diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 80f5be974e1..ae60d4d7452 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -1,7 +1,7 @@ { "domain": "plugwise", "name": "Plugwise", - "codeowners": ["@CoMPaTech", "@bouwew", "@frenck"], + "codeowners": ["@CoMPaTech", "@bouwew"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plugwise", "integration_type": "hub", diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 8564827d839..a40760c67f4 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,7 +28,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.event_type import EventType -from . import entity_registry, websocket_api +# Pre-import backup to avoid it being imported +# later when the import executor is busy and delaying +# startup +from . import ( + backup, # noqa: F401 + entity_registry, + websocket_api, +) from .const import ( # noqa: F401 CONF_DB_INTEGRITY_CHECK, DOMAIN, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 53152131bdb..283c1d42e89 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -115,7 +115,7 @@ }, "firmware_update": { "title": "Reolink firmware update required", - "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The latest firmware can be downloaded from the [Reolink download center]({download_link})." + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})." }, "hdr_switch_deprecated": { "title": "Reolink HDR switch deprecated", diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index c0cff8d511b..e44d9b18ae1 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -93,7 +93,7 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): ) self._configured_heat_modes.append(HEAT_MODE.HEATER) self._attr_preset_modes = [ - HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes + HEAT_MODE(mode_num).name.lower() for mode_num in self._configured_heat_modes ] self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] @@ -137,8 +137,8 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): def preset_mode(self) -> str: """Return current/last preset mode.""" if self.hvac_mode == HVACMode.OFF: - return HEAT_MODE(self._last_preset).title - return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title + return HEAT_MODE(self._last_preset).name.lower() + return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).name.lower() async def async_set_temperature(self, **kwargs: Any) -> None: """Change the setpoint of the heater.""" diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 6ae6e802859..df6c5ef7acb 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -189,8 +189,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [ data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), key=VALUE.ORP_DOSING_STATE, device_class=SensorDeviceClass.ENUM, - options=["Dosing", "Mixing", "Monitoring"], - value_mod=lambda val: DOSE_STATE(val).title, + options=["dosing", "mixing", "monitoring"], + value_mod=lambda val: DOSE_STATE(val).name.lower(), translation_key="chem_dose_state", translation_placeholders={"chem": "ORP"}, ), @@ -217,8 +217,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [ data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), key=VALUE.PH_DOSING_STATE, device_class=SensorDeviceClass.ENUM, - options=["Dosing", "Mixing", "Monitoring"], - value_mod=lambda val: DOSE_STATE(val).title, + options=["dosing", "mixing", "monitoring"], + value_mod=lambda val: DOSE_STATE(val).name.lower(), translation_key="chem_dose_state", translation_placeholders={"chem": "pH"}, ), diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index da5e3156592..1f5695d6613 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -3,8 +3,9 @@ "service_config_entry_name": "Config entry", "service_config_entry_description": "The config entry to use for this action.", "climate_preset_solar": "Solar", - "climate_preset_solar_prefered": "Solar Prefered", - "climate_preset_heater": "Heater" + "climate_preset_solar_preferred": "Solar Preferred", + "climate_preset_heater": "Heater", + "climate_preset_dont_change": "Don't Change" }, "config": { "flow_title": "{name}", @@ -133,10 +134,30 @@ }, "climate": { "body_0": { - "name": "Pool heat" + "name": "Pool heat", + "state_attributes": { + "preset_mode": { + "state": { + "solar": "[%key:component::screenlogic::common::climate_preset_solar%]", + "solar_preferred": "[%key:component::screenlogic::common::climate_preset_solar_preferred%]", + "heater": "[%key:component::screenlogic::common::climate_preset_heater%]", + "dont_change": "[%key:component::screenlogic::common::climate_preset_dont_change%]" + } + } + } }, "body_1": { - "name": "Spa heat" + "name": "Spa heat", + "state_attributes": { + "preset_mode": { + "state": { + "solar": "[%key:component::screenlogic::common::climate_preset_solar%]", + "solar_preferred": "[%key:component::screenlogic::common::climate_preset_solar_preferred%]", + "heater": "[%key:component::screenlogic::common::climate_preset_heater%]", + "dont_change": "[%key:component::screenlogic::common::climate_preset_dont_change%]" + } + } + } } }, "number": { @@ -191,7 +212,12 @@ "name": "[%key:component::screenlogic::entity::number::salt_tds_ppm::name%]" }, "chem_dose_state": { - "name": "{chem} dosing state" + "name": "{chem} dosing state", + "state": { + "dosing": "Dosing", + "mixing": "Mixing", + "monitoring": "Monitoring" + } }, "chem_last_dose_time": { "name": "{chem} last dose time" diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 3762b16e58b..4068507ed14 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -49,7 +49,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Subaru from a config entry.""" config = entry.data - websession = aiohttp_client.async_get_clientsession(hass) + websession = aiohttp_client.async_create_clientsession(hass) try: controller = SubaruAPI( websession, diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index e7232d0f87c..303a3250edf 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -18,7 +18,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ENERGY_HISTORY_FIELDS, LOGGER from .helpers import flatten -VEHICLE_INTERVAL = timedelta(seconds=30) +VEHICLE_INTERVAL = timedelta(seconds=60) VEHICLE_WAIT = timedelta(minutes=15) ENERGY_LIVE_INTERVAL = timedelta(seconds=30) ENERGY_INFO_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index e14ecf01749..f3a7e7a7ce7 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -96,6 +96,7 @@ class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntit entity_description: TPLinkBinarySensorEntityDescription @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_is_on = cast(bool | None, self._feature.value) + return True diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 6e0d34864d9..753efcf89f4 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -52,15 +52,19 @@ BUTTON_DESCRIPTIONS: Final = [ ), TPLinkButtonEntityDescription( key="pan_left", + available_fn=lambda dev: dev.is_on, ), TPLinkButtonEntityDescription( key="pan_right", + available_fn=lambda dev: dev.is_on, ), TPLinkButtonEntityDescription( key="tilt_up", + available_fn=lambda dev: dev.is_on, ), TPLinkButtonEntityDescription( key="tilt_down", + available_fn=lambda dev: dev.is_on, ), ] @@ -100,5 +104,6 @@ class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity): """Execute action.""" await self._feature.set_value(True) - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """No need to update anything.""" + return self.entity_description.available_fn(self._device) diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py index 5ed279909d6..4a6859a8414 100644 --- a/homeassistant/components/tplink/camera.py +++ b/homeassistant/components/tplink/camera.py @@ -7,7 +7,7 @@ import time from aiohttp import web from haffmpeg.camera import CameraMjpeg -from kasa import Credentials, Device, Module +from kasa import Credentials, Device, Module, StreamResolution from kasa.smartcam.modules import Camera as CameraModule from homeassistant.components import ffmpeg, stream @@ -40,6 +40,7 @@ CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = ( TPLinkCameraEntityDescription( key="live_view", translation_key="live_view", + available_fn=lambda dev: dev.is_on, ), ) @@ -95,7 +96,9 @@ class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera): """Initialize a TPlink camera.""" self.entity_description = description self._camera_module = camera_module - self._video_url = camera_module.stream_rtsp_url(camera_credentials) + self._video_url = camera_module.stream_rtsp_url( + camera_credentials, stream_resolution=StreamResolution.SD + ) self._image: bytes | None = None super().__init__(device, coordinator, parent=parent) Camera.__init__(self) @@ -108,16 +111,19 @@ class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera): def _get_unique_id(self) -> str: """Return unique ID for the entity.""" - return f"{legacy_device_id(self._device)}-{self.entity_description}" + return f"{legacy_device_id(self._device)}-{self.entity_description.key}" @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_is_on = self._camera_module.is_on + return self.entity_description.available_fn(self._device) async def stream_source(self) -> str | None: """Return the source of the stream.""" - return self._video_url + return self._camera_module.stream_rtsp_url( + self._camera_credentials, stream_resolution=StreamResolution.HD + ) async def _async_check_stream_auth(self, video_url: str) -> None: """Check for an auth error and start reauth flow.""" @@ -148,7 +154,7 @@ class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera): return self._image # Don't try to capture a new image if a stream is running - if (self.stream and self.stream.available) or self._http_mpeg_stream_running: + if self._http_mpeg_stream_running: return self._image if self._can_stream and (video_url := self._video_url): diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index 75a6599959d..f53a0d093ac 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -113,7 +113,7 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): await self._state_feature.set_value(False) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_current_temperature = cast(float | None, self._temp_feature.value) self._attr_target_temperature = cast(float | None, self._target_feature.value) @@ -131,11 +131,12 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): self._mode_feature.value, ) self._attr_hvac_action = HVACAction.OFF - return + return True self._attr_hvac_action = STATE_TO_ACTION[ cast(ThermostatState, self._mode_feature.value) ] + return True def _get_unique_id(self) -> str: """Return unique id.""" diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index d7b02b80177..935857e5db1 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -89,6 +89,7 @@ class TPLinkFeatureEntityDescription(EntityDescription): """Base class for a TPLink feature based entity description.""" deprecated_info: DeprecatedInfo | None = None + available_fn: Callable[[Device], bool] = lambda _: True @dataclass(frozen=True, kw_only=True) @@ -96,6 +97,7 @@ class TPLinkModuleEntityDescription(EntityDescription): """Base class for a TPLink module based entity description.""" deprecated_info: DeprecatedInfo | None = None + available_fn: Callable[[Device], bool] = lambda _: True def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( @@ -207,15 +209,18 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB @abstractmethod @callback - def _async_update_attrs(self) -> None: - """Platforms implement this to update the entity internals.""" + def _async_update_attrs(self) -> bool: + """Platforms implement this to update the entity internals. + + The return value is used to the set the entity available attribute. + """ raise NotImplementedError @callback def _async_call_update_attrs(self) -> None: """Call update_attrs and make entity unavailable on errors.""" try: - self._async_update_attrs() + available = self._async_update_attrs() except Exception as ex: # noqa: BLE001 if self._attr_available: _LOGGER.warning( @@ -226,7 +231,7 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB ) self._attr_available = False else: - self._attr_available = True + self._attr_available = available @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py index 64ad01eb671..a1e62e4ed69 100644 --- a/homeassistant/components/tplink/fan.py +++ b/homeassistant/components/tplink/fan.py @@ -106,7 +106,7 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): await self.fan_module.set_fan_speed_level(value_in_range) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" fan_speed = self.fan_module.fan_speed_level self._attr_is_on = fan_speed != 0 @@ -114,3 +114,4 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): self._attr_percentage = ranged_value_to_percentage(SPEED_RANGE, fan_speed) else: self._attr_percentage = None + return True diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index f3207d754f3..91e2a784af2 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -335,7 +335,7 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): return ColorMode.HS @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" light_module = self._light_module self._attr_is_on = light_module.state.light_on is True @@ -349,6 +349,8 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): hue, saturation, _ = light_module.hsv self._attr_hs_color = hue, saturation + return True + class TPLinkLightEffectEntity(TPLinkLightEntity): """Representation of a TPLink Smart Light Strip.""" @@ -368,7 +370,7 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" super()._async_update_attrs() effect_module = self._effect_module @@ -381,6 +383,7 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): self._attr_effect_list = effect_list else: self._attr_effect_list = None + return True @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 489805029ea..3f7fa9c3e0f 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -114,6 +114,7 @@ class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): await self._feature.set_value(int(value)) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_native_value = cast(float | None, self._feature.value) + return True diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 3755a1d0be2..5dd8e54fca8 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -91,6 +91,7 @@ class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity): await self._feature.set_value(option) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_current_option = cast(str | None, self._feature.value) + return True diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 8b7351f8d7d..da4bf72122d 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -153,7 +153,7 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): entity_description: TPLinkSensorEntityDescription @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" value = self._feature.value if value is not None and self._feature.precision_hint is not None: @@ -171,3 +171,4 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): # Map to homeassistant units and fallback to upstream one if none found if (unit := self._feature.unit) is not None: self._attr_native_unit_of_measurement = UNIT_MAPPING.get(unit, unit) + return True diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index c4ece56f0f6..141ea696358 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -56,6 +56,7 @@ class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity): await self._alarm_module.stop() @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_is_on = self._alarm_module.active + return True diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 28dedc7e7a1..7a879fb3c70 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -109,6 +109,7 @@ class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity): await self._feature.set_value(False) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_is_on = cast(bool | None, self._feature.value) + return True diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index b53e6fa27d8..96ee50a38c9 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -1,7 +1,7 @@ { "domain": "tuya", "name": "Tuya", - "codeowners": ["@Tuya", "@zlinoliver", "@frenck"], + "codeowners": ["@Tuya", "@zlinoliver"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [ diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 90981c426f9..94c823888b7 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.12.2"], + "requirements": ["velbus-aio==2024.12.3"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index 69ff6ccb1fa..8d09867a46e 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -53,7 +53,9 @@ LIGHT_CAPABILITIES_COLOR_MODE_MAPPING: dict[LightCapability, list[ColorMode]] = ColorMode.COLOR_TEMP, ], LightCapability.RGB_COLOR | LightCapability.COLOR_TEMPERATURE: [ - ColorMode.RGBWW, + # Technically this is RGBWW but wled does not support RGBWW colors (with warm and cold white separately) + # but rather RGB + CCT which does not have a direct mapping in HA + ColorMode.RGB, ], LightCapability.WHITE_CHANNEL | LightCapability.COLOR_TEMPERATURE: [ ColorMode.COLOR_TEMP, diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 6ab46cea069..5282a34903a 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -10,11 +10,7 @@ from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - config_validation as cv, -) +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from . import api from .const import DOMAIN @@ -40,9 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth( - aiohttp_client.async_get_clientsession(hass), session - ) + auth = api.AsyncConfigEntryAuth(session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index a0c2d4cfb16..d4c47e4cc39 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -1,24 +1,20 @@ """API for xbox bound to Home Assistant OAuth.""" -from aiohttp import ClientSession from xbox.webapi.authentication.manager import AuthenticationManager from xbox.webapi.authentication.models import OAuth2TokenResponse +from xbox.webapi.common.signed_session import SignedSession -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__( - self, - websession: ClientSession, - oauth_session: config_entry_oauth2_flow.OAuth2Session, - ) -> None: + def __init__(self, oauth_session: OAuth2Session) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(websession, "", "", "") + super().__init__(SignedSession(), "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() diff --git a/homeassistant/const.py b/homeassistant/const.py index eed8d73a4ee..4c4d2fa90c2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 1 +MINOR_VERSION: Final = 2 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ad4af2f024c..005fb7f694f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2475,6 +2475,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "harvey": { + "name": "Harvey", + "integration_type": "virtual", + "supported_by": "aquacell" + }, "hassio": { "name": "Home Assistant Supervisor", "integration_type": "hub", diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index a3eb19657e8..4ded7444989 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -175,6 +175,9 @@ async def async_process_integration_platforms( else: integration_platforms = hass.data[DATA_INTEGRATION_PLATFORMS] + # Tell the loader that it should try to pre-load the integration + # for any future components that are loaded so we can reduce the + # amount of import executor usage. async_register_preload_platform(hass, platform_name) top_level_components = hass.config.top_level_components.copy() process_job = HassJob( @@ -187,10 +190,6 @@ async def async_process_integration_platforms( integration_platform = IntegrationPlatform( platform_name, process_job, top_level_components ) - # Tell the loader that it should try to pre-load the integration - # for any future components that are loaded so we can reduce the - # amount of import executor usage. - async_register_preload_platform(hass, platform_name) integration_platforms.append(integration_platform) if not top_level_components: return diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 78c89b94765..93dc7677bba 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -65,20 +65,20 @@ _LOGGER = logging.getLogger(__name__) # This list can be extended by calling async_register_preload_platform # BASE_PRELOAD_PLATFORMS = [ + "backup", "config", "config_flow", "diagnostics", "energy", "group", - "logbook", "hardware", "intent", + "logbook", "media_source", "recorder", "repairs", "system_health", "trigger", - "backup", ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6863da50af3..620eb4c00ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,6 +25,7 @@ bluetooth-data-tools==1.20.0 cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 +cronsim==2.6 cryptography==44.0.0 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 @@ -34,11 +35,11 @@ habluetooth==3.6.0 hass-nabucasa==0.87.0 hassil==2.0.5 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241127.8 +home-assistant-frontend==20241223.1 home-assistant-intents==2024.12.20 httpx==0.27.2 ifaddr==0.2.0 -Jinja2==3.1.4 +Jinja2==3.1.5 lru-dict==1.3.0 mutagen==1.47.0 orjson==3.10.12 diff --git a/pyproject.toml b/pyproject.toml index 369f6f40921..2ec1e1cea38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0.dev0" +version = "2025.2.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -42,6 +42,7 @@ dependencies = [ "bcrypt==4.2.0", "certifi>=2021.5.30", "ciso8601==2.3.2", + "cronsim==2.6", "fnv-hash-fast==1.0.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration @@ -51,7 +52,7 @@ dependencies = [ "httpx==0.27.2", "home-assistant-bluetooth==1.13.0", "ifaddr==0.2.0", - "Jinja2==3.1.4", + "Jinja2==3.1.5", "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. @@ -903,6 +904,7 @@ mark-parentheses = false [tool.ruff.lint.flake8-tidy-imports.banned-api] "async_timeout".msg = "use asyncio.timeout instead" "pytz".msg = "use zoneinfo instead" +"tests".msg = "You should not import tests" [tool.ruff.lint.isort] force-sort-within-sections = true diff --git a/requirements.txt b/requirements.txt index 82405dc44ef..0d898edcd4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,12 +18,13 @@ awesomeversion==24.6.0 bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 +cronsim==2.6 fnv-hash-fast==1.0.2 hass-nabucasa==0.87.0 httpx==0.27.2 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 -Jinja2==3.1.4 +Jinja2==3.1.5 lru-dict==1.3.0 PyJWT==2.10.1 cryptography==44.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 965b5673961..1409aae12f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -470,7 +470,7 @@ anthropic==0.31.2 apple_weatherkit==1.1.3 # homeassistant.components.apprise -apprise==1.9.0 +apprise==1.9.1 # homeassistant.components.aprs aprslib==0.7.2 @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241127.8 +home-assistant-frontend==20241223.1 # homeassistant.components.conversation home-assistant-intents==2024.12.20 @@ -1248,7 +1248,7 @@ justnimbus==0.7.4 kaiterra-async-client==1.0.0 # homeassistant.components.keba -keba-kecontact==1.1.0 +keba-kecontact==1.3.0 # homeassistant.components.kegtron kegtron-ble==0.4.0 @@ -2951,7 +2951,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.12.2 +velbus-aio==2024.12.3 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b59be622158..fcc06732914 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -443,7 +443,7 @@ anthropic==0.31.2 apple_weatherkit==1.1.3 # homeassistant.components.apprise -apprise==1.9.0 +apprise==1.9.1 # homeassistant.components.aprs aprslib==0.7.2 @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241127.8 +home-assistant-frontend==20241223.1 # homeassistant.components.conversation home-assistant-intents==2024.12.20 @@ -2367,7 +2367,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.12.2 +velbus-aio==2024.12.3 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/script/ruff.toml b/script/ruff.toml index c32b39022cc..a14712ec142 100644 --- a/script/ruff.toml +++ b/script/ruff.toml @@ -5,3 +5,7 @@ extend = "../pyproject.toml" forced-separate = [ "tests", ] + +[lint.flake8-tidy-imports.banned-api] +"async_timeout".msg = "use asyncio.timeout instead" +"pytz".msg = "use zoneinfo instead" diff --git a/tests/components/aemet/snapshots/test_diagnostics.ambr b/tests/components/aemet/snapshots/test_diagnostics.ambr index 54546507dfa..0e40cce1b86 100644 --- a/tests/components/aemet/snapshots/test_diagnostics.ambr +++ b/tests/components/aemet/snapshots/test_diagnostics.ambr @@ -17,6 +17,7 @@ 'entry_id': '7442b231f139e813fc1939281123f220', 'minor_version': 1, 'options': dict({ + 'radar_updates': True, }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, @@ -33,6 +34,12 @@ ]), }), 'lib': dict({ + 'radar': dict({ + 'datetime': '2021-01-09T11:34:06.448809+00:00', + 'id': 'national', + 'image-bytes': '**REDACTED**', + 'image-type': 'image/gif', + }), 'station': dict({ 'altitude': 667.0, 'coordinates': '**REDACTED**', diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index 0f3491b1c43..3dd8303c8cb 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -6,7 +6,11 @@ from aemet_opendata.exceptions import AuthError from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.aemet.const import CONF_STATION_UPDATES, DOMAIN +from homeassistant.components.aemet.const import ( + CONF_RADAR_UPDATES, + CONF_STATION_UPDATES, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant @@ -61,13 +65,20 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @pytest.mark.parametrize( - ("user_input", "expected"), [({}, True), ({CONF_STATION_UPDATES: False}, False)] + ("user_input", "expected"), + [ + ({}, {CONF_RADAR_UPDATES: False, CONF_STATION_UPDATES: True}), + ( + {CONF_RADAR_UPDATES: False, CONF_STATION_UPDATES: False}, + {CONF_RADAR_UPDATES: False, CONF_STATION_UPDATES: False}, + ), + ], ) async def test_form_options( hass: HomeAssistant, freezer: FrozenDateTimeFactory, user_input: dict[str, bool], - expected: bool, + expected: dict[str, bool], ) -> None: """Test the form options.""" @@ -98,7 +109,8 @@ async def test_form_options( assert result["type"] is FlowResultType.CREATE_ENTRY assert entry.options == { - CONF_STATION_UPDATES: expected, + CONF_RADAR_UPDATES: expected[CONF_RADAR_UPDATES], + CONF_STATION_UPDATES: expected[CONF_STATION_UPDATES], } await hass.async_block_till_done() diff --git a/tests/components/aemet/test_image.py b/tests/components/aemet/test_image.py new file mode 100644 index 00000000000..4321daac883 --- /dev/null +++ b/tests/components/aemet/test_image.py @@ -0,0 +1,22 @@ +"""The image tests for the AEMET OpenData platform.""" + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + + +async def test_aemet_create_images( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test creation of AEMET images.""" + + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) + + state = hass.states.get("image.aemet_weather_radar") + assert state is not None + assert state.state == "2021-01-09T11:34:06.448809+00:00" diff --git a/tests/components/aemet/util.py b/tests/components/aemet/util.py index 162ee657513..0361ca9e6d8 100644 --- a/tests/components/aemet/util.py +++ b/tests/components/aemet/util.py @@ -3,9 +3,9 @@ from typing import Any from unittest.mock import patch -from aemet_opendata.const import ATTR_DATA +from aemet_opendata.const import ATTR_BYTES, ATTR_DATA, ATTR_TIMESTAMP, ATTR_TYPE -from homeassistant.components.aemet.const import DOMAIN +from homeassistant.components.aemet.const import CONF_RADAR_UPDATES, DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant @@ -19,6 +19,14 @@ FORECAST_HOURLY_DATA_MOCK = { ATTR_DATA: load_json_value_fixture("aemet/town-28065-forecast-hourly-data.json"), } +RADAR_DATA_MOCK = { + ATTR_DATA: { + ATTR_TYPE: "image/gif", + ATTR_BYTES: bytes([0]), + }, + ATTR_TIMESTAMP: "2021-01-09T11:34:06.448809+00:00", +} + STATION_DATA_MOCK = { ATTR_DATA: load_json_value_fixture("aemet/station-3195-data.json"), } @@ -53,6 +61,9 @@ def mock_api_call(cmd: str, fetch_data: bool = False) -> dict[str, Any]: return FORECAST_DAILY_DATA_MOCK if cmd == "prediccion/especifica/municipio/horaria/28065": return FORECAST_HOURLY_DATA_MOCK + if cmd == "red/radar/nacional": + return RADAR_DATA_MOCK + return {} @@ -69,6 +80,9 @@ async def async_init_integration(hass: HomeAssistant): }, entry_id="7442b231f139e813fc1939281123f220", unique_id="40.30403754--3.72935236", + options={ + CONF_RADAR_UPDATES: True, + }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/apsystems/test_number.py b/tests/components/apsystems/test_number.py index 5868bd3da34..912759b4a17 100644 --- a/tests/components/apsystems/test_number.py +++ b/tests/components/apsystems/test_number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -46,6 +46,17 @@ async def test_number( await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "50" + mock_apsystems.get_max_power.side_effect = TimeoutError() + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50.1}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE @pytest.mark.usefixtures("mock_apsystems") diff --git a/tests/components/ecovacs/fixtures/devices/qhe2o2/device.json b/tests/components/ecovacs/fixtures/devices/qhe2o2/device.json new file mode 100644 index 00000000000..0fbaaf896ee --- /dev/null +++ b/tests/components/ecovacs/fixtures/devices/qhe2o2/device.json @@ -0,0 +1,29 @@ +{ + "did": "8516fbb1-17f1-4194-0000001", + "name": "E1234567890000000003", + "class": "qhe2o2", + "resource": "NHl5", + "company": "eco-ng", + "bindTs": 1734792100110, + "service": { + "jmq": "jmq-ngiot-eu.dc.ww.ecouser.net", + "mqs": "api-ngiot.dc-eu.ww.ecouser.net" + }, + "deviceName": "DEEBOT N20 PRO PLUS", + "icon": "https: //portal-ww.ecouser.net/api/pim/file/get/0000001", + "ota": true, + "UILogicId": "y2_ww_h_y2h5", + "materialNo": "110-2406-0001", + "pid": "0000001", + "product_category": "DEEBOT", + "model": "Y2_AES_BLACK_INT", + "updateInfo": { + "needUpdate": false, + "changeLog": "" + }, + "nick": "Dusty", + "homeId": "1234567890abcdef12345678", + "homeSort": 1, + "status": 1, + "otaUpgrade": {} +} diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr index efae8896962..f21d019a7b1 100644 --- a/tests/components/ecovacs/snapshots/test_button.ambr +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -91,6 +91,328 @@ 'state': '2024-01-01T00:00:00+00:00', }) # --- +# name: test_buttons[qhe2o2][button.dusty_empty_dustbin:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.dusty_empty_dustbin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Empty dustbin', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'station_action_empty_dustbin', + 'unique_id': '8516fbb1-17f1-4194-0000001_station_action_empty_dustbin', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_empty_dustbin:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Empty dustbin', + }), + 'context': , + 'entity_id': 'button.dusty_empty_dustbin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_relocate:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_relocate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relocate', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relocate', + 'unique_id': '8516fbb1-17f1-4194-0000001_relocate', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_relocate:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Relocate', + }), + 'context': , + 'entity_id': 'button.dusty_relocate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_filter', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset filter lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_filter_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_main_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_main_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset main brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_brush', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_main_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset main brush lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_main_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_round_mop_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_round_mop_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset round mop lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_round_mop', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_round_mop', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_round_mop_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset round mop lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_round_mop_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_side_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_side_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_side_brush', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_side_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_side_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset side brush lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_side_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_unit_care_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_unit_care_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset unit care lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_unit_care', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_unit_care', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_unit_care_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset unit care lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_unit_care_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- # name: test_buttons[yna5x1][button.ozmo_950_relocate:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index 9c76c00b5b7..755fcda9e7d 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -725,6 +725,781 @@ 'state': 'Testnetwork', }) # --- +# name: test_sensors[qhe2o2][sensor.dusty_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stats_area', + 'unique_id': '8516fbb1-17f1-4194-0000001_stats_area', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Area cleaned', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dusty_area_cleaned', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_battery:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '8516fbb1-17f1-4194-0000001_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_battery:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dusty Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_cleaning_duration:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_cleaning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning duration', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stats_time', + 'unique_id': '8516fbb1-17f1-4194-0000001_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_cleaning_duration:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Dusty Cleaning duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dusty_cleaning_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_error:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error', + 'unique_id': '8516fbb1-17f1-4194-0000001_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_error:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'description': 'NoError: Robot is operational', + 'friendly_name': 'Dusty Error', + }), + 'context': , + 'entity_id': 'sensor.dusty_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_filter', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_filter', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Filter lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_filter_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '56', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_ip_address:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_ip_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IP address', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_ip', + 'unique_id': '8516fbb1-17f1-4194-0000001_network_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_ip_address:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty IP address', + }), + 'context': , + 'entity_id': 'sensor.dusty_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '192.168.0.10', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_main_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_main_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Main brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_brush', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_main_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Main brush lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_main_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_round_mop_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_round_mop_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Round mop lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_round_mop', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_round_mop', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_round_mop_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Round mop lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_round_mop_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_side_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_side_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Side brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_side_brush', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_side_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_side_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Side brush lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_side_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_station_state:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'emptying_dustbin', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_station_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Station state', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'station_state', + 'unique_id': '8516fbb1-17f1-4194-0000001_station_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_station_state:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dusty Station state', + 'options': list([ + 'idle', + 'emptying_dustbin', + ]), + }), + 'context': , + 'entity_id': 'sensor.dusty_station_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'emptying_dustbin', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_total_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_area', + 'unique_id': '8516fbb1-17f1-4194-0000001_total_stats_area', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Total area cleaned', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dusty_total_area_cleaned', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_cleaning_duration:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_total_cleaning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning duration', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_time', + 'unique_id': '8516fbb1-17f1-4194-0000001_total_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_cleaning_duration:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Dusty Total cleaning duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dusty_total_cleaning_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.000', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_cleanings:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_total_cleanings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleanings', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_cleanings', + 'unique_id': '8516fbb1-17f1-4194-0000001_total_stats_cleanings', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_cleanings:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Total cleanings', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.dusty_total_cleanings', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_unit_care_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_unit_care_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unit care lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_unit_care', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_unit_care', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_unit_care_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Unit care lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_unit_care_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_wi_fi_rssi:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_wi_fi_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi RSSI', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_rssi', + 'unique_id': '8516fbb1-17f1-4194-0000001_network_rssi', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_wi_fi_rssi:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Wi-Fi RSSI', + }), + 'context': , + 'entity_id': 'sensor.dusty_wi_fi_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-62', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_ssid', + 'unique_id': '8516fbb1-17f1-4194-0000001_network_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.dusty_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Testnetwork', + }) +# --- # name: test_sensors[yna5x1][sensor.ozmo_950_area_cleaned:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index 4b3068f6cda..65e0b19ea02 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -1,7 +1,12 @@ """Tests for Ecovacs sensors.""" from deebot_client.command import Command -from deebot_client.commands.json import ResetLifeSpan, SetRelocationState +from deebot_client.commands import StationAction +from deebot_client.commands.json import ( + ResetLifeSpan, + SetRelocationState, + station_action, +) from deebot_client.events import LifeSpan import pytest from syrupy import SnapshotAssertion @@ -60,8 +65,38 @@ def platforms() -> Platform | list[Platform]: ), ], ), + ( + "qhe2o2", + [ + ("button.dusty_relocate", SetRelocationState()), + ( + "button.dusty_reset_main_brush_lifespan", + ResetLifeSpan(LifeSpan.BRUSH), + ), + ( + "button.dusty_reset_filter_lifespan", + ResetLifeSpan(LifeSpan.FILTER), + ), + ( + "button.dusty_reset_side_brush_lifespan", + ResetLifeSpan(LifeSpan.SIDE_BRUSH), + ), + ( + "button.dusty_reset_unit_care_lifespan", + ResetLifeSpan(LifeSpan.UNIT_CARE), + ), + ( + "button.dusty_reset_round_mop_lifespan", + ResetLifeSpan(LifeSpan.ROUND_MOP), + ), + ( + "button.dusty_empty_dustbin", + station_action.StationAction(StationAction.EMPTY_DUSTBIN), + ), + ], + ), ], - ids=["yna5x1", "5xu9h3"], + ids=["yna5x1", "5xu9h3", "qhe2o2"], ) async def test_buttons( hass: HomeAssistant, diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 53c57999776..5bcd8385320 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -11,6 +11,7 @@ from deebot_client.events import ( NetworkInfoEvent, StatsEvent, TotalStatsEvent, + station, ) import pytest from syrupy import SnapshotAssertion @@ -45,6 +46,7 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): event_bus.notify(LifeSpanEvent(LifeSpan.FILTER, 56, 40 * 60)) event_bus.notify(LifeSpanEvent(LifeSpan.SIDE_BRUSH, 40, 20 * 60)) event_bus.notify(ErrorEvent(0, "NoError: Robot is operational")) + event_bus.notify(station.StationEvent(station.State.EMPTYING)) await block_till_done(hass, event_bus) @@ -87,8 +89,29 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): "sensor.goat_g1_error", ], ), + ( + "qhe2o2", + [ + "sensor.dusty_area_cleaned", + "sensor.dusty_cleaning_duration", + "sensor.dusty_total_area_cleaned", + "sensor.dusty_total_cleaning_duration", + "sensor.dusty_total_cleanings", + "sensor.dusty_battery", + "sensor.dusty_ip_address", + "sensor.dusty_wi_fi_rssi", + "sensor.dusty_wi_fi_ssid", + "sensor.dusty_station_state", + "sensor.dusty_main_brush_lifespan", + "sensor.dusty_filter_lifespan", + "sensor.dusty_side_brush_lifespan", + "sensor.dusty_unit_care_lifespan", + "sensor.dusty_round_mop_lifespan", + "sensor.dusty_error", + ], + ), ], - ids=["yna5x1", "5xu9h3"], + ids=["yna5x1", "5xu9h3", "qhe2o2"], ) async def test_sensors( hass: HomeAssistant, @@ -99,7 +122,7 @@ async def test_sensors( entity_ids: list[str], ) -> None: """Test that sensor entity snapshots match.""" - assert entity_ids == hass.states.async_entity_ids() + assert hass.states.async_entity_ids() == entity_ids for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert state.state == STATE_UNKNOWN diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index d60203676e6..3039612d1a0 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -1465,6 +1465,105 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None assert hass.states.get("sensor.sensor4").state == "50.0" +async def test_state_change_during_window_rollover( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test when the tracked sensor and the start/end window change during the same update.""" + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=23, minute=0, second=0, microsecond=0) + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=start_time - timedelta(hours=11), + last_updated=start_time - timedelta(hours=11), + ), + ] + } + + # The test begins at 23:00, and queries from the database that the sensor has been on since 12:00. + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(start_time), + ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor1", + "state": "on", + "start": "{{ today_at() }}", + "end": "{{ now() }}", + "type": "time", + } + ] + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "sensor.sensor1") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "11.0" + + # Advance 59 minutes, to record the last minute update just before midnight, just like a real system would do. + t2 = start_time + timedelta(minutes=59, microseconds=300) + with freeze_time(t2): + async_fire_time_changed(hass, t2) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "11.98" + + # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. The sensor will then query the database for updates, + # and will see that the sensor is ON starting from midnight. + t3 = t2 + timedelta(minutes=1) + + def _fake_states_t3(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=t3.replace(hour=0, minute=0, second=0, microsecond=0), + last_updated=t3.replace(hour=0, minute=0, second=0, microsecond=0), + ), + ] + } + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states_t3, + ), + freeze_time(t3), + ): + # The sensor turns off around this time, before the sensor does its normal polled update. + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get("sensor.sensor1").state == "0.0" + + # More time passes, and the history stats does a polled update again. It should be 0 since the sensor has been off since midnight. + t4 = t3 + timedelta(minutes=10) + with freeze_time(t4): + async_fire_time_changed(hass, t4) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "0.0" + + @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii"]) async def test_end_time_with_microseconds_zeroed( time_zone: str, diff --git a/tests/components/modbus/fixtures/configuration_2.yaml b/tests/components/modbus/fixtures/configuration_2.yaml new file mode 100644 index 00000000000..3f7b062c4cb --- /dev/null +++ b/tests/components/modbus/fixtures/configuration_2.yaml @@ -0,0 +1,12 @@ +modbus: + type: "tcp" + host: "testHost" + port: 5001 + name: "testModbus" + sensors: + - name: "dummy" + address: 117 + slave: 0 + - name: "dummy_2" + address: 118 + slave: 1 diff --git a/tests/components/modbus/fixtures/configuration_empty.yaml b/tests/components/modbus/fixtures/configuration_empty.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 0cfa7ba8b24..5dd3f6e9033 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -25,7 +25,6 @@ import voluptuous as vol from homeassistant import config as hass_config from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.modbus import async_reset_platform from homeassistant.components.modbus.const import ( ATTR_ADDRESS, ATTR_HUB, @@ -1159,22 +1158,61 @@ async def test_integration_reload( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus, - freezer: FrozenDateTimeFactory, ) -> None: """Run test for integration reload.""" caplog.set_level(logging.DEBUG) caplog.clear() - yaml_path = get_fixture_path("configuration.yaml", "modbus") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + yaml_path = get_fixture_path("configuration.yaml", DOMAIN) with mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) await hass.async_block_till_done() - for _ in range(4): - freezer.tick(timedelta(seconds=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() assert "Modbus reloading" in caplog.text + state_sensor_1 = hass.states.get("sensor.dummy") + state_sensor_2 = hass.states.get("sensor.dummy_2") + assert state_sensor_1 + assert not state_sensor_2 + + caplog.clear() + yaml_path = get_fixture_path("configuration_2.yaml", DOMAIN) + with mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + assert "Modbus reloading" in caplog.text + state_sensor_1 = hass.states.get("sensor.dummy") + state_sensor_2 = hass.states.get("sensor.dummy_2") + assert state_sensor_1 + assert state_sensor_2 + + caplog.clear() + yaml_path = get_fixture_path("configuration_empty.yaml", DOMAIN) + with mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + assert "Modbus not present anymore" in caplog.text + state_sensor_1 = hass.states.get("sensor.dummy") + state_sensor_2 = hass.states.get("sensor.dummy_2") + assert not state_sensor_1 + assert not state_sensor_2 @pytest.mark.parametrize("do_config", [{}]) @@ -1227,9 +1265,3 @@ async def test_no_entities(hass: HomeAssistant) -> None: ] } assert await async_setup_component(hass, DOMAIN, config) is False - - -async def test_reset_platform(hass: HomeAssistant) -> None: - """Run test for async_reset_platform.""" - await async_reset_platform(hass, "modbus") - assert DOMAIN not in hass.data diff --git a/tests/components/niko_home_control/__init__.py b/tests/components/niko_home_control/__init__.py index f6e8187bf0f..0182a24ba7c 100644 --- a/tests/components/niko_home_control/__init__.py +++ b/tests/components/niko_home_control/__init__.py @@ -1,5 +1,10 @@ """Tests for the niko_home_control integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -11,3 +16,13 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +def find_update_callback( + mock: AsyncMock, identifier: int +) -> Callable[[int], Awaitable[None]]: + """Find the update callback for a specific identifier.""" + for call in mock.register_callback.call_args_list: + if call[0][0] == identifier: + return call[0][1] + pytest.fail(f"Callback for identifier {identifier} not found") diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py index b3dedd0c182..130baf72228 100644 --- a/tests/components/niko_home_control/conftest.py +++ b/tests/components/niko_home_control/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from nhc.cover import NHCCover from nhc.light import NHCLight import pytest @@ -48,9 +49,21 @@ def dimmable_light() -> NHCLight: return mock +@pytest.fixture +def cover() -> NHCCover: + """Return a cover mock.""" + mock = AsyncMock(spec=NHCCover) + mock.id = 3 + mock.type = 4 + mock.name = "cover" + mock.suggested_area = "room" + mock.state = 100 + return mock + + @pytest.fixture def mock_niko_home_control_connection( - light: NHCLight, dimmable_light: NHCLight + light: NHCLight, dimmable_light: NHCLight, cover: NHCCover ) -> Generator[AsyncMock]: """Mock a NHC client.""" with ( @@ -65,6 +78,7 @@ def mock_niko_home_control_connection( ): client = mock_client.return_value client.lights = [light, dimmable_light] + client.covers = [cover] yield client diff --git a/tests/components/niko_home_control/snapshots/test_cover.ambr b/tests/components/niko_home_control/snapshots/test_cover.ambr new file mode 100644 index 00000000000..6f99c1adb8c --- /dev/null +++ b/tests/components/niko_home_control/snapshots/test_cover.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_cover[cover.cover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.cover', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'niko_home_control', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JFN93M7KRA38V5AMPCJ2JYYV-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.cover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'cover', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/niko_home_control/test_cover.py b/tests/components/niko_home_control/test_cover.py new file mode 100644 index 00000000000..5e9a17c3324 --- /dev/null +++ b/tests/components/niko_home_control/test_cover.py @@ -0,0 +1,138 @@ +"""Tests for the Niko Home Control cover platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import find_update_callback, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_cover( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.niko_home_control.PLATFORMS", [Platform.COVER] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("cover_id", "entity_id"), + [ + (0, "cover.cover"), + ], +) +async def test_open_cover( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + cover_id: int, + entity_id: int, +) -> None: + """Test opening the cover.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_niko_home_control_connection.covers[cover_id].open.assert_called_once_with() + + +@pytest.mark.parametrize( + ("cover_id", "entity_id"), + [ + (0, "cover.cover"), + ], +) +async def test_close_cover( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + cover_id: int, + entity_id: str, +) -> None: + """Test closing the cover.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_niko_home_control_connection.covers[cover_id].close.assert_called_once_with() + + +@pytest.mark.parametrize( + ("cover_id", "entity_id"), + [ + (0, "cover.cover"), + ], +) +async def test_stop_cover( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + cover_id: int, + entity_id: str, +) -> None: + """Test closing the cover.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_niko_home_control_connection.covers[cover_id].stop.assert_called_once_with() + + +async def test_updating( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + cover: AsyncMock, +) -> None: + """Test closing the cover.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.cover").state == STATE_OPEN + + cover.state = 0 + await find_update_callback(mock_niko_home_control_connection, 3)(0) + await hass.async_block_till_done() + + assert hass.states.get("cover.cover").state == STATE_CLOSED + + cover.state = 100 + await find_update_callback(mock_niko_home_control_connection, 3)(100) + await hass.async_block_till_done() + + assert hass.states.get("cover.cover").state == STATE_OPEN diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py index 801bdf6a296..a61cc5204f6 100644 --- a/tests/components/niko_home_control/test_light.py +++ b/tests/components/niko_home_control/test_light.py @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import find_update_callback, setup_integration from tests.common import MockConfigEntry, snapshot_platform @@ -113,7 +113,7 @@ async def test_updating( assert hass.states.get("light.light").state == STATE_ON light.state = 0 - await mock_niko_home_control_connection.register_callback.call_args_list[0][0][1](0) + await find_update_callback(mock_niko_home_control_connection, 1)(0) await hass.async_block_till_done() assert hass.states.get("light.light").state == STATE_OFF @@ -122,16 +122,14 @@ async def test_updating( assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] == 255 dimmable_light.state = 80 - await mock_niko_home_control_connection.register_callback.call_args_list[1][0][1]( - 80 - ) + await find_update_callback(mock_niko_home_control_connection, 2)(80) await hass.async_block_till_done() assert hass.states.get("light.dimmable_light").state == STATE_ON assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] == 204 dimmable_light.state = 0 - await mock_niko_home_control_connection.register_callback.call_args_list[1][0][1](0) + await find_update_callback(mock_niko_home_control_connection, 2)(0) await hass.async_block_till_done() assert hass.states.get("light.dimmable_light").state == STATE_OFF diff --git a/tests/components/peblar/test_binary_sensor.py b/tests/components/peblar/test_binary_sensor.py index 670b5b67145..affcde483ea 100644 --- a/tests/components/peblar/test_binary_sensor.py +++ b/tests/components/peblar/test_binary_sensor.py @@ -23,7 +23,7 @@ async def test_entities( """Test the binary sensors entities.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - # Ensure all entities are correctly assigned to the Peblar device + # Ensure all entities are correctly assigned to the Peblar EV charger device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "23-45-A4O-MOF")} ) diff --git a/tests/components/peblar/test_button.py b/tests/components/peblar/test_button.py index e9ab377db67..a47f190a941 100644 --- a/tests/components/peblar/test_button.py +++ b/tests/components/peblar/test_button.py @@ -34,7 +34,7 @@ async def test_entities( """Test the button entities.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - # Ensure all entities are correctly assigned to the Peblar device + # Ensure all entities are correctly assigned to the Peblar EV charger device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "23-45-A4O-MOF")} ) @@ -81,7 +81,7 @@ async def test_buttons( HomeAssistantError, match=( r"An error occurred while communicating " - r"with the Peblar device: Could not connect" + r"with the Peblar EV charger: Could not connect" ), ) as excinfo: await hass.services.async_call( @@ -101,7 +101,7 @@ async def test_buttons( HomeAssistantError, match=( r"An unknown error occurred while communicating " - r"with the Peblar device: Unknown error" + r"with the Peblar EV charger: Unknown error" ), ) as excinfo: await hass.services.async_call( @@ -122,7 +122,7 @@ async def test_buttons( HomeAssistantError, match=( r"An authentication failure occurred while communicating " - r"with the Peblar device" + r"with the Peblar EV charger" ), ) as excinfo: await hass.services.async_call( diff --git a/tests/components/peblar/test_coordinator.py b/tests/components/peblar/test_coordinator.py index f438d807920..7f073af9554 100644 --- a/tests/components/peblar/test_coordinator.py +++ b/tests/components/peblar/test_coordinator.py @@ -26,7 +26,7 @@ pytestmark = [ ( PeblarConnectionError("Could not connect"), ( - "An error occurred while communicating with the Peblar device: " + "An error occurred while communicating with the Peblar EV charger: " "Could not connect" ), ), @@ -34,7 +34,7 @@ pytestmark = [ PeblarError("Unknown error"), ( "An unknown error occurred while communicating " - "with the Peblar device: Unknown error" + "with the Peblar EV charger: Unknown error" ), ), ], diff --git a/tests/components/peblar/test_number.py b/tests/components/peblar/test_number.py index 2a8fca46e91..57469fecbc6 100644 --- a/tests/components/peblar/test_number.py +++ b/tests/components/peblar/test_number.py @@ -36,7 +36,7 @@ async def test_entities( """Test the number entities.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - # Ensure all entities are correctly assigned to the Peblar device + # Ensure all entities are correctly assigned to the Peblar EV charger device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "23-45-A4O-MOF")} ) @@ -80,7 +80,7 @@ async def test_number_set_value( PeblarConnectionError("Could not connect"), ( r"An error occurred while communicating " - r"with the Peblar device: Could not connect" + r"with the Peblar EV charger: Could not connect" ), "communication_error", {"error": "Could not connect"}, @@ -89,7 +89,7 @@ async def test_number_set_value( PeblarError("Unknown error"), ( r"An unknown error occurred while communicating " - r"with the Peblar device: Unknown error" + r"with the Peblar EV charger: Unknown error" ), "unknown_error", {"error": "Unknown error"}, @@ -143,7 +143,7 @@ async def test_number_set_value_authentication_error( HomeAssistantError, match=( r"An authentication failure occurred while communicating " - r"with the Peblar device" + r"with the Peblar EV charger" ), ) as excinfo: await hass.services.async_call( diff --git a/tests/components/peblar/test_select.py b/tests/components/peblar/test_select.py index 5e4ab4609d4..be7e182dc39 100644 --- a/tests/components/peblar/test_select.py +++ b/tests/components/peblar/test_select.py @@ -41,7 +41,7 @@ async def test_entities( """Test the select entities.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - # Ensure all entities are correctly assigned to the Peblar device + # Ensure all entities are correctly assigned to the Peblar EV charger device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "23-45-A4O-MOF")} ) @@ -85,7 +85,7 @@ async def test_select_option( PeblarConnectionError("Could not connect"), ( r"An error occurred while communicating " - r"with the Peblar device: Could not connect" + r"with the Peblar EV charger: Could not connect" ), "communication_error", {"error": "Could not connect"}, @@ -94,7 +94,7 @@ async def test_select_option( PeblarError("Unknown error"), ( r"An unknown error occurred while communicating " - r"with the Peblar device: Unknown error" + r"with the Peblar EV charger: Unknown error" ), "unknown_error", {"error": "Unknown error"}, @@ -150,7 +150,7 @@ async def test_select_option_authentication_error( HomeAssistantError, match=( r"An authentication failure occurred while communicating " - r"with the Peblar device" + r"with the Peblar EV charger" ), ) as excinfo: await hass.services.async_call( diff --git a/tests/components/peblar/test_sensor.py b/tests/components/peblar/test_sensor.py index bad81486838..d689e66e944 100644 --- a/tests/components/peblar/test_sensor.py +++ b/tests/components/peblar/test_sensor.py @@ -24,7 +24,7 @@ async def test_entities( """Test the sensor entities.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - # Ensure all entities are correctly assigned to the Peblar device + # Ensure all entities are correctly assigned to the Peblar EV charger device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "23-45-A4O-MOF")} ) diff --git a/tests/components/peblar/test_switch.py b/tests/components/peblar/test_switch.py index 6436ac78109..75deeb2d5d3 100644 --- a/tests/components/peblar/test_switch.py +++ b/tests/components/peblar/test_switch.py @@ -36,7 +36,7 @@ async def test_entities( """Test the switch entities.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - # Ensure all entities are correctly assigned to the Peblar device + # Ensure all entities are correctly assigned to the Peblar EV charger device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "23-45-A4O-MOF")} ) @@ -88,7 +88,7 @@ async def test_switch( PeblarConnectionError("Could not connect"), ( r"An error occurred while communicating " - r"with the Peblar device: Could not connect" + r"with the Peblar EV charger: Could not connect" ), "communication_error", {"error": "Could not connect"}, @@ -97,7 +97,7 @@ async def test_switch( PeblarError("Unknown error"), ( r"An unknown error occurred while communicating " - r"with the Peblar device: Unknown error" + r"with the Peblar EV charger: Unknown error" ), "unknown_error", {"error": "Unknown error"}, @@ -152,7 +152,7 @@ async def test_switch_authentication_error( HomeAssistantError, match=( r"An authentication failure occurred while communicating " - r"with the Peblar device" + r"with the Peblar EV charger" ), ) as excinfo: await hass.services.async_call( diff --git a/tests/components/peblar/test_update.py b/tests/components/peblar/test_update.py index 7a772fbe96c..54eb77abc24 100644 --- a/tests/components/peblar/test_update.py +++ b/tests/components/peblar/test_update.py @@ -23,7 +23,7 @@ async def test_entities( """Test the update entities.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - # Ensure all entities are correctly assigned to the Peblar device + # Ensure all entities are correctly assigned to the Peblar EV charger device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "23-45-A4O-MOF")} ) diff --git a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json index aa0df6e3df6..04437c12ebf 100644 --- a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json +++ b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json @@ -619,7 +619,7 @@ }, "heat_mode": { "name": "Spa Heat Mode", - "value": 0, + "value": 3, "device_type": "enum", "enum_options": [ "Off", diff --git a/tests/components/screenlogic/test_climate.py b/tests/components/screenlogic/test_climate.py new file mode 100644 index 00000000000..c2e96a35508 --- /dev/null +++ b/tests/components/screenlogic/test_climate.py @@ -0,0 +1,103 @@ +"""Tests for ScreenLogic climate entity.""" + +import logging +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.device_const.heat import HEAT_MODE + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + HVACAction, + HVACMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import slugify + +from . import ( + DATA_MISSING_VALUES_CHEM_CHLOR, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.parametrize( + ( + "tested_dataset", + "expected_entity_states", + ), + [ + ( + DATA_MISSING_VALUES_CHEM_CHLOR, + { + f"{CLIMATE_DOMAIN}.{slugify(MOCK_ADAPTER_NAME)}_pool_heat": { + "state": HVACMode.OFF, + "attributes": { + ATTR_CURRENT_TEMPERATURE: 27.2, + ATTR_TEMPERATURE: 28.3, + ATTR_HVAC_ACTION: HVACAction.OFF, + ATTR_HVAC_MODES: [HVACMode.OFF, HVACMode.HEAT], + ATTR_PRESET_MODE: "heater", + ATTR_PRESET_MODES: [HEAT_MODE.HEATER.name.lower()], + }, + }, + f"{CLIMATE_DOMAIN}.{slugify(MOCK_ADAPTER_NAME)}_spa_heat": { + "state": HVACMode.HEAT, + "attributes": { + ATTR_CURRENT_TEMPERATURE: 28.9, + ATTR_TEMPERATURE: 34.4, + ATTR_HVAC_ACTION: HVACAction.IDLE, + ATTR_HVAC_MODES: [HVACMode.OFF, HVACMode.HEAT], + ATTR_PRESET_MODE: "heater", + ATTR_PRESET_MODES: [HEAT_MODE.HEATER.name.lower()], + }, + }, + }, + ) + ], +) +async def test_climate_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + tested_dataset: dict, + expected_entity_states: dict, +) -> None: + """Test setup for platforms that define expected data.""" + + def stub_connect(*args, **kwargs): + return stub_async_connect(tested_dataset, *args, **kwargs) + + mock_config_entry.add_to_hass(hass) + + with ( + patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), + patch.multiple( + ScreenLogicGateway, + async_connect=stub_connect, + is_connected=True, + _async_connected_request=DEFAULT, + ), + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + for entity_id, state_data in expected_entity_states.items(): + assert (climate_state := hass.states.get(entity_id)) is not None + assert climate_state.state == state_data["state"] + for attribute, value in state_data["attributes"].items(): + assert climate_state.attributes[attribute] == value diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index 4ce1813d704..4417395078a 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'live_view', - 'unique_id': "123456789ABCDEFGH-TPLinkCameraEntityDescription(key='live_view', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key='live_view', translation_placeholders=None, unit_of_measurement=None, deprecated_info=None)", + 'unique_id': '123456789ABCDEFGH-live_view', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tplink/test_camera.py b/tests/components/tplink/test_camera.py index d8b0f82e32a..8ca56a84b6b 100644 --- a/tests/components/tplink/test_camera.py +++ b/tests/components/tplink/test_camera.py @@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import ( + DEVICE_ID, IP_ADDRESS3, MAC_ADDRESS3, SMALLEST_VALID_JPEG_BYTES, @@ -68,6 +69,33 @@ async def test_states( ) +async def test_camera_unique_id( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test camera unique id.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + device_id=DEVICE_ID, + ) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_camera_config_entry.entry_id + ) + assert device_entries + entity_id = "camera.my_camera_live_view" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == f"{DEVICE_ID}-live_view" + + async def test_handle_mjpeg_stream( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, diff --git a/tests/ruff.toml b/tests/ruff.toml index bbfbfe1305d..c56b8f68ffc 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -10,6 +10,10 @@ extend-ignore = [ "SLF001", # Private member accessed: Tests do often test internals a lot ] +[lint.flake8-tidy-imports.banned-api] +"async_timeout".msg = "use asyncio.timeout instead" +"pytz".msg = "use zoneinfo instead" + [lint.isort] known-first-party = [ "homeassistant",