diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4978fd6b2a4b9..dff9bd3cf76d8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: category: "/language:python" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1d9a093a7e652..f6cb56594c64d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: exclude_types: [csv, json, html] exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.30.0 + rev: v1.30.1 hooks: - id: zizmor args: diff --git a/homeassistant/components/airthings_ble/coordinator.py b/homeassistant/components/airthings_ble/coordinator.py index 7c7284f7e7516..487fe635fa07d 100644 --- a/homeassistant/components/airthings_ble/coordinator.py +++ b/homeassistant/components/airthings_ble/coordinator.py @@ -102,4 +102,14 @@ async def _async_update_data(self) -> AirthingsDevice: data = await self.airthings.update_device(self.ble_device) except Exception as err: raise UpdateFailed(f"Unable to fetch data: {err}") from err + + if not data.address: + # The device did not report its address, which means the read did not + # complete. Building entities from this would create a duplicate device + # and entities with an empty unique id prefix. + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="incomplete_read", + ) + return data diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index ccc467160c173..7dc45d2880c42 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -67,6 +67,9 @@ "exceptions": { "device_not_found": { "message": "Could not find Airthings device with address {address}: {reason}" + }, + "incomplete_read": { + "message": "The Airthings device did not return complete data, retrying" } } } diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index 47697c414ccee..c97078b0a7ee1 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -25,8 +25,11 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN -from .const import AlarmControlPanelEntityFeature, AlarmControlPanelEntityStateAttribute +from .const import ( + DOMAIN, + AlarmControlPanelEntityFeature, + AlarmControlPanelEntityStateAttribute, +) ACTION_TYPES: Final[set[str]] = { "arm_away", diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index 150b75166e584..d317f424d4ad7 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, AlarmControlPanelState +from . import AlarmControlPanelState from .const import ( CONDITION_ARMED_AWAY, CONDITION_ARMED_CUSTOM_BYPASS, @@ -31,6 +31,7 @@ CONDITION_ARMED_VACATION, CONDITION_DISARMED, CONDITION_TRIGGERED, + DOMAIN, AlarmControlPanelEntityFeature, ) diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 8bb005ac1b450..c4658da5094ad 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -20,8 +20,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, AlarmControlPanelState -from .const import AlarmControlPanelEntityFeature +from . import AlarmControlPanelState +from .const import DOMAIN, AlarmControlPanelEntityFeature BASIC_TRIGGER_TYPES: Final[set[str]] = {"triggered", "disarmed", "arming"} TRIGGER_TYPES: Final[set[str]] = BASIC_TRIGGER_TYPES | { diff --git a/homeassistant/components/alarm_control_panel/reproduce_state.py b/homeassistant/components/alarm_control_panel/reproduce_state.py index 80fdc2049c9d1..0f0af528ea1f3 100644 --- a/homeassistant/components/alarm_control_panel/reproduce_state.py +++ b/homeassistant/components/alarm_control_panel/reproduce_state.py @@ -17,7 +17,8 @@ ) from homeassistant.core import Context, HomeAssistant, State -from . import DOMAIN, AlarmControlPanelState +from . import AlarmControlPanelState +from .const import DOMAIN _LOGGER: Final = logging.getLogger(__name__) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 50acd4c6fa9b6..0968f7dfe0f84 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -1,11 +1,9 @@ """Component to interface with binary sensors.""" from datetime import timedelta -from enum import StrEnum import logging -from typing import Final, Literal, final, override +from typing import Literal, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -18,9 +16,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey +from .const import DEVICE_CLASSES_SCHEMA, DOMAIN, BinarySensorDeviceClass # noqa: F401 + _LOGGER = logging.getLogger(__name__) -DOMAIN: Final = "binary_sensor" DATA_COMPONENT: HassKey[EntityComponent[BinarySensorEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA @@ -28,100 +27,6 @@ SCAN_INTERVAL = timedelta(seconds=30) -class BinarySensorDeviceClass(StrEnum): - """Device class for binary sensors.""" - - # On means low, Off means normal - BATTERY = "battery" - - # On means charging, Off means not charging - BATTERY_CHARGING = "battery_charging" - - # On means carbon monoxide detected, Off means no carbon monoxide (clear) - CO = "carbon_monoxide" - - # On means cold, Off means normal - COLD = "cold" - - # On means connected, Off means disconnected - CONNECTIVITY = "connectivity" - - # On means open, Off means closed - DOOR = "door" - - # On means open, Off means closed - GARAGE_DOOR = "garage_door" - - # On means gas detected, Off means no gas (clear) - GAS = "gas" - - # On means glass break detected, Off means no glass break (clear) - GLASS_BREAK = "glass_break" - - # On means hot, Off means normal - HEAT = "heat" - - # On means light detected, Off means no light - LIGHT = "light" - - # On means open (unlocked), Off means closed (locked) - LOCK = "lock" - - # On means wet, Off means dry - MOISTURE = "moisture" - - # On means motion detected, Off means no motion (clear) - MOTION = "motion" - - # On means moving, Off means not moving (stopped) - MOVING = "moving" - - # On means occupied, Off means not occupied (clear) - OCCUPANCY = "occupancy" - - # On means open, Off means closed - OPENING = "opening" - - # On means plugged in, Off means unplugged - PLUG = "plug" - - # On means power detected, Off means no power - POWER = "power" - - # On means home, Off means away - PRESENCE = "presence" - - # On means problem detected, Off means no problem (OK) - PROBLEM = "problem" - - # On means running, Off means not running - RUNNING = "running" - - # On means unsafe, Off means safe - SAFETY = "safety" - - # On means smoke detected, Off means no smoke (clear) - SMOKE = "smoke" - - # On means sound detected, Off means no sound (clear) - SOUND = "sound" - - # On means tampering detected, Off means no tampering (clear) - TAMPER = "tamper" - - # On means update available, Off means up-to-date - UPDATE = "update" - - # On means vibration detected, Off means no vibration - VIBRATION = "vibration" - - # On means open, Off means closed - WINDOW = "window" - - -DEVICE_CLASSES_SCHEMA = probatio.All( - probatio.Lower, probatio.Coerce(BinarySensorDeviceClass) -) DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass] # mypy: disallow-any-generics diff --git a/homeassistant/components/binary_sensor/const.py b/homeassistant/components/binary_sensor/const.py new file mode 100644 index 0000000000000..29516153f4c84 --- /dev/null +++ b/homeassistant/components/binary_sensor/const.py @@ -0,0 +1,104 @@ +"""Constants for the binary_sensor integration.""" + +from enum import StrEnum +from typing import Final + +import probatio + +DOMAIN: Final = "binary_sensor" + + +class BinarySensorDeviceClass(StrEnum): + """Device class for binary sensors.""" + + # On means low, Off means normal + BATTERY = "battery" + + # On means charging, Off means not charging + BATTERY_CHARGING = "battery_charging" + + # On means carbon monoxide detected, Off means no carbon monoxide (clear) + CO = "carbon_monoxide" + + # On means cold, Off means normal + COLD = "cold" + + # On means connected, Off means disconnected + CONNECTIVITY = "connectivity" + + # On means open, Off means closed + DOOR = "door" + + # On means open, Off means closed + GARAGE_DOOR = "garage_door" + + # On means gas detected, Off means no gas (clear) + GAS = "gas" + + # On means glass break detected, Off means no glass break (clear) + GLASS_BREAK = "glass_break" + + # On means hot, Off means normal + HEAT = "heat" + + # On means light detected, Off means no light + LIGHT = "light" + + # On means open (unlocked), Off means closed (locked) + LOCK = "lock" + + # On means wet, Off means dry + MOISTURE = "moisture" + + # On means motion detected, Off means no motion (clear) + MOTION = "motion" + + # On means moving, Off means not moving (stopped) + MOVING = "moving" + + # On means occupied, Off means not occupied (clear) + OCCUPANCY = "occupancy" + + # On means open, Off means closed + OPENING = "opening" + + # On means plugged in, Off means unplugged + PLUG = "plug" + + # On means power detected, Off means no power + POWER = "power" + + # On means home, Off means away + PRESENCE = "presence" + + # On means problem detected, Off means no problem (OK) + PROBLEM = "problem" + + # On means running, Off means not running + RUNNING = "running" + + # On means unsafe, Off means safe + SAFETY = "safety" + + # On means smoke detected, Off means no smoke (clear) + SMOKE = "smoke" + + # On means sound detected, Off means no sound (clear) + SOUND = "sound" + + # On means tampering detected, Off means no tampering (clear) + TAMPER = "tamper" + + # On means update available, Off means up-to-date + UPDATE = "update" + + # On means vibration detected, Off means no vibration + VIBRATION = "vibration" + + # On means open, Off means closed + WINDOW = "window" + + +DEVICE_CLASSES_SCHEMA = probatio.All( + probatio.Lower, probatio.Coerce(BinarySensorDeviceClass) +) diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index ea27a1f727a17..eb6454dd5a27b 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, BinarySensorDeviceClass +from .const import DOMAIN, BinarySensorDeviceClass # mypy: disallow-any-generics diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index dbba3ddbed707..56b28ccc8f011 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -15,7 +15,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, BinarySensorDeviceClass +from .const import DOMAIN, BinarySensorDeviceClass DEVICE_CLASS_NONE = "none" diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 64da00f308f44..946f6e15d2d44 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -1,11 +1,9 @@ """Component to pressing a button as platforms.""" from datetime import timedelta -from enum import StrEnum import logging from typing import final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -19,7 +17,12 @@ from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, SERVICE_PRESS +from .const import ( # noqa: F401 + DEVICE_CLASSES_SCHEMA, + DOMAIN, + SERVICE_PRESS, + ButtonDeviceClass, +) _LOGGER = logging.getLogger(__name__) @@ -32,16 +35,6 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -class ButtonDeviceClass(StrEnum): - """Device class for buttons.""" - - IDENTIFY = "identify" - RESTART = "restart" - UPDATE = "update" - - -DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(ButtonDeviceClass)) - # mypy: disallow-any-generics diff --git a/homeassistant/components/button/const.py b/homeassistant/components/button/const.py index 20adaa9c12385..25d40bacb5233 100644 --- a/homeassistant/components/button/const.py +++ b/homeassistant/components/button/const.py @@ -1,6 +1,21 @@ """Provides the constants needed for the component.""" +from enum import StrEnum from typing import Final +import probatio + DOMAIN: Final = "button" + SERVICE_PRESS = "press" + + +class ButtonDeviceClass(StrEnum): + """Device class for buttons.""" + + IDENTIFY = "identify" + RESTART = "restart" + UPDATE = "update" + + +DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(ButtonDeviceClass)) diff --git a/homeassistant/components/button/trigger.py b/homeassistant/components/button/trigger.py index 90cb7a50b2eb8..383c4836221c1 100644 --- a/homeassistant/components/button/trigger.py +++ b/homeassistant/components/button/trigger.py @@ -5,7 +5,7 @@ from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger -from . import DOMAIN +from .const import DOMAIN class ButtonPressedTrigger(StatelessEntityTriggerBase): diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index dc48881004aa5..f97d2e257082c 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -6,7 +6,6 @@ from contextlib import suppress from dataclasses import asdict, dataclass from datetime import datetime, timedelta -from enum import IntFlag from functools import partial import logging import os @@ -69,6 +68,7 @@ PREF_ORIENTATION, PREF_PRELOAD_STREAM, SERVICE_RECORD, + CameraEntityFeature, CameraEntityStateAttribute, CameraState, StreamType, @@ -114,13 +114,6 @@ ATTR_FORMAT: Final = "format" -class CameraEntityFeature(IntFlag): - """Supported features of the camera entity.""" - - ON_OFF = 1 - STREAM = 2 - - DEFAULT_CONTENT_TYPE: Final = "image/jpeg" ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}" diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index 2df843aa2e28e..9637cf2b76b29 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -1,6 +1,6 @@ """Constants for Camera component.""" -from enum import StrEnum +from enum import IntFlag, StrEnum from typing import TYPE_CHECKING, Final from homeassistant.util.hass_dict import HassKey @@ -58,3 +58,10 @@ class StreamType(StrEnum): HLS = "hls" WEB_RTC = "web_rtc" + + +class CameraEntityFeature(IntFlag): + """Supported features of the camera entity.""" + + ON_OFF = 1 + STREAM = 2 diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index c14577c05b387..8249f40fd7c63 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -5,6 +5,8 @@ from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers +DOMAIN: Final = "climate" + class HVACMode(StrEnum): """HVAC mode for climate devices.""" @@ -127,7 +129,6 @@ class HVACAction(StrEnum): DEFAULT_MIN_HUMIDITY = 30 DEFAULT_MAX_HUMIDITY = 99 -DOMAIN: Final = "climate" INTENT_SET_FAN_MODE = "HassClimateSetFanMode" INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 00eac9ace8a73..f70cc18bf5a2a 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -19,7 +19,8 @@ from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, const +from . import const +from .const import DOMAIN ACTION_TYPES = {"set_hvac_mode", "set_preset_mode"} diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 6ddb545eacb3d..f4610483c9f3a 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -24,7 +24,8 @@ from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, const +from . import const +from .const import DOMAIN CONDITION_TYPES = {"is_hvac_mode", "is_preset_mode"} diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 730b1c61dad54..5bea6c6cd053d 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -23,7 +23,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, const +from . import const +from .const import DOMAIN TRIGGER_TYPES = { "current_temperature_changed", diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index e20d2aace5415..95f939fc3342f 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -12,13 +12,13 @@ ATTR_FAN_MODE, ATTR_FAN_MODES, ATTR_TEMPERATURE, - DOMAIN, INTENT_SET_FAN_MODE, INTENT_SET_TEMPERATURE, SERVICE_SET_FAN_MODE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, ) +from .const import DOMAIN FAN_MODE_TRANSLATION_PREFIX = ( f"component.{DOMAIN}.entity_component._.state_attributes.{ATTR_FAN_MODE}.state." diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 7a4a23b61cc2a..0dbd36396aabf 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -39,6 +39,7 @@ ATTR_POSITION, ATTR_SPEED, ATTR_TILT_POSITION, + DEVICE_CLASSES_SCHEMA, DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER, @@ -58,7 +59,7 @@ PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=15) -DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(CoverDeviceClass)) + DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] # mypy: disallow-any-generics diff --git a/homeassistant/components/cover/const.py b/homeassistant/components/cover/const.py index 98da9073a0374..aadf0b13985d4 100644 --- a/homeassistant/components/cover/const.py +++ b/homeassistant/components/cover/const.py @@ -3,6 +3,8 @@ from enum import IntFlag, StrEnum from typing import Final +import probatio + DOMAIN: Final = "cover" ATTR_CURRENT_POSITION = "current_position" @@ -68,3 +70,6 @@ class CoverDeviceClass(StrEnum): SHADE = "shade" SHUTTER = "shutter" WINDOW = "window" + + +DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(CoverDeviceClass)) diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index 53a970b885270..c1d0e8e8d695a 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -22,7 +22,8 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, CoverEntityFeature +from . import ATTR_POSITION, ATTR_TILT_POSITION, CoverEntityFeature +from .const import DOMAIN CMD_ACTION_TYPES = {"open", "close", "stop", "open_tilt", "close_tilt"} POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index 74a0edd36915a..936cdf22db2e5 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -21,7 +21,8 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, CoverEntityFeature, CoverEntityStateAttribute, CoverState +from . import CoverEntityFeature, CoverEntityStateAttribute, CoverState +from .const import DOMAIN # mypy: disallow-any-generics diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index 8b2f25d7ab357..51547be2325c3 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -24,7 +24,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, CoverEntityFeature, CoverEntityStateAttribute, CoverState +from . import CoverEntityFeature, CoverEntityStateAttribute, CoverState +from .const import DOMAIN POSITION_TRIGGER_TYPES = {"position", "tilt_position"} STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py index a54cfd98eacaa..2915b8425d3c3 100644 --- a/homeassistant/components/cover/intent.py +++ b/homeassistant/components/cover/intent.py @@ -4,7 +4,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from . import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER, CoverDeviceClass +from . import INTENT_CLOSE_COVER, INTENT_OPEN_COVER, CoverDeviceClass +from .const import DOMAIN async def async_setup_intents(hass: HomeAssistant) -> None: diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index 1079673a0ac2f..2a9a492f7628c 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -22,11 +22,11 @@ from . import ( ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, CoverEntityFeature, CoverEntityStateAttribute, CoverState, ) +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 7fcbb74ca3d05..d8ee6ffa40a6b 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -8,9 +8,10 @@ from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers from homeassistant.util.signal_type import SignalType +DOMAIN: Final = "device_tracker" + LOGGER: Final = logging.getLogger(__package__) -DOMAIN: Final = "device_tracker" ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_TYPE_LEGACY: Final = "legacy" diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index b06ce4e1bcc2f..28379510e5206 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -2,7 +2,6 @@ from dataclasses import asdict, dataclass from datetime import datetime, timedelta -from enum import StrEnum import logging from typing import Any, Self, final, override @@ -25,6 +24,7 @@ DOMAIN, ButtonEventType, DoorbellEventType, + EventDeviceClass, EventEntityCapabilityAttribute, EventEntityStateAttribute, ) @@ -37,14 +37,6 @@ SCAN_INTERVAL = timedelta(seconds=30) -class EventDeviceClass(StrEnum): - """Device class for events.""" - - DOORBELL = "doorbell" - BUTTON = "button" - MOTION = "motion" - - __all__ = [ "ATTR_EVENT_TYPE", "ATTR_EVENT_TYPES", diff --git a/homeassistant/components/event/const.py b/homeassistant/components/event/const.py index ce1327fb54ea9..3d6ab7f129986 100644 --- a/homeassistant/components/event/const.py +++ b/homeassistant/components/event/const.py @@ -4,6 +4,7 @@ from typing import Final DOMAIN: Final = "event" + ATTR_EVENT_TYPE = "event_type" ATTR_EVENT_TYPES = "event_types" ATTR_MULTI_PRESS_COUNT = "multi_press_count" @@ -40,3 +41,11 @@ class ButtonEventType(StrEnum): LONG_PRESS_END = "long_press_end" MULTI_PRESS_ONGOING = "multi_press_ongoing" MULTI_PRESS_END = "multi_press_end" + + +class EventDeviceClass(StrEnum): + """Device class for events.""" + + DOORBELL = "doorbell" + BUTTON = "button" + MOTION = "motion" diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 6a578c687d9e8..921a6b9c3a1d3 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -1,11 +1,10 @@ """Provides functionality to interact with fans.""" from datetime import timedelta -from enum import IntFlag import functools as ft import logging import math -from typing import Any, Final, final, override +from typing import Any, final, override import probatio from propcache.api import cached_property @@ -29,11 +28,15 @@ ranged_value_to_percentage, ) -from .const import FanEntityCapabilityAttribute, FanEntityStateAttribute +from .const import ( + DOMAIN, + FanEntityCapabilityAttribute, + FanEntityFeature, + FanEntityStateAttribute, +) _LOGGER = logging.getLogger(__name__) -DOMAIN: Final = "fan" DATA_COMPONENT: HassKey[EntityComponent[FanEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA @@ -41,17 +44,6 @@ SCAN_INTERVAL = timedelta(seconds=30) -class FanEntityFeature(IntFlag): - """Supported features of the fan entity.""" - - SET_SPEED = 1 - OSCILLATE = 2 - DIRECTION = 4 - PRESET_MODE = 8 - TURN_OFF = 16 - TURN_ON = 32 - - SERVICE_INCREASE_SPEED = "increase_speed" SERVICE_DECREASE_SPEED = "decrease_speed" SERVICE_OSCILLATE = "oscillate" diff --git a/homeassistant/components/fan/condition.py b/homeassistant/components/fan/condition.py index 2063e98033edd..3dcee3e3a1541 100644 --- a/homeassistant/components/fan/condition.py +++ b/homeassistant/components/fan/condition.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.condition import Condition, make_entity_state_condition -from . import DOMAIN +from .const import DOMAIN CONDITIONS: dict[str, type[Condition]] = { "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), diff --git a/homeassistant/components/fan/const.py b/homeassistant/components/fan/const.py index 7f9d0009cf26f..1d565787f2147 100644 --- a/homeassistant/components/fan/const.py +++ b/homeassistant/components/fan/const.py @@ -1,6 +1,9 @@ """Constants for the fan component.""" -from enum import StrEnum +from enum import IntFlag, StrEnum +from typing import Final + +DOMAIN: Final = "fan" class FanEntityCapabilityAttribute(StrEnum): @@ -17,3 +20,14 @@ class FanEntityStateAttribute(StrEnum): PERCENTAGE = "percentage" PERCENTAGE_STEP = "percentage_step" PRESET_MODE = "preset_mode" + + +class FanEntityFeature(IntFlag): + """Supported features of the fan entity.""" + + SET_SPEED = 1 + OSCILLATE = 2 + DIRECTION = 4 + PRESET_MODE = 8 + TURN_OFF = 16 + TURN_ON = 32 diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index 6ec41af6c110e..f87fdcf0c8958 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -10,7 +10,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN +from .const import DOMAIN _ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend( {probatio.Required(CONF_DOMAIN): DOMAIN} diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index e6198a043f699..3fa2c35474e80 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -21,7 +21,7 @@ from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN +from .const import DOMAIN CONDITION_TYPES = {"is_on", "is_off"} diff --git a/homeassistant/components/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py index 6bcc65b90d832..0ae93d1b3e19b 100644 --- a/homeassistant/components/fan/device_trigger.py +++ b/homeassistant/components/fan/device_trigger.py @@ -8,7 +8,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN TRIGGER_SCHEMA = probatio.All( toggle_entity.TRIGGER_SCHEMA, diff --git a/homeassistant/components/fan/intent.py b/homeassistant/components/fan/intent.py index c648af7b1e97c..6d97d6a8cbea0 100644 --- a/homeassistant/components/fan/intent.py +++ b/homeassistant/components/fan/intent.py @@ -5,7 +5,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from . import ATTR_PERCENTAGE, DOMAIN, SERVICE_TURN_ON +from . import ATTR_PERCENTAGE, SERVICE_TURN_ON +from .const import DOMAIN INTENT_FAN_SET_SPEED = "HassFanSetSpeed" diff --git a/homeassistant/components/fan/llm.py b/homeassistant/components/fan/llm.py index 21284eb8f636b..0e3c3262b8c6f 100644 --- a/homeassistant/components/fan/llm.py +++ b/homeassistant/components/fan/llm.py @@ -6,7 +6,7 @@ from homeassistant.helpers import intent from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool -from . import DOMAIN +from .const import DOMAIN from .intent import INTENT_FAN_SET_SPEED # Intents owned by this integration that are exposed as LLM tools. diff --git a/homeassistant/components/fan/reproduce_state.py b/homeassistant/components/fan/reproduce_state.py index 9458d5ce1c74e..239fa62531a18 100644 --- a/homeassistant/components/fan/reproduce_state.py +++ b/homeassistant/components/fan/reproduce_state.py @@ -19,13 +19,12 @@ ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE, - DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, ) -from .const import FanEntityStateAttribute +from .const import DOMAIN, FanEntityStateAttribute _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fan/trigger.py b/homeassistant/components/fan/trigger.py index e36970afdfca1..5f65367380509 100644 --- a/homeassistant/components/fan/trigger.py +++ b/homeassistant/components/fan/trigger.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger -from . import DOMAIN +from .const import DOMAIN TRIGGERS: dict[str, type[Trigger]] = { "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 3412915cfec3e..67753c11b1669 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from typing import Any, Final, final, override +from typing import Any, final, override from propcache.api import cached_property @@ -19,11 +19,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey -from .const import GeolocationEntityStateAttribute +from .const import DOMAIN, GeolocationEntityStateAttribute _LOGGER = logging.getLogger(__name__) -DOMAIN: Final = "geo_location" DATA_COMPONENT: HassKey[EntityComponent[GeolocationEvent]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA diff --git a/homeassistant/components/geo_location/const.py b/homeassistant/components/geo_location/const.py index 6d1513ccb7143..ff6e90a6bbd66 100644 --- a/homeassistant/components/geo_location/const.py +++ b/homeassistant/components/geo_location/const.py @@ -1,9 +1,12 @@ """Constants for the geo_location component.""" from enum import StrEnum +from typing import Final from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers +DOMAIN: Final = "geo_location" + class GeolocationEntityStateAttribute( StrEnum, diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index c461b1f48fe68..8ef39ed65e92b 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -22,8 +22,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN -from .const import GeolocationEntityStateAttribute +from .const import DOMAIN, GeolocationEntityStateAttribute _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 9edf74104b2e8..10994b43399b1 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -1,7 +1,6 @@ """Provides functionality to interact with humidifier devices.""" from datetime import timedelta -from enum import StrEnum import logging from typing import Any, final, override @@ -34,6 +33,7 @@ ATTR_TARGET_HUMIDITY_STEP, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, + DEVICE_CLASSES_SCHEMA, DOMAIN, MODE_AUTO, MODE_AWAY, @@ -47,6 +47,7 @@ SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, HumidifierAction, + HumidifierDeviceClass, HumidifierEntityCapabilityAttribute, HumidifierEntityFeature, HumidifierEntityStateAttribute, @@ -61,17 +62,6 @@ SCAN_INTERVAL = timedelta(seconds=60) -class HumidifierDeviceClass(StrEnum): - """Device class for humidifiers.""" - - HUMIDIFIER = "humidifier" - DEHUMIDIFIER = "dehumidifier" - - -DEVICE_CLASSES_SCHEMA = probatio.All( - probatio.Lower, probatio.Coerce(HumidifierDeviceClass) -) - # DEVICE_CLASSES below is deprecated as of 2021.12 # use the HumidifierDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in HumidifierDeviceClass] diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 8b2a8b6c95aa2..c0e5f77084cf1 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -3,6 +3,10 @@ from enum import IntFlag, StrEnum from typing import Final +import probatio + +DOMAIN: Final = "humidifier" + MODE_NORMAL = "normal" MODE_ECO = "eco" MODE_AWAY = "away" @@ -34,7 +38,6 @@ class HumidifierAction(StrEnum): DEFAULT_MIN_HUMIDITY = 0 DEFAULT_MAX_HUMIDITY = 100 -DOMAIN: Final = "humidifier" SERVICE_SET_MODE = "set_mode" SERVICE_SET_HUMIDITY = "set_humidity" @@ -62,3 +65,15 @@ class HumidifierEntityFeature(IntFlag): """Supported features of the humidifier entity.""" MODES = 1 + + +class HumidifierDeviceClass(StrEnum): + """Device class for humidifiers.""" + + HUMIDIFIER = "humidifier" + DEHUMIDIFIER = "dehumidifier" + + +DEVICE_CLASSES_SCHEMA = probatio.All( + probatio.Lower, probatio.Coerce(HumidifierDeviceClass) +) diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index b4bdfdbadd075..a83815aaa51c4 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -21,7 +21,8 @@ from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolDictType -from . import DOMAIN, const +from . import const +from .const import DOMAIN # mypy: disallow-any-generics diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index 4ca2d16fdf87c..b101a392bee1f 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -26,7 +26,8 @@ from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, const +from . import const +from .const import DOMAIN TOGGLE_CONDITION = toggle_entity.CONDITION_SCHEMA.extend( {probatio.Required(CONF_DOMAIN): DOMAIN} diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index aff95e3471631..ff84c4e9893ab 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -25,8 +25,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN -from .const import HumidifierEntityStateAttribute +from .const import DOMAIN, HumidifierEntityStateAttribute # mypy: disallow-any-generics diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index 6f3bcf2b4d336..2dbbd5857f695 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -10,13 +10,12 @@ from . import ( ATTR_HUMIDITY, - DOMAIN, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, SERVICE_TURN_ON, HumidifierEntityFeature, ) -from .const import HumidifierEntityCapabilityAttribute +from .const import DOMAIN, HumidifierEntityCapabilityAttribute INTENT_HUMIDITY = "HassHumidifierSetpoint" INTENT_MODE = "HassHumidifierMode" diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 852b31b41eafb..46b32e6b4be40 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -2,7 +2,6 @@ import asyncio from datetime import timedelta -from enum import StrEnum import logging from typing import Any, Final, TypedDict, final, override @@ -24,27 +23,17 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from .const import ImageProcessingEntityStateAttribute +from .const import ( + DOMAIN, + ImageProcessingDeviceClass, + ImageProcessingEntityStateAttribute, +) _LOGGER = logging.getLogger(__name__) -DOMAIN: Final = "image_processing" SCAN_INTERVAL = timedelta(seconds=10) -class ImageProcessingDeviceClass(StrEnum): - """Device class for image processing entities.""" - - # Automatic license plate recognition - ALPR = "alpr" - - # Face - FACE = "face" - - # OCR - OCR = "ocr" - - SERVICE_SCAN = "scan" EVENT_DETECT_FACE = "image_processing.detect_face" diff --git a/homeassistant/components/image_processing/const.py b/homeassistant/components/image_processing/const.py index c9bda715135cb..496affd1d4056 100644 --- a/homeassistant/components/image_processing/const.py +++ b/homeassistant/components/image_processing/const.py @@ -1,6 +1,9 @@ """Constants for the image_processing component.""" from enum import StrEnum +from typing import Final + +DOMAIN: Final = "image_processing" class ImageProcessingEntityStateAttribute(StrEnum): @@ -8,3 +11,16 @@ class ImageProcessingEntityStateAttribute(StrEnum): FACES = "faces" TOTAL_FACES = "total_faces" + + +class ImageProcessingDeviceClass(StrEnum): + """Device class for image processing entities.""" + + # Automatic license plate recognition + ALPR = "alpr" + + # Face + FACE = "face" + + # OCR + OCR = "ocr" diff --git a/homeassistant/components/indi_allsky/manifest.json b/homeassistant/components/indi_allsky/manifest.json index c6666ed120bc9..fb63bfd8105df 100644 --- a/homeassistant/components/indi_allsky/manifest.json +++ b/homeassistant/components/indi_allsky/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aioindiallsky"], "quality_scale": "bronze", - "requirements": ["aioindiallsky==0.1.1"] + "requirements": ["aioindiallsky==0.1.2"] } diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index f4bda801d83ff..419facabc78b9 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==10.0.0"] + "requirements": ["infrared-protocols==10.1.0"] } diff --git a/homeassistant/components/lametric/const.py b/homeassistant/components/lametric/const.py index 8c05b15ad1f54..0ca6762f4850c 100644 --- a/homeassistant/components/lametric/const.py +++ b/homeassistant/components/lametric/const.py @@ -9,6 +9,7 @@ DOMAIN: Final = "lametric" PLATFORMS = [ Platform.BUTTON, + Platform.NOTIFY, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/lametric/helpers.py b/homeassistant/components/lametric/helpers.py index a0fab0ec31a65..b50d07298eae0 100644 --- a/homeassistant/components/lametric/helpers.py +++ b/homeassistant/components/lametric/helpers.py @@ -34,12 +34,14 @@ async def handler( self.coordinator.last_update_success = False self.coordinator.async_update_listeners() raise HomeAssistantError( - "Error communicating with the LaMetric device" + translation_domain=DOMAIN, + translation_key="communication_error", ) from error except LaMetricError as error: raise HomeAssistantError( - "Invalid response from the LaMetric device" + translation_domain=DOMAIN, + translation_key="invalid_response", ) from error return handler diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py index c6e0e9bec8f1f..7972486d58f1b 100644 --- a/homeassistant/components/lametric/notify.py +++ b/homeassistant/components/lametric/notify.py @@ -15,15 +15,56 @@ Sound, ) -from homeassistant.components.notify import ATTR_DATA, BaseNotificationService +from homeassistant.components.notify import ( + ATTR_DATA, + BaseNotificationService, + NotifyEntity, +) from homeassistant.const import CONF_ICON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.enum import try_parse_enum from .const import CONF_CYCLES, CONF_ICON_TYPE, CONF_PRIORITY, CONF_SOUND -from .coordinator import LaMetricConfigEntry +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator +from .entity import LaMetricEntity +from .helpers import lametric_exception_handler + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LaMetricConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LaMetric notify entity based on a config entry.""" + async_add_entities([LaMetricNotifyEntity(entry.runtime_data)]) + + +class LaMetricNotifyEntity(LaMetricEntity, NotifyEntity): + """Representation of a LaMetric notify entity.""" + + _attr_translation_key = "message" + + def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None: + """Initialize the notify entity.""" + super().__init__(coordinator=coordinator) + self._attr_unique_id = f"{coordinator.data.serial_number}-message" + + @lametric_exception_handler + @override + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message to the LaMetric device.""" + await self.coordinator.lametric.notify( + notification=Notification( + icon_type=NotificationIconType.NONE, + priority=NotificationPriority.INFO, + model=Model(frames=[Simple(text=message)]), + ) + ) async def async_get_service( diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index d9c29af7bd97a..5148378e437fb 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -65,6 +65,11 @@ "name": "Dismiss current notification" } }, + "notify": { + "message": { + "name": "Message" + } + }, "number": { "brightness": { "name": "Brightness" @@ -93,6 +98,14 @@ } } }, + "exceptions": { + "communication_error": { + "message": "Error communicating with the LaMetric device" + }, + "invalid_response": { + "message": "Invalid response from the LaMetric device" + } + }, "selector": { "icon_type": { "options": { diff --git a/homeassistant/components/lawn_mower/const.py b/homeassistant/components/lawn_mower/const.py index 125675c006327..00d5502c97fea 100644 --- a/homeassistant/components/lawn_mower/const.py +++ b/homeassistant/components/lawn_mower/const.py @@ -3,6 +3,8 @@ from enum import IntFlag, StrEnum from typing import Final +DOMAIN: Final = "lawn_mower" + class LawnMowerActivity(StrEnum): """Activity state of lawn mower devices.""" @@ -35,8 +37,6 @@ class LawnMowerEntityFeature(IntFlag): STOP = 8 -DOMAIN: Final = "lawn_mower" - SERVICE_START_MOWING = "start_mowing" SERVICE_PAUSE = "pause" SERVICE_DOCK = "dock" diff --git a/homeassistant/components/lawn_mower/intent.py b/homeassistant/components/lawn_mower/intent.py index a0176446b7709..5df20a999c7d6 100644 --- a/homeassistant/components/lawn_mower/intent.py +++ b/homeassistant/components/lawn_mower/intent.py @@ -3,7 +3,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from . import DOMAIN, SERVICE_DOCK, SERVICE_START_MOWING, LawnMowerEntityFeature +from . import SERVICE_DOCK, SERVICE_START_MOWING, LawnMowerEntityFeature +from .const import DOMAIN INTENT_LANW_MOWER_START_MOWING = "HassLawnMowerStartMowing" INTENT_LANW_MOWER_DOCK = "HassLawnMowerDock" diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index dd41d21b3e64d..66c6566ff0d5a 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -1,7 +1,6 @@ """Component to interface with locks that can be controlled remotely.""" from datetime import timedelta -from enum import IntFlag import functools as ft import logging import re @@ -26,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, LockEntityStateAttribute, LockState +from .const import DOMAIN, LockEntityFeature, LockEntityStateAttribute, LockState _LOGGER = logging.getLogger(__name__) @@ -46,12 +45,6 @@ ) -class LockEntityFeature(IntFlag): - """Supported features of the lock entity.""" - - OPEN = 1 - - PROP_TO_ATTR = { "changed_by": LockEntityStateAttribute.CHANGED_BY, "code_format": LockEntityStateAttribute.CODE_FORMAT, diff --git a/homeassistant/components/lock/const.py b/homeassistant/components/lock/const.py index 3c5a712c5493d..8e89832581160 100644 --- a/homeassistant/components/lock/const.py +++ b/homeassistant/components/lock/const.py @@ -1,6 +1,6 @@ """Constants for the lock entity platform.""" -from enum import StrEnum +from enum import IntFlag, StrEnum from typing import Final DOMAIN: Final = "lock" @@ -23,3 +23,9 @@ class LockState(StrEnum): UNLOCKING = "unlocking" LOCKED = "locked" UNLOCKED = "unlocked" + + +class LockEntityFeature(IntFlag): + """Supported features of the lock entity.""" + + OPEN = 1 diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index c970fae2b342e..ee0a66867765b 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, LockEntityFeature +from .const import DOMAIN, LockEntityFeature ACTION_TYPES = {"lock", "unlock", "open"} diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index c956f7acd0842..dde969b792a36 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -19,7 +19,8 @@ from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, LockState +from . import LockState +from .const import DOMAIN # mypy: disallow-any-generics diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 4eec3d0ce9dc3..7f6fcce54ea63 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -17,7 +17,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, LockState +from . import LockState +from .const import DOMAIN TRIGGER_TYPES = { "jammed", diff --git a/homeassistant/components/lock/reproduce_state.py b/homeassistant/components/lock/reproduce_state.py index 10e808f6dc76d..053bb4e60866e 100644 --- a/homeassistant/components/lock/reproduce_state.py +++ b/homeassistant/components/lock/reproduce_state.py @@ -13,7 +13,8 @@ ) from homeassistant.core import Context, HomeAssistant, State -from . import DOMAIN, LockState +from . import LockState +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f3c1debd3bba6..aa4fcd7cbb962 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -101,6 +101,7 @@ ATTR_SOUND_MODE, ATTR_SOUND_MODE_LIST, CONTENT_AUTH_EXPIRY_TIME, + DEVICE_CLASSES_SCHEMA, DOMAIN, INTENT_MEDIA_SEARCH_AND_PLAY, REPEAT_MODES, @@ -113,6 +114,7 @@ SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, MediaClass, + MediaPlayerDeviceClass, MediaPlayerEntityCapabilityAttribute, MediaPlayerEntityFeature, MediaPlayerEntityStateAttribute, @@ -152,20 +154,6 @@ class MediaPlayerEnqueue(StrEnum): REPLACE = "replace" -class MediaPlayerDeviceClass(StrEnum): - """Device class for media players.""" - - TV = "tv" - SPEAKER = "speaker" - RECEIVER = "receiver" - PROJECTOR = "projector" - - -DEVICE_CLASSES_SCHEMA = probatio.All( - probatio.Lower, probatio.Coerce(MediaPlayerDeviceClass) -) - - DEVICE_CLASSES = [cls.value for cls in MediaPlayerDeviceClass] diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index a090c870440ae..610b89faeb50b 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -3,8 +3,12 @@ from enum import IntFlag, StrEnum from typing import Final +import probatio + from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers +DOMAIN: Final = "media_player" + # How long our auth signature on the content should be valid for CONTENT_AUTH_EXPIRY_TIME = 3600 * 24 @@ -42,7 +46,6 @@ ATTR_SOUND_MODE = "sound_mode" ATTR_SOUND_MODE_LIST = "sound_mode_list" -DOMAIN: Final = "media_player" INTENT_MEDIA_PAUSE = "HassMediaPause" INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" @@ -208,3 +211,17 @@ class MediaPlayerEntityFeature(IntFlag): MEDIA_ANNOUNCE = 1048576 MEDIA_ENQUEUE = 2097152 SEARCH_MEDIA = 4194304 + + +class MediaPlayerDeviceClass(StrEnum): + """Device class for media players.""" + + TV = "tv" + SPEAKER = "speaker" + RECEIVER = "receiver" + PROJECTOR = "projector" + + +DEVICE_CLASSES_SCHEMA = probatio.All( + probatio.Lower, probatio.Coerce(MediaPlayerDeviceClass) +) diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index 7c5e53a23de96..cc55ecb4c485b 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -23,7 +23,7 @@ from homeassistant.helpers import config_validation as cv, intent from homeassistant.helpers.entity_component import EntityComponent -from . import MediaPlayerDeviceClass, MediaPlayerEntity +from . import MediaPlayerEntity from .browse_media import SearchMedia from .const import ( ATTR_MEDIA_FILTER_CLASSES, @@ -42,6 +42,7 @@ SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, MediaClass, + MediaPlayerDeviceClass, MediaPlayerEntityFeature, MediaPlayerState, ) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 92cafcb8bd8f0..2c29a921ee9ca 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -1,7 +1,6 @@ """Provides functionality to notify people.""" from datetime import timedelta -from enum import IntFlag from functools import partial import logging from typing import Any, final, override @@ -33,6 +32,7 @@ SERVICE_NOTIFY, SERVICE_PERSISTENT_NOTIFICATION, SERVICE_SEND_MESSAGE, + NotifyEntityFeature, ) from .legacy import ( # noqa: F401 BaseNotificationService, @@ -63,12 +63,6 @@ ) -class NotifyEntityFeature(IntFlag): - """Supported features of a notify entity.""" - - TITLE = 1 - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the notify services.""" diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py index d65e74ae6ae78..255dae157e417 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -1,5 +1,6 @@ """Provide common notify constants.""" +from enum import IntFlag import logging from typing import Final @@ -7,6 +8,8 @@ from homeassistant.helpers import config_validation as cv +DOMAIN: Final = "notify" + ATTR_DATA = "data" # Text to notify user of @@ -21,7 +24,6 @@ # Title of notification ATTR_TITLE = "title" -DOMAIN: Final = "notify" LOGGER = logging.getLogger(__package__) @@ -37,3 +39,9 @@ probatio.Optional(ATTR_DATA): dict, } ) + + +class NotifyEntityFeature(IntFlag): + """Supported features of a notify entity.""" + + TITLE = 1 diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 99aae56b18cde..5344b46ecaf18 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -75,6 +75,8 @@ VolumeFlowRateConverter, ) +DOMAIN: Final = "number" + ATTR_VALUE = "value" ATTR_MIN = "min" ATTR_MAX = "max" @@ -84,7 +86,6 @@ DEFAULT_MAX_VALUE = 100.0 DEFAULT_STEP = 1.0 -DOMAIN: Final = "number" SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 3eee6c020820f..aab883a5961f6 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -2,10 +2,9 @@ from collections.abc import Iterable from datetime import timedelta -from enum import IntFlag import functools as ft import logging -from typing import Any, Final, final, override +from typing import Any, final, override import probatio from propcache.api import cached_property @@ -25,11 +24,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey -from .const import RemoteEntityStateAttribute +from .const import DOMAIN, RemoteEntityFeature, RemoteEntityStateAttribute _LOGGER = logging.getLogger(__name__) -DOMAIN: Final = "remote" DATA_COMPONENT: HassKey[EntityComponent[RemoteEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA @@ -59,14 +57,6 @@ DEFAULT_HOLD_SECS = 0 -class RemoteEntityFeature(IntFlag): - """Supported features of the remote entity.""" - - LEARN_COMMAND = 1 - DELETE_COMMAND = 2 - ACTIVITY = 4 - - REMOTE_SERVICE_ACTIVITY_SCHEMA = cv.make_entity_service_schema( {probatio.Optional(ATTR_ACTIVITY): cv.string} ) diff --git a/homeassistant/components/remote/condition.py b/homeassistant/components/remote/condition.py index 51788c95fa8be..05400e62a4d1a 100644 --- a/homeassistant/components/remote/condition.py +++ b/homeassistant/components/remote/condition.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.condition import Condition, make_entity_state_condition -from . import DOMAIN +from .const import DOMAIN CONDITIONS: dict[str, type[Condition]] = { "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), diff --git a/homeassistant/components/remote/const.py b/homeassistant/components/remote/const.py index 8a25802dd5235..72aa671df9237 100644 --- a/homeassistant/components/remote/const.py +++ b/homeassistant/components/remote/const.py @@ -1,6 +1,9 @@ """Constants for the remote component.""" -from enum import StrEnum +from enum import IntFlag, StrEnum +from typing import Final + +DOMAIN: Final = "remote" class RemoteEntityStateAttribute(StrEnum): @@ -8,3 +11,11 @@ class RemoteEntityStateAttribute(StrEnum): ACTIVITY_LIST = "activity_list" CURRENT_ACTIVITY = "current_activity" + + +class RemoteEntityFeature(IntFlag): + """Supported features of the remote entity.""" + + LEARN_COMMAND = 1 + DELETE_COMMAND = 2 + ACTIVITY = 4 diff --git a/homeassistant/components/remote/device_action.py b/homeassistant/components/remote/device_action.py index 74cbeeb15eb2c..96b0a27a57123 100644 --- a/homeassistant/components/remote/device_action.py +++ b/homeassistant/components/remote/device_action.py @@ -10,7 +10,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN +from .const import DOMAIN # mypy: disallow-any-generics diff --git a/homeassistant/components/remote/device_condition.py b/homeassistant/components/remote/device_condition.py index 4fd8992b81d4e..da4fdd67765af 100644 --- a/homeassistant/components/remote/device_condition.py +++ b/homeassistant/components/remote/device_condition.py @@ -8,7 +8,7 @@ from homeassistant.helpers.condition import ConditionCheckerType from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN # mypy: disallow-any-generics diff --git a/homeassistant/components/remote/device_trigger.py b/homeassistant/components/remote/device_trigger.py index 47cb64fd75328..5acbb53a9eb9c 100644 --- a/homeassistant/components/remote/device_trigger.py +++ b/homeassistant/components/remote/device_trigger.py @@ -8,7 +8,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN TRIGGER_SCHEMA = probatio.All( toggle_entity.TRIGGER_SCHEMA, diff --git a/homeassistant/components/remote/reproduce_state.py b/homeassistant/components/remote/reproduce_state.py index b84982d65c7dc..39a597061fc2b 100644 --- a/homeassistant/components/remote/reproduce_state.py +++ b/homeassistant/components/remote/reproduce_state.py @@ -14,7 +14,7 @@ ) from homeassistant.core import Context, HomeAssistant, State -from . import DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/remote/trigger.py b/homeassistant/components/remote/trigger.py index 92a946c5ab77b..9340da64d0c2b 100644 --- a/homeassistant/components/remote/trigger.py +++ b/homeassistant/components/remote/trigger.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger -from . import DOMAIN +from .const import DOMAIN TRIGGERS: dict[str, type[Trigger]] = { "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 9c27f5b690768..be8b4e0f15285 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -18,7 +18,8 @@ from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey -DOMAIN: Final = "scene" +from .const import DOMAIN + DATA_COMPONENT: HassKey[EntityComponent[BaseScene]] = HassKey(DOMAIN) STATES: Final = "states" diff --git a/homeassistant/components/scene/const.py b/homeassistant/components/scene/const.py new file mode 100644 index 0000000000000..f2c09d5539235 --- /dev/null +++ b/homeassistant/components/scene/const.py @@ -0,0 +1,5 @@ +"""Constants for the scene integration.""" + +from typing import Final + +DOMAIN: Final = "scene" diff --git a/homeassistant/components/scene/trigger.py b/homeassistant/components/scene/trigger.py index cefeb14c7bbbe..eca0e376b2d60 100644 --- a/homeassistant/components/scene/trigger.py +++ b/homeassistant/components/scene/trigger.py @@ -4,7 +4,7 @@ from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger -from . import DOMAIN +from .const import DOMAIN class SceneActivatedTrigger(StatelessEntityTriggerBase): diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index f90f7b40ef459..1be5a04082a10 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -27,7 +27,8 @@ ) from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, SensorDeviceClass, SensorEntityCapabilityAttribute +from . import SensorDeviceClass, SensorEntityCapabilityAttribute +from .const import DOMAIN DEVICE_CLASS_NONE = "none" diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 78a1bc8d8e0b7..365bfb470f65a 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -28,7 +28,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, SensorDeviceClass, SensorEntityCapabilityAttribute +from . import SensorDeviceClass, SensorEntityCapabilityAttribute +from .const import DOMAIN DEVICE_CLASS_NONE = "none" diff --git a/homeassistant/components/siren/condition.py b/homeassistant/components/siren/condition.py index 2593b00428d98..02444869871d6 100644 --- a/homeassistant/components/siren/condition.py +++ b/homeassistant/components/siren/condition.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.condition import Condition, make_entity_state_condition -from . import DOMAIN +from .const import DOMAIN CONDITIONS: dict[str, type[Condition]] = { "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), diff --git a/homeassistant/components/siren/trigger.py b/homeassistant/components/siren/trigger.py index d6c73e823d079..4e79a932782bc 100644 --- a/homeassistant/components/siren/trigger.py +++ b/homeassistant/components/siren/trigger.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger -from . import DOMAIN +from .const import DOMAIN TRIGGERS: dict[str, type[Trigger]] = { "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 7f321455aff89..54ab6b2f577ed 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -1,11 +1,9 @@ """Component to interface with switches that can be controlled remotely.""" from datetime import timedelta -from enum import StrEnum import logging from typing import override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -22,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import DEVICE_CLASSES_SCHEMA, DOMAIN, SwitchDeviceClass # noqa: F401 _LOGGER = logging.getLogger(__name__) @@ -35,17 +33,8 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -class SwitchDeviceClass(StrEnum): - """Device class for switches.""" - - OUTLET = "outlet" - SWITCH = "switch" - - -DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(SwitchDeviceClass)) DEVICE_CLASSES = [cls.value for cls in SwitchDeviceClass] - # mypy: disallow-any-generics diff --git a/homeassistant/components/switch/const.py b/homeassistant/components/switch/const.py index e7d8f9a1ad028..4410669bc952b 100644 --- a/homeassistant/components/switch/const.py +++ b/homeassistant/components/switch/const.py @@ -1,5 +1,18 @@ """Constants for the Switch integration.""" +from enum import StrEnum from typing import Final +import probatio + DOMAIN: Final = "switch" + + +class SwitchDeviceClass(StrEnum): + """Device class for switches.""" + + OUTLET = "outlet" + SWITCH = "switch" + + +DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(SwitchDeviceClass)) diff --git a/homeassistant/components/switch/device_action.py b/homeassistant/components/switch/device_action.py index 77fee17767aeb..63ffe64755439 100644 --- a/homeassistant/components/switch/device_action.py +++ b/homeassistant/components/switch/device_action.py @@ -10,7 +10,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN +from .const import DOMAIN # mypy: disallow-any-generics diff --git a/homeassistant/components/switch/device_condition.py b/homeassistant/components/switch/device_condition.py index fa55c4f3de5c7..b14bb526042db 100644 --- a/homeassistant/components/switch/device_condition.py +++ b/homeassistant/components/switch/device_condition.py @@ -8,7 +8,7 @@ from homeassistant.helpers.condition import ConditionCheckerType from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN # mypy: disallow-any-generics diff --git a/homeassistant/components/switch/device_trigger.py b/homeassistant/components/switch/device_trigger.py index a6f750db067d0..7a300a2b9695e 100644 --- a/homeassistant/components/switch/device_trigger.py +++ b/homeassistant/components/switch/device_trigger.py @@ -8,7 +8,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN TRIGGER_SCHEMA = probatio.All( toggle_entity.TRIGGER_SCHEMA, diff --git a/homeassistant/components/switch/reproduce_state.py b/homeassistant/components/switch/reproduce_state.py index 9e5657151c41b..212f67f4dcf3a 100644 --- a/homeassistant/components/switch/reproduce_state.py +++ b/homeassistant/components/switch/reproduce_state.py @@ -14,7 +14,7 @@ ) from homeassistant.core import Context, HomeAssistant, State -from . import DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/template/climate.py b/homeassistant/components/template/climate.py new file mode 100644 index 0000000000000..082555df37d6c --- /dev/null +++ b/homeassistant/components/template/climate.py @@ -0,0 +1,897 @@ +"""Support for Template climates.""" + +from collections.abc import Callable +import contextlib +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal +from typing import TYPE_CHECKING, Any, Self, override + +import probatio + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + ENTITY_ID_FORMAT, + ClimateEntity, + ClimateEntityCapabilityAttribute, + ClimateEntityFeature, + ClimateEntityStateAttribute, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_NAME, + CONF_TEMPERATURE_UNIT, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.unit_conversion import TemperatureConverter + +from . import TriggerUpdateCoordinator, validators as tcv +from .const import DOMAIN +from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .schemas import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, + make_template_entity_common_schema, +) +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity + +DEFAULT_NAME = "Template Climate" + +CONF_CURRENT_HUMIDITY = "current_humidity" +CONF_CURRENT_TEMPERATURE = "current_temperature" +CONF_FAN_MODE = "fan_mode" +CONF_FAN_MODES = "fan_modes" +CONF_HVAC_ACTION = "hvac_action" +CONF_HVAC_MODE = "hvac_mode" +CONF_HVAC_MODES = "hvac_modes" +CONF_MAX_HUMIDITY = "max_humidity" +CONF_MAX_TEMPERATURE = "max_temperature" +CONF_MIN_HUMIDITY = "min_humidity" +CONF_MIN_TEMPERATURE = "min_temperature" +CONF_PRECISION = "precision" +CONF_PRESET_MODE = "preset_mode" +CONF_PRESET_MODES = "preset_modes" +CONF_SWING_HORIZONTAL_MODE = "swing_horizontal_mode" +CONF_SWING_HORIZONTAL_MODES = "swing_horizontal_modes" +CONF_SWING_MODE = "swing_mode" +CONF_SWING_MODES = "swing_modes" +CONF_TARGET_HUMIDITY = "target_humidity" +CONF_TARGET_HUMIDITY_STEP = "target_humidity_step" +CONF_TARGET_TEMPERATURE = "target_temperature" +CONF_TARGET_TEMPERATURE_HIGH = "target_temperature_high" +CONF_TARGET_TEMPERATURE_LOW = "target_temperature_low" +CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step" + +SET_FAN_MODE_ACTION = "set_fan_mode" +SET_HUMIDITY_ACTION = "set_humidity" +SET_HVAC_MODE_ACTION = "set_hvac_mode" +SET_PRESET_MODE_ACTION = "set_preset_mode" +SET_SWING_HORIZONTAL_MODE_ACTION = "set_swing_horizontal_mode" +SET_SWING_MODE_ACTION = "set_swing_mode" +SET_TEMPERATURE_ACTION = "set_temperature" + +SCRIPT_FIELDS = ( + SET_FAN_MODE_ACTION, + SET_HUMIDITY_ACTION, + SET_HVAC_MODE_ACTION, + SET_PRESET_MODE_ACTION, + SET_SWING_HORIZONTAL_MODE_ACTION, + SET_SWING_MODE_ACTION, + SET_TEMPERATURE_ACTION, +) + + +_BLOCKED_ATTRIBUTES = tcv.BlockedTemplateAttributes( + attributes=(ClimateEntityCapabilityAttribute, ClimateEntityStateAttribute) +) + + +def _round_to_step(value: float, step: float) -> float: + """Round a temperature to the nearest step using half-up midpoint handling.""" + decimal_value = Decimal(str(value)) + decimal_step = Decimal(str(step)) + return float( + (decimal_value / decimal_step).quantize(0, ROUND_HALF_UP) * decimal_step + ) + + +CLIMATE_COMMON_SCHEMA = probatio.Schema( + { + probatio.Optional(CONF_CURRENT_HUMIDITY): cv.template, + probatio.Optional(CONF_CURRENT_TEMPERATURE): cv.template, + probatio.Optional(CONF_FAN_MODE): cv.template, + probatio.Optional(CONF_FAN_MODES): cv.template, + probatio.Optional(CONF_HVAC_ACTION): cv.template, + probatio.Optional(CONF_HVAC_MODE): cv.template, + probatio.Required(CONF_HVAC_MODES): cv.template, + probatio.Optional(CONF_MAX_HUMIDITY): probatio.Coerce(int), + probatio.Optional(CONF_MAX_TEMPERATURE): probatio.Coerce(float), + probatio.Optional(CONF_MIN_HUMIDITY): probatio.Coerce(int), + probatio.Optional(CONF_MIN_TEMPERATURE): probatio.Coerce(float), + probatio.Optional(CONF_PRECISION): probatio.Any( + PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE + ), + probatio.Optional(CONF_PRESET_MODE): cv.template, + probatio.Optional(CONF_PRESET_MODES): cv.template, + probatio.Optional(CONF_SWING_MODE): cv.template, + probatio.Optional(CONF_SWING_MODES): cv.template, + probatio.Optional(CONF_SWING_HORIZONTAL_MODE): cv.template, + probatio.Optional(CONF_SWING_HORIZONTAL_MODES): cv.template, + probatio.Optional(CONF_TARGET_HUMIDITY): cv.template, + probatio.Optional(CONF_TARGET_HUMIDITY_STEP): probatio.All( + probatio.Coerce(int), probatio.Range(min=1) + ), + probatio.Inclusive( + CONF_TARGET_TEMPERATURE_HIGH, "temperature_limits" + ): cv.template, + probatio.Inclusive( + CONF_TARGET_TEMPERATURE_LOW, "temperature_limits" + ): cv.template, + probatio.Optional(CONF_TARGET_TEMPERATURE_STEP): probatio.All( + probatio.Coerce(float), probatio.Range(min=PRECISION_TENTHS) + ), + probatio.Optional(CONF_TARGET_TEMPERATURE): cv.template, + probatio.Optional(CONF_TEMPERATURE_UNIT): probatio.In( + TemperatureConverter.VALID_UNITS + ), + probatio.Optional(SET_FAN_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_HUMIDITY_ACTION): cv.SCRIPT_SCHEMA, + probatio.Required(SET_HVAC_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_SWING_HORIZONTAL_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_SWING_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, + }, +) + +_CLIMATE_VALIDATION = ( + tcv.inclusive_group("fan_mode", CONF_FAN_MODE, CONF_FAN_MODES, SET_FAN_MODE_ACTION), + tcv.inclusive_group( + "preset_mode", CONF_PRESET_MODE, CONF_PRESET_MODES, SET_PRESET_MODE_ACTION + ), + tcv.inclusive_group( + "swing_mode", CONF_SWING_MODE, CONF_SWING_MODES, SET_SWING_MODE_ACTION + ), + tcv.inclusive_group( + "swing_horizontal_mode", + CONF_SWING_HORIZONTAL_MODE, + CONF_SWING_HORIZONTAL_MODES, + SET_SWING_HORIZONTAL_MODE_ACTION, + ), + tcv.requires_option(CONF_TARGET_HUMIDITY, SET_HUMIDITY_ACTION), + tcv.requires_option(CONF_TARGET_TEMPERATURE, SET_TEMPERATURE_ACTION), + tcv.requires_option(CONF_TARGET_TEMPERATURE_HIGH, SET_TEMPERATURE_ACTION), + tcv.requires_option(CONF_TARGET_TEMPERATURE_LOW, SET_TEMPERATURE_ACTION), +) + + +CLIMATE_YAML_SCHEMA = probatio.All( + CLIMATE_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_schema( + CLIMATE_DOMAIN, + DEFAULT_NAME, + _BLOCKED_ATTRIBUTES, + ).schema + ), + *_CLIMATE_VALIDATION, +) + +CLIMATE_CONFIG_ENTRY_SCHEMA = probatio.All( + CLIMATE_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema), + *_CLIMATE_VALIDATION, +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the template climates.""" + await async_setup_template_platform( + hass, + CLIMATE_DOMAIN, + config, + StateClimateEntity, + TriggerClimateEntity, + async_add_entities, + discovery_info, + script_options=SCRIPT_FIELDS, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateClimateEntity, + CLIMATE_CONFIG_ENTRY_SCHEMA, + script_options=SCRIPT_FIELDS, + ) + + +@callback +def async_create_preview_climate( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateClimateEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateClimateEntity, + CLIMATE_CONFIG_ENTRY_SCHEMA, + ) + + +def _string_to_list(result: str) -> list[str]: + for char in "()[] ": + result = result.replace(char, "") + return list(result.split(",")) + + +def hvac_modes_list( + entity: AbstractTemplateClimate, +) -> Callable[[Any], list[HVACMode] | None]: + """Convert the result to a list of numbers that represent hvac modes.""" + + expected = f"expected a list of hvac modes: [{', '.join([str(item) for item in HVACMode])}]" + + def convert(result: Any) -> list[HVACMode] | None: + if tcv.check_result_for_none(result): + return [] + + if isinstance(result, str): + with contextlib.suppress(ValueError): + result = _string_to_list(result) + + if isinstance(result, (list, tuple)) and all( + isinstance(value, str) for value in result + ): + validated = [] + invalid = [] + for item in result: + if item in HVACMode: + validated.append(HVACMode(item)) + else: + invalid.append(item) + + if invalid: + tcv.log_validation_result_error( + entity, CONF_HVAC_MODES, result, expected + ) + + return validated + + tcv.log_validation_result_error(entity, CONF_HVAC_MODES, result, expected) + return [] + + return convert + + +@dataclass(kw_only=True) +class ClimateExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + current_humidity: float | None + current_temperature: float | None + fan_mode: str | None + fan_modes: list[str] | None + hvac_action: HVACAction | None + hvac_mode: HVACMode | None + hvac_modes: list[HVACMode] | None + preset_mode: str | None + preset_modes: list[str] | None + swing_mode: str | None + swing_modes: list[str] | None + swing_horizontal_mode: str | None + swing_horizontal_modes: list[str] | None + target_humidity: float | None = None + target_temperature_high: float | None + target_temperature_low: float | None + target_temperature: float | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "current_humidity": self.current_humidity, + "current_temperature": self.current_temperature, + "fan_mode": self.fan_mode, + "fan_modes": self.fan_modes, + "hvac_action": self.hvac_action.value if self.hvac_action else None, + "hvac_mode": self.hvac_mode.value if self.hvac_mode else None, + "hvac_modes": ( + [mode.value for mode in self.hvac_modes] if self.hvac_modes else None + ), + "preset_mode": self.preset_mode, + "preset_modes": self.preset_modes, + "swing_mode": self.swing_mode, + "swing_modes": self.swing_modes, + "swing_horizontal_mode": self.swing_horizontal_mode, + "swing_horizontal_modes": self.swing_horizontal_modes, + "target_humidity": self.target_humidity, + "target_temperature_high": self.target_temperature_high, + "target_temperature_low": self.target_temperature_low, + "target_temperature": self.target_temperature, + } + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored state from a dict.""" + + try: + hvac_action: HVACAction | None = None + if _hvac_action := restored["hvac_action"]: + hvac_action = HVACAction(_hvac_action) + + hvac_mode: HVACMode | None = None + if _hvac_mode := restored["hvac_mode"]: + hvac_mode = HVACMode(_hvac_mode) + + hvac_modes: list[HVACMode] | None = None + if _hvac_modes := restored["hvac_modes"]: + hvac_modes = [HVACMode(item) for item in _hvac_modes] + + return cls( + current_humidity=restored["current_humidity"], + current_temperature=restored["current_temperature"], + fan_mode=restored["fan_mode"], + fan_modes=restored["fan_modes"], + hvac_action=hvac_action, + hvac_mode=hvac_mode, + hvac_modes=hvac_modes, + preset_mode=restored["preset_mode"], + preset_modes=restored["preset_modes"], + swing_mode=restored["swing_mode"], + swing_modes=restored["swing_modes"], + swing_horizontal_mode=restored["swing_horizontal_mode"], + swing_horizontal_modes=restored["swing_horizontal_modes"], + target_humidity=restored["target_humidity"], + target_temperature_high=restored["target_temperature_high"], + target_temperature_low=restored["target_temperature_low"], + target_temperature=restored["target_temperature"], + ) + except KeyError, ValueError: + return None + + +class AbstractTemplateClimate(AbstractTemplateEntity, ClimateEntity, RestoreEntity): + """Representation of template climate features.""" + + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + _state_option = CONF_HVAC_MODE + _restore_state_extra_data = ClimateExtraStoredData + _restore_state_properties = ("_attr_hvac_mode",) + _blocked_attributes = _BLOCKED_ATTRIBUTES + + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. + def __init__( # pylint: disable=super-init-not-called + self, hass: HomeAssistant, name: str, config: dict[str, Any] + ) -> None: + """Initialize the features.""" + + self._attr_temperature_unit = ( + config.get(CONF_TEMPERATURE_UNIT) or hass.config.units.temperature_unit + ) + self._attr_target_humidity_step = config.get(CONF_TARGET_HUMIDITY_STEP) + self._attr_target_temperature_step = config.get(CONF_TARGET_TEMPERATURE_STEP) + + # Only set these options when it exists in the configuration in order + # to properly use default values set by the upstream class. + for attr, option in ( + ("_attr_max_temp", CONF_MAX_TEMPERATURE), + ("_attr_min_temp", CONF_MIN_TEMPERATURE), + ("_attr_max_humidity", CONF_MAX_HUMIDITY), + ("_attr_min_humidity", CONF_MIN_HUMIDITY), + ("_attr_precision", CONF_PRECISION), + ): + if (option_value := config.get(option)) is not None: + setattr(self, attr, option_value) + + self._attr_hvac_mode = None + self._attr_hvac_modes = [] + self._attr_fan_mode = None + self._attr_fan_modes = None + self._attr_preset_mode = None + self._attr_preset_modes = None + self._attr_swing_mode = None + self._attr_swing_modes = None + self._attr_swing_horizontal_mode = None + self._attr_swing_horizontal_modes = None + self._attr_target_temperature_low = None + self._attr_target_temperature_high = None + + self.setup_template( + CONF_HVAC_MODES, + "_attr_hvac_modes", + hvac_modes_list(self), + self._update_hvac_modes, + none_on_template_error=False, + ) + self.setup_state_template( + "_attr_hvac_mode", + tcv.item_in_list(self, CONF_HVAC_MODE, "_attr_hvac_modes", CONF_HVAC_MODES), + self._update_hvac_mode, + ) + self.setup_template( + CONF_HVAC_ACTION, + "_attr_hvac_action", + tcv.strenum(self, CONF_HVAC_ACTION, HVACAction), + ) + self.add_assumed_attribute( + "_attr_hvac_action", CONF_HVAC_ACTION, SET_HVAC_MODE_ACTION + ) + + self.setup_template( + CONF_CURRENT_TEMPERATURE, + "_attr_current_temperature", + tcv.number(self, CONF_CURRENT_TEMPERATURE), + ) + + for option, attr in ( + ( + CONF_TARGET_TEMPERATURE, + "_attr_target_temperature", + ), + (CONF_TARGET_TEMPERATURE_LOW, "_attr_target_temperature_low"), + (CONF_TARGET_TEMPERATURE_HIGH, "_attr_target_temperature_high"), + ): + self.setup_template( + option, + attr, + tcv.number(self, option, self.min_temp, self.max_temp), + on_update=self._update_target_temperature(attr), + ) + self.add_assumed_attribute(attr, option, SET_TEMPERATURE_ACTION) + + self.setup_template( + CONF_TARGET_HUMIDITY, + "_attr_target_humidity", + tcv.number( + self, + CONF_TARGET_HUMIDITY, + self._attr_min_humidity, + self._attr_max_humidity, + int, + ), + self._update_target_humidity, + ) + self.add_assumed_attribute( + "_attr_target_humidity", CONF_TARGET_HUMIDITY, SET_HUMIDITY_ACTION + ) + self.setup_template( + CONF_CURRENT_HUMIDITY, + "_attr_current_humidity", + tcv.number(self, CONF_CURRENT_HUMIDITY, 0, 100, int), + ) + + self.setup_template( + CONF_FAN_MODES, + "_attr_fan_modes", + tcv.list_of_strings(self, CONF_FAN_MODES), + ) + self.setup_template( + CONF_FAN_MODE, + "_attr_fan_mode", + tcv.item_in_list(self, CONF_FAN_MODE, "_attr_fan_modes", CONF_FAN_MODES), + ) + self.add_assumed_attribute("_attr_fan_mode", CONF_FAN_MODE, SET_FAN_MODE_ACTION) + + self.setup_template( + CONF_SWING_MODES, + "_attr_swing_modes", + tcv.list_of_strings(self, CONF_SWING_MODES), + ) + self.setup_template( + CONF_SWING_MODE, + "_attr_swing_mode", + tcv.item_in_list( + self, CONF_SWING_MODE, "_attr_swing_modes", CONF_SWING_MODES + ), + ) + self.add_assumed_attribute( + "_attr_swing_mode", CONF_SWING_MODE, SET_SWING_MODE_ACTION + ) + + self.setup_template( + CONF_SWING_HORIZONTAL_MODES, + "_attr_swing_horizontal_modes", + tcv.list_of_strings(self, CONF_SWING_HORIZONTAL_MODES), + ) + self.setup_template( + CONF_SWING_HORIZONTAL_MODE, + "_attr_swing_horizontal_mode", + tcv.item_in_list( + self, + CONF_SWING_HORIZONTAL_MODE, + "_attr_swing_horizontal_modes", + CONF_SWING_HORIZONTAL_MODES, + ), + ) + self.add_assumed_attribute( + "_attr_swing_horizontal_mode", + CONF_SWING_HORIZONTAL_MODE, + SET_SWING_HORIZONTAL_MODE_ACTION, + ) + + self.setup_template( + CONF_PRESET_MODES, + "_attr_preset_modes", + tcv.list_of_strings(self, CONF_PRESET_MODES), + ) + self.setup_template( + CONF_PRESET_MODE, + "_attr_preset_mode", + tcv.item_in_list( + self, + CONF_PRESET_MODE, + "_attr_preset_modes", + CONF_PRESET_MODES, + ), + ) + self.add_assumed_attribute( + "_attr_preset_mode", CONF_PRESET_MODE, SET_PRESET_MODE_ACTION + ) + + self._attr_supported_features = ClimateEntityFeature(0) + for action_id, supported_feature in ( + (SET_FAN_MODE_ACTION, ClimateEntityFeature.FAN_MODE), + (SET_HUMIDITY_ACTION, ClimateEntityFeature.TARGET_HUMIDITY), + (SET_HVAC_MODE_ACTION, 0), + (SET_PRESET_MODE_ACTION, ClimateEntityFeature.PRESET_MODE), + ( + SET_SWING_HORIZONTAL_MODE_ACTION, + ClimateEntityFeature.SWING_HORIZONTAL_MODE, + ), + (SET_SWING_MODE_ACTION, ClimateEntityFeature.SWING_MODE), + (SET_TEMPERATURE_ACTION, ClimateEntityFeature.TARGET_TEMPERATURE), + ): + if (action_config := self._config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + if ( + ( + CONF_TARGET_TEMPERATURE_HIGH in self._templates + and CONF_TARGET_TEMPERATURE_LOW in self._templates + ) + or ( + CONF_TARGET_TEMPERATURE_HIGH in self._assumed_attributes + and CONF_TARGET_TEMPERATURE_LOW in self._assumed_attributes + ) + ) and SET_TEMPERATURE_ACTION in self._action_scripts: + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + + def _update_hvac_mode(self, render) -> None: + if render is None: + self._attr_hvac_mode = None + return + + self._attr_hvac_mode = HVACMode(render) + + def _update_hvac_modes(self, render) -> None: + + if isinstance(render, TemplateError): + self._attr_hvac_modes = [] + return + + if HVACMode.OFF in render: + self._attr_supported_features |= ClimateEntityFeature.TURN_OFF + else: + self._attr_supported_features &= ~ClimateEntityFeature.TURN_OFF + + if any( + mode in render + for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL) + ) or ( + len(render) == 2 + and HVACMode.OFF in render + and any(mode != HVACMode.OFF for mode in render) + ): + self._attr_supported_features |= ClimateEntityFeature.TURN_ON + else: + self._attr_supported_features &= ~ClimateEntityFeature.TURN_ON + + self._attr_hvac_modes = render + + def _round_temperature_value(self, value: Any) -> float: + if self._attr_target_temperature_step is None: + return value + rounded = _round_to_step(float(value), self._attr_target_temperature_step) + return min(self.max_temp, max(self.min_temp, rounded)) + + def _update_target_temperature(self, attr: str) -> Callable[[Any], None]: + def update(result: Any) -> None: + if result is None: + setattr(self, attr, None) + return + + value = self._round_temperature_value(result) + setattr(self, attr, value) + + return update + + def _round_humidity_value(self, value: Any) -> int: + result = ( + value + if self._attr_target_humidity_step is None + else int( + _round_to_step( + float(value) / 10.0, self._attr_target_humidity_step / 10.0 + ) + * 10.0 + ) + ) + return int(min(self.max_humidity, max(self.min_humidity, result))) + + def _update_target_humidity( + self, + result, + ) -> None: + if result is None: + self._attr_target_humidity = None + return + + self._attr_target_humidity = self._round_humidity_value(result) + + async def _async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + if script := self._action_scripts.get(SET_HVAC_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"hvac_mode": hvac_mode}, + context=self._context, + ) + + @override + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + await self._async_set_hvac_mode(hvac_mode) + + if self._attr_assumed_state: + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + + @override + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + if script := self._action_scripts.get(SET_PRESET_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"preset_mode": preset_mode}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_PRESET_MODE, preset_mode) + + @override + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + if script := self._action_scripts.get(SET_FAN_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"fan_mode": fan_mode}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_FAN_MODE, fan_mode) + + @override + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing mode.""" + if script := self._action_scripts.get(SET_SWING_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"swing_mode": swing_mode}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_SWING_MODE, swing_mode) + + @override + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set the swing horizontal mode.""" + if script := self._action_scripts.get(SET_SWING_HORIZONTAL_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"swing_horizontal_mode": swing_horizontal_mode}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_SWING_HORIZONTAL_MODE, swing_horizontal_mode) + + @override + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity.""" + rounded = self._round_humidity_value(humidity) + if script := self._action_scripts.get(SET_HUMIDITY_ACTION): + await self.async_run_script( + script, + run_variables={"humidity": rounded}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_TARGET_HUMIDITY, rounded) + + @override + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set one or more target temperatures.""" + common_params: dict[str, Any] = { + "temperature": None, + "target_temp_high": None, + "target_temp_low": None, + "hvac_mode": None, + } + write_state = False + + breadcrumb = f"{SET_TEMPERATURE_ACTION} {ATTR_HVAC_MODE}" + if (hvac_value := kwargs.get(ATTR_HVAC_MODE)) and ( + hvac_mode := tcv.strenum(self, breadcrumb, HVACMode)(hvac_value) + ) is not None: + self._valid_mode_or_raise("hvac", hvac_mode, self.hvac_modes) + common_params["hvac_mode"] = hvac_mode + await self._async_set_hvac_mode(HVACMode(hvac_mode)) + if self._attr_assumed_state: + self._attr_hvac_mode = hvac_mode + write_state = True + + updates = [] + for option, attr, param in ( + ( + CONF_TARGET_TEMPERATURE, + ATTR_TEMPERATURE, + "temperature", + ), + ( + CONF_TARGET_TEMPERATURE_HIGH, + ATTR_TARGET_TEMP_HIGH, + "target_temp_high", + ), + ( + CONF_TARGET_TEMPERATURE_LOW, + ATTR_TARGET_TEMP_LOW, + "target_temp_low", + ), + ): + if (value := kwargs.get(attr)) is not None and ( + validated := tcv.number( + self, + f"{SET_TEMPERATURE_ACTION} {attr}", + self.min_temp, + self.max_temp, + )(value) + ) is not None: + rounded = self._round_temperature_value(validated) + common_params[param] = rounded + updates.append((option, rounded)) + + if script := self._action_scripts.get(SET_TEMPERATURE_ACTION): + await self.async_run_script( + script, + run_variables=common_params, + context=self._context, + ) + + for option, value in updates: + if self.update_assumed_attribute(option, value): + write_state = True + + if write_state: + self.async_write_ha_state() + + @property + @override + def extra_restore_state_data(self) -> ClimateExtraStoredData: + """Return climate specific state data to be restored.""" + return ClimateExtraStoredData( + current_humidity=self._attr_current_humidity, + current_temperature=self._attr_current_temperature, + fan_mode=self._attr_fan_mode, + fan_modes=self._attr_fan_modes, + hvac_action=self._attr_hvac_action, + hvac_mode=self._attr_hvac_mode, + hvac_modes=self._attr_hvac_modes, + preset_mode=self._attr_preset_mode, + preset_modes=self._attr_preset_modes, + swing_mode=self._attr_swing_mode, + swing_modes=self._attr_swing_modes, + swing_horizontal_mode=self._attr_swing_horizontal_mode, + swing_horizontal_modes=self._attr_swing_horizontal_modes, + target_humidity=self._attr_target_humidity, + target_temperature_high=self._attr_target_temperature_high, + target_temperature_low=self._attr_target_temperature_low, + target_temperature=self._attr_target_temperature, + ) + + @override + def restore_extra_data(self, extra_data: ClimateExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_current_humidity = extra_data.current_humidity + self._attr_current_temperature = extra_data.current_temperature + self._attr_fan_mode = extra_data.fan_mode + self._attr_fan_modes = extra_data.fan_modes + self._attr_hvac_action = extra_data.hvac_action + self._attr_hvac_mode = extra_data.hvac_mode + self._update_hvac_modes(extra_data.hvac_modes or []) + self._attr_preset_mode = extra_data.preset_mode + self._attr_preset_modes = extra_data.preset_modes + self._attr_swing_mode = extra_data.swing_mode + self._attr_swing_modes = extra_data.swing_modes + self._attr_swing_horizontal_mode = extra_data.swing_horizontal_mode + self._attr_swing_horizontal_modes = extra_data.swing_horizontal_modes + self._attr_target_humidity = extra_data.target_humidity + self._attr_target_temperature_high = extra_data.target_temperature_high + self._attr_target_temperature_low = extra_data.target_temperature_low + self._attr_target_temperature = extra_data.target_temperature + + +class StateClimateEntity(TemplateEntity, AbstractTemplateClimate): + """Representation of a state-based template climate.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + unique_id: str | None, + ) -> None: + """Initialize the state-based template climate.""" + TemplateEntity.__init__(self, hass, config, unique_id) + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + AbstractTemplateClimate.__init__(self, hass, name, config) + + +class TriggerClimateEntity(TriggerEntity, AbstractTemplateClimate): + """Representation of a trigger-based template climate.""" + + domain = CLIMATE_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the trigger-based template climate.""" + TriggerEntity.__init__(self, hass, coordinator, config) + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + AbstractTemplateClimate.__init__(self, hass, name, config) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 08d22e11a1a65..df6cb47c54d28 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -17,6 +17,7 @@ schemas as blueprint_schemas, ) from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.event import DOMAIN as EVENT_DOMAIN @@ -60,6 +61,7 @@ alarm_control_panel as alarm_control_panel_platform, binary_sensor as binary_sensor_platform, button as button_platform, + climate as climate_platform, cover as cover_platform, device_tracker as device_tracker_platform, event as event_platform, @@ -89,6 +91,7 @@ Platform.BINARY_SENSOR: binary_sensor_platform.DEFAULT_NAME, Platform.BUTTON: button_platform.DEFAULT_NAME, Platform.COVER: cover_platform.DEFAULT_NAME, + Platform.CLIMATE: climate_platform.DEFAULT_NAME, Platform.DEVICE_TRACKER: device_tracker_platform.DEFAULT_NAME, Platform.EVENT: event_platform.DEFAULT_NAME, Platform.FAN: fan_platform.DEFAULT_NAME, @@ -250,6 +253,9 @@ def _backward_compat_schema(value: Any | None) -> Any: probatio.Optional(BUTTON_DOMAIN): probatio.All( cv.ensure_list, [button_platform.BUTTON_YAML_SCHEMA] ), + probatio.Optional(CLIMATE_DOMAIN): probatio.All( + cv.ensure_list, [climate_platform.CLIMATE_YAML_SCHEMA] + ), probatio.Optional(COVER_DOMAIN): probatio.All( cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] ), diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index cbce57db40fd3..84901194565b2 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -23,6 +23,7 @@ CONF_URL, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, + DEGREE, Platform, UnitOfTemperature, ) @@ -37,6 +38,7 @@ SchemaFlowMenuStep, ) +from . import validators as tcv from .alarm_control_panel import ( CONF_ARM_AWAY_ACTION, CONF_ARM_CUSTOM_BYPASS_ACTION, @@ -51,6 +53,18 @@ async_create_preview_alarm_control_panel, ) from .binary_sensor import async_create_preview_binary_sensor +from .climate import ( + CONF_CURRENT_TEMPERATURE, + CONF_HVAC_ACTION, + CONF_HVAC_MODE, + CONF_HVAC_MODES, + CONF_MAX_TEMPERATURE, + CONF_MIN_TEMPERATURE, + CONF_TARGET_TEMPERATURE, + SET_HVAC_MODE_ACTION, + SET_TEMPERATURE_ACTION, + async_create_preview_climate, +) from .const import ( CONF_ADDITIONAL_OPTIONS, CONF_AVAILABILITY, @@ -196,6 +210,40 @@ def generate_schema(domain: str, flow_type: str) -> probatio.Schema: ), } + if domain == Platform.CLIMATE: + schema |= { + probatio.Required(CONF_HVAC_MODES): selector.TemplateSelector(), + probatio.Optional(CONF_HVAC_MODE): selector.TemplateSelector(), + probatio.Required(SET_HVAC_MODE_ACTION): selector.ActionSelector(), + probatio.Optional(CONF_HVAC_ACTION): selector.TemplateSelector(), + probatio.Optional(CONF_CURRENT_TEMPERATURE): selector.TemplateSelector(), + probatio.Optional(CONF_TARGET_TEMPERATURE): selector.TemplateSelector(), + probatio.Optional(SET_TEMPERATURE_ACTION): selector.ActionSelector(), + probatio.Optional(CONF_TEMPERATURE_UNIT): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in UnitOfTemperature], + mode=selector.SelectSelectorMode.DROPDOWN, + sort=True, + ), + ), + } + additional_options |= { + probatio.Optional(CONF_MIN_TEMPERATURE): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, + unit_of_measurement=DEGREE, + step=0.1, + ) + ), + probatio.Optional(CONF_MAX_TEMPERATURE): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, + unit_of_measurement=DEGREE, + step=0.1, + ) + ), + } + if domain == Platform.COVER: schema |= _SCHEMA_STATE | { probatio.Inclusive( @@ -505,6 +553,10 @@ async def _validate_user_input( if template_type == Platform.SENSOR: _validate_unit(user_input) _validate_state_class(user_input) + if template_type == Platform.CLIMATE: + tcv.requires_option(CONF_TARGET_TEMPERATURE, SET_TEMPERATURE_ACTION)( + user_input + ) return {"template_type": template_type} | user_input return _validate_user_input @@ -514,6 +566,7 @@ async def _validate_user_input( Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, Platform.EVENT, @@ -546,6 +599,11 @@ async def _validate_user_input( config_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.CLIMATE: SchemaFlowFormStep( + config_schema(Platform.CLIMATE), + preview="template", + validate_user_input=validate_user_input(Platform.CLIMATE), + ), Platform.COVER: SchemaFlowFormStep( config_schema(Platform.COVER), preview="template", @@ -635,6 +693,11 @@ async def _validate_user_input( options_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.CLIMATE: SchemaFlowFormStep( + options_schema(Platform.CLIMATE), + preview="template", + validate_user_input=validate_user_input(Platform.CLIMATE), + ), Platform.COVER: SchemaFlowFormStep( options_schema(Platform.COVER), preview="template", @@ -713,6 +776,7 @@ async def _validate_user_input( ] = { Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, Platform.BINARY_SENSOR: async_create_preview_binary_sensor, + Platform.CLIMATE: async_create_preview_climate, Platform.COVER: async_create_preview_cover, Platform.DEVICE_TRACKER: async_create_preview_tracker, Platform.EVENT: async_create_preview_event, diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 816b77b5284df..bf34f3b620bd3 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -25,6 +25,7 @@ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, Platform.EVENT, diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index eb50c80c5c2d5..766edc1995791 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -64,6 +64,7 @@ def __init__( self._templates: dict[str, EntityTemplate] = {} self._action_scripts: dict[str, Script] = {} self._attr_extra_state_attributes = {} + self._assumed_attributes: dict[str, str] = {} self._attribute_templates: dict[str, Template] | None = None self._attributes_template: Template | None = None @@ -201,6 +202,24 @@ def add_script( domain, ) + def add_assumed_attribute(self, attr: str, option: str, action_option: str): + """Add an optimistic option.""" + if option not in self._config and action_option in self._config: + self._assumed_attributes[option] = attr + + def update_assumed_attribute(self, option: str, value: Any) -> bool: + """If the attribute is assumed, update attribute with the new value.""" + attr = self._assumed_attributes.get(option) + if assumed_attribute := attr is not None: + setattr(self, attr, value) + + return assumed_attribute + + def write_assumed_attribute(self, option: str, value: Any) -> None: + """If the attribute is assumed, write the value to the attribute and update the ha state.""" + if self.update_assumed_attribute(option, value): + self.async_write_ha_state() + @override async def async_will_remove_from_hass(self) -> None: """Clean up scripts when removing from Home Assistant.""" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 570c91aabdb9f..99bfe69c5301f 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -7,6 +7,8 @@ "device_class": "Device class", "device_id_description": "Select a device to link to this entity.", "state": "State", + "temperature_unit": "Temperature unit", + "temperature_unit_description": "The unit for any temperature template output. One of `°C`, `°F`, or `K`.", "turn_off": "Actions on turn off", "turn_on": "Actions on turn on", "unit_of_measurement": "Unit of measurement" @@ -102,6 +104,46 @@ }, "title": "Template button" }, + "climate": { + "data": { + "current_temperature": "[%key:component::climate::entity_component::_::state_attributes::current_temperature::name%]", + "device_id": "[%key:common::config_flow::data::device%]", + "hvac_action": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::name%]", + "hvac_mode": "HVAC mode", + "hvac_modes": "[%key:component::climate::entity_component::_::state_attributes::hvac_modes::name%]", + "name": "[%key:common::config_flow::data::name%]", + "set_hvac_mode": "Actions on set HVAC mode", + "set_temperature": "Actions on set temperature", + "target_temperature": "[%key:component::climate::entity_component::_::state_attributes::temperature::name%]", + "temperature_unit": "[%key:component::template::common::temperature_unit%]" + }, + "data_description": { + "current_temperature": "Defines a template for the current temperature.", + "device_id": "[%key:component::template::common::device_id_description%]", + "hvac_action": "Defines a template for the current HVAC action. Valid HVAC actions are `cooling`, `defrosting`, `drying`, `fan`, `heating`, `idle`, `off`, and `preheating`.", + "hvac_mode": "Defines a template for the current HVAC mode. Valid HVAC modes are `auto`, `cool`, `dry`, `fan_only`, `heat`, `heat_cool` and `off`.", + "hvac_modes": "Defines a template for a list of available HVAC modes. Valid HVAC modes for the list are `auto`, `cool`, `dry`, `fan_only`, `heat`, `heat_cool` and `off`.", + "set_hvac_mode": "Defines actions to run when the climate is given a `set_hvac_mode` command. Receives variable `hvac_mode`.", + "set_temperature": "Defines actions to run when the climate is given a `set_temperature` command. Receives variable `hvac_mode`, `temperature`, `target_temp_high`, and `target_temp_low`. If `hvac_mode` is received, the `set_hvac_mode` actions are executed before the actions in `set_temperature`.", + "target_temperature": "Defines a template for the target temperature.", + "temperature_unit": "[%key:component::template::common::temperature_unit_description%]" + }, + + "sections": { + "additional_options": { + "data": { + "availability": "[%key:component::template::common::availability%]", + "max_temperature": "[%key:component::climate::entity_component::_::state_attributes::max_temp::name%]", + "min_temperature": "[%key:component::climate::entity_component::_::state_attributes::min_temp::name%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + }, + "name": "[%key:component::template::common::additional_options%]" + } + }, + "title": "Template Thermostat" + }, "cover": { "data": { "close_cover": "Actions on close", @@ -483,6 +525,7 @@ "alarm_control_panel": "[%key:component::alarm_control_panel::title%]", "binary_sensor": "[%key:component::binary_sensor::title%]", "button": "[%key:component::button::title%]", + "climate": "[%key:component::climate::title%]", "cover": "[%key:component::cover::title%]", "device_tracker": "[%key:component::device_tracker::title%]", "event": "[%key:component::event::title%]", @@ -550,7 +593,7 @@ "humidity": "Humidity", "name": "[%key:common::config_flow::data::name%]", "temperature": "Temperature", - "temperature_unit": "Temperature unit" + "temperature_unit": "[%key:component::template::common::temperature_unit%]" }, "data_description": { "condition": "Defines a template to get the current weather condition", @@ -559,7 +602,7 @@ "forecast_hourly": "Defines a template to get the [hourly forecast data]({hourly_link})", "humidity": "Defines a template to get the current humidity", "temperature": "Defines a template to get the current temperature", - "temperature_unit": "The temperature unit" + "temperature_unit": "[%key:component::template::common::temperature_unit_description%]" }, "sections": { "additional_options": { @@ -701,6 +744,46 @@ }, "title": "[%key:component::template::config::step::button::title%]" }, + "climate": { + "data": { + "current_temperature": "[%key:component::climate::entity_component::_::state_attributes::current_temperature::name%]", + "device_id": "[%key:common::config_flow::data::device%]", + "hvac_action": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::name%]", + "hvac_mode": "[%key:component::template::config::step::climate::data::hvac_mode%]", + "hvac_modes": "[%key:component::climate::entity_component::_::state_attributes::hvac_modes::name%]", + "name": "[%key:common::config_flow::data::name%]", + "set_hvac_mode": "[%key:component::template::config::step::climate::data::set_hvac_mode%]", + "set_temperature": "[%key:component::template::config::step::climate::data::set_temperature%]", + "target_temperature": "[%key:component::climate::entity_component::_::state_attributes::temperature::name%]", + "temperature_unit": "[%key:component::template::common::temperature_unit%]" + }, + "data_description": { + "current_temperature": "[%key:component::template::config::step::climate::data_description::current_temperature%]", + "device_id": "[%key:component::template::common::device_id_description%]", + "hvac_action": "[%key:component::template::config::step::climate::data_description::hvac_action%]", + "hvac_mode": "[%key:component::template::config::step::climate::data_description::hvac_mode%]", + "hvac_modes": "[%key:component::template::config::step::climate::data_description::hvac_modes%]", + "set_hvac_mode": "[%key:component::template::config::step::climate::data_description::set_hvac_mode%]", + "set_temperature": "[%key:component::template::config::step::climate::data_description::set_temperature%]", + "target_temperature": "[%key:component::template::config::step::climate::data_description::target_temperature%]", + "temperature_unit": "[%key:component::template::common::temperature_unit_description%]" + }, + + "sections": { + "additional_options": { + "data": { + "availability": "[%key:component::template::common::availability%]", + "max_temperature": "[%key:component::climate::entity_component::_::state_attributes::max_temp::name%]", + "min_temperature": "[%key:component::climate::entity_component::_::state_attributes::min_temp::name%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + }, + "name": "[%key:component::template::common::additional_options%]" + } + }, + "title": "[%key:component::template::config::step::climate::title%]" + }, "cover": { "data": { "close_cover": "[%key:component::template::config::step::cover::data::close_cover%]", @@ -1115,7 +1198,7 @@ "humidity": "[%key:component::template::config::step::weather::data::humidity%]", "name": "[%key:common::config_flow::data::name%]", "temperature": "[%key:component::template::config::step::weather::data::temperature%]", - "temperature_unit": "[%key:component::template::config::step::weather::data::temperature_unit%]" + "temperature_unit": "[%key:component::template::common::temperature_unit%]" }, "data_description": { "condition": "[%key:component::template::config::step::weather::data_description::condition%]", @@ -1124,7 +1207,7 @@ "forecast_hourly": "[%key:component::template::config::step::weather::data_description::forecast_hourly%]", "humidity": "[%key:component::template::config::step::weather::data_description::humidity%]", "temperature": "[%key:component::template::config::step::weather::data_description::temperature%]", - "temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]" + "temperature_unit": "[%key:component::template::common::temperature_unit_description%]" }, "sections": { "additional_options": { diff --git a/homeassistant/components/template/validators.py b/homeassistant/components/template/validators.py index 82147be7d4eee..7f946e54abb3a 100644 --- a/homeassistant/components/template/validators.py +++ b/homeassistant/components/template/validators.py @@ -463,3 +463,42 @@ def check_conditions( ) return condition_result + + +def inclusive_group(name: str, optional: str, *required: str) -> Callable[[dict], dict]: + """Validate an inclusive group of configuration options, with 1 optional option. + + The optional member requires all required options, however the required options + do not require the optional option. + """ + _all = {optional, *required} + _required = set(required) + + def verify(obj: dict) -> dict: + options = set(obj.keys()) + if not (common := options.intersection(_all)) or common in (_required, _all): + return obj + + missing = _required - common + raise probatio.Invalid( + f"Some required option(s) are missing from inclusive group '{name}', expected missing options: {', '.join(missing)}." + ) + + return verify + + +def requires_option(option: str, required_option: str) -> Callable[[dict], dict]: + """Validate a pair of options. + + Raises probatio.Invalid if required_option is missing when option is present. + """ + + def verify(obj: dict) -> dict: + if (option in obj and required_option in obj) or option not in obj: + return obj + + raise probatio.Invalid( + f"Required option: '{required_option}' is missing for option '{option}'. Remove '{option}' from your config or add '{required_option}'." + ) + + return verify diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index 196f9c9b2ddc3..a12b0a6d466c2 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -2,11 +2,9 @@ from typing import Any, NamedTuple, override -import probatio from pytouchline_extended import PyTouchline from homeassistant.components.climate import ( - PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, PRESET_AWAY, PRESET_NONE, PRESET_SLEEP, @@ -14,18 +12,11 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .data import TouchlineConfigEntry @@ -52,10 +43,6 @@ class PresetMode(NamedTuple): for preset, settings in PRESET_MODES.items() } -PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend( - {probatio.Required(CONF_HOST): cv.string} -) - async def async_setup_entry( hass: HomeAssistant, @@ -79,60 +66,6 @@ async def async_setup_entry( async_add_entities(devices) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Touchline devices from YAML. - - Touchline now uses config entries. If an entry exists in configuration.yaml, - the import flow will attempt to import it and create a config entry. - """ - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: config[CONF_HOST]}, - ) - if ( - result.get("type") is FlowResultType.ABORT - and result.get("reason") != "already_configured" - ): - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result.get('reason')}", - breaks_in_ha_version="2026.10.0", - is_fixable=False, - is_persistent=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Roth Touchline", - }, - ) - return - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2026.10.0", - is_fixable=False, - is_persistent=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Roth Touchline", - }, - ) - - class Touchline(ClimateEntity): """Representation of a Touchline device.""" diff --git a/homeassistant/components/touchline/config_flow.py b/homeassistant/components/touchline/config_flow.py index 41dc88e5c138e..a85a54f9b537d 100644 --- a/homeassistant/components/touchline/config_flow.py +++ b/homeassistant/components/touchline/config_flow.py @@ -85,25 +85,3 @@ async def async_step_user( data_schema=STEP_USER_DATA_SCHEMA, errors=errors, ) - - async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Handle import from YAML.""" - - # Abort if an entry with the same host already exists, to avoid duplicates - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - - # Validate the user input allows us to connect - try: - unique_id = await _async_validate_input(self.hass, user_input) - except CannotConnect: - return self.async_abort(reason="cannot_connect") - except Exception: # noqa: BLE001 - return self.async_abort(reason="unknown") - - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=user_input[CONF_HOST], - data=user_input, - ) diff --git a/homeassistant/components/touchline/strings.json b/homeassistant/components/touchline/strings.json index c6f0726fc5de6..d5d5beaec0a95 100644 --- a/homeassistant/components/touchline/strings.json +++ b/homeassistant/components/touchline/strings.json @@ -32,15 +32,5 @@ } } } - }, - "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "description": "Home Assistant could not connect to the Roth Touchline controller while importing your YAML configuration. Remove the YAML configuration for Roth Touchline from configuration.yaml and set up the integration again from the Home Assistant UI.", - "title": "Roth Touchline YAML configuration import failed" - }, - "deprecated_yaml_import_issue_unknown": { - "description": "An unknown error occurred while importing your Roth Touchline YAML configuration. Remove the YAML configuration for Roth Touchline from configuration.yaml and set up the integration again from the Home Assistant UI.", - "title": "Roth Touchline YAML configuration import issue" - } } } diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 967a0816e575c..a881992ebdfee 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "silver", - "requirements": ["aiounifi==95"] + "requirements": ["aiounifi==96"] } diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index f495267e5591d..554d6cb04c716 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -1,7 +1,6 @@ """Component to allow for providing device or service updates.""" from datetime import timedelta -from enum import StrEnum from functools import lru_cache import logging from typing import Any, Final, final, override @@ -40,9 +39,11 @@ ATTR_TITLE, ATTR_UPDATE_PERCENTAGE, ATTR_VERSION, + DEVICE_CLASSES_SCHEMA, DOMAIN, SERVICE_INSTALL, SERVICE_SKIP, + UpdateDeviceClass, UpdateEntityFeature, UpdateEntityStateAttribute, ) @@ -56,15 +57,6 @@ SCAN_INTERVAL = timedelta(minutes=15) -class UpdateDeviceClass(StrEnum): - """Device class for update.""" - - FIRMWARE = "firmware" - - -DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(UpdateDeviceClass)) - - __all__ = [ "ATTR_BACKUP", "ATTR_INSTALLED_VERSION", diff --git a/homeassistant/components/update/const.py b/homeassistant/components/update/const.py index 27f4f19f3bf47..50c7bed340f2d 100644 --- a/homeassistant/components/update/const.py +++ b/homeassistant/components/update/const.py @@ -3,6 +3,8 @@ from enum import IntFlag, StrEnum from typing import Final +import probatio + DOMAIN: Final = "update" @@ -46,3 +48,12 @@ class UpdateEntityFeature(IntFlag): ATTR_TITLE: Final = "title" ATTR_UPDATE_PERCENTAGE: Final = "update_percentage" ATTR_VERSION: Final = "version" + + +class UpdateDeviceClass(StrEnum): + """Device class for update.""" + + FIRMWARE = "firmware" + + +DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(UpdateDeviceClass)) diff --git a/homeassistant/components/update/device_trigger.py b/homeassistant/components/update/device_trigger.py index ab3a2ca49e490..fcc48d8c4d78b 100644 --- a/homeassistant/components/update/device_trigger.py +++ b/homeassistant/components/update/device_trigger.py @@ -8,7 +8,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN TRIGGER_SCHEMA = probatio.All( toggle_entity.TRIGGER_SCHEMA, diff --git a/homeassistant/components/usage_prediction/__init__.py b/homeassistant/components/usage_prediction/__init__.py index 72198e42022f9..f74e500896f55 100644 --- a/homeassistant/components/usage_prediction/__init__.py +++ b/homeassistant/components/usage_prediction/__init__.py @@ -4,6 +4,8 @@ from datetime import timedelta from typing import Any +import probatio + from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -11,7 +13,7 @@ from homeassistant.util import dt as dt_util from . import common_control -from .const import DATA_CACHE, DOMAIN +from .const import DATA_CACHE, DEFAULT_LIMIT, DOMAIN from .models import EntityUsageDataCache, EntityUsagePredictions CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -26,7 +28,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@websocket_api.websocket_command({"type": f"{DOMAIN}/common_control"}) +@websocket_api.websocket_command( + { + probatio.Required("type"): f"{DOMAIN}/common_control", + probatio.Optional("limit", default=DEFAULT_LIMIT): probatio.All( + int, probatio.Range(min=1) + ), + } +) @websocket_api.async_response async def ws_common_control( hass: HomeAssistant, @@ -39,7 +48,7 @@ async def ws_common_control( connection.send_result( msg["id"], { - "entities": getattr(result, time_category), + "entities": getattr(result, time_category)[: msg["limit"]], }, ) diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py index d3f1da724035d..543d9de2af577 100644 --- a/homeassistant/components/usage_prediction/common_control.py +++ b/homeassistant/components/usage_prediction/common_control.py @@ -25,8 +25,6 @@ # Time categories for usage patterns TIME_CATEGORIES = ["morning", "afternoon", "evening", "night"] -RESULTS_TO_INCLUDE = 8 - # Rows fetched per round trip while streaming the events query QUERY_YIELD_PER = 4096 @@ -106,19 +104,10 @@ async def async_predict_common_control( ) return EntityUsagePredictions( - morning=[ - ent_id for (ent_id, _) in results["morning"].most_common(RESULTS_TO_INCLUDE) - ], - afternoon=[ - ent_id - for (ent_id, _) in results["afternoon"].most_common(RESULTS_TO_INCLUDE) - ], - evening=[ - ent_id for (ent_id, _) in results["evening"].most_common(RESULTS_TO_INCLUDE) - ], - night=[ - ent_id for (ent_id, _) in results["night"].most_common(RESULTS_TO_INCLUDE) - ], + morning=[ent_id for (ent_id, _) in results["morning"].most_common()], + afternoon=[ent_id for (ent_id, _) in results["afternoon"].most_common()], + evening=[ent_id for (ent_id, _) in results["evening"].most_common()], + night=[ent_id for (ent_id, _) in results["night"].most_common()], ) diff --git a/homeassistant/components/usage_prediction/const.py b/homeassistant/components/usage_prediction/const.py index 65aeb1773fead..665e465090d62 100644 --- a/homeassistant/components/usage_prediction/const.py +++ b/homeassistant/components/usage_prediction/const.py @@ -8,6 +8,8 @@ DOMAIN = "usage_prediction" +DEFAULT_LIMIT = 8 + DATA_CACHE: HassKey[ dict[str, asyncio.Task[EntityUsagePredictions] | EntityUsageDataCache] ] = HassKey("usage_prediction") diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index 4503920cda217..1f619ad2efee2 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -24,6 +24,7 @@ from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 + DEVICE_CLASSES_SCHEMA, DOMAIN, ValveDeviceClass, ValveEntityFeature, @@ -46,9 +47,6 @@ SCAN_INTERVAL = timedelta(seconds=15) -DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(ValveDeviceClass)) - - ATTR_POSITION = "position" diff --git a/homeassistant/components/valve/const.py b/homeassistant/components/valve/const.py index 37774a56867d3..35afe1ac57105 100644 --- a/homeassistant/components/valve/const.py +++ b/homeassistant/components/valve/const.py @@ -3,6 +3,8 @@ from enum import IntFlag, StrEnum from typing import Final +import probatio + DOMAIN: Final = "valve" @@ -37,3 +39,6 @@ class ValveState(StrEnum): CLOSING = "closing" CLOSED = "closed" OPEN = "open" + + +DEVICE_CLASSES_SCHEMA = probatio.All(probatio.Lower, probatio.Coerce(ValveDeviceClass)) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 57845d96297a5..40dc20d7d1836 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -1,7 +1,6 @@ """Support for water heater devices.""" from datetime import timedelta -from enum import IntFlag import functools as ft import logging from typing import Any, final, override @@ -30,7 +29,12 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import TemperatureConverter -from .const import DOMAIN, WaterHeaterCapabilityAttribute, WaterHeaterStateAttribute +from .const import ( + DOMAIN, + WaterHeaterCapabilityAttribute, + WaterHeaterEntityFeature, + WaterHeaterStateAttribute, +) DATA_COMPONENT: HassKey[EntityComponent[WaterHeaterEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -53,15 +57,6 @@ STATE_GAS = "gas" -class WaterHeaterEntityFeature(IntFlag): - """Supported features of the water heater entity.""" - - TARGET_TEMPERATURE = 1 - OPERATION_MODE = 2 - AWAY_MODE = 4 - ON_OFF = 8 - - ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" ATTR_AWAY_MODE = "away_mode" diff --git a/homeassistant/components/water_heater/const.py b/homeassistant/components/water_heater/const.py index 694473a40a619..78dd9a00e2b9b 100644 --- a/homeassistant/components/water_heater/const.py +++ b/homeassistant/components/water_heater/const.py @@ -1,6 +1,6 @@ """Support for water heater devices.""" -from enum import StrEnum +from enum import IntFlag, StrEnum from typing import Final from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers @@ -41,3 +41,12 @@ class WaterHeaterStateAttribute( STATE_HIGH_DEMAND = "high_demand" STATE_HEAT_PUMP = "heat_pump" STATE_GAS = "gas" + + +class WaterHeaterEntityFeature(IntFlag): + """Supported features of the water heater entity.""" + + TARGET_TEMPERATURE = 1 + OPERATION_MODE = 2 + AWAY_MODE = 4 + ON_OFF = 8 diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 81447b7e88644..15875e50967a8 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN +from .const import DOMAIN ACTION_TYPES = {"turn_on", "turn_off"} diff --git a/homeassistant/components/water_heater/reproduce_state.py b/homeassistant/components/water_heater/reproduce_state.py index 889ed8c3855e8..3b0712a8a17e5 100644 --- a/homeassistant/components/water_heater/reproduce_state.py +++ b/homeassistant/components/water_heater/reproduce_state.py @@ -18,7 +18,6 @@ from . import ( ATTR_AWAY_MODE, ATTR_OPERATION_MODE, - DOMAIN, SERVICE_SET_AWAY_MODE, SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE, @@ -29,7 +28,7 @@ STATE_HIGH_DEMAND, STATE_PERFORMANCE, ) -from .const import WaterHeaterStateAttribute +from .const import DOMAIN, WaterHeaterStateAttribute _LOGGER = logging.getLogger(__name__) diff --git a/requirements.txt b/requirements.txt index 9cae7538b02e0..2f227208c585b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.8.28 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==10.0.0 +infrared-protocols==10.1.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9a751b16bb1fc..936d8cb728aa2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -315,7 +315,7 @@ aioimaplib==2.0.1 aioimmich==0.17.0 # homeassistant.components.indi_allsky -aioindiallsky==0.1.1 +aioindiallsky==0.1.2 # homeassistant.components.ipp aioipp==0.19.3 @@ -474,7 +474,7 @@ aiotedee==0.3.0 aiotractive==1.0.3 # homeassistant.components.unifi -aiounifi==95 +aiounifi==96 # homeassistant.components.usb aiousbwatcher==1.1.2 @@ -1414,7 +1414,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==10.0.0 +infrared-protocols==10.1.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index cbf553e69a09f..89ce864961d6d 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -3,4 +3,4 @@ codespell==2.4.3 ruff==0.16.6 yamllint==1.38.0 -zizmor==1.30.0 +zizmor==1.30.1 diff --git a/tests/components/airthings_ble/test_init.py b/tests/components/airthings_ble/test_init.py index 7cf03940190d9..1ee615f5cbf26 100644 --- a/tests/components/airthings_ble/test_init.py +++ b/tests/components/airthings_ble/test_init.py @@ -98,6 +98,33 @@ async def test_setup_retries_when_device_not_found( ) +async def test_setup_retries_on_incomplete_read( + hass: HomeAssistant, +) -> None: + """Test setup is retried when the device returns data without an address.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WAVE_SERVICE_INFO.address, + data={DEVICE_MODEL: WAVE_DEVICE_INFO.model.value}, + ) + entry.add_to_hass(hass) + + inject_bluetooth_service_info(hass, WAVE_SERVICE_INFO) + + incomplete_device_info = deepcopy(WAVE_DEVICE_INFO) + incomplete_device_info.address = "" + + with ( + patch_async_ble_device_from_address(WAVE_SERVICE_INFO.device), + patch_airthings_ble(incomplete_device_info), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert len(hass.states.async_all()) == 0 + + async def test_no_migration_when_device_model_exists( hass: HomeAssistant, ) -> None: diff --git a/tests/components/lametric/snapshots/test_notify.ambr b/tests/components/lametric/snapshots/test_notify.ambr new file mode 100644 index 0000000000000..e9ec419528c75 --- /dev/null +++ b/tests/components/lametric/snapshots/test_notify.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_all_entities[notify][notify.frenck_s_lametric_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.frenck_s_lametric_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Message', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Message', + 'platform': 'lametric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'message', + 'unique_id': 'SA110405124500W00BS9-message', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[notify][notify.frenck_s_lametric_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : "Frenck's LaMetric Message", + : , + }), + 'context': , + 'entity_id': 'notify.frenck_s_lametric_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lametric/test_notify.py b/tests/components/lametric/test_notify.py index d30a8c86543a6..eb26ad0467c63 100644 --- a/tests/components/lametric/test_notify.py +++ b/tests/components/lametric/test_notify.py @@ -3,7 +3,9 @@ from unittest.mock import MagicMock from demetriek import ( + LaMetricConnectionError, LaMetricError, + Model, Notification, NotificationIconType, NotificationPriority, @@ -12,18 +14,34 @@ Simple, ) import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.lametric.const import DOMAIN from homeassistant.components.notify import ( ATTR_DATA, ATTR_MESSAGE, DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform NOTIFY_SERVICE = "frenck_s_lametric" +ENTITY_ID = "notify.frenck_s_lametric_message" -pytestmark = pytest.mark.usefixtures("init_integration") +pytestmark = [ + pytest.mark.parametrize("init_integration", [Platform.NOTIFY], indirect=True), + pytest.mark.usefixtures("init_integration"), +] async def test_notification_defaults( @@ -122,3 +140,94 @@ async def test_notification_error( }, blocking=True, ) + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2022-09-19 12:07:30") +async def test_send_message( + hass: HomeAssistant, + mock_lametric: MagicMock, +) -> None: + """Test sending a message through the LaMetric notify entity.""" + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MESSAGE: "The way to get started is to quit talking and begin doing", + }, + blocking=True, + ) + + mock_lametric.notify.assert_called_once_with( + notification=Notification( + icon_type=NotificationIconType.NONE, + priority=NotificationPriority.INFO, + model=Model( + frames=[ + Simple( + text="The way to get started is to quit talking and begin doing" + ) + ] + ), + ) + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == "2022-09-19T12:07:30+00:00" + + +@pytest.mark.parametrize( + ("side_effect", "translation_key", "expected_state"), + [ + pytest.param( + LaMetricError, + "invalid_response", + STATE_UNKNOWN, + id="error", + ), + pytest.param( + LaMetricConnectionError, + "communication_error", + STATE_UNAVAILABLE, + id="connection_error", + ), + ], +) +async def test_send_message_error( + hass: HomeAssistant, + mock_lametric: MagicMock, + side_effect: type[LaMetricError], + translation_key: str, + expected_state: str, +) -> None: + """Test error handling of the LaMetric notify entity.""" + mock_lametric.notify.side_effect = side_effect + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MESSAGE: "It's failure that gives you the proper perspective", + }, + blocking=True, + ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == translation_key + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == expected_state diff --git a/tests/components/template/snapshots/test_climate.ambr b/tests/components/template/snapshots/test_climate.ambr new file mode 100644 index 0000000000000..089d3796e9f2e --- /dev/null +++ b/tests/components/template/snapshots/test_climate.ambr @@ -0,0 +1,24 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'My template', + : list([ + , + , + , + , + ]), + : 35, + : 7, + : , + }), + 'context': , + 'entity_id': 'climate.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/template/test_climate.py b/tests/components/template/test_climate.py new file mode 100644 index 0000000000000..45c4e3f97c62d --- /dev/null +++ b/tests/components/template/test_climate.py @@ -0,0 +1,2451 @@ +"""The tests for the Template climate platform.""" + +from enum import StrEnum +from itertools import chain +from typing import Any + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import climate, template +from homeassistant.components.climate import ( + ClimateEntityCapabilityAttribute, + ClimateEntityFeature, + ClimateEntityStateAttribute, + HVACAction, + HVACMode, +) +from homeassistant.components.template.climate import DEFAULT_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.helpers.typing import ConfigType + +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + assert_action, + assert_attributes_template, + assert_extra_template_attributes, + assert_invalid_config_entry_actions_do_not_create_entities, + assert_invalid_yaml_actions_do_not_create_entities, + assert_state_and_attributes, + async_get_flow_preview_state, + async_trigger, + make_test_action, + make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, +) + +from tests.common import MockConfigEntry, async_mock_restore_state_shutdown_restart +from tests.typing import WebSocketGenerator + +TEST_STATE_ENTITY_ID = "sensor.test_state" +TEST_ATTRIBUTE_ENTITY_ID = "sensor.test_attribute" +TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" + +TEST_CLIMATE = TemplatePlatformSetup( + climate.DOMAIN, + "test_climate", + make_test_trigger( + TEST_STATE_ENTITY_ID, + TEST_AVAILABILITY_ENTITY, + TEST_ATTRIBUTE_ENTITY_ID, + ), +) + +SET_FAN_MODE_ACTION = make_test_action( + "set_fan_mode", + { + "fan_mode": "{{ fan_mode }}", + }, +) +SET_HUMIDITY_ACTION = make_test_action( + "set_humidity", + { + "humidity": "{{ humidity }}", + }, +) +SET_HVAC_MODE_ACTION = make_test_action( + "set_hvac_mode", + { + "hvac_mode": "{{ hvac_mode }}", + }, +) +SET_PRESET_MODE_ACTION = make_test_action( + "set_preset_mode", + { + "preset_mode": "{{ preset_mode }}", + }, +) +SET_SWING_HORIZONTAL_MODE_ACTION = make_test_action( + "set_swing_horizontal_mode", + { + "swing_horizontal_mode": "{{ swing_horizontal_mode }}", + }, +) +SET_SWING_MODE_ACTION = make_test_action( + "set_swing_mode", + { + "swing_mode": "{{ swing_mode }}", + }, +) +SET_TEMPERATURE_ACTION = make_test_action( + "set_temperature", + { + "temperature": "{{ temperature }}", + "target_temp_high": "{{ target_temp_high }}", + "target_temp_low": "{{ target_temp_low }}", + "hvac_mode": "{{ hvac_mode }}", + }, +) + +HVAC_MODES = {"hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}"} +EXPECTED_HVAC_MODES = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] +MINIMUM_REQUIREMENTS = { + **HVAC_MODES, + **SET_HVAC_MODE_ACTION, +} + + +async def _call_and_assert_action( + hass: HomeAssistant, + calls: list[ServiceCall], + service: str, + service_data: ConfigType | None = None, + expected_data: ConfigType | None = None, + expected_action: str | None = None, +) -> None: + """Call a service and validate that it was called properly. + + The service is validated when expected_action is omitted. + """ + if expected_action is None: + expected_action = service + current = len(calls) + await hass.services.async_call( + climate.DOMAIN, + service, + {**(service_data or {}), ATTR_ENTITY_ID: TEST_CLIMATE.entity_id}, + blocking=True, + ) + assert_action( + TEST_CLIMATE, calls, current + 1, expected_action, **(expected_data or {}) + ) + + +@pytest.fixture +async def setup_base_climate( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + config: dict[str, Any], +) -> None: + """Do setup of climate integration.""" + await setup_entity(hass, TEST_CLIMATE, style, count, config) + + +@pytest.fixture +async def setup_climate( + hass: HomeAssistant, + style: ConfigurationStyle, + config: dict[str, Any], + extra_config: dict[str, Any], +) -> None: + """Do setup of climate integration.""" + await setup_entity(hass, TEST_CLIMATE, style, 1, config, extra_config=extra_config) + + +@pytest.fixture +async def setup_single_attribute_climate( + hass: HomeAssistant, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of climate integration.""" + await setup_entity( + hass, + TEST_CLIMATE, + style, + 1, + {attribute: attribute_template} if attribute and attribute_template else {}, + extra_config=extra_config, + ) + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [("current_humidity", MINIMUM_REQUIREMENTS)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 20 }}", 20), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45), + ("{{ -1 }}", None), + ("{{ 101 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_humidity_template(hass: HomeAssistant, expected: Any) -> None: + """Test template humidity.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("current_humidity") == expected + + +@pytest.mark.parametrize( + ("config", "extra_config"), + [(MINIMUM_REQUIREMENTS, {"target_humidity": "{{ 65 }}", **SET_HUMIDITY_ACTION})], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_set_humidity_action( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: + """Test set_humidity action.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_humidity", + {"humidity": 45}, + {"humidity": 45}, + "set_humidity", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes["humidity"] == 65 + + +@pytest.mark.parametrize( + ("config", "extra_config"), [(MINIMUM_REQUIREMENTS, SET_HUMIDITY_ACTION)] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_optimistic_set_humidity_action( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: + """Test optimistic set_humidity action.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_humidity", + {"humidity": 45}, + {"humidity": 45}, + "set_humidity", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes["humidity"] == 45 + + await _call_and_assert_action( + hass, + calls, + "set_humidity", + {"humidity": 65}, + {"humidity": 65}, + "set_humidity", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes["humidity"] == 65 + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [("current_temperature", MINIMUM_REQUIREMENTS)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ -1 }}", -1), + ("{{ 5.3423 }}", 5.3), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45.5), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_temperature_template(hass: HomeAssistant, expected: Any) -> None: + """Test template temperature.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("current_temperature") == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [("hvac_action", MINIMUM_REQUIREMENTS)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 'cooling' }}", HVACAction.COOLING), + ("{{ 'defrosting' }}", HVACAction.DEFROSTING), + ("{{ 'drying' }}", HVACAction.DRYING), + ("{{ 'fan' }}", HVACAction.FAN), + ("{{ 'heating' }}", HVACAction.HEATING), + ("{{ 'idle' }}", HVACAction.IDLE), + ("{{ 'off' }}", HVACAction.OFF), + ("{{ 'preheating' }}", HVACAction.PREHEATING), + ("{{ 100 }}", None), + ("{{ 45.5 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_hvac_action_template(hass: HomeAssistant, expected: Any) -> None: + """Test template hvac_action.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("hvac_action") == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "target_humidity", + { + "min_humidity": 19, + "max_humidity": 100, + **SET_HUMIDITY_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 20 }}", 20), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45), + ("{{ -1 }}", None), + ("{{ 101 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_target_humidity_template(hass: HomeAssistant, expected: Any) -> None: + """Test template target_humidity.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("humidity") == expected + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "config", + [ + {"target_humidity": 32, **MINIMUM_REQUIREMENTS}, + ], +) +async def test_missing_set_humidity_config( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad target humidity configuration.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + "Invalid config for 'template': Required option: 'set_humidity' is missing for option" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "target_temperature", + { + "min_temperature": -2, + "max_temperature": 101, + **SET_TEMPERATURE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ -1 }}", -1), + ("{{ 5.3423 }}", 5.3), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45.5), + ("{{ -3 }}", None), + ("{{ 103 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_target_temperature_template(hass: HomeAssistant, expected: Any) -> None: + """Test template target_temperature.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("temperature") == expected + + +@pytest.mark.parametrize( + "config", + [{"hvac_mode": "{{ 'cool' }}", **MINIMUM_REQUIREMENTS, **SET_TEMPERATURE_ACTION}], +) +@pytest.mark.parametrize( + ( + "extra_config", + "action_variables", + "expected_variables", + "expected_attributes", + ), + [ + ( + { + "target_temperature": "{{ 21 }}", + }, + {"temperature": 18}, + {"temperature": 18}, + {"temperature": 21}, + ), + ( + { + "target_temperature_low": "{{ 10 }}", + "target_temperature_high": "{{ 30 }}", + }, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 10, "target_temp_high": 30}, + ), + ( + { + "target_temperature": "{{ 20 }}", + "target_temperature_low": "{{ 10 }}", + "target_temperature_high": "{{ 30 }}", + }, + {"temperature": 21, "target_temp_low": 11, "target_temp_high": 29}, + {"temperature": 21, "target_temp_low": 11, "target_temp_high": 29}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_set_temperature_action_with_hvac_mode( + hass: HomeAssistant, + action_variables: ConfigType, + expected_variables: ConfigType, + expected_attributes: ConfigType, + calls: list[ServiceCall], +) -> None: + """Test set_temperature action.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await hass.services.async_call( + climate.DOMAIN, + "set_temperature", + { + "hvac_mode": "heat", + **action_variables, + ATTR_ENTITY_ID: TEST_CLIMATE.entity_id, + }, + blocking=True, + ) + assert_action(TEST_CLIMATE, calls, 2, "set_hvac_mode", 0, hvac_mode="heat") + assert_action( + TEST_CLIMATE, + calls, + 2, + "set_temperature", + hvac_mode="heat", + **expected_variables, + ) + + assert_state_and_attributes(hass, TEST_CLIMATE, HVACMode.COOL, expected_attributes) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + climate.DOMAIN, + "set_temperature", + { + "hvac_mode": "fan_only", + "temperature": 20, + ATTR_ENTITY_ID: TEST_CLIMATE.entity_id, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("config", [{**MINIMUM_REQUIREMENTS, **SET_TEMPERATURE_ACTION}]) +@pytest.mark.parametrize( + ( + "extra_config", + "action_variables", + "expected_variables", + "expected_attributes", + ), + [ + ( + {"target_temperature": "{{ 21 }}"}, + {"temperature": 18}, + {"temperature": 18}, + {"temperature": 21}, + ), + ( + { + "target_temperature_low": "{{ 10 }}", + "target_temperature_high": "{{ 30 }}", + }, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 10, "target_temp_high": 30}, + ), + ( + { + "target_temperature": "{{ 20 }}", + "target_temperature_low": "{{ 10 }}", + "target_temperature_high": "{{ 30 }}", + }, + {"temperature": 21, "target_temp_low": 11, "target_temp_high": 29}, + {"temperature": 21, "target_temp_low": 11, "target_temp_high": 29}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_set_temperature_action_without_hvac_mode( + hass: HomeAssistant, + action_variables: ConfigType, + expected_variables: ConfigType, + expected_attributes: ConfigType, + calls: list[ServiceCall], +) -> None: + """Test set_temperature action does not call set_hvac_mode action without hvac_mode.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_temperature", + action_variables, + expected_variables, + "set_temperature", + ) + + assert_state_and_attributes(hass, TEST_CLIMATE, STATE_UNKNOWN, expected_attributes) + + +@pytest.mark.parametrize( + ("config", "extra_config"), [(MINIMUM_REQUIREMENTS, SET_TEMPERATURE_ACTION)] +) +@pytest.mark.parametrize( + ( + "action_variables", + "expected_variables", + "expected_attributes", + ), + [ + ( + {"temperature": 22}, + {"temperature": 22}, + {"temperature": 22}, + ), + ( + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + ), + ( + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_optimistic_set_temperature_action_with_hvac_mode( + hass: HomeAssistant, + action_variables: ConfigType, + expected_variables: ConfigType, + expected_attributes: ConfigType, + calls: list[ServiceCall], +) -> None: + """Test optimistic set_temperature action with hvac_mode.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + assert_state_and_attributes( + hass, + TEST_CLIMATE, + STATE_UNKNOWN, + {"temperature": None, "target_temp_low": None, "target_temp_high": None}, + ) + + await hass.services.async_call( + climate.DOMAIN, + "set_temperature", + { + "hvac_mode": "cool", + **action_variables, + ATTR_ENTITY_ID: TEST_CLIMATE.entity_id, + }, + blocking=True, + ) + assert_action(TEST_CLIMATE, calls, 2, "set_hvac_mode", 0, hvac_mode="cool") + assert_action( + TEST_CLIMATE, + calls, + 2, + "set_temperature", + hvac_mode="cool", + **expected_variables, + ) + + assert_state_and_attributes(hass, TEST_CLIMATE, HVACMode.COOL, expected_attributes) + + +@pytest.mark.parametrize( + ("config", "extra_config"), [(MINIMUM_REQUIREMENTS, SET_TEMPERATURE_ACTION)] +) +@pytest.mark.parametrize( + ( + "action_variables", + "expected_variables", + "expected_attributes", + ), + [ + ( + {"temperature": 21}, + {"temperature": 21}, + {"temperature": 21}, + ), + ( + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + ), + ( + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_optimistic_set_temperature_action_without_hvac_mode( + hass: HomeAssistant, + action_variables: ConfigType, + expected_variables: ConfigType, + expected_attributes: ConfigType, + calls: list[ServiceCall], +) -> None: + """Test optimistic set_temperature action.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + assert_state_and_attributes( + hass, + TEST_CLIMATE, + STATE_UNKNOWN, + {"temperature": None, "target_temp_low": None, "target_temp_high": None}, + ) + + await _call_and_assert_action( + hass, + calls, + "set_temperature", + action_variables, + expected_variables, + "set_temperature", + ) + assert_state_and_attributes(hass, TEST_CLIMATE, STATE_UNKNOWN, expected_attributes) + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "target_temperature_high", + { + "min_temperature": -3, + "max_temperature": 101, + "target_temperature_low": "{{ -3 }}", + **SET_TEMPERATURE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ -1 }}", -1), + ("{{ 5.3423 }}", 5.3), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45.5), + ("{{ -4 }}", None), + ("{{ 103 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_target_temperature_high_template( + hass: HomeAssistant, expected: Any +) -> None: + """Test template target_temperature_high.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("target_temp_high") == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "target_temperature_low", + { + "min_temperature": -2, + "max_temperature": 102, + "target_temperature_high": "{{ 102 }}", + **SET_TEMPERATURE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ -1 }}", -1), + ("{{ 5.3423 }}", 5.3), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45.5), + ("{{ -3 }}", None), + ("{{ 103 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_target_temperature_low_template( + hass: HomeAssistant, expected: Any +) -> None: + """Test template target_temperature_low.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("target_temp_low") == expected + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "config", + [ + {"target_temperature_high": 30, **MINIMUM_REQUIREMENTS}, + {"target_temperature_low": 30, **MINIMUM_REQUIREMENTS}, + ], +) +async def test_bad_target_temperature_range_config( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad target temperature range configuration.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + "Invalid config for 'template': some but not all values in the same group of inclusion 'temperature_limits'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "config", + [ + { + "target_temperature_high": 32, + "target_temperature_low": 17, + **MINIMUM_REQUIREMENTS, + }, + {"target_temperature": 21, **MINIMUM_REQUIREMENTS}, + ], +) +async def test_missing_set_temperature_config( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad target temperature range configuration.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + "Invalid config for 'template': Required option: 'set_temperature' is missing for option" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [("hvac_modes", SET_HVAC_MODE_ACTION)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ( + "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL], + ), + ( + "{{ ['dry', 'auto', 'fan_only'] }}", + [HVACMode.DRY, HVACMode.AUTO, HVACMode.FAN_ONLY], + ), + ("{{ [] }}", []), + ("{{ '[]' }}", []), + ( + "{{ ['dry', 'auto2', 'fan_only'] }}", + [HVACMode.DRY, HVACMode.FAN_ONLY], + ), + ("{{ -3 }}", []), + ("{{ 103.3 }}", []), + ("{{ True }}", []), + ("{{ False }}", []), + ("{{ 'something' }}", []), + ("{{ x - 1 }}", []), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_hvac_modes_template( + hass: HomeAssistant, attribute: str, expected: Any +) -> None: + """Test hvac_modes template.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "hvac_mode", + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool', 'dry', 'auto'] }}", + **SET_HVAC_MODE_ACTION, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 'off' }}", HVACMode.OFF), + ("{{ 'heat' }}", HVACMode.HEAT), + ("{{ 'cool' }}", HVACMode.COOL), + ("{{ 'heat_cool' }}", HVACMode.HEAT_COOL), + ("{{ 'dry' }}", HVACMode.DRY), + ("{{ 'auto' }}", HVACMode.AUTO), + ("{{ 'fan_only' }}", STATE_UNKNOWN), + ("{{ -3 }}", STATE_UNKNOWN), + ("{{ 103.3 }}", STATE_UNKNOWN), + ("{{ True }}", STATE_UNKNOWN), + ("{{ False }}", STATE_UNKNOWN), + ("{{ 'something' }}", STATE_UNKNOWN), + ("{{ x - 1 }}", STATE_UNAVAILABLE), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_hvac_mode_template(hass: HomeAssistant, expected: Any) -> None: + """Test hvac_mode template.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == expected + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("config", "option"), + [ + ( + SET_HVAC_MODE_ACTION, + "hvac_modes", + ), + ( + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool', 'dry', 'auto', 'fan_only'] }}", + }, + "set_hvac_mode", + ), + ], +) +async def test_required_hvac_mode_options( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + option: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test missing required options.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + f"Invalid config for 'template': required key '{option}' not provided" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("attribute", "attribute_template", "extra_config"), + [ + ( + "hvac_modes", + "{{ state_attr('sensor.test_attribute', 'hvac_modes') or [] }}", + SET_HVAC_MODE_ACTION, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_hvac_modes_updates_supported_features(hass: HomeAssistant) -> None: + """Test hvac_modes updates supported features.""" + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == STATE_UNKNOWN + assert state.attributes["hvac_modes"] == [] + assert state.attributes["supported_features"] == 0 + + await async_trigger( + hass, + TEST_ATTRIBUTE_ENTITY_ID, + "anything", + {"hvac_modes": ["heat"]}, + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == STATE_UNKNOWN + assert state.attributes["hvac_modes"] == [HVACMode.HEAT] + assert state.attributes["supported_features"] == ClimateEntityFeature.TURN_ON + + await async_trigger( + hass, + TEST_ATTRIBUTE_ENTITY_ID, + "anything", + {"hvac_modes": ["off", "heat"]}, + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == STATE_UNKNOWN + assert state.attributes["hvac_modes"] == [HVACMode.OFF, HVACMode.HEAT] + assert ( + state.attributes["supported_features"] + == ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + + await async_trigger( + hass, + TEST_ATTRIBUTE_ENTITY_ID, + "anything", + {"hvac_modes": ["cool"]}, + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == STATE_UNKNOWN + assert state.attributes["hvac_modes"] == [HVACMode.COOL] + assert state.attributes["supported_features"] == ClimateEntityFeature.TURN_ON + + +@pytest.mark.parametrize(("config", "extra_config"), [(MINIMUM_REQUIREMENTS, {})]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_set_hvac_mode_action( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: + """Test setting valid group mode actions with template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_hvac_mode", + {"hvac_mode": "heat"}, + {"hvac_mode": "heat"}, + "set_hvac_mode", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + climate.DOMAIN, + "set_hvac_mode", + {"hvac_mode": "fan_only", ATTR_ENTITY_ID: TEST_CLIMATE.entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize(("config", "extra_config"), [(MINIMUM_REQUIREMENTS, {})]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_optimistic_set_hvac_mode_action( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: + """Test setting valid group mode actions with template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_hvac_mode", + {"hvac_mode": "heat"}, + {"hvac_mode": "heat"}, + "set_hvac_mode", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.state == HVACMode.HEAT + + await _call_and_assert_action( + hass, + calls, + "set_hvac_mode", + {"hvac_mode": "cool"}, + {"hvac_mode": "cool"}, + "set_hvac_mode", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.state == HVACMode.COOL + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ("fan_modes", {**SET_FAN_MODE_ACTION, **MINIMUM_REQUIREMENTS}), + ("swing_modes", {**SET_SWING_MODE_ACTION, **MINIMUM_REQUIREMENTS}), + ( + "swing_horizontal_modes", + {**SET_SWING_HORIZONTAL_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + ), + ( + "preset_modes", + {**SET_PRESET_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ ['off', 'low', 'medium', 'high'] }}", ["off", "low", "medium", "high"]), + ("{{ ['off', 'high'] }}", ["off", "high"]), + ("{{ [] }}", []), + ("{{ '[]' }}", []), + ("{{ -3 }}", None), + ("{{ 103.3 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_group_modes_template( + hass: HomeAssistant, attribute: str, expected: Any +) -> None: + """Test template modes for inclusive group.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "fan_mode", + { + "fan_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_FAN_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "swing_mode", + { + "swing_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_HORIZONTAL_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "preset_mode", + { + "preset_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_PRESET_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 'off' }}", "off"), + ("{{ 'low' }}", "low"), + ("{{ 'medium' }}", "medium"), + ("{{ 'high' }}", "high"), + ("{{ -3 }}", None), + ("{{ 103.3 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_group_mode_template( + hass: HomeAssistant, attribute: str, expected: Any +) -> None: + """Test template mode for inclusive group.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("config", "group"), + [ + ( + {**SET_FAN_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + "fan_mode", + ), + ( + { + "fan_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **MINIMUM_REQUIREMENTS, + }, + "fan_mode", + ), + ( + {**SET_SWING_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + "swing_mode", + ), + ( + { + "swing_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **MINIMUM_REQUIREMENTS, + }, + "swing_mode", + ), + ( + {**SET_SWING_HORIZONTAL_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + "swing_horizontal_mode", + ), + ( + { + "swing_horizontal_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **MINIMUM_REQUIREMENTS, + }, + "swing_horizontal_mode", + ), + ( + {**SET_PRESET_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + "preset_mode", + ), + ( + { + "preset_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **MINIMUM_REQUIREMENTS, + }, + "preset_mode", + ), + ], +) +async def test_bad_mode_group_config( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + group: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad mode group configuration.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + f"Invalid config for 'template': Some required option(s) are missing from inclusive group '{group}', expected missing options" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("action", "attribute", "extra_config"), + [ + ( + "set_fan_mode", + "fan_mode", + { + "fan_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_FAN_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_mode", + "swing_mode", + { + "swing_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_horizontal_mode", + "swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_HORIZONTAL_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_preset_mode", + "preset_mode", + { + "preset_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_PRESET_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "mode", "expected"), + [ + ("{{ 'off' }}", "off", "off"), + ("{{ 'low' }}", "low", "low"), + ("{{ 'medium' }}", "medium", "medium"), + ("{{ 'high' }}", "high", "high"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_set_group_actions( + hass: HomeAssistant, + action: str, + attribute: str, + mode: str, + expected: Any, + calls: list[ServiceCall], +) -> None: + """Test setting valid group mode actions with template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, calls, action, {attribute: mode}, {attribute: mode}, action + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes.get(attribute) == expected + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + climate.DOMAIN, + action, + {attribute: "turbo", ATTR_ENTITY_ID: TEST_CLIMATE.entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("action", "attribute", "config"), + [ + ( + "set_fan_mode", + "fan_mode", + { + "fan_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_FAN_MODE_ACTION, + }, + ), + ( + "set_swing_mode", + "swing_mode", + { + "swing_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_MODE_ACTION, + }, + ), + ( + "set_swing_horizontal_mode", + "swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_HORIZONTAL_MODE_ACTION, + }, + ), + ( + "set_preset_mode", + "preset_mode", + { + "preset_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_PRESET_MODE_ACTION, + }, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "extra_config", + [MINIMUM_REQUIREMENTS], +) +@pytest.mark.parametrize( + ("mode", "expected"), + [ + ("off", "off"), + ("low", "low"), + ("medium", "medium"), + ("high", "high"), + ], +) +@pytest.mark.usefixtures("setup_climate") +async def test_group_optimistic_actions( + hass: HomeAssistant, + action: str, + attribute: str, + mode: str, + expected: Any, + calls: list[ServiceCall], +) -> None: + """Test setting valid group mode actions with template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + await _call_and_assert_action( + hass, calls, action, {attribute: mode}, {attribute: mode}, action + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("option", "option_type"), + [ + ("max_humidity", "int"), + ("min_humidity", "int"), + ("max_temperature", "float"), + ("min_temperature", "float"), + ], +) +@pytest.mark.parametrize("value", ["not a number", None]) +async def test_bad_min_max_options( + hass: HomeAssistant, + style: ConfigurationStyle, + option: str, + option_type: str, + value: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad min max options in configuration.""" + platform = TEST_CLIMATE + await setup_entity( + hass, platform, style, 0, {option: value, **MINIMUM_REQUIREMENTS} + ) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + f"Invalid config for 'template': expected {option_type} for dictionary value 'climate->0->{option}'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "config", + [ + { + "target_temperature": "{{ state_attr('sensor.test_attribute', 'value') or 0.0 }}", + **SET_TEMPERATURE_ACTION, + **MINIMUM_REQUIREMENTS, + } + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("extra_config", "set_attribute", "expected"), + [ + ({"target_temperature_step": 0.1}, 7.54, 7.5), + ({"target_temperature_step": 1.0}, 7.54, 8), + ({"target_temperature_step": 5.0}, 7.54, 10), + ], +) +@pytest.mark.usefixtures("setup_climate") +async def test_target_temperature_step( + hass: HomeAssistant, set_attribute: float, expected: float +) -> None: + """Test target temperature step.""" + await async_trigger( + hass, TEST_ATTRIBUTE_ENTITY_ID, "anything", {"value": set_attribute} + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("temperature") == expected + + +@pytest.mark.parametrize( + "config", + [ + { + "target_humidity": "{{ state_attr('sensor.test_attribute', 'value') or 0.0 }}", + **SET_HUMIDITY_ACTION, + **MINIMUM_REQUIREMENTS, + } + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("extra_config", "set_attribute", "expected"), + [ + ({"target_humidity_step": 1}, 44, 44), + ({"target_humidity_step": 5}, 44, 45), + ({"target_humidity_step": 7}, 44, 42), + ({"target_humidity_step": 10}, 44, 40), + ({"target_humidity_step": 15}, 44, 45), + ], +) +@pytest.mark.usefixtures("setup_climate") +async def test_target_humidity_step( + hass: HomeAssistant, set_attribute: int, expected: int +) -> None: + """Test target humidity step.""" + await async_trigger( + hass, TEST_ATTRIBUTE_ENTITY_ID, "anything", {"value": set_attribute} + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("humidity") == expected + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("option", "option_type", "minimum"), + [ + ("target_humidity_step", "int", 1), + ("target_temperature_step", "float", 0.1), + ], +) +@pytest.mark.parametrize("value", [-1, 0, "not a number", None]) +async def test_bad_step_options( + hass: HomeAssistant, + style: ConfigurationStyle, + option: str, + option_type: str, + value: Any, + minimum: float, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad step options in configuration.""" + platform = TEST_CLIMATE + await setup_entity( + hass, platform, style, 0, {option: value, **MINIMUM_REQUIREMENTS} + ) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + f"Invalid config for 'template': expected {option_type} for dictionary value 'climate->0->{option}'" + in caplog.text + ) or ( + f"Invalid config for 'template': value must be at least {minimum} for dictionary value 'climate->0->{option}'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("value", "expected"), + [(0.5, 1.5), (0.1, 1.4), (1, 1)], +) +async def test_precision_option( + hass: HomeAssistant, style: ConfigurationStyle, value: float, expected: float +) -> None: + """Test precision option.""" + platform = TEST_CLIMATE + await setup_entity( + hass, + platform, + style, + 1, + { + "precision": value, + "current_temperature": "{{ 1.4 }}", + **MINIMUM_REQUIREMENTS, + }, + ) + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("current_temperature") == expected + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize("value", [-1, 0.0, "not a number", False, None]) +async def test_bad_precision_option( + hass: HomeAssistant, + style: ConfigurationStyle, + value: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad precision option.""" + platform = TEST_CLIMATE + await setup_entity( + hass, platform, style, 0, {"precision": value, **MINIMUM_REQUIREMENTS} + ) + + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + "Invalid config for 'template': expected 0.5 or 0.1 or 1 for dictionary value 'climate->0->precision'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "value", + [UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS, UnitOfTemperature.KELVIN], +) +async def test_temperature_unit( + hass: HomeAssistant, style: ConfigurationStyle, value: str +) -> None: + """Test temperature_unit option.""" + platform = TEST_CLIMATE + await setup_entity( + hass, + platform, + style, + 1, + { + "temperature_unit": value, + **MINIMUM_REQUIREMENTS, + }, + ) + + assert len(hass.states.async_all(platform.domain)) == 1 + assert hass.states.get(TEST_CLIMATE.entity_id) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize("value", [-1, 0.0, "not a number", False, None]) +async def test_bad_temperature_unit( + hass: HomeAssistant, + style: ConfigurationStyle, + value: Any, +) -> None: + """Test a bad temperature_unit option.""" + platform = TEST_CLIMATE + await setup_entity( + hass, platform, style, 0, {"temperature_unit": value, **MINIMUM_REQUIREMENTS} + ) + + assert len(hass.states.async_all(platform.domain)) == 0 + + +@pytest.mark.parametrize( + ("extra_config", "attribute_template"), + [ + ( + {"hvac_mode": "{{ states('sensor.test_state') }}", **MINIMUM_REQUIREMENTS}, + "{{ is_state('binary_sensor.availability', 'on') }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_available_template_with_entities(hass: HomeAssistant) -> None: + """Test availability templates with values from other entities.""" + hass.states.async_set(TEST_AVAILABILITY_ENTITY, STATE_ON) + await hass.async_block_till_done() + + await async_trigger(hass, TEST_STATE_ENTITY_ID, HVACMode.HEAT) + + assert hass.states.get(TEST_CLIMATE.entity_id).state != STATE_UNAVAILABLE + + hass.states.async_set(TEST_AVAILABILITY_ENTITY, STATE_OFF) + await hass.async_block_till_done() + + await async_trigger(hass, TEST_STATE_ENTITY_ID, HVACMode.COOL) + + assert hass.states.get(TEST_CLIMATE.entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("extra_config", "attribute_template"), + [ + ( + {"hvac_mode": "{{ states('sensor.test_state') }}", **MINIMUM_REQUIREMENTS}, + "{{ x - 12 }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_invalid_availability_template_keeps_component_available( + hass: HomeAssistant, caplog_setup_text: str +) -> None: + """Test that an invalid availability keeps the device available.""" + assert hass.states.get(TEST_CLIMATE.entity_id).state != STATE_UNAVAILABLE + assert "UndefinedError: 'x' is undefined" in caplog_setup_text + + +@pytest.mark.parametrize("config", [MINIMUM_REQUIREMENTS]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +async def test_unique_id( + hass: HomeAssistant, style: ConfigurationStyle, config: ConfigType +) -> None: + """Test unique_id option only creates one entity per id.""" + await setup_and_test_unique_id(hass, TEST_CLIMATE, style, config) + + +@pytest.mark.parametrize("config", [MINIMUM_REQUIREMENTS]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_nested_unique_id( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + entity_registry: er.EntityRegistry, +) -> None: + """Test a template unique_id propagates to entity unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_CLIMATE, style, entity_registry, config + ) + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a entity from a config entry.""" + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "hvac_mode": "{{ 'heat' }}", + **MINIMUM_REQUIREMENTS, + "template_type": climate.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + climate.DOMAIN, + {"name": "My template", "hvac_mode": "{{ 'heat' }}", **MINIMUM_REQUIREMENTS}, + ) + + assert state["state"] == HVACMode.HEAT + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": HVACAction.IDLE, + "hvac_modes": [HVACMode.COOL], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "humidity": None, + "target_temp_high": None, + "target_temp_low": None, + "temperature": None, + }, + ), + ( + # Missing key + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + # Bad hvac mode + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "not_cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + # Bad supported hvac modes + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "cool", + "hvac_modes": ["not_cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + # Bad hvac action + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idlex", + "hvac_mode": "cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + STATE_UNAVAILABLE, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + STATE_UNKNOWN, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": None, + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring trigger template climate.""" + + restored_attributes = { # These should be ignored + "current_position": 5, + "current_tilt_position": 5, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_CLIMATE, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_CLIMATE, + style, + { + "current_humidity": "{{ state_attr('sensor.test_state', 'current_humidity') }}", + "current_temperature": "{{ state_attr('sensor.test_state', 'current_temperature') }}", + "fan_mode": "{{ state_attr('sensor.test_state', 'fan_mode') }}", + "fan_modes": "{{ state_attr('sensor.test_state', 'fan_modes') or [] }}", + "set_fan_mode": [], + "hvac_mode": "{{ state_attr('sensor.test_state', 'hvac_mode') }}", + "hvac_modes": "{{ state_attr('sensor.test_state', 'hvac_modes') or [] }}", + "set_hvac_mode": [], + "preset_mode": "{{ state_attr('sensor.test_state', 'preset_mode') }}", + "preset_modes": "{{ state_attr('sensor.test_state', 'preset_modes') or [] }}", + "set_preset_mode": [], + "swing_horizontal_mode": "{{ state_attr('sensor.test_state', 'swing_horizontal_mode') }}", + "swing_horizontal_modes": "{{ state_attr('sensor.test_state', 'swing_horizontal_modes') or [] }}", + "set_swing_horizontal_mode": [], + "swing_mode": "{{ state_attr('sensor.test_state', 'swing_mode') }}", + "swing_modes": "{{ state_attr('sensor.test_state', 'swing_modes') or [] }}", + "set_swing_mode": [], + "target_humidity": "{{ state_attr('sensor.test_state', 'target_humidity') }}", + "target_temperature": "{{ state_attr('sensor.test_state', 'target_temperature') }}", + "target_temperature_high": "{{ state_attr('sensor.test_state', 'target_temperature_high') }}", + "target_temperature_low": "{{ state_attr('sensor.test_state', 'target_temperature_low') }}", + "set_temperature": [], + "set_humidity": [], + }, + "is_state_attr('sensor.test_state', 'hvac_mode', 'heat')", + ) + + assert_state_and_attributes( + hass, + TEST_CLIMATE, + initial_state, + initial_attributes, + ) + + await async_trigger(hass, "sensor.test_state", "x", {"hvac_modes": ["heat"]}) + await async_trigger( + hass, + "sensor.test_state", + "x", + {"hvac_modes": ["heat"], "hvac_mode": HVACMode.HEAT}, + ) + + assert_state_and_attributes(hass, TEST_CLIMATE, HVACMode.HEAT) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_saving_state( + hass: HomeAssistant, + style: ConfigurationStyle, + hass_storage: dict[str, Any], +) -> None: + """Test restore saved state.""" + + await setup_entity( + hass, + TEST_CLIMATE, + style, + 1, + config={ + "hvac_mode": "{{ state_attr('sensor.test_state', 'hvac_mode') }}", + **MINIMUM_REQUIREMENTS, + }, + ) + + await async_trigger( + hass, + TEST_STATE_ENTITY_ID, + "anything", + {"hvac_mode": HVACMode.COOL}, + ) + + assert_state_and_attributes( + hass, TEST_CLIMATE, HVACMode.COOL, {"hvac_modes": EXPECTED_HVAC_MODES} + ) + + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == TEST_CLIMATE.entity_id + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == { + "current_humidity": None, + "current_temperature": None, + "fan_mode": None, + "fan_modes": None, + "hvac_action": None, + "hvac_mode": "cool", + "hvac_modes": EXPECTED_HVAC_MODES, + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + } + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("action", "config"), + [ + ( + "set_fan_mode", + { + "fan_modes": "{{ ['Disco', 'Police'] }}", + "fan_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ("set_humidity", MINIMUM_REQUIREMENTS), + ("set_hvac_mode", HVAC_MODES), + ( + "set_preset_mode", + { + "preset_modes": "{{ ['Disco', 'Police'] }}", + "preset_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['Disco', 'Police'] }}", + "swing_horizontal_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_mode", + { + "swing_modes": "{{ ['Disco', 'Police'] }}", + "swing_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ("set_temperature", MINIMUM_REQUIREMENTS), + ], +) +async def test_invalid_yaml_actions_do_not_create_entities( + hass: HomeAssistant, + style: ConfigurationStyle, + action: str, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid yaml actions do not create entities.""" + await assert_invalid_yaml_actions_do_not_create_entities( + hass, TEST_CLIMATE, style, config, action, caplog + ) + + +@pytest.mark.parametrize( + ("action", "config"), + [ + ( + "set_fan_mode", + { + "fan_modes": "{{ ['Disco', 'Police'] }}", + "fan_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ("set_humidity", MINIMUM_REQUIREMENTS), + ("set_hvac_mode", HVAC_MODES), + ( + "set_preset_mode", + { + "preset_modes": "{{ ['Disco', 'Police'] }}", + "preset_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['Disco', 'Police'] }}", + "swing_horizontal_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_mode", + { + "swing_modes": "{{ ['Disco', 'Police'] }}", + "swing_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ("set_temperature", MINIMUM_REQUIREMENTS), + ], +) +async def test_invalid_config_entry_actions_do_not_create_entities( + hass: HomeAssistant, + action: str, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid config entry actions do not create entities.""" + await assert_invalid_config_entry_actions_do_not_create_entities( + hass, TEST_CLIMATE, config, action, caplog + ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_extra_template_attributes( + hass: HomeAssistant, style: ConfigurationStyle +) -> None: + """Test extra attributes.""" + await assert_extra_template_attributes( + hass, TEST_CLIMATE, style, MINIMUM_REQUIREMENTS + ) + + +@pytest.mark.parametrize( + "attribute", + list(chain(ClimateEntityCapabilityAttribute, ClimateEntityStateAttribute)), +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_blocked_template_attributes( + hass: HomeAssistant, + style: ConfigurationStyle, + attribute: StrEnum, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test blocked extra attributes.""" + await setup_entity( + hass, + TEST_CLIMATE, + style, + 0, + { + **MINIMUM_REQUIREMENTS, + "attributes": {str(attribute): "{{ 'does not matter' }}"}, + }, + ) + assert ( + f"Unsupported attribute(s) found for {DEFAULT_NAME}: {attribute}" in caplog.text + ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_attributes_template( + hass: HomeAssistant, + style: ConfigurationStyle, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes as a single template.""" + await assert_attributes_template( + hass, + TEST_CLIMATE, + style, + MINIMUM_REQUIREMENTS, + caplog, + ) + + +@pytest.mark.parametrize( + "attribute", + list(chain(ClimateEntityCapabilityAttribute, ClimateEntityStateAttribute)), +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_attributes_template_with_blocked_attributes( + hass: HomeAssistant, + style: ConfigurationStyle, + attribute: StrEnum, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test blocked attributes for a single attributes template.""" + await setup_entity( + hass, + TEST_CLIMATE, + style, + 1, + { + **MINIMUM_REQUIREMENTS, + "attributes": f"{{{{ dict({attribute}='does not matter') }}}}", + }, + ) + + await async_trigger(hass, "sensor.test_extra_attributes", "anything") + + error = f"Unsupported attribute(s) found for {TEST_CLIMATE.entity_id}: {attribute}" + assert error in caplog.text diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 5991a94ef8c72..fb5f2ba180f82 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -85,6 +85,22 @@ {}, {}, ), + ( + "climate", + {"hvac_mode": "{{ states('climate.one') }}"}, + "heat", + {"one": "heat", "two": "cool"}, + {}, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + {}, + ), ( "sensor", { @@ -380,6 +396,18 @@ async def test_config_flow( {}, {}, ), + ( + "climate", + {"hvac_mode": "{{ 'heat' }}"}, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + ), ( "switch", {"value_template": "{{ false }}"}, @@ -651,6 +679,23 @@ async def test_config_flow_device( "state", None, ), + ( + "climate", + {"hvac_mode": "{{ states('climate.one') }}"}, + {"hvac_mode": "{{ states('climate.two') }}"}, + ["heat", "cool"], + {"one": "heat", "two": "cool"}, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + "state", + None, + ), ( "event", {"event_type": "{{ states('event.one') }}"}, @@ -1701,6 +1746,18 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "climate", + {"hvac_mode": "{{ states('climate.one') }}"}, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + ), ( "cover", {"state": "{{ states('cover.one') }}"}, diff --git a/tests/components/touchline/test_config_flow.py b/tests/components/touchline/test_config_flow.py index 4e9acf4cfde7d..449e6914d6961 100644 --- a/tests/components/touchline/test_config_flow.py +++ b/tests/components/touchline/test_config_flow.py @@ -114,56 +114,3 @@ async def test_already_configured_by_unique_id( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import_success( - hass: HomeAssistant, mock_pytouchline: MagicMock, mock_setup_entry: MagicMock -) -> None: - """Test YAML import creates an entry.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=TEST_DATA, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_HOST - assert result["data"] == TEST_DATA - assert result["result"].unique_id == TEST_UNIQUE_ID - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_cannot_connect( - hass: HomeAssistant, mock_pytouchline: MagicMock -) -> None: - """Test YAML import aborts when it cannot connect.""" - mock_pytouchline.get_number_of_devices.side_effect = ConnectionError - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=TEST_DATA, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_already_configured( - hass: HomeAssistant, mock_pytouchline: MagicMock -) -> None: - """Test YAML import aborts when already configured.""" - MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "5.6.7.8"}, - unique_id=TEST_UNIQUE_ID, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=TEST_DATA, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/usage_prediction/test_common_control.py b/tests/components/usage_prediction/test_common_control.py index bb6b1a41bf391..f408de5c72baa 100644 --- a/tests/components/usage_prediction/test_common_control.py +++ b/tests/components/usage_prediction/test_common_control.py @@ -11,6 +11,7 @@ async_predict_common_control, time_category, ) +from homeassistant.components.usage_prediction.const import DEFAULT_LIMIT from homeassistant.components.usage_prediction.models import EntityUsagePredictions from homeassistant.const import EVENT_CALL_SERVICE from homeassistant.core import Context, HomeAssistant @@ -292,34 +293,18 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("recorder_mock") -async def test_entities_limit(hass: HomeAssistant) -> None: - """Test that only top entities are returned per time category.""" +async def test_more_than_default_limit_predicted(hass: HomeAssistant) -> None: + """Test more entities are predicted than a client gets by default.""" user_id = str(uuid.uuid4()) + entity_ids = [f"light.light_{index}" for index in range(DEFAULT_LIMIT + 2)] - hass.states.async_set("light.most_used", "off") - hass.states.async_set("light.second", "off") - hass.states.async_set("light.third", "off") - hass.states.async_set("light.fourth", "off") - hass.states.async_set("light.fifth", "off") - hass.states.async_set("light.sixth", "off") - hass.states.async_set("light.seventh", "off") + for entity_id in entity_ids: + hass.states.async_set(entity_id, "off") - # Create more than 5 different entities in morning with freeze_time("2023-07-01 08:00:00"): - # Create entities with different frequencies - entities_with_counts = [ - ("light.most_used", 10), - ("light.second", 8), - ("light.third", 6), - ("light.fourth", 4), - ("light.fifth", 2), - ("light.sixth", 1), - ("light.seventh", 1), - ] - - for entity_id, count in entities_with_counts: + # Distinct counts so the expected order is deterministic + for count, entity_id in enumerate(reversed(entity_ids), start=1): for _ in range(count): - # Use different context for each call hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -333,26 +318,11 @@ async def test_entities_limit(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) - with ( - freeze_time("2023-07-02 10:00:00"), - patch( - "homeassistant.components.usage_prediction.common_control.RESULTS_TO_INCLUDE", - 5, - ), - ): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) - # Should be the top 5 most used (08:00 UTC = 00:00 local = night) - assert results.night == [ - "light.most_used", - "light.second", - "light.third", - "light.fourth", - "light.fifth", - ] - assert results.morning == [] - assert results.afternoon == [] - assert results.evening == [] + # 08:00 UTC = 00:00 local = night + assert results == EntityUsagePredictions(night=entity_ids) @pytest.mark.usefixtures("recorder_mock") diff --git a/tests/components/usage_prediction/test_websocket.py b/tests/components/usage_prediction/test_websocket.py index 198c50553a9f8..e7d32794f6c69 100644 --- a/tests/components/usage_prediction/test_websocket.py +++ b/tests/components/usage_prediction/test_websocket.py @@ -3,12 +3,14 @@ from collections.abc import Generator from copy import deepcopy from datetime import datetime, timedelta +from typing import Any from unittest.mock import Mock, patch from freezegun import freeze_time import pytest from homeassistant.components.usage_prediction import DOMAIN +from homeassistant.components.usage_prediction.const import DEFAULT_LIMIT from homeassistant.components.usage_prediction.models import EntityUsagePredictions from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,8 +19,11 @@ from tests.common import MockUser from tests.typing import WebSocketGenerator +# Morning in the test time zone NOW = datetime(2026, 8, 26, 15, 0, 0, tzinfo=dt_util.UTC) +MORNING_ENTITIES = [f"light.morning_{index}" for index in range(60)] + @pytest.fixture def mock_predict_common_control() -> Generator[Mock]: @@ -114,3 +119,97 @@ async def test_caching_behavior( assert msg["result"] == {"entities": ["light.kitchen", "light.bla"]} # Should now be 2 (new database call) assert mock_predict_common_control.call_count == 2 + + +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize( + ("extra_msg", "expected_entities"), + [ + pytest.param({}, MORNING_ENTITIES[:DEFAULT_LIMIT], id="default"), + pytest.param({"limit": 3}, MORNING_ENTITIES[:3], id="fewer"), + pytest.param({"limit": 100}, MORNING_ENTITIES, id="more_than_predicted"), + ], +) +async def test_common_control_limit( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_predict_common_control: Mock, + extra_msg: dict[str, Any], + expected_entities: list[str], +) -> None: + """Test the client can ask for how many entities it wants.""" + mock_predict_common_control.return_value = EntityUsagePredictions( + morning=MORNING_ENTITIES + ) + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + with freeze_time(NOW): + await client.send_json( + {"id": 1, "type": "usage_prediction/common_control"} | extra_msg + ) + msg = await client.receive_json() + + assert msg["success"] is True + assert msg["result"] == {"entities": expected_entities} + + +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize( + "limit", + [ + pytest.param(0, id="below_minimum"), + pytest.param(-1, id="negative"), + pytest.param("3", id="string"), + pytest.param(3.5, id="float"), + ], +) +async def test_common_control_invalid_limit( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_predict_common_control: Mock, + limit: float | str, +) -> None: + """Test an invalid limit is rejected without predicting.""" + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 1, "type": "usage_prediction/common_control", "limit": limit} + ) + msg = await client.receive_json() + + assert msg["success"] is False + assert msg["error"]["code"] == "invalid_format" + assert mock_predict_common_control.call_count == 0 + + +@pytest.mark.usefixtures("recorder_mock") +async def test_common_control_limit_served_from_cache( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_predict_common_control: Mock, +) -> None: + """Test a later call with a different limit is served from the cache.""" + mock_predict_common_control.return_value = EntityUsagePredictions( + morning=MORNING_ENTITIES + ) + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + with freeze_time(NOW): + await client.send_json( + {"id": 1, "type": "usage_prediction/common_control", "limit": 3} + ) + first = await client.receive_json() + await client.send_json( + {"id": 2, "type": "usage_prediction/common_control", "limit": 20} + ) + second = await client.receive_json() + + assert first["result"] == {"entities": MORNING_ENTITIES[:3]} + assert second["result"] == {"entities": MORNING_ENTITIES[:20]} + assert mock_predict_common_control.call_count == 1