diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index c307e96e9f0fc8..356a8a3164e4e9 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from functools import partial import logging from typing import Any, Final, final @@ -22,26 +23,36 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from .const import ( # noqa: F401 + _DEPRECATED_FORMAT_NUMBER, + _DEPRECATED_FORMAT_TEXT, + _DEPRECATED_SUPPORT_ALARM_ARM_AWAY, + _DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + _DEPRECATED_SUPPORT_ALARM_ARM_HOME, + _DEPRECATED_SUPPORT_ALARM_ARM_NIGHT, + _DEPRECATED_SUPPORT_ALARM_ARM_VACATION, + _DEPRECATED_SUPPORT_ALARM_TRIGGER, ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED, DOMAIN, - FORMAT_NUMBER, - FORMAT_TEXT, - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, AlarmControlPanelEntityFeature, CodeFormat, ) +# As we import constants of the cost module here, we need to add the following +# functions to check for deprecated constants again +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + _LOGGER: Final = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=30) diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index f14a1ce66e0743..90bbcba1314b5f 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -1,7 +1,14 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "alarm_control_panel" ATTR_CHANGED_BY: Final = "changed_by" @@ -15,10 +22,10 @@ class CodeFormat(StrEnum): NUMBER = "number" -# These constants are deprecated as of Home Assistant 2022.5 +# These constants are deprecated as of Home Assistant 2022.5, can be removed in 2025.1 # Please use the CodeFormat enum instead. -FORMAT_TEXT: Final = "text" -FORMAT_NUMBER: Final = "number" +_DEPRECATED_FORMAT_TEXT: Final = DeprecatedConstantEnum(CodeFormat.TEXT, "2025.1") +_DEPRECATED_FORMAT_NUMBER: Final = DeprecatedConstantEnum(CodeFormat.NUMBER, "2025.1") class AlarmControlPanelEntityFeature(IntFlag): @@ -34,12 +41,28 @@ class AlarmControlPanelEntityFeature(IntFlag): # These constants are deprecated as of Home Assistant 2022.5 # Please use the AlarmControlPanelEntityFeature enum instead. -SUPPORT_ALARM_ARM_HOME: Final = 1 -SUPPORT_ALARM_ARM_AWAY: Final = 2 -SUPPORT_ALARM_ARM_NIGHT: Final = 4 -SUPPORT_ALARM_TRIGGER: Final = 8 -SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = 16 -SUPPORT_ALARM_ARM_VACATION: Final = 32 +_DEPRECATED_SUPPORT_ALARM_ARM_HOME: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_HOME, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_AWAY: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_AWAY, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_NIGHT: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_NIGHT, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_TRIGGER: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.TRIGGER, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_VACATION: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_VACATION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) CONDITION_TRIGGERED: Final = "is_triggered" CONDITION_DISARMED: Final = "is_disarmed" diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index e453be88934a47..9c068bb33275eb 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -28,13 +28,7 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import ATTR_CODE_ARM_REQUIRED, DOMAIN -from .const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, -) +from .const import AlarmControlPanelEntityFeature ACTION_TYPES: Final[set[str]] = { "arm_away", @@ -82,16 +76,16 @@ async def async_get_actions( } # Add actions for each entity that belongs to this integration - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: actions.append({**base_action, CONF_TYPE: "arm_away"}) - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: actions.append({**base_action, CONF_TYPE: "arm_home"}) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: actions.append({**base_action, CONF_TYPE: "arm_night"}) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: actions.append({**base_action, CONF_TYPE: "arm_vacation"}) actions.append({**base_action, CONF_TYPE: "disarm"}) - if supported_features & SUPPORT_ALARM_TRIGGER: + if supported_features & AlarmControlPanelEntityFeature.TRIGGER: actions.append({**base_action, CONF_TYPE: "trigger"}) return actions diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index ee8cb57f568a84..e3c627d17a3032 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -39,11 +39,7 @@ CONDITION_ARMED_VACATION, CONDITION_DISARMED, CONDITION_TRIGGERED, - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, + AlarmControlPanelEntityFeature, ) CONDITION_TYPES: Final[set[str]] = { @@ -90,15 +86,15 @@ async def async_get_conditions( {**base_condition, CONF_TYPE: CONDITION_DISARMED}, {**base_condition, CONF_TYPE: CONDITION_TRIGGERED}, ] - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_HOME}) - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_AWAY}) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_NIGHT}) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_VACATION}) - if supported_features & SUPPORT_ALARM_ARM_CUSTOM_BYPASS: + if supported_features & AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS: conditions.append( {**base_condition, CONF_TYPE: CONDITION_ARMED_CUSTOM_BYPASS} ) diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index fc3850dce30ff0..e5141a1dfd539f 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -29,12 +29,7 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN -from .const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, -) +from .const import AlarmControlPanelEntityFeature BASIC_TRIGGER_TYPES: Final[set[str]] = {"triggered", "disarmed", "arming"} TRIGGER_TYPES: Final[set[str]] = BASIC_TRIGGER_TYPES | { @@ -82,28 +77,28 @@ async def async_get_triggers( } for trigger in BASIC_TRIGGER_TYPES ] - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: triggers.append( { **base_trigger, CONF_TYPE: "armed_home", } ) - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: triggers.append( { **base_trigger, CONF_TYPE: "armed_away", } ) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: triggers.append( { **base_trigger, CONF_TYPE: "armed_night", } ) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: triggers.append( { **base_trigger, diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 0856c39946b453..955502c8149fba 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -19,6 +19,7 @@ number, timer, vacuum, + water_heater, ) from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, @@ -435,7 +436,8 @@ def get_property(self, name: str) -> Any: is_on = self.entity.state == vacuum.STATE_CLEANING elif self.entity.domain == timer.DOMAIN: is_on = self.entity.state != STATE_IDLE - + elif self.entity.domain == water_heater.DOMAIN: + is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN) else: is_on = self.entity.state != STATE_OFF @@ -938,6 +940,9 @@ def get_property(self, name: str) -> Any: if self.entity.domain == climate.DOMAIN: unit = self.hass.config.units.temperature_unit temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) + elif self.entity.domain == water_heater.DOMAIN: + unit = self.hass.config.units.temperature_unit + temp = self.entity.attributes.get(water_heater.ATTR_CURRENT_TEMPERATURE) if temp is None or temp in (STATE_UNAVAILABLE, STATE_UNKNOWN): return None @@ -1108,6 +1113,8 @@ def properties_supported(self) -> list[dict[str, str]]: supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE: properties.append({"name": "targetSetpoint"}) + if supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE: + properties.append({"name": "targetSetpoint"}) if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: properties.append({"name": "lowerSetpoint"}) properties.append({"name": "upperSetpoint"}) @@ -1127,6 +1134,8 @@ def get_property(self, name: str) -> Any: return None if name == "thermostatMode": + if self.entity.domain == water_heater.DOMAIN: + return None preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE) mode: dict[str, str] | str | None @@ -1176,9 +1185,13 @@ def configuration(self) -> dict[str, Any] | None: ThermostatMode Values. ThermostatMode Value must be AUTO, COOL, HEAT, ECO, OFF, or CUSTOM. + Water heater devices do not return thermostat modes. """ + if self.entity.domain == water_heater.DOMAIN: + return None + supported_modes: list[str] = [] - hvac_modes = self.entity.attributes[climate.ATTR_HVAC_MODES] + hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) for mode in hvac_modes: if thermostat_mode := API_THERMOSTAT_MODES.get(mode): supported_modes.append(thermostat_mode) @@ -1408,6 +1421,16 @@ def get_property(self, name: str) -> Any: if mode in self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []): return f"{humidifier.ATTR_MODE}.{mode}" + # Water heater operation mode + if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + operation_mode = self.entity.attributes.get( + water_heater.ATTR_OPERATION_MODE, None + ) + if operation_mode in self.entity.attributes.get( + water_heater.ATTR_OPERATION_LIST, [] + ): + return f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}" + # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": # Return state instead of position when using ModeController. @@ -1478,6 +1501,26 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: ) return self._resource.serialize_capability_resources() + # Water heater operation modes + if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) + operation_modes = self.entity.attributes.get( + water_heater.ATTR_OPERATION_LIST, [] + ) + for operation_mode in operation_modes: + self._resource.add_mode( + f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}", + [operation_mode], + ) + # Devices with a single mode completely break Alexa discovery, + # add a fake preset (see issue #53832). + if len(operation_modes) == 1: + self._resource.add_mode( + f"{water_heater.ATTR_OPERATION_MODE}.{PRESET_MODE_NA}", + [PRESET_MODE_NA], + ) + return self._resource.serialize_capability_resources() + # Cover Position Resources if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": self._resource = AlexaModeResource( diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index da0bd8b36aaa9d..2f89058514b906 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -32,6 +32,7 @@ switch, timer, vacuum, + water_heater, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -248,6 +249,9 @@ class DisplayCategory: # Indicates a vacuum cleaner. VACUUM_CLEANER = "VACUUM_CLEANER" + # Indicates a water heater. + WATER_HEATER = "WATER_HEATER" + # Indicates a network-connected wearable device, such as an Apple Watch, # Fitbit, or Samsung Gear. WEARABLE = "WEARABLE" @@ -456,23 +460,46 @@ def interfaces(self) -> list[AlexaCapability]: @ENTITY_ADAPTERS.register(climate.DOMAIN) +@ENTITY_ADAPTERS.register(water_heater.DOMAIN) class ClimateCapabilities(AlexaEntity): """Class to represent Climate capabilities.""" def default_display_categories(self) -> list[str]: """Return the display categories for this entity.""" + if self.entity.domain == water_heater.DOMAIN: + return [DisplayCategory.WATER_HEATER] return [DisplayCategory.THERMOSTAT] def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" # If we support two modes, one being off, we allow turning on too. - if climate.HVACMode.OFF in self.entity.attributes.get( - climate.ATTR_HVAC_MODES, [] + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + self.entity.domain == climate.DOMAIN + and climate.HVACMode.OFF + in self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) + or self.entity.domain == water_heater.DOMAIN + and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) ): yield AlexaPowerController(self.entity) - yield AlexaThermostatController(self.hass, self.entity) - yield AlexaTemperatureSensor(self.hass, self.entity) + if ( + self.entity.domain == climate.DOMAIN + or self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + ): + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + if self.entity.domain == water_heater.DOMAIN and ( + supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ): + yield AlexaModeController( + self.entity, + instance=f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}", + ) yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 2796c10795b895..8e81cf1a2c626b 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -22,6 +22,7 @@ number, timer, vacuum, + water_heater, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -80,6 +81,23 @@ _LOGGER = logging.getLogger(__name__) DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive" + +MIN_MAX_TEMP = { + climate.DOMAIN: { + "min_temp": climate.ATTR_MIN_TEMP, + "max_temp": climate.ATTR_MAX_TEMP, + }, + water_heater.DOMAIN: { + "min_temp": water_heater.ATTR_MIN_TEMP, + "max_temp": water_heater.ATTR_MAX_TEMP, + }, +} + +SERVICE_SET_TEMPERATURE = { + climate.DOMAIN: climate.SERVICE_SET_TEMPERATURE, + water_heater.DOMAIN: water_heater.SERVICE_SET_TEMPERATURE, +} + HANDLERS: Registry[ tuple[str, str], Callable[ @@ -804,8 +822,10 @@ async def async_api_set_target_temp( ) -> AlexaResponse: """Process a set target temperature request.""" entity = directive.entity - min_temp = entity.attributes[climate.ATTR_MIN_TEMP] - max_temp = entity.attributes[climate.ATTR_MAX_TEMP] + domain = entity.domain + + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes["max_temp"] unit = hass.config.units.temperature_unit data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} @@ -849,9 +869,11 @@ async def async_api_set_target_temp( } ) + service = SERVICE_SET_TEMPERATURE[domain] + await hass.services.async_call( entity.domain, - climate.SERVICE_SET_TEMPERATURE, + service, data, blocking=False, context=context, @@ -867,11 +889,12 @@ async def async_api_adjust_target_temp( directive: AlexaDirective, context: ha.Context, ) -> AlexaResponse: - """Process an adjust target temperature request.""" + """Process an adjust target temperature request for climates and water heaters.""" data: dict[str, Any] entity = directive.entity - min_temp = entity.attributes[climate.ATTR_MIN_TEMP] - max_temp = entity.attributes[climate.ATTR_MAX_TEMP] + domain = entity.domain + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes[MIN_MAX_TEMP[domain]["max_temp"]] unit = hass.config.units.temperature_unit temp_delta = temperature_from_object( @@ -932,9 +955,11 @@ async def async_api_adjust_target_temp( } ) + service = SERVICE_SET_TEMPERATURE[domain] + await hass.services.async_call( entity.domain, - climate.SERVICE_SET_TEMPERATURE, + service, data, blocking=False, context=context, @@ -1163,6 +1188,23 @@ async def async_api_set_mode( msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'" raise AlexaInvalidValueError(msg) + # Water heater operation mode + elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + operation_mode = mode.split(".")[1] + operation_modes: list[str] | None = entity.attributes.get( + water_heater.ATTR_OPERATION_LIST + ) + if ( + operation_mode != PRESET_MODE_NA + and operation_modes + and operation_mode in operation_modes + ): + service = water_heater.SERVICE_SET_OPERATION_MODE + data[water_heater.ATTR_OPERATION_MODE] = operation_mode + else: + msg = f"Entity '{entity.entity_id}' does not support Operation mode '{operation_mode}'" + raise AlexaInvalidValueError(msg) + # Cover Position elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": position = mode.split(".")[1] diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 633300af591c11..1f365b07099b92 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -32,6 +32,11 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -69,16 +74,32 @@ class CoverDeviceClass(StrEnum): # DEVICE_CLASS* below are deprecated as of 2021.12 # use the CoverDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] -DEVICE_CLASS_AWNING = CoverDeviceClass.AWNING.value -DEVICE_CLASS_BLIND = CoverDeviceClass.BLIND.value -DEVICE_CLASS_CURTAIN = CoverDeviceClass.CURTAIN.value -DEVICE_CLASS_DAMPER = CoverDeviceClass.DAMPER.value -DEVICE_CLASS_DOOR = CoverDeviceClass.DOOR.value -DEVICE_CLASS_GARAGE = CoverDeviceClass.GARAGE.value -DEVICE_CLASS_GATE = CoverDeviceClass.GATE.value -DEVICE_CLASS_SHADE = CoverDeviceClass.SHADE.value -DEVICE_CLASS_SHUTTER = CoverDeviceClass.SHUTTER.value -DEVICE_CLASS_WINDOW = CoverDeviceClass.WINDOW.value +_DEPRECATED_DEVICE_CLASS_AWNING = DeprecatedConstantEnum( + CoverDeviceClass.AWNING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BLIND = DeprecatedConstantEnum( + CoverDeviceClass.BLIND, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CURTAIN = DeprecatedConstantEnum( + CoverDeviceClass.CURTAIN, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DAMPER = DeprecatedConstantEnum( + CoverDeviceClass.DAMPER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum(CoverDeviceClass.DOOR, "2025.1") +_DEPRECATED_DEVICE_CLASS_GARAGE = DeprecatedConstantEnum( + CoverDeviceClass.GARAGE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GATE = DeprecatedConstantEnum(CoverDeviceClass.GATE, "2025.1") +_DEPRECATED_DEVICE_CLASS_SHADE = DeprecatedConstantEnum( + CoverDeviceClass.SHADE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SHUTTER = DeprecatedConstantEnum( + CoverDeviceClass.SHUTTER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( + CoverDeviceClass.WINDOW, "2025.1" +) # mypy: disallow-any-generics @@ -98,14 +119,28 @@ class CoverEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the CoverEntityFeature enum instead. -SUPPORT_OPEN = 1 -SUPPORT_CLOSE = 2 -SUPPORT_SET_POSITION = 4 -SUPPORT_STOP = 8 -SUPPORT_OPEN_TILT = 16 -SUPPORT_CLOSE_TILT = 32 -SUPPORT_STOP_TILT = 64 -SUPPORT_SET_TILT_POSITION = 128 +_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(CoverEntityFeature.OPEN, "2025.1") +_DEPRECATED_SUPPORT_CLOSE = DeprecatedConstantEnum(CoverEntityFeature.CLOSE, "2025.1") +_DEPRECATED_SUPPORT_SET_POSITION = DeprecatedConstantEnum( + CoverEntityFeature.SET_POSITION, "2025.1" +) +_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(CoverEntityFeature.STOP, "2025.1") +_DEPRECATED_SUPPORT_OPEN_TILT = DeprecatedConstantEnum( + CoverEntityFeature.OPEN_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_CLOSE_TILT = DeprecatedConstantEnum( + CoverEntityFeature.CLOSE_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_STOP_TILT = DeprecatedConstantEnum( + CoverEntityFeature.STOP_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_SET_TILT_POSITION = DeprecatedConstantEnum( + CoverEntityFeature.SET_TILT_POSITION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) ATTR_CURRENT_POSITION = "current_position" ATTR_CURRENT_TILT_POSITION = "current_tilt_position" diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index e34a623be937d8..2224e5bab1c767 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -24,18 +24,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ( - ATTR_POSITION, - ATTR_TILT_POSITION, - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_CLOSE_TILT, - SUPPORT_OPEN, - SUPPORT_OPEN_TILT, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, - SUPPORT_STOP, -) +from . import ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, CoverEntityFeature CMD_ACTION_TYPES = {"open", "close", "stop", "open_tilt", "close_tilt"} POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} @@ -88,20 +77,20 @@ async def async_get_actions( CONF_ENTITY_ID: entry.id, } - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: actions.append({**base_action, CONF_TYPE: "set_position"}) - if supported_features & SUPPORT_OPEN: + if supported_features & CoverEntityFeature.OPEN: actions.append({**base_action, CONF_TYPE: "open"}) - if supported_features & SUPPORT_CLOSE: + if supported_features & CoverEntityFeature.CLOSE: actions.append({**base_action, CONF_TYPE: "close"}) - if supported_features & SUPPORT_STOP: + if supported_features & CoverEntityFeature.STOP: actions.append({**base_action, CONF_TYPE: "stop"}) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: actions.append({**base_action, CONF_TYPE: "set_tilt_position"}) - if supported_features & SUPPORT_OPEN_TILT: + if supported_features & CoverEntityFeature.OPEN_TILT: actions.append({**base_action, CONF_TYPE: "open_tilt"}) - if supported_features & SUPPORT_CLOSE_TILT: + if supported_features & CoverEntityFeature.CLOSE_TILT: actions.append({**base_action, CONF_TYPE: "close_tilt"}) return actions diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index 2aa0a1dd2fb845..23ec7d7565029f 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -26,13 +26,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ( - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, -) +from . import DOMAIN, CoverEntityFeature # mypy: disallow-any-generics @@ -78,7 +72,9 @@ async def async_get_conditions( continue supported_features = get_supported_features(hass, entry.entity_id) - supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + supports_open_close = supported_features & ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) # Add conditions for each entity that belongs to this integration base_condition = { @@ -92,9 +88,9 @@ async def async_get_conditions( conditions += [ {**base_condition, CONF_TYPE: cond} for cond in STATE_CONDITION_TYPES ] - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: conditions.append({**base_condition, CONF_TYPE: "is_position"}) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: conditions.append({**base_condition, CONF_TYPE: "is_tilt_position"}) return conditions diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index 2fb456d726d74c..8225348619d784 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -29,13 +29,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import ( - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, -) +from . import DOMAIN, CoverEntityFeature POSITION_TRIGGER_TYPES = {"position", "tilt_position"} STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} @@ -80,7 +74,9 @@ async def async_get_triggers( continue supported_features = get_supported_features(hass, entry.entity_id) - supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + supports_open_close = supported_features & ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) # Add triggers for each entity that belongs to this integration base_trigger = { @@ -98,14 +94,14 @@ async def async_get_triggers( } for trigger in STATE_TRIGGER_TYPES ] - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: triggers.append( { **base_trigger, CONF_TYPE: "position", } ) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: triggers.append( { **base_trigger, diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a6a8e9d2d8ced9..b5ad4660cde215 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,8 +1,14 @@ """Provide functionality to keep track of devices.""" from __future__ import annotations +from functools import partial + from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401 from homeassistant.core import HomeAssistant +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -13,6 +19,10 @@ async_unload_entry, ) from .const import ( # noqa: F401 + _DEPRECATED_SOURCE_TYPE_BLUETOOTH, + _DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE, + _DEPRECATED_SOURCE_TYPE_GPS, + _DEPRECATED_SOURCE_TYPE_ROUTER, ATTR_ATTRIBUTES, ATTR_BATTERY, ATTR_DEV_ID, @@ -32,10 +42,6 @@ DOMAIN, ENTITY_ID_FORMAT, SCAN_INTERVAL, - SOURCE_TYPE_BLUETOOTH, - SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS, - SOURCE_TYPE_ROUTER, SourceType, ) from .legacy import ( # noqa: F401 @@ -51,6 +57,12 @@ see, ) +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + @bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 3a0b0afd7c9fbf..10c16e09107026 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -3,9 +3,16 @@ from datetime import timedelta from enum import StrEnum +from functools import partial import logging from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + LOGGER: Final = logging.getLogger(__package__) DOMAIN: Final = "device_tracker" @@ -14,13 +21,6 @@ PLATFORM_TYPE_LEGACY: Final = "legacy" PLATFORM_TYPE_ENTITY: Final = "entity_platform" -# SOURCE_TYPE_* below are deprecated as of 2022.9 -# use the SourceType enum instead. -SOURCE_TYPE_GPS: Final = "gps" -SOURCE_TYPE_ROUTER: Final = "router" -SOURCE_TYPE_BLUETOOTH: Final = "bluetooth" -SOURCE_TYPE_BLUETOOTH_LE: Final = "bluetooth_le" - class SourceType(StrEnum): """Source type for device trackers.""" @@ -31,6 +31,23 @@ class SourceType(StrEnum): BLUETOOTH_LE = "bluetooth_le" +# SOURCE_TYPE_* below are deprecated as of 2022.9 +# use the SourceType enum instead. +_DEPRECATED_SOURCE_TYPE_GPS: Final = DeprecatedConstantEnum(SourceType.GPS, "2025.1") +_DEPRECATED_SOURCE_TYPE_ROUTER: Final = DeprecatedConstantEnum( + SourceType.ROUTER, "2025.1" +) +_DEPRECATED_SOURCE_TYPE_BLUETOOTH: Final = DeprecatedConstantEnum( + SourceType.BLUETOOTH, "2025.1" +) +_DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE: Final = DeprecatedConstantEnum( + SourceType.BLUETOOTH_LE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + CONF_SCAN_INTERVAL: Final = "interval_seconds" SCAN_INTERVAL: Final = timedelta(seconds=12) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 0fa884319c414a..03c9942968c3ab 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index b3fa91a2e70d2b..6173c9a3843e89 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.36.2"], + "requirements": ["async-upnp-client==0.38.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/enigma2/const.py b/homeassistant/components/enigma2/const.py new file mode 100644 index 00000000000000..0511a79417267c --- /dev/null +++ b/homeassistant/components/enigma2/const.py @@ -0,0 +1,17 @@ +"""Constants for the Enigma2 platform.""" +DOMAIN = "enigma2" + +CONF_USE_CHANNEL_ICON = "use_channel_icon" +CONF_DEEP_STANDBY = "deep_standby" +CONF_SOURCE_BOUQUET = "source_bouquet" +CONF_MAC_ADDRESS = "mac_address" + +DEFAULT_NAME = "Enigma2 Media Player" +DEFAULT_PORT = 80 +DEFAULT_SSL = False +DEFAULT_USE_CHANNEL_ICON = False +DEFAULT_USERNAME = "root" +DEFAULT_PASSWORD = "dreambox" +DEFAULT_DEEP_STANDBY = False +DEFAULT_SOURCE_BOUQUET = "" +DEFAULT_MAC_ADDRESS = "" diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 345ba1f8acb8db..8e24caf1b089f4 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -24,26 +24,27 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import ( + CONF_DEEP_STANDBY, + CONF_MAC_ADDRESS, + CONF_SOURCE_BOUQUET, + CONF_USE_CHANNEL_ICON, + DEFAULT_DEEP_STANDBY, + DEFAULT_MAC_ADDRESS, + DEFAULT_NAME, + DEFAULT_PASSWORD, + DEFAULT_PORT, + DEFAULT_SOURCE_BOUQUET, + DEFAULT_SSL, + DEFAULT_USE_CHANNEL_ICON, + DEFAULT_USERNAME, +) + ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_DESCRIPTION = "media_description" ATTR_MEDIA_END_TIME = "media_end_time" ATTR_MEDIA_START_TIME = "media_start_time" -CONF_USE_CHANNEL_ICON = "use_channel_icon" -CONF_DEEP_STANDBY = "deep_standby" -CONF_MAC_ADDRESS = "mac_address" -CONF_SOURCE_BOUQUET = "source_bouquet" - -DEFAULT_NAME = "Enigma2 Media Player" -DEFAULT_PORT = 80 -DEFAULT_SSL = False -DEFAULT_USE_CHANNEL_ICON = False -DEFAULT_USERNAME = "root" -DEFAULT_PASSWORD = "dreambox" -DEFAULT_DEEP_STANDBY = False -DEFAULT_MAC_ADDRESS = "" -DEFAULT_SOURCE_BOUQUET = "" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 770040746bba8a..4a1301ccf297b1 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "aioesphomeapi==21.0.0", + "aioesphomeapi==21.0.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==0.4.0" ], diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 23261c4d944311..ec6fc1aad7e0aa 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -24,6 +24,11 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -52,10 +57,22 @@ class FanEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the FanEntityFeature enum instead. -SUPPORT_SET_SPEED = 1 -SUPPORT_OSCILLATE = 2 -SUPPORT_DIRECTION = 4 -SUPPORT_PRESET_MODE = 8 +_DEPRECATED_SUPPORT_SET_SPEED = DeprecatedConstantEnum( + FanEntityFeature.SET_SPEED, "2025.1" +) +_DEPRECATED_SUPPORT_OSCILLATE = DeprecatedConstantEnum( + FanEntityFeature.OSCILLATE, "2025.1" +) +_DEPRECATED_SUPPORT_DIRECTION = DeprecatedConstantEnum( + FanEntityFeature.DIRECTION, "2025.1" +) +_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( + FanEntityFeature.PRESET_MODE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) SERVICE_INCREASE_SPEED = "increase_speed" SERVICE_DECREASE_SPEED = "decrease_speed" diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 39150126b7abe5..d9c804279b2794 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -3,6 +3,7 @@ from datetime import timedelta from enum import StrEnum +from functools import partial import logging from typing import Any, final @@ -22,12 +23,19 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from .const import ( # noqa: F401 + _DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER, + _DEPRECATED_DEVICE_CLASS_HUMIDIFIER, + _DEPRECATED_SUPPORT_MODES, ATTR_ACTION, ATTR_AVAILABLE_MODES, ATTR_CURRENT_HUMIDITY, @@ -36,15 +44,12 @@ ATTR_MIN_HUMIDITY, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, - DEVICE_CLASS_DEHUMIDIFIER, - DEVICE_CLASS_HUMIDIFIER, DOMAIN, MODE_AUTO, MODE_AWAY, MODE_NORMAL, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, - SUPPORT_MODES, HumidifierAction, HumidifierEntityFeature, ) @@ -70,6 +75,12 @@ class HumidifierDeviceClass(StrEnum): # use the HumidifierDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in HumidifierDeviceClass] +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 09c0714cbebc2a..a1a219ddce7649 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,5 +1,13 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) MODE_NORMAL = "normal" MODE_ECO = "eco" @@ -35,8 +43,12 @@ class HumidifierAction(StrEnum): # DEVICE_CLASS_* below are deprecated as of 2021.12 # use the HumidifierDeviceClass enum instead. -DEVICE_CLASS_HUMIDIFIER = "humidifier" -DEVICE_CLASS_DEHUMIDIFIER = "dehumidifier" +_DEPRECATED_DEVICE_CLASS_HUMIDIFIER = DeprecatedConstant( + "humidifier", "HumidifierDeviceClass.HUMIDIFIER", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER = DeprecatedConstant( + "dehumidifier", "HumidifierDeviceClass.DEHUMIDIFIER", "2025.1" +) SERVICE_SET_MODE = "set_mode" SERVICE_SET_HUMIDITY = "set_humidity" @@ -50,4 +62,10 @@ class HumidifierEntityFeature(IntFlag): # The SUPPORT_MODES constant is deprecated as of Home Assistant 2022.5. # Please use the HumidifierEntityFeature enum instead. -SUPPORT_MODES = 1 +_DEPRECATED_SUPPORT_MODES = DeprecatedConstantEnum( + HumidifierEntityFeature.MODES, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index b28aa9d0a1b241..a9f31a3a41097b 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -30,6 +30,11 @@ PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType @@ -57,7 +62,11 @@ class LockEntityFeature(IntFlag): # The SUPPORT_OPEN constant is deprecated as of Home Assistant 2022.5. # Please use the LockEntityFeature enum instead. -SUPPORT_OPEN = 1 +_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(LockEntityFeature.OPEN, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT} diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index eb9ab56208e4c3..64d8c27f1de1d1 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -166,6 +166,7 @@ "pl_ton": "payload_turn_on", "pl_trig": "payload_trigger", "pl_unlk": "payload_unlock", + "pos": "reports_position", "pos_clsd": "position_closed", "pos_open": "position_open", "pow_cmd_t": "power_command_topic", diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 71260dc023913c..0f2d617930db68 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -53,6 +53,7 @@ Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), Platform.UPDATE.value: vol.All(cv.ensure_list, [dict]), Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), + Platform.VALVE.value: vol.All(cv.ensure_list, [dict]), Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]), } ) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 685e45700b54de..50ea3860d9e180 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -42,9 +42,18 @@ CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_PAYLOAD_CLOSE = "payload_close" +CONF_PAYLOAD_OPEN = "payload_open" +CONF_PAYLOAD_STOP = "payload_stop" +CONF_POSITION_CLOSED = "position_closed" +CONF_POSITION_OPEN = "position_open" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" +CONF_STATE_CLOSED = "state_closed" +CONF_STATE_CLOSING = "state_closing" +CONF_STATE_OPEN = "state_open" +CONF_STATE_OPENING = "state_opening" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" @@ -81,11 +90,16 @@ DEFAULT_OPTIMISTIC = False DEFAULT_QOS = 0 DEFAULT_PAYLOAD_AVAILABLE = "online" +DEFAULT_PAYLOAD_CLOSE = "CLOSE" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" +DEFAULT_PAYLOAD_OPEN = "OPEN" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False DEFAULT_WS_HEADERS: dict[str, str] = {} DEFAULT_WS_PATH = "/" +DEFAULT_POSITION_CLOSED = 0 +DEFAULT_POSITION_OPEN = 100 +DEFAULT_RETAIN = False PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" @@ -146,6 +160,7 @@ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, ] @@ -173,5 +188,6 @@ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 4e8cf0f4129bf1..912de7e367bbbd 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -38,10 +38,24 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_PAYLOAD_STOP, + CONF_POSITION_CLOSED, + CONF_POSITION_OPEN, CONF_QOS, CONF_RETAIN, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, CONF_STATE_TOPIC, DEFAULT_OPTIMISTIC, + DEFAULT_PAYLOAD_CLOSE, + DEFAULT_PAYLOAD_OPEN, + DEFAULT_POSITION_CLOSED, + DEFAULT_POSITION_OPEN, + DEFAULT_RETAIN, ) from .debug_info import log_messages from .mixins import ( @@ -64,15 +78,6 @@ CONF_TILT_STATUS_TOPIC = "tilt_status_topic" CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" -CONF_PAYLOAD_CLOSE = "payload_close" -CONF_PAYLOAD_OPEN = "payload_open" -CONF_PAYLOAD_STOP = "payload_stop" -CONF_POSITION_CLOSED = "position_closed" -CONF_POSITION_OPEN = "position_open" -CONF_STATE_CLOSED = "state_closed" -CONF_STATE_CLOSING = "state_closing" -CONF_STATE_OPEN = "state_open" -CONF_STATE_OPENING = "state_opening" CONF_STATE_STOPPED = "state_stopped" CONF_TILT_CLOSED_POSITION = "tilt_closed_value" CONF_TILT_MAX = "tilt_max" @@ -84,13 +89,10 @@ COVER_PAYLOAD = "cover" DEFAULT_NAME = "MQTT Cover" -DEFAULT_PAYLOAD_CLOSE = "CLOSE" -DEFAULT_PAYLOAD_OPEN = "OPEN" -DEFAULT_PAYLOAD_STOP = "STOP" -DEFAULT_POSITION_CLOSED = 0 -DEFAULT_POSITION_OPEN = 100 -DEFAULT_RETAIN = False + DEFAULT_STATE_STOPPED = "stopped" +DEFAULT_PAYLOAD_STOP = "STOP" + DEFAULT_TILT_CLOSED_POSITION = 0 DEFAULT_TILT_MAX = 100 DEFAULT_TILT_MIN = 0 diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index c78319bb46a58b..84163e217df7fd 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -74,6 +74,7 @@ "text", "update", "vacuum", + "valve", "water_heater", } diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py new file mode 100644 index 00000000000000..2c1618c60bafb1 --- /dev/null +++ b/homeassistant/components/mqtt/valve.py @@ -0,0 +1,420 @@ +"""Support for MQTT valve devices.""" +from __future__ import annotations + +from contextlib import suppress +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import valve +from homeassistant.components.valve import ( + DEVICE_CLASSES_SCHEMA, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_OPTIMISTIC, + CONF_VALUE_TEMPLATE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from . import subscription +from .config import MQTT_BASE_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_PAYLOAD_STOP, + CONF_POSITION_CLOSED, + CONF_POSITION_OPEN, + CONF_QOS, + CONF_RETAIN, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, + CONF_STATE_TOPIC, + DEFAULT_OPTIMISTIC, + DEFAULT_PAYLOAD_CLOSE, + DEFAULT_PAYLOAD_OPEN, + DEFAULT_POSITION_CLOSED, + DEFAULT_POSITION_OPEN, + DEFAULT_RETAIN, +) +from .debug_info import log_messages +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, + write_state_on_attr_change, +) +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + ReceiveMessage, + ReceivePayloadType, +) +from .util import valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +CONF_REPORTS_POSITION = "reports_position" + +DEFAULT_NAME = "MQTT Valve" + +MQTT_VALVE_ATTRIBUTES_BLOCKED = frozenset( + { + valve.ATTR_CURRENT_POSITION, + } +) + +NO_POSITION_KEYS = ( + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_STATE_CLOSED, + CONF_STATE_OPEN, +) + +DEFAULTS = { + CONF_PAYLOAD_CLOSE: DEFAULT_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN: DEFAULT_PAYLOAD_OPEN, + CONF_STATE_OPEN: STATE_OPEN, + CONF_STATE_CLOSED: STATE_CLOSED, +} + + +def _validate_and_add_defaults(config: ConfigType) -> ConfigType: + """Validate config options and set defaults.""" + if config[CONF_REPORTS_POSITION] and any(key in config for key in NO_POSITION_KEYS): + raise vol.Invalid( + "Options `payload_open`, `payload_close`, `state_open` and " + "`state_closed` are not allowed if the valve reports a position." + ) + return {**DEFAULTS, **config} + + +_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PAYLOAD_CLOSE): vol.Any(cv.string, None), + vol.Optional(CONF_PAYLOAD_OPEN): vol.Any(cv.string, None), + vol.Optional(CONF_PAYLOAD_STOP): vol.Any(cv.string, None), + vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): int, + vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): int, + vol.Optional(CONF_REPORTS_POSITION, default=False): cv.boolean, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_STATE_CLOSED): cv.string, + vol.Optional(CONF_STATE_CLOSING, default=STATE_CLOSING): cv.string, + vol.Optional(CONF_STATE_OPEN): cv.string, + vol.Optional(CONF_STATE_OPENING, default=STATE_OPENING): cv.string, + vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All(_PLATFORM_SCHEMA_BASE, _validate_and_add_defaults) + +DISCOVERY_SCHEMA = vol.All( + _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), + _validate_and_add_defaults, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT valve through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttValve, + valve.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttValve(MqttEntity, ValveEntity): + """Representation of a valve that can be controlled using MQTT.""" + + _attr_is_closed: bool | None = None + _attributes_extra_blocked: frozenset[str] = MQTT_VALVE_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format: str = valve.ENTITY_ID_FORMAT + _optimistic: bool + _range: tuple[int, int] + _tilt_optimistic: bool + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """Set up valve from config.""" + self._attr_reports_position = config[CONF_REPORTS_POSITION] + self._range = ( + self._config[CONF_POSITION_CLOSED] + 1, + self._config[CONF_POSITION_OPEN], + ) + no_state_topic = config.get(CONF_STATE_TOPIC) is None + self._optimistic = config[CONF_OPTIMISTIC] or no_state_topic + self._attr_assumed_state = self._optimistic + + template_config_attributes = { + "position_open": config[CONF_POSITION_OPEN], + "position_closed": config[CONF_POSITION_CLOSED], + } + + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), entity=self + ).async_render_with_possible_json_value + + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), entity=self + ).async_render + + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + config_attributes=template_config_attributes, + ).async_render_with_possible_json_value + + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + + supported_features = ValveEntityFeature(0) + if CONF_COMMAND_TOPIC in config: + if config[CONF_PAYLOAD_OPEN] is not None: + supported_features |= ValveEntityFeature.OPEN + if config[CONF_PAYLOAD_CLOSE] is not None: + supported_features |= ValveEntityFeature.CLOSE + + if config[CONF_REPORTS_POSITION]: + supported_features |= ValveEntityFeature.SET_POSITION + if config.get(CONF_PAYLOAD_STOP) is not None: + supported_features |= ValveEntityFeature.STOP + + self._attr_supported_features = supported_features + + @callback + def _update_state(self, state: str) -> None: + """Update the valve state based on static payload.""" + self._attr_is_closed = state == STATE_CLOSED + self._attr_is_opening = state == STATE_OPENING + self._attr_is_closing = state == STATE_CLOSING + + @callback + def _process_binary_valve_update( + self, payload: ReceivePayloadType, state_payload: str + ) -> None: + """Process an update for a valve that does not report the position.""" + state: str | None = None + if state_payload == self._config[CONF_STATE_OPENING]: + state = STATE_OPENING + elif state_payload == self._config[CONF_STATE_CLOSING]: + state = STATE_CLOSING + elif state_payload == self._config[CONF_STATE_OPEN]: + state = STATE_OPEN + elif state_payload == self._config[CONF_STATE_CLOSED]: + state = STATE_CLOSED + if state is None: + _LOGGER.warning( + "Payload is not one of [open, closed, opening, closing], got: %s", + payload, + ) + return + self._update_state(state) + + @callback + def _process_position_valve_update( + self, payload: ReceivePayloadType, position_payload: str, state_payload: str + ) -> None: + """Process an update for a valve that reports the position.""" + state: str | None = None + if state_payload == self._config[CONF_STATE_OPENING]: + state = STATE_OPENING + elif state_payload == self._config[CONF_STATE_CLOSING]: + state = STATE_CLOSING + if state is None or position_payload != state_payload: + try: + percentage_payload = ranged_value_to_percentage( + self._range, float(position_payload) + ) + except ValueError: + _LOGGER.warning("Payload '%s' is not numeric", position_payload) + return + + self._attr_current_valve_position = min(max(percentage_payload, 0), 100) + if state is None: + _LOGGER.warning( + "Payload is not one of [opening, closing], got: %s", + payload, + ) + return + self._update_state(state) + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics = {} + + @callback + @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_current_valve_position", + "_attr_is_closed", + "_attr_is_closing", + "_attr_is_opening", + }, + ) + def state_message_received(msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + payload_dict: Any = None + position_payload: Any = None + state_payload: Any = None + payload = self._value_template(msg.payload) + + if not payload: + _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) + return + + with suppress(*JSON_DECODE_EXCEPTIONS): + payload_dict = json_loads(payload) + if isinstance(payload_dict, dict) and "position" in payload_dict: + position_payload = payload_dict["position"] + if isinstance(payload_dict, dict) and "state" in payload_dict: + state_payload = payload_dict["state"] + state_payload = payload if state_payload is None else state_payload + position_payload = payload if position_payload is None else position_payload + + if self._config[CONF_REPORTS_POSITION]: + self._process_position_valve_update( + payload, position_payload, state_payload + ) + else: + self._process_binary_valve_update(payload, state_payload) + + if self._config.get(CONF_STATE_TOPIC): + topics["state_topic"] = { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": state_message_received, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def async_open_valve(self) -> None: + """Move the valve up. + + This method is a coroutine. + """ + payload = self._command_template( + self._config.get(CONF_PAYLOAD_OPEN, DEFAULT_PAYLOAD_OPEN) + ) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + # Optimistically assume that valve has changed state. + self._update_state(STATE_OPEN) + self.async_write_ha_state() + + async def async_close_valve(self) -> None: + """Move the valve down. + + This method is a coroutine. + """ + payload = self._command_template( + self._config.get(CONF_PAYLOAD_CLOSE, DEFAULT_PAYLOAD_CLOSE) + ) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + # Optimistically assume that valve has changed state. + self._update_state(STATE_CLOSED) + self.async_write_ha_state() + + async def async_stop_valve(self) -> None: + """Stop valve positioning. + + This method is a coroutine. + """ + payload = self._command_template(self._config[CONF_PAYLOAD_STOP]) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + percentage_position = position + scaled_position = round( + percentage_to_ranged_value(self._range, percentage_position) + ) + variables = { + "position": percentage_position, + "position_open": self._config[CONF_POSITION_OPEN], + "position_closed": self._config[CONF_POSITION_CLOSED], + } + rendered_position = self._command_template(scaled_position, variables=variables) + + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + rendered_position, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + self._update_state( + STATE_CLOSED + if percentage_position == self._config[CONF_POSITION_CLOSED] + else STATE_OPEN + ) + self._attr_current_valve_position = percentage_position + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 3860c70bbea706..f5f2d67947f6a3 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.0.0"] + "requirements": ["pyatmo==8.0.1"] } diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 9248d3f9e575a0..4107509e01f30e 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import StrEnum +from functools import partial from typing import Final import voluptuous as vol @@ -35,6 +36,11 @@ UnitOfVolume, UnitOfVolumetricFlux, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.util.unit_conversion import BaseUnitConverter, TemperatureConverter ATTR_VALUE = "value" @@ -50,10 +56,23 @@ SERVICE_SET_VALUE = "set_value" + +class NumberMode(StrEnum): + """Modes for number entities.""" + + AUTO = "auto" + BOX = "box" + SLIDER = "slider" + + # MODE_* are deprecated as of 2021.12, use the NumberMode enum instead. -MODE_AUTO: Final = "auto" -MODE_BOX: Final = "box" -MODE_SLIDER: Final = "slider" +_DEPRECATED_MODE_AUTO: Final = DeprecatedConstantEnum(NumberMode.AUTO, "2025.1") +_DEPRECATED_MODE_BOX: Final = DeprecatedConstantEnum(NumberMode.BOX, "2025.1") +_DEPRECATED_MODE_SLIDER: Final = DeprecatedConstantEnum(NumberMode.SLIDER, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) class NumberDeviceClass(StrEnum): @@ -385,14 +404,6 @@ class NumberDeviceClass(StrEnum): """ -class NumberMode(StrEnum): - """Modes for number entities.""" - - AUTO = "auto" - BOX = "box" - SLIDER = "slider" - - DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index d645b8617c2c79..ebb928e72d098d 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -24,16 +24,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) data = entry.data og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) - lists = [] try: await og.login() - lists = (await og.get_my_lists())["shoppingLists"] except (AsyncIOTimeoutError, ClientError) as error: raise ConfigEntryNotReady from error except InvalidLoginException: return False - coordinator = OurGroceriesDataUpdateCoordinator(hass, og, lists) + coordinator = OurGroceriesDataUpdateCoordinator(hass, og) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = coordinator diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py index 636ebcc300a244..c583fb4d5b104d 100644 --- a/homeassistant/components/ourgroceries/coordinator.py +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -20,13 +20,11 @@ class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage fetching OurGroceries data.""" - def __init__( - self, hass: HomeAssistant, og: OurGroceries, lists: list[dict] - ) -> None: + def __init__(self, hass: HomeAssistant, og: OurGroceries) -> None: """Initialize global OurGroceries data updater.""" self.og = og - self.lists = lists - self._ids = [sl["id"] for sl in lists] + self.lists: list[dict] = [] + self._cache: dict[str, dict] = {} interval = timedelta(seconds=SCAN_INTERVAL) super().__init__( hass, @@ -35,13 +33,16 @@ def __init__( update_interval=interval, ) + async def _update_list(self, list_id: str, version_id: str) -> None: + old_version = self._cache.get(list_id, {}).get("list", {}).get("versionId", "") + if old_version == version_id: + return + self._cache[list_id] = await self.og.get_list_items(list_id=list_id) + async def _async_update_data(self) -> dict[str, dict]: """Fetch data from OurGroceries.""" - return dict( - zip( - self._ids, - await asyncio.gather( - *[self.og.get_list_items(list_id=id) for id in self._ids] - ), - ) + self.lists = (await self.og.get_my_lists())["shoppingLists"] + await asyncio.gather( + *[self._update_list(sl["id"], sl["versionId"]) for sl in self.lists] ) + return self._cache diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 98dc7cb47aec64..b6a00bbaf10566 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -20,6 +20,7 @@ Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo @@ -29,73 +30,24 @@ UpdateFailed, ) +from .config_flow import ConfigFlow from .const import DOMAIN PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def _migrate_to_version_2( - hass: HomeAssistant, entry: ConfigEntry -) -> PrusaLink | None: - """Migrate to Version 2.""" - _LOGGER.debug("Migrating entry to version 2") - - data = dict(entry.data) - # "maker" is currently hardcoded in the firmware - # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 - data = { - **entry.data, - CONF_USERNAME: "maker", - CONF_PASSWORD: entry.data[CONF_API_KEY], - } - data.pop(CONF_API_KEY) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up PrusaLink from a config entry.""" + if entry.version == 1 and entry.minor_version < 2: + raise ConfigEntryError("Please upgrade your printer's firmware.") api = PrusaLink( async_get_clientsession(hass), - data[CONF_HOST], - data[CONF_USERNAME], - data[CONF_PASSWORD], + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], ) - try: - await api.get_info() - except InvalidAuth: - # We are unable to reach the new API which usually means - # that the user is running an outdated firmware version - ir.async_create_issue( - hass, - DOMAIN, - "firmware_5_1_required", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="firmware_5_1_required", - translation_placeholders={ - "entry_title": entry.title, - "prusa_mini_firmware_update": "https://help.prusa3d.com/article/firmware-updating-mini-mini_124784", - "prusa_mk4_xl_firmware_update": "https://help.prusa3d.com/article/how-to-update-firmware-mk4-xl_453086", - }, - ) - return None - - entry.version = 2 - hass.config_entries.async_update_entry(entry, data=data) - _LOGGER.info("Migrated config entry to version %d", entry.version) - return api - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up PrusaLink from a config entry.""" - if entry.version == 1: - if (api := await _migrate_to_version_2(hass, entry)) is None: - return False - ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required") - else: - api = PrusaLink( - async_get_clientsession(hass), - entry.data[CONF_HOST], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) coordinators = { "legacy_status": LegacyStatusCoordinator(hass, api), @@ -112,9 +64,59 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - # Version 1->2 migration are handled in async_setup_entry. + if config_entry.version > ConfigFlow.VERSION: + # This means the user has downgraded from a future version + return False + + new_data = dict(config_entry.data) + if config_entry.version == 1: + if config_entry.minor_version < 2: + # Add username and password + # "maker" is currently hardcoded in the firmware + # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 + username = "maker" + password = config_entry.data[CONF_API_KEY] + + api = PrusaLink( + async_get_clientsession(hass), + config_entry.data[CONF_HOST], + username, + password, + ) + try: + await api.get_info() + except InvalidAuth: + # We are unable to reach the new API which usually means + # that the user is running an outdated firmware version + ir.async_create_issue( + hass, + DOMAIN, + "firmware_5_1_required", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="firmware_5_1_required", + translation_placeholders={ + "entry_title": config_entry.title, + "prusa_mini_firmware_update": "https://help.prusa3d.com/article/firmware-updating-mini-mini_124784", + "prusa_mk4_xl_firmware_update": "https://help.prusa3d.com/article/how-to-update-firmware-mk4-xl_453086", + }, + ) + # There is a check in the async_setup_entry to prevent the setup if minor_version < 2 + # Currently we can't reload the config entry + # if the migration returns False. + # Return True here to workaround that. + return True + + new_data[CONF_USERNAME] = username + new_data[CONF_PASSWORD] = password + + ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required") + config_entry.minor_version = 2 + + hass.config_entries.async_update_entry(config_entry, data=new_data) + return True diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index e967cefaffde42..378c5e7395a873 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -66,7 +66,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PrusaLink.""" - VERSION = 2 + VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 2901c14c45514d..a85784a33a71b4 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -25,6 +25,11 @@ PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -70,9 +75,20 @@ class RemoteEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the RemoteEntityFeature enum instead. -SUPPORT_LEARN_COMMAND = 1 -SUPPORT_DELETE_COMMAND = 2 -SUPPORT_ACTIVITY = 4 +_DEPRECATED_SUPPORT_LEARN_COMMAND = DeprecatedConstantEnum( + RemoteEntityFeature.LEARN_COMMAND, "2025.1" +) +_DEPRECATED_SUPPORT_DELETE_COMMAND = DeprecatedConstantEnum( + RemoteEntityFeature.DELETE_COMMAND, "2025.1" +) +_DEPRECATED_SUPPORT_ACTIVITY = DeprecatedConstantEnum( + RemoteEntityFeature.ACTIVITY, "2025.1" +) + + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) REMOTE_SERVICE_ACTIVITY_SCHEMA = make_entity_service_schema( {vol.Optional(ATTR_ACTIVITY): cv.string} diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index f80143e2f9e659..7dbe295afee18c 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -202,22 +202,22 @@ async def set_device_state(self, body: Any) -> httpx.Response: rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with asyncio.timeout(self._timeout): - req: httpx.Response = await getattr(websession, self._method)( - self._resource, - auth=self._auth, - content=bytes(body, "utf-8"), - headers=rendered_headers, - params=rendered_params, - ) - return req + req: httpx.Response = await getattr(websession, self._method)( + self._resource, + auth=self._auth, + content=bytes(body, "utf-8"), + headers=rendered_headers, + params=rendered_params, + timeout=self._timeout, + ) + return req async def async_update(self) -> None: """Get the current state, catching errors.""" req = None try: req = await self.get_device_state(self.hass) - except asyncio.TimeoutError: + except (asyncio.TimeoutError, httpx.TimeoutException): _LOGGER.exception("Timed out while fetching data") except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) @@ -233,14 +233,14 @@ async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with asyncio.timeout(self._timeout): - req = await websession.get( - self._state_resource, - auth=self._auth, - headers=rendered_headers, - params=rendered_params, - ) - text = req.text + req = await websession.get( + self._state_resource, + auth=self._auth, + headers=rendered_headers, + params=rendered_params, + timeout=self._timeout, + ) + text = req.text if self._is_on_template is not None: text = self._is_on_template.async_render_with_possible_json_value( diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 48bdb7083b42cc..6f5defe4c5781a 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.36.2" + "async-upnp-client==0.38.0" ], "ssdp": [ { diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index d7e8843f54b926..37bab7a995d27b 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from functools import partial import logging from typing import Any, TypedDict, cast, final @@ -15,21 +16,25 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from .const import ( # noqa: F401 + _DEPRECATED_SUPPORT_DURATION, + _DEPRECATED_SUPPORT_TONES, + _DEPRECATED_SUPPORT_TURN_OFF, + _DEPRECATED_SUPPORT_TURN_ON, + _DEPRECATED_SUPPORT_VOLUME_SET, ATTR_AVAILABLE_TONES, ATTR_DURATION, ATTR_TONE, ATTR_VOLUME_LEVEL, DOMAIN, - SUPPORT_DURATION, - SUPPORT_TONES, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - SUPPORT_VOLUME_SET, SirenEntityFeature, ) @@ -43,6 +48,12 @@ vol.Optional(ATTR_VOLUME_LEVEL): cv.small_float, } +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + class SirenTurnOnServiceParameters(TypedDict, total=False): """Represent possible parameters to siren.turn_on service data dict type.""" diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py index 374b1d59e2afb3..50c3af61c8dd6e 100644 --- a/homeassistant/components/siren/const.py +++ b/homeassistant/components/siren/const.py @@ -1,8 +1,15 @@ """Constants for the siren component.""" from enum import IntFlag +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "siren" ATTR_TONE: Final = "tone" @@ -24,8 +31,22 @@ class SirenEntityFeature(IntFlag): # These constants are deprecated as of Home Assistant 2022.5 # Please use the SirenEntityFeature enum instead. -SUPPORT_TURN_ON: Final = 1 -SUPPORT_TURN_OFF: Final = 2 -SUPPORT_TONES: Final = 4 -SUPPORT_VOLUME_SET: Final = 8 -SUPPORT_DURATION: Final = 16 +_DEPRECATED_SUPPORT_TURN_ON: Final = DeprecatedConstantEnum( + SirenEntityFeature.TURN_ON, "2025.1" +) +_DEPRECATED_SUPPORT_TURN_OFF: Final = DeprecatedConstantEnum( + SirenEntityFeature.TURN_OFF, "2025.1" +) +_DEPRECATED_SUPPORT_TONES: Final = DeprecatedConstantEnum( + SirenEntityFeature.TONES, "2025.1" +) +_DEPRECATED_SUPPORT_VOLUME_SET: Final = DeprecatedConstantEnum( + SirenEntityFeature.VOLUME_SET, "2025.1" +) +_DEPRECATED_SUPPORT_DURATION: Final = DeprecatedConstantEnum( + SirenEntityFeature.DURATION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index bf48b44e5dc553..e6f18190c0bc46 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.36.2"] + "requirements": ["async-upnp-client==0.38.0"] } diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index f0dea6660858db..d2b7e3a4aa1e00 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -136,6 +136,7 @@ def device_info(device: StarlineDevice) -> DeviceInfo: model=device.typename, name=device.name, sw_version=device.fw_version, + configuration_url="https://starline-online.ru/", ) @staticmethod diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 55aa853208160d..99cae9650ff516 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -104,6 +104,9 @@ }, "horn": { "name": "Horn" + }, + "service_mode": { + "name": "Service mode" } }, "button": { diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 600dac34fe3508..ef24dd52c02531 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -56,6 +56,12 @@ class StarlineSwitchEntityDescription( icon_on="mdi:bullhorn-outline", icon_off="mdi:bullhorn-outline", ), + StarlineSwitchEntityDescription( + key="valet", + translation_key="service_mode", + icon_on="mdi:wrench-clock", + icon_off="mdi:car-wrench", + ), ) diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index 89e018b6635ca5..bcfd10d2f0208b 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/surepetcare", "iot_class": "cloud_polling", "loggers": ["rich", "surepy"], - "requirements": ["surepy==0.8.0"] + "requirements": ["surepy==0.9.0"] } diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 9a4e4fbe1969af..1539c81331ee22 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==1.2.1"] + "requirements": ["switchbot-api==1.3.0"] } diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index 7eec74042e2402..13a987bb998bd5 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -46,7 +46,7 @@ async def async_setup_entry( """Set up Tailwind binary sensor based on a config entry.""" coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TailwindDoorBinarySensorEntity(coordinator, description, door_id) + TailwindDoorBinarySensorEntity(coordinator, door_id, description) for description in DESCRIPTIONS for door_id in coordinator.data.doors ) @@ -57,19 +57,6 @@ class TailwindDoorBinarySensorEntity(TailwindDoorEntity, BinarySensorEntity): entity_description: TailwindDoorBinarySensorEntityDescription - def __init__( - self, - coordinator: TailwindDataUpdateCoordinator, - description: TailwindDoorBinarySensorEntityDescription, - door_id: str, - ) -> None: - """Initiate Tailwind button entity.""" - super().__init__(coordinator, door_id) - self.entity_description = description - self._attr_unique_id = ( - f"{coordinator.data.device_id}-{door_id}-{description.key}" - ) - @property def is_on(self) -> bool | None: """Return the state of the binary sensor.""" diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index e66a95f3ac4a23..dd9548d131c978 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -60,16 +60,6 @@ class TailwindButtonEntity(TailwindEntity, ButtonEntity): entity_description: TailwindButtonEntityDescription - def __init__( - self, - coordinator: TailwindDataUpdateCoordinator, - description: TailwindButtonEntityDescription, - ) -> None: - """Initiate Tailwind button entity.""" - super().__init__(coordinator=coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.data.device_id}-{description.key}" - async def async_press(self) -> None: """Trigger button press on the Tailwind device.""" await self.entity_description.press_fn(self.coordinator.tailwind) diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 5a1f9cb8d730db..4280b6c4baf66f 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -41,15 +41,6 @@ class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): _attr_name = None _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - def __init__( - self, - coordinator: TailwindDataUpdateCoordinator, - door_id: str, - ) -> None: - """Initiate Tailwind button entity.""" - super().__init__(coordinator, door_id) - self._attr_unique_id = f"{coordinator.data.device_id}-{door_id}" - @property def is_closed(self) -> bool: """Return if the cover is closed or not.""" diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index e4b18d5e4da9fb..a97d74490dc49e 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -13,9 +14,15 @@ class TailwindEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): _attr_has_entity_name = True - def __init__(self, coordinator: TailwindDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: """Initialize an Tailwind entity.""" super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.data.device_id}-{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.data.device_id)}, connections={(CONNECTION_NETWORK_MAC, coordinator.data.mac_address)}, @@ -35,11 +42,22 @@ class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): _attr_has_entity_name = True def __init__( - self, coordinator: TailwindDataUpdateCoordinator, door_id: str + self, + coordinator: TailwindDataUpdateCoordinator, + door_id: str, + entity_description: EntityDescription | None = None, ) -> None: """Initialize an Tailwind door entity.""" - self.door_id = door_id super().__init__(coordinator) + self.door_id = door_id + + self._attr_unique_id = f"{coordinator.data.device_id}-{door_id}" + if entity_description: + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.data.device_id}-{door_id}-{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, via_device=(DOMAIN, coordinator.data.device_id), diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index 3d932939ba48c3..88940d110fa247 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -65,16 +65,6 @@ class TailwindNumberEntity(TailwindEntity, NumberEntity): entity_description: TailwindNumberEntityDescription - def __init__( - self, - coordinator: TailwindDataUpdateCoordinator, - description: TailwindNumberEntityDescription, - ) -> None: - """Initiate Tailwind number entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.data.device_id}-{description.key}" - @property def native_value(self) -> int | None: """Return the number value.""" diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 93f551bea371eb..2f52a5d008f593 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -157,7 +157,7 @@ async def async_get_data(self) -> dict[str, str | datetime | int | float | None] _LOGGER.debug("Getting data for device: %s", self) igd_state = await self._igd_device.async_get_traffic_and_status_data() status_info = igd_state.status_info - if status_info is not None and not isinstance(status_info, Exception): + if status_info is not None and not isinstance(status_info, BaseException): wan_status = status_info.connection_status router_uptime = status_info.uptime else: @@ -165,7 +165,7 @@ async def async_get_data(self) -> dict[str, str | datetime | int | float | None] router_uptime = None def get_value(value: Any) -> Any: - if value is None or isinstance(value, Exception): + if value is None or isinstance(value, BaseException): return None return value diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 25f83e0dbf513b..4c3fdb658090bd 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index b3bc0c30bf449a..4881d8c576d4b7 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.36.2"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 8e5514696d20fb..aecc88968f301a 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.130.0"] + "requirements": ["zeroconf==0.131.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 88f7937bb12036..50b6fe01a6be10 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiohttp-zlib-ng==0.1.1 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.11.0 @@ -58,7 +58,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.4 -zeroconf==0.130.0 +zeroconf==0.131.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 6665cdfede47e0..0a13f0f109beba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -239,7 +239,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==21.0.0 +aioesphomeapi==21.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -469,7 +469,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -1637,7 +1637,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.0 +pyatmo==8.0.1 # homeassistant.components.apple_tv pyatv==0.14.3 @@ -2561,13 +2561,13 @@ sunwatcher==0.2.1 sunweg==2.0.1 # homeassistant.components.surepetcare -surepy==0.8.0 +surepy==0.9.0 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.2.1 +switchbot-api==1.3.0 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -2837,7 +2837,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.130.0 +zeroconf==0.131.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b31008f592fa2d..fbf46da4cbf8b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==21.0.0 +aioesphomeapi==21.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -421,7 +421,7 @@ arcam-fmj==1.4.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 # homeassistant.components.sleepiq asyncsleepiq==1.4.0 @@ -1253,7 +1253,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.0 +pyatmo==8.0.1 # homeassistant.components.apple_tv pyatv==0.14.3 @@ -1925,10 +1925,10 @@ sunwatcher==0.2.1 sunweg==2.0.1 # homeassistant.components.surepetcare -surepy==0.8.0 +surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.2.1 +switchbot-api==1.3.0 # homeassistant.components.system_bridge systembridgeconnector==3.10.0 @@ -2135,7 +2135,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.130.0 +zeroconf==0.131.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py new file mode 100644 index 00000000000000..c447119c119044 --- /dev/null +++ b/tests/components/alarm_control_panel/test_init.py @@ -0,0 +1,47 @@ +"""Test for the alarm control panel const module.""" + +from types import ModuleType + +import pytest + +from homeassistant.components import alarm_control_panel + +from tests.common import import_and_test_deprecated_constant_enum + + +@pytest.mark.parametrize( + "code_format", + list(alarm_control_panel.CodeFormat), +) +@pytest.mark.parametrize( + "module", + [alarm_control_panel, alarm_control_panel.const], +) +def test_deprecated_constant_code_format( + caplog: pytest.LogCaptureFixture, + code_format: alarm_control_panel.CodeFormat, + module: ModuleType, +) -> None: + """Test deprecated format constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, code_format, "FORMAT_", "2025.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(alarm_control_panel.AlarmControlPanelEntityFeature), +) +@pytest.mark.parametrize( + "module", + [alarm_control_panel, alarm_control_panel.const], +) +def test_deprecated_support_alarm_constants( + caplog: pytest.LogCaptureFixture, + entity_feature: alarm_control_panel.AlarmControlPanelEntityFeature, + module: ModuleType, +) -> None: + """Test deprecated support alarm constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, entity_feature, "SUPPORT_ALARM_", "2025.1" + ) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 11e39c40cb1b98..7c39e34ac38ddb 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -8,6 +8,13 @@ from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, + STATE_ECO, + STATE_GAS, + STATE_HEAT_PUMP, +) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ALARM_ARMED_AWAY, @@ -16,6 +23,7 @@ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_LOCKED, + STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, @@ -777,6 +785,96 @@ async def test_report_climate_state(hass: HomeAssistant) -> None: assert msg["event"]["payload"]["type"] == "INTERNAL_ERROR" +async def test_report_water_heater_state(hass: HomeAssistant) -> None: + """Test ThermostatController also reports state correctly for water heaters.""" + for operation_mode in (STATE_ECO, STATE_GAS, STATE_HEAT_PUMP): + hass.states.async_set( + "water_heater.boyler", + operation_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_OPERATION_LIST: [STATE_ECO, STATE_GAS, STATE_HEAT_PUMP], + ATTR_OPERATION_MODE: operation_mode, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_equal( + "Alexa.ModeController", "mode", f"operation_mode.{operation_mode}" + ) + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for off_mode in [STATE_OFF]: + hass.states.async_set( + "water_heater.boyler", + off_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_not_has_property("Alexa.ModeController", "mode") + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for state in "unavailable", "unknown": + hass.states.async_set( + f"water_heater.{state}", + state, + {"friendly_name": f"Boyler {state}", "supported_features": 11}, + ) + properties = await reported_properties(hass, f"water_heater.{state}") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_not_has_property("Alexa.ModeController", "mode") + + +async def test_report_singe_mode_water_heater(hass: HomeAssistant) -> None: + """Test ThermostatController also reports state correctly for water heaters.""" + operation_mode = STATE_ECO + hass.states.async_set( + "water_heater.boyler", + operation_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_OPERATION_LIST: [STATE_ECO], + ATTR_OPERATION_MODE: operation_mode, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property("Alexa.ThermostatController", "thermostatMode") + properties.assert_equal( + "Alexa.ModeController", "mode", f"operation_mode.{operation_mode}" + ) + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + async def test_temperature_sensor_sensor(hass: HomeAssistant) -> None: """Test TemperatureSensor reports sensor temperature correctly.""" for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): @@ -823,6 +921,29 @@ async def test_temperature_sensor_climate(hass: HomeAssistant) -> None: ) +async def test_temperature_sensor_water_heater(hass: HomeAssistant) -> None: + """Test TemperatureSensor reports climate temperature correctly.""" + for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): + hass.states.async_set( + "water_heater.boyler", + STATE_ECO, + {"supported_features": 11, ATTR_CURRENT_TEMPERATURE: bad_value}, + ) + + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property("Alexa.TemperatureSensor", "temperature") + + hass.states.async_set( + "water_heater.boyler", + STATE_ECO, + {"supported_features": 11, ATTR_CURRENT_TEMPERATURE: 34}, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"} + ) + + async def test_report_alarm_control_panel_state(hass: HomeAssistant) -> None: """Test SecurityPanelController implements armState property.""" hass.states.async_set("alarm_control_panel.armed_away", STATE_ALARM_ARMED_AWAY, {}) diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py index 4cbe112af499e8..d3ea1bcda3ec6d 100644 --- a/tests/components/alexa/test_common.py +++ b/tests/components/alexa/test_common.py @@ -128,12 +128,14 @@ async def assert_request_calls_service( async def assert_request_fails( - namespace, name, endpoint, service_not_called, hass, payload=None + namespace, name, endpoint, service_not_called, hass, payload=None, instance=None ): """Assert an API request returns an ErrorResponse.""" request = get_new_request(namespace, name, endpoint) if payload: request["directive"]["payload"] = payload + if instance: + request["directive"]["header"]["instance"] = instance domain, service_name = service_not_called.split(".") call = async_mock_service(hass, domain, service_name) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 0a5b1f79f72a04..d025b1586f5bb8 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -2700,6 +2700,181 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_water_heater(hass: HomeAssistant) -> None: + """Test water_heater discovery.""" + hass.config.units = US_CUSTOMARY_SYSTEM + device = ( + "water_heater.boyler", + "gas", + { + "temperature": 70.0, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 75.0, + "friendly_name": "Test water heater", + "supported_features": 1 | 2 | 8, + "operation_list": ["off", "gas", "eco"], + "operation_mode": "gas", + "min_temp": 50, + "max_temp": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "water_heater#boyler" + assert appliance["displayCategories"][0] == "WATER_HEATER" + assert appliance["friendlyName"] == "Test water heater" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.ModeController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "water_heater#boyler") + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas") + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 70.0, "scale": "FAHRENHEIT"}, + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 75.0, "scale": "FAHRENHEIT"} + ) + + modes_capability = get_capability(capabilities, "Alexa.ModeController") + assert modes_capability is not None + configuration = modes_capability["configuration"] + + supported_modes = ["operation_mode.off", "operation_mode.gas", "operation_mode.eco"] + for mode in supported_modes: + assert mode in [item["value"] for item in configuration["supportedModes"]] + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpoint": {"value": 69.0, "scale": "FAHRENHEIT"}}, + ) + assert call.data["temperature"] == 69.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 69.0, "scale": "FAHRENHEIT"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpoint": {"value": 0.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={ + "targetSetpoint": {"value": 30.0, "scale": "CELSIUS"}, + }, + ) + assert call.data["temperature"] == 86.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 86.0, "scale": "FAHRENHEIT"}, + ) + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": -10.0, "scale": "KELVIN"}}, + ) + assert call.data["temperature"] == 52.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 52.0, "scale": "FAHRENHEIT"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": 20.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + # Setting mode, the payload can be an object with a value attribute... + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.eco"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "eco" + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.eco") + + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.gas"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "gas" + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas") + + # assert unsupported mode + msg = await assert_request_fails( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.invalid"}, + instance="water_heater.operation_mode", + ) + assert msg["event"]["payload"]["type"] == "INVALID_VALUE" + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.off"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "off" + + async def test_no_current_target_temp_adjusting_temp(hass: HomeAssistant) -> None: """Test thermostat adjusting temp with no initial target temperature.""" hass.config.units = US_CUSTOMARY_SYSTEM diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index a2f2dfbf9073f4..235ca48f095e42 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2582,7 +2582,7 @@ def test_deprecated_constants( constant_name: str, replacement: Any, ) -> None: - """Test deprecated binary sensor device classes.""" + """Test deprecated automation constants.""" import_and_test_deprecated_constant( caplog, automation, constant_name, replacement.__name__, replacement, "2025.1" ) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 802bf759d81a30..062440e6b3916c 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -1,4 +1,8 @@ """The tests for Cover.""" +from enum import Enum + +import pytest + import homeassistant.components.cover as cover from homeassistant.const import ( ATTR_ENTITY_ID, @@ -12,6 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import import_and_test_deprecated_constant_enum + async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -> None: """Test the provided services.""" @@ -112,3 +118,26 @@ def is_closed(hass, ent): def is_closing(hass, ent): """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(ent.entity_id, STATE_CLOSING) + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(cover.CoverEntityFeature, "SUPPORT_") + + _create_tuples(cover.CoverDeviceClass, "DEVICE_CLASS_"), +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, cover, enum, constant_prefix, "2025.1" + ) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 2960789c64624b..024187a33f6a9e 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -3,6 +3,7 @@ import json import logging import os +from types import ModuleType from unittest.mock import Mock, call, patch import pytest @@ -33,6 +34,7 @@ from tests.common import ( assert_setup_component, async_fire_time_changed, + import_and_test_deprecated_constant_enum, mock_registry, mock_restore_cache, patch_yaml_files, @@ -681,3 +683,19 @@ def test_see_schema_allowing_ios_calls() -> None: "hostname": "beer", } ) + + +@pytest.mark.parametrize(("enum"), list(SourceType)) +@pytest.mark.parametrize( + "module", + [device_tracker, device_tracker.const], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: SourceType, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "SOURCE_TYPE_", "2025.1" + ) diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index ec421141768cd6..e6a3ab546cc6a6 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,6 +1,7 @@ """Tests for fan platforms.""" import pytest +from homeassistant.components import fan from homeassistant.components.fan import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, @@ -13,6 +14,7 @@ import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component +from tests.common import import_and_test_deprecated_constant_enum from tests.testing_config.custom_components.test.fan import MockFan @@ -145,3 +147,12 @@ async def test_preset_mode_validation( with pytest.raises(NotValidPresetModeError) as exc: await test_fan._valid_preset_mode_or_raise("invalid") assert exc.value.translation_key == "not_valid_preset_mode" + + +@pytest.mark.parametrize(("enum"), list(fan.FanEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: fan.FanEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, fan, enum, "SUPPORT_", "2025.1") diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index a80f3956f203a9..da45e1f1661fd0 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -1,9 +1,16 @@ """The tests for the humidifier component.""" +from enum import Enum +from types import ModuleType from unittest.mock import MagicMock +import pytest + +from homeassistant.components import humidifier from homeassistant.components.humidifier import HumidifierEntity from homeassistant.core import HomeAssistant +from tests.common import import_and_test_deprecated_constant_enum + class MockHumidifierEntity(HumidifierEntity): """Mock Humidifier device to use in tests.""" @@ -34,3 +41,28 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await humidifier.async_turn_off() assert humidifier.turn_off.called + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(humidifier.HumidifierEntityFeature, "SUPPORT_") + + _create_tuples(humidifier.HumidifierDeviceClass, "DEVICE_CLASS_"), +) +@pytest.mark.parametrize(("module"), [humidifier, humidifier.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.1" + ) diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index d8589ea265ec46..a03d975ed8a18a 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -5,6 +5,7 @@ import pytest +from homeassistant.components import lock from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, @@ -25,6 +26,8 @@ from .conftest import MockLock +from tests.common import import_and_test_deprecated_constant_enum + async def help_test_async_lock_service( hass: HomeAssistant, @@ -353,3 +356,12 @@ async def test_lock_with_illegal_default_code( await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_UNLOCK ) + + +@pytest.mark.parametrize(("enum"), list(LockEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: LockEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, lock, enum, "SUPPORT_", "2025.1") diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py new file mode 100644 index 00000000000000..27be72ecabc28f --- /dev/null +++ b/tests/components/mqtt/test_valve.py @@ -0,0 +1,1399 @@ +"""The tests for the MQTT valve platform.""" +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, valve +from homeassistant.components.mqtt.valve import ( + MQTT_VALVE_ATTRIBUTES_BLOCKED, + ValveEntityFeature, +) +from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + SERVICE_SET_VALVE_POSITION, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_STOP_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant + +from .test_common import ( + help_custom_config, + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + valve.DOMAIN: { + "command_topic": "command-topic", + "state_topic": "test-topic", + "name": "test", + } + } +} + +DEFAULT_CONFIG_REPORTS_POSITION = { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "state_topic": "test-topic", + "reports_position": True, + } + } +} + + +@pytest.fixture(autouse=True) +def valve_platform_only(): + """Only setup the valve platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.VALVE]): + yield + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ("open", STATE_OPEN), + ("closed", STATE_CLOSED), + ("closing", STATE_CLOSING), + ("opening", STATE_OPENING), + ('{"state" : "open"}', STATE_OPEN), + ('{"state" : "closed"}', STATE_CLOSED), + ('{"state" : "closing"}', STATE_CLOSING), + ('{"state" : "opening"}', STATE_OPENING), + ], +) +async def test_state_via_state_topic_no_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic without position and without template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "value_template": "{{ value_json.state }}", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ('{"state":"open"}', STATE_OPEN), + ('{"state":"closed"}', STATE_CLOSED), + ('{"state":"closing"}', STATE_CLOSING), + ('{"state":"opening"}', STATE_OPENING), + ], +) +async def test_state_via_state_topic_with_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic with template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "value_template": "{{ value_json.position }}", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ('{"position":100}', STATE_OPEN), + ('{"position":50.0}', STATE_OPEN), + ('{"position":0}', STATE_CLOSED), + ('{"position":"non_numeric"}', STATE_UNKNOWN), + ('{"ignored":12}', STATE_UNKNOWN), + ], +) +async def test_state_via_state_topic_with_position_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic with position template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state", "valve_position"), + [ + ("0", STATE_CLOSED, 0), + ("opening", STATE_OPENING, None), + ("50", STATE_OPEN, 50), + ("closing", STATE_CLOSING, None), + ("100", STATE_OPEN, 100), + ("open", STATE_UNKNOWN, None), + ("closed", STATE_UNKNOWN, None), + ("-10", STATE_CLOSED, 0), + ("110", STATE_OPEN, 100), + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 10, "state": "opening"}', STATE_OPENING, 10), + ('{"position": 50, "state": "open"}', STATE_OPEN, 50), + ('{"position": 100, "state": "closing"}', STATE_CLOSING, 100), + ('{"position": 90, "state": "closing"}', STATE_CLOSING, 90), + ('{"position": 0, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": -10, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": 110, "state": "open"}', STATE_OPEN, 100), + ], +) +async def test_state_via_state_topic_through_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, + valve_position: int | None, +) -> None: + """Test the controlling state via topic through position. + + Test is still possible to process a `opening` or `closing` state update. + Additional we test json messages can be processed containing both position and state. + Incoming rendered positions are clamped between 0..100. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state", "valve_position"), + [ + ("-128", STATE_CLOSED, 0), + ("0", STATE_OPEN, 50), + ("127", STATE_OPEN, 100), + ("-130", STATE_CLOSED, 0), + ("130", STATE_OPEN, 100), + ('{"position": -128, "state": "opening"}', STATE_OPENING, 0), + ('{"position": -30, "state": "opening"}', STATE_OPENING, 38), + ('{"position": 30, "state": "open"}', STATE_OPEN, 61), + ('{"position": 127, "state": "closing"}', STATE_CLOSING, 100), + ('{"position": 100, "state": "closing"}', STATE_CLOSING, 89), + ('{"position": -128, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": -130, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": 130, "state": "open"}', STATE_OPEN, 100), + ], +) +async def test_state_via_state_trough_position_with_alt_range( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, + valve_position: int | None, +) -> None: + """Test the controlling state via topic through position and an alternative range. + + Test is still possible to process a `opening` or `closing` state update. + Additional we test json messages can be processed containing both position and state. + Incoming rendered positions are clamped between 0..100. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "SToP", + "payload_open": "OPeN", + "payload_close": "CLOsE", + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "CLOsE"), + (SERVICE_OPEN_VALVE, "OPeN"), + (SERVICE_STOP_VALVE, "SToP"), + ], +) +async def tests_controling_valve_by_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve by state.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("hass_config", "supported_features"), + [ + (DEFAULT_CONFIG, ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": "OPEN", "payload_close": "CLOSE"},), + ), + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": "OPEN", "payload_close": None},), + ), + ValveEntityFeature.OPEN, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": None, "payload_close": "CLOSE"},), + ), + ValveEntityFeature.CLOSE, + ), + ( + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG, ({"payload_stop": "STOP"},) + ), + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG_REPORTS_POSITION, + ({"payload_stop": "STOP"},), + ), + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION, + ), + ], +) +async def tests_supported_features( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + supported_features: ValveEntityFeature, +) -> None: + """Test the valve's supported features.""" + assert await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state is not None + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == supported_features + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"payload_open": "OPEN"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"payload_close": "CLOSE"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"state_open": "open"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"state_closed": "closed"},) + ), + ], +) +async def tests_open_close_payload_config_not_allowed( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test open or close payload configs fail if valve reports position.""" + assert await mqtt_mock_entry() + + assert hass.states.get("valve.test") is None + + assert ( + "Options `payload_open`, `payload_close`, `state_open` and " + "`state_closed` are not allowed if the valve reports a position." in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "STOP", + "optimistic": True, + } + } + }, + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_stop": "STOP", + } + } + }, + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message", "asserted_state"), + [ + (SERVICE_CLOSE_VALVE, "CLOSE", STATE_CLOSED), + (SERVICE_OPEN_VALVE, "OPEN", STATE_OPEN), + ], +) +async def tests_controling_valve_by_state_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, + asserted_state: str, +) -> None: + """Test controlling a valve by state explicit and implicit optimistic.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "0"), + (SERVICE_OPEN_VALVE, "100"), + (SERVICE_STOP_VALVE, "-1"), + ], +) +async def tests_controling_valve_by_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message"), + [ + (0, "0"), + (30, "30"), + (100, "100"), + ], +) +async def tests_controling_valve_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, +) -> None: + """Test controlling a valve by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "optimistic": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message", "asserted_position", "asserted_state"), + [ + (0, "0", 0, STATE_CLOSED), + (30, "30", 30, STATE_OPEN), + (100, "100", 100, STATE_OPEN), + ], +) +async def tests_controling_valve_optimistic_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, + asserted_position: int, + asserted_state: str, +) -> None: + """Test controlling a valve optimistic by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == asserted_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message"), + [ + (0, "-128"), + (30, "-52"), + (80, "76"), + (100, "127"), + ], +) +async def tests_controling_valve_with_alt_range_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, +) -> None: + """Test controlling a valve with an alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "-128"), + (SERVICE_OPEN_VALVE, "127"), + ], +) +async def tests_controling_valve_with_alt_range_by_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve with an alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "STOP", + "optimistic": True, + "reports_position": True, + } + } + }, + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_stop": "STOP", + "reports_position": True, + } + } + }, + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message", "asserted_state", "asserted_position"), + [ + (SERVICE_CLOSE_VALVE, "0", STATE_CLOSED, 0), + (SERVICE_OPEN_VALVE, "100", STATE_OPEN, 100), + ], +) +async def tests_controling_valve_by_position_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, + asserted_state: str, + asserted_position: int, +) -> None: + """Test controlling a valve by state explicit and implicit optimistic.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_CURRENT_POSITION) is None + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes[ATTR_CURRENT_POSITION] == asserted_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "optimistic": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message", "asserted_position", "asserted_state"), + [ + (0, "-128", 0, STATE_CLOSED), + (30, "-52", 30, STATE_OPEN), + (50, "0", 50, STATE_OPEN), + (100, "127", 100, STATE_OPEN), + ], +) +async def tests_controling_valve_optimistic_alt_trange_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, + asserted_position: int, + asserted_state: str, +) -> None: + """Test controlling a valve optimistic and alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == asserted_position + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, valve.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "device_class": "water", + "state_topic": "test-topic", + } + } + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid device class.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.attributes.get("device_class") == "water" + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "device_class": "abc123", + "state_topic": "test-topic", + } + } + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid device class.""" + assert await mqtt_mock_entry() + assert "expected ValveDeviceClass" in caplog.text + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, + mqtt_mock_entry, + valve.DOMAIN, + DEFAULT_CONFIG, + MQTT_VALVE_ATTRIBUTES_BLOCKED, + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + valve.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + valve.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + valve.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique_id option only creates one valve per id.""" + await help_test_unique_id(hass, mqtt_mock_entry, valve.DOMAIN) + + +async def test_discovery_removal_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered valve.""" + data = '{ "name": "test", "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, valve.DOMAIN, data) + + +async def test_discovery_update_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered valve.""" + config1 = {"name": "Beer", "command_topic": "test_topic"} + config2 = {"name": "Milk", "command_topic": "test_topic"} + await help_test_discovery_update( + hass, mqtt_mock_entry, caplog, valve.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered valve.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.valve.MqttValve.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + valve.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, valve.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT valve device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT valve device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + valve.DOMAIN, + DEFAULT_CONFIG, + SERVICE_OPEN_VALVE, + command_payload="OPEN", + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + SERVICE_OPEN_VALVE, + "command_topic", + None, + "OPEN", + None, + ), + ], +) +async def test_publishing_with_custom_encoding( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, +) -> None: + """Test publishing MQTT payload with different encoding.""" + domain = valve.DOMAIN + config = DEFAULT_CONFIG + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = valve.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("state_topic", "open", None, None), + ("state_topic", "closing", None, None), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + valve.DOMAIN, + DEFAULT_CONFIG[mqtt.DOMAIN][valve.DOMAIN], + topic, + value, + attribute, + attribute_value, + skip_raw_test=True, + ) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = valve.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = valve.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "open", "closed"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/number/test_const.py b/tests/components/number/test_const.py new file mode 100644 index 00000000000000..e4b47e17e6edeb --- /dev/null +++ b/tests/components/number/test_const.py @@ -0,0 +1,16 @@ +"""Test the number const module.""" + +import pytest + +from homeassistant.components.number import const + +from tests.common import import_and_test_deprecated_constant_enum + + +@pytest.mark.parametrize(("enum"), list(const.NumberMode)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: const.NumberMode, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, const, enum, "MODE_", "2025.1") diff --git a/tests/components/ourgroceries/__init__.py b/tests/components/ourgroceries/__init__.py index 67fcb439908ea5..6f90cb7ea1b2fe 100644 --- a/tests/components/ourgroceries/__init__.py +++ b/tests/components/ourgroceries/__init__.py @@ -1,6 +1,6 @@ """Tests for the OurGroceries integration.""" -def items_to_shopping_list(items: list) -> dict[dict[list]]: +def items_to_shopping_list(items: list, version_id: str = "1") -> dict[dict[list]]: """Convert a list of items into a shopping list.""" - return {"list": {"items": items}} + return {"list": {"versionId": version_id, "items": items}} diff --git a/tests/components/ourgroceries/conftest.py b/tests/components/ourgroceries/conftest.py index 7f113da26333f4..c5fdec3ecb7055 100644 --- a/tests/components/ourgroceries/conftest.py +++ b/tests/components/ourgroceries/conftest.py @@ -46,7 +46,7 @@ def mock_ourgroceries(items: list[dict]) -> AsyncMock: og = AsyncMock() og.login.return_value = True og.get_my_lists.return_value = { - "shoppingLists": [{"id": "test_list", "name": "Test List"}] + "shoppingLists": [{"id": "test_list", "name": "Test List", "versionId": "1"}] } og.get_list_items.return_value = items_to_shopping_list(items) return og diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py index 8686c52d79b65d..649e86f2b056e7 100644 --- a/tests/components/ourgroceries/test_todo.py +++ b/tests/components/ourgroceries/test_todo.py @@ -17,6 +17,10 @@ from tests.common import async_fire_time_changed +def _mock_version_id(og: AsyncMock, version: int) -> None: + og.get_my_lists.return_value["shoppingLists"][0]["versionId"] = str(version) + + @pytest.mark.parametrize( ("items", "expected_state"), [ @@ -57,8 +61,10 @@ async def test_add_todo_list_item( ourgroceries.add_item_to_list = AsyncMock() # Fake API response when state is refreshed after create + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( - [{"id": "12345", "name": "Soda"}] + [{"id": "12345", "name": "Soda"}], + version_id="2", ) await hass.services.async_call( @@ -95,6 +101,7 @@ async def test_update_todo_item_status( ourgroceries.toggle_item_crossed_off = AsyncMock() # Fake API response when state is refreshed after crossing off + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( [{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}] ) @@ -118,6 +125,7 @@ async def test_update_todo_item_status( assert state.state == "0" # Fake API response when state is refreshed after reopen + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( [{"id": "12345", "name": "Soda"}] ) @@ -166,6 +174,7 @@ async def test_update_todo_item_summary( ourgroceries.change_item_on_list = AsyncMock() # Fake API response when state is refreshed update + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( [{"id": "12345", "name": "Milk"}] ) @@ -204,6 +213,7 @@ async def test_remove_todo_item( ourgroceries.remove_item_from_list = AsyncMock() # Fake API response when state is refreshed after remove + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list([]) await hass.services.async_call( @@ -224,6 +234,25 @@ async def test_remove_todo_item( assert state.state == "0" +async def test_version_id_optimization( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test that list items aren't being retrieved if version id stays the same.""" + state = hass.states.get("todo.test_list") + assert state.state == "0" + assert ourgroceries.get_list_items.call_count == 1 + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("todo.test_list") + assert state.state == "0" + assert ourgroceries.get_list_items.call_count == 1 + + @pytest.mark.parametrize( ("exception"), [ @@ -242,6 +271,7 @@ async def test_coordinator_error( state = hass.states.get("todo.test_list") assert state.state == "0" + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.side_effect = exception freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/prusalink/conftest.py b/tests/components/prusalink/conftest.py index 97f4bd92d7dfac..1e514342068c2a 100644 --- a/tests/components/prusalink/conftest.py +++ b/tests/components/prusalink/conftest.py @@ -14,7 +14,8 @@ def mock_config_entry(hass): entry = MockConfigEntry( domain=DOMAIN, data={"host": "http://example.com", "username": "dummy", "password": "dummypw"}, - version=2, + version=1, + minor_version=2, ) entry.add_to_hass(hass) return entry diff --git a/tests/components/prusalink/test_init.py b/tests/components/prusalink/test_init.py index 963750ef8bea8a..5b261207e9340e 100644 --- a/tests/components/prusalink/test_init.py +++ b/tests/components/prusalink/test_init.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.prusalink import DOMAIN +from homeassistant.components.prusalink.config_flow import ConfigFlow from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -14,11 +15,12 @@ from tests.common import MockConfigEntry, async_fire_time_changed +pytestmark = pytest.mark.usefixtures("mock_api") + async def test_unloading( hass: HomeAssistant, mock_config_entry: ConfigEntry, - mock_api, ) -> None: """Test unloading prusalink.""" assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -35,7 +37,7 @@ async def test_unloading( @pytest.mark.parametrize("exception", [InvalidAuth, PrusaLinkError]) async def test_failed_update( - hass: HomeAssistant, mock_config_entry: ConfigEntry, mock_api, exception + hass: HomeAssistant, mock_config_entry: ConfigEntry, exception ) -> None: """Test failed update marks prusalink unavailable.""" assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -61,16 +63,17 @@ async def test_failed_update( assert state.state == "unavailable" -async def test_migration_1_2( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api +async def test_migration_from_1_1_to_1_2( + hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test migrating from version 1 to 2.""" + data = { + CONF_HOST: "http://prusaxl.local", + CONF_API_KEY: "api-key", + } entry = MockConfigEntry( domain=DOMAIN, - data={ - CONF_HOST: "http://prusaxl.local", - CONF_API_KEY: "api-key", - }, + data=data, version=1, ) entry.add_to_hass(hass) @@ -83,7 +86,7 @@ async def test_migration_1_2( # Ensure that we have username, password after migration assert len(config_entries) == 1 assert config_entries[0].data == { - CONF_HOST: "http://prusaxl.local", + **data, CONF_USERNAME: "maker", CONF_PASSWORD: "api-key", } @@ -91,10 +94,10 @@ async def test_migration_1_2( assert len(issue_registry.issues) == 0 -async def test_outdated_firmware_migration_1_2( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api +async def test_migration_from_1_1_to_1_2_outdated_firmware( + hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: - """Test migrating from version 1 to 2.""" + """Test migrating from version 1.1 to 1.2.""" entry = MockConfigEntry( domain=DOMAIN, data={ @@ -107,14 +110,14 @@ async def test_outdated_firmware_migration_1_2( with patch( "pyprusalink.PrusaLink.get_info", - side_effect=InvalidAuth, + side_effect=InvalidAuth, # Simulate firmware update required ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state == ConfigEntryState.SETUP_ERROR - # Make sure that we don't have thrown the issues - assert len(issue_registry.issues) == 1 + assert entry.minor_version == 1 + assert (DOMAIN, "firmware_5_1_required") in issue_registry.issues # Reloading the integration with a working API (e.g. User updated firmware) await hass.config_entries.async_reload(entry.entry_id) @@ -122,4 +125,22 @@ async def test_outdated_firmware_migration_1_2( # Integration should be running now, the issue should be gone assert entry.state == ConfigEntryState.LOADED - assert len(issue_registry.issues) == 0 + assert entry.minor_version == 2 + assert (DOMAIN, "firmware_5_1_required") not in issue_registry.issues + + +async def test_migration_fails_on_future_version( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test migrating fails on a version higher than the current one.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + version=ConfigFlow.VERSION + 1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 6219943693b89e..b185b229cd2d05 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -1,4 +1,6 @@ """The tests for the Remote component, adapted from Light Test.""" +import pytest + import homeassistant.components.remote as remote from homeassistant.components.remote import ( ATTR_ALTERNATIVE, @@ -20,7 +22,7 @@ ) from homeassistant.core import HomeAssistant -from tests.common import async_mock_service +from tests.common import async_mock_service, import_and_test_deprecated_constant_enum TEST_PLATFORM = {DOMAIN: {CONF_PLATFORM: "test"}} SERVICE_SEND_COMMAND = "send_command" @@ -139,3 +141,12 @@ async def test_delete_command(hass: HomeAssistant) -> None: assert call.domain == remote.DOMAIN assert call.service == SERVICE_DELETE_COMMAND assert call.data[ATTR_ENTITY_ID] == ENTITY_ID + + +@pytest.mark.parametrize(("enum"), list(remote.RemoteEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: remote.RemoteEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, remote, enum, "SUPPORT_", "2025.1") diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 6224d98f694815..7be2ce4c63ebf7 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -1,5 +1,4 @@ """The tests for the REST switch platform.""" -import asyncio from http import HTTPStatus import httpx @@ -103,7 +102,7 @@ async def test_setup_failed_connect( caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection error occurs.""" - respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=httpx.ConnectError("")) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -117,7 +116,7 @@ async def test_setup_timeout( caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection timeout occurs.""" - respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=httpx.TimeoutException("")) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -326,7 +325,7 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None: """Test turn_on when timeout occurs.""" await _async_setup_test_switch(hass) - respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR + respx.post(RESOURCE).mock(side_effect=httpx.TimeoutException("")) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -389,7 +388,7 @@ async def test_turn_off_timeout(hass: HomeAssistant) -> None: """Test turn_off when timeout occurs.""" await _async_setup_test_switch(hass) - respx.post(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.post(RESOURCE).mock(side_effect=httpx.TimeoutException("")) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -442,7 +441,7 @@ async def test_update_timeout(hass: HomeAssistant) -> None: """Test update when timeout occurs.""" await _async_setup_test_switch(hass) - respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=httpx.TimeoutException("")) async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index 267b1c1e30d97f..ee007f6f1f5334 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -1,8 +1,10 @@ """The tests for the siren component.""" +from types import ModuleType from unittest.mock import MagicMock import pytest +from homeassistant.components import siren from homeassistant.components.siren import ( SirenEntity, SirenEntityDescription, @@ -11,6 +13,8 @@ from homeassistant.components.siren.const import SirenEntityFeature from homeassistant.core import HomeAssistant +from tests.common import import_and_test_deprecated_constant_enum + class MockSirenEntity(SirenEntity): """Mock siren device to use in tests.""" @@ -104,3 +108,14 @@ async def test_missing_tones_dict(hass: HomeAssistant) -> None: siren.hass = hass with pytest.raises(ValueError): process_turn_on_params(siren, {"tone": 3}) + + +@pytest.mark.parametrize(("enum"), list(SirenEntityFeature)) +@pytest.mark.parametrize(("module"), [siren, siren.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: SirenEntityFeature, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, module, enum, "SUPPORT_", "2025.1") diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 18145d0274e536..e3da11f28d125d 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -143,3 +143,147 @@ 'via_device_id': None, }) # --- +# name: test_number_entities[binary_sensor.door_1_operational_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door 1 Operational status', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_1_operational_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_number_entities[binary_sensor.door_1_operational_status].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_1_operational_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational status', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_status', + 'unique_id': '_3c_e9_e_6d_21_84_-door1-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_1_operational_status].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door1', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door 2 Operational status', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_2_operational_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_status].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_2_operational_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational status', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_status', + 'unique_id': '_3c_e9_e_6d_21_84_-door2-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_status].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door2', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/test_binary_sensor.py b/tests/components/tailwind/test_binary_sensor.py index 1a8269e8457062..8715c1436286c8 100644 --- a/tests/components/tailwind/test_binary_sensor.py +++ b/tests/components/tailwind/test_binary_sensor.py @@ -9,23 +9,27 @@ pytestmark = pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "entity_id", + [ + "binary_sensor.door_1_operational_status", + "binary_sensor.door_2_operational_status", + ], +) async def test_number_entities( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + entity_id: str, ) -> None: """Test binary sensor entities provided by the Tailwind integration.""" - for entity_id in ( - "binary_sensor.door_1_operational_status", - "binary_sensor.door_2_operational_status", - ): - assert (state := hass.states.get(entity_id)) - assert snapshot == state + assert (state := hass.states.get(entity_id)) + assert snapshot == state - assert (entity_entry := entity_registry.async_get(state.entity_id)) - assert snapshot == entity_entry + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry - assert entity_entry.device_id - assert (device_entry := device_registry.async_get(entity_entry.device_id)) - assert snapshot == device_entry + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index cae65521e21eab..26f8dee4a9df40 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -35,16 +35,16 @@ from tests.typing import MqttMockHAClient, MqttMockPahoClient COVER_SUPPORT = ( - cover.SUPPORT_OPEN - | cover.SUPPORT_CLOSE - | cover.SUPPORT_STOP - | cover.SUPPORT_SET_POSITION + cover.CoverEntityFeature.OPEN + | cover.CoverEntityFeature.CLOSE + | cover.CoverEntityFeature.STOP + | cover.CoverEntityFeature.SET_POSITION ) TILT_SUPPORT = ( - cover.SUPPORT_OPEN_TILT - | cover.SUPPORT_CLOSE_TILT - | cover.SUPPORT_STOP_TILT - | cover.SUPPORT_SET_TILT_POSITION + cover.CoverEntityFeature.OPEN_TILT + | cover.CoverEntityFeature.CLOSE_TILT + | cover.CoverEntityFeature.STOP_TILT + | cover.CoverEntityFeature.SET_TILT_POSITION ) diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 05e3151be2e6fe..727fddc9bd387b 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -60,7 +60,7 @@ async def test_controlling_state_via_mqtt( state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF assert state.attributes["percentage"] is None - assert state.attributes["supported_features"] == fan.SUPPORT_SET_SPEED + assert state.attributes["supported_features"] == fan.FanEntityFeature.SET_SPEED assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":1}') diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 4ad1677a16fb6f..6816c7701aadcd 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -293,10 +293,8 @@ def test_check_if_deprecated_constant( } filename = f"/home/paulus/{module_name.replace('.', '/')}.py" - # mock module for homeassistant/helpers/frame.py#get_integration_frame - sys.modules[module_name] = Mock(__file__=filename) - - with patch( + # mock sys.modules for homeassistant/helpers/frame.py#get_integration_frame + with patch.dict(sys.modules, {module_name: Mock(__file__=filename)}), patch( "homeassistant.helpers.frame.extract_stack", return_value=[ Mock( diff --git a/tests/testing_config/custom_components/test/alarm_control_panel.py b/tests/testing_config/custom_components/test/alarm_control_panel.py index b39c2c71edad45..7490a7703a4e52 100644 --- a/tests/testing_config/custom_components/test/alarm_control_panel.py +++ b/tests/testing_config/custom_components/test/alarm_control_panel.py @@ -4,11 +4,7 @@ """ from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.components.alarm_control_panel.const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, + AlarmControlPanelEntityFeature, ) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, @@ -73,14 +69,14 @@ def state(self): return self._state @property - def supported_features(self) -> int: + def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" return ( - SUPPORT_ALARM_ARM_HOME - | SUPPORT_ALARM_ARM_AWAY - | SUPPORT_ALARM_ARM_NIGHT - | SUPPORT_ALARM_TRIGGER - | SUPPORT_ALARM_ARM_VACATION + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.TRIGGER + | AlarmControlPanelEntityFeature.ARM_VACATION ) def alarm_arm_away(self, code=None): diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index 51a4a9dc83b391..2a57412ea9e595 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -2,17 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.cover import ( - SUPPORT_CLOSE, - SUPPORT_CLOSE_TILT, - SUPPORT_OPEN, - SUPPORT_OPEN_TILT, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, - SUPPORT_STOP, - SUPPORT_STOP_TILT, - CoverEntity, -) +from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING from tests.common import MockEntity @@ -32,38 +22,38 @@ def init(empty=False): name="Simple cover", is_on=True, unique_id="unique_cover", - supported_features=SUPPORT_OPEN | SUPPORT_CLOSE, + supported_features=CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, ), MockCover( name="Set position cover", is_on=True, unique_id="unique_set_pos_cover", current_cover_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_STOP - | SUPPORT_SET_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION, ), MockCover( name="Simple tilt cover", is_on=True, unique_id="unique_tilt_cover", - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT, ), MockCover( name="Set tilt position cover", is_on=True, unique_id="unique_set_pos_tilt_cover", current_cover_tilt_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, ), MockCover( name="All functions cover", @@ -71,14 +61,14 @@ def init(empty=False): unique_id="unique_all_functions_cover", current_cover_position=50, current_cover_tilt_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_STOP - | SUPPORT_SET_POSITION - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, ), ] ) @@ -97,7 +87,7 @@ class MockCover(MockEntity, CoverEntity): @property def is_closed(self): """Return if the cover is closed or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: return self.current_cover_position == 0 if "state" in self._values: @@ -107,7 +97,7 @@ def is_closed(self): @property def is_opening(self): """Return if the cover is opening or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: if "state" in self._values: return self._values["state"] == STATE_OPENING @@ -116,7 +106,7 @@ def is_opening(self): @property def is_closing(self): """Return if the cover is closing or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: if "state" in self._values: return self._values["state"] == STATE_CLOSING @@ -124,14 +114,14 @@ def is_closing(self): def open_cover(self, **kwargs) -> None: """Open cover.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: self._values["state"] = STATE_OPENING else: self._values["state"] = STATE_OPEN def close_cover(self, **kwargs) -> None: """Close cover.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: self._values["state"] = STATE_CLOSING else: self._values["state"] = STATE_CLOSED diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py index 31294a48e3d57b..11eb366f2fcb45 100644 --- a/tests/testing_config/custom_components/test/device_tracker.py +++ b/tests/testing_config/custom_components/test/device_tracker.py @@ -2,7 +2,7 @@ from homeassistant.components.device_tracker import DeviceScanner from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker.const import SourceType async def async_get_scanner(hass, config): @@ -23,7 +23,7 @@ def __init__(self): @property def source_type(self): """Return the source type, eg gps or router, of the device.""" - return SOURCE_TYPE_ROUTER + return SourceType.ROUTER @property def battery_level(self): diff --git a/tests/testing_config/custom_components/test/lock.py b/tests/testing_config/custom_components/test/lock.py index b48e8b1fad9ea4..9cefa34363e9ee 100644 --- a/tests/testing_config/custom_components/test/lock.py +++ b/tests/testing_config/custom_components/test/lock.py @@ -2,7 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.lock import SUPPORT_OPEN, LockEntity +from homeassistant.components.lock import LockEntity, LockEntityFeature from tests.common import MockEntity @@ -20,7 +20,7 @@ def init(empty=False): "support_open": MockLock( name="Support open Lock", is_locked=True, - supported_features=SUPPORT_OPEN, + supported_features=LockEntityFeature.OPEN, unique_id="unique_support_open", ), "no_support_open": MockLock(