diff --git a/homeassistant/components/environment_canada/icons.json b/homeassistant/components/environment_canada/icons.json index 691485b34afad5..e35d6faef74dae 100644 --- a/homeassistant/components/environment_canada/icons.json +++ b/homeassistant/components/environment_canada/icons.json @@ -25,6 +25,9 @@ "get_forecasts": { "service": "mdi:weather-cloudy-clock" }, + "get_precipitation_forecast": { + "service": "mdi:weather-pouring" + }, "set_radar_type": { "service": "mdi:radar" } diff --git a/homeassistant/components/environment_canada/services.py b/homeassistant/components/environment_canada/services.py index 1d7f6ad138a9f6..490db9207cda8a 100644 --- a/homeassistant/components/environment_canada/services.py +++ b/homeassistant/components/environment_canada/services.py @@ -2,10 +2,15 @@ from typing import Any -from env_canada import ECWeather +from env_canada import ECPrecipForecast, ECWeather import probatio -from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LONGITUDE, +) from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service @@ -17,11 +22,29 @@ {probatio.Required(ATTR_CONFIG_ENTRY_ID): cv.string} ) +SERVICE_GET_PRECIPITATION_FORECAST = "get_precipitation_forecast" +SERVICE_GET_PRECIPITATION_FORECAST_SCHEMA = probatio.Schema( + { + probatio.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + probatio.Optional("precip_type"): probatio.In(["auto", "rain", "snow"]), + probatio.Optional("past_minutes"): probatio.All(int, probatio.Range(0, 180)), + probatio.Optional("future_minutes"): probatio.All(int, probatio.Range(0, 72)), + probatio.Optional("hourly_hours"): probatio.All(int, probatio.Range(0, 48)), + } +) + SNAKE_MAPPING = { "alertColourLevel": "alert_colour_level", "expiryTime": "expiry_time", } +PRECIP_FORECAST_OPTIONS = ( + "precip_type", + "past_minutes", + "future_minutes", + "hourly_hours", +) + async def _async_get_alerts(call: ServiceCall) -> dict[str, Any]: """Return the active alerts.""" @@ -46,6 +69,39 @@ async def _async_get_alerts(call: ServiceCall) -> dict[str, Any]: } +async def _async_get_precipitation_forecast(call: ServiceCall) -> dict[str, Any]: + """Return the precipitation forecast series.""" + entry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + + # A fresh object per call, rather than one shared across calls: its + # options would otherwise leak between calls that omit them, and + # concurrent calls could race on the same instance's attributes. + kwargs: dict[str, Any] = { + "coordinates": (entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE]), + "language": entry.data.get(CONF_LANGUAGE, "English").lower(), + } + for option in PRECIP_FORECAST_OPTIONS: + if option in call.data: + kwargs[option] = call.data[option] + + precip = ECPrecipForecast(**kwargs) + await precip.update() + + return { + "nowcast": [ + {**item, "timestamp": item["timestamp"].isoformat()} + for item in precip.nowcast + ], + "hourly": [ + {**item, "timestamp": item["timestamp"].isoformat()} + for item in precip.hourly + ], + "metadata": precip.metadata, + } + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Environment Canada integration.""" @@ -56,3 +112,10 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_GET_ALERTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_PRECIPITATION_FORECAST, + _async_get_precipitation_forecast, + schema=SERVICE_GET_PRECIPITATION_FORECAST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/environment_canada/services.yaml b/homeassistant/components/environment_canada/services.yaml index afa5ed49abca2e..917b4b914613b2 100644 --- a/homeassistant/components/environment_canada/services.yaml +++ b/homeassistant/components/environment_canada/services.yaml @@ -12,6 +12,39 @@ get_forecasts: integration: environment_canada domain: weather +get_precipitation_forecast: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: environment_canada + precip_type: + selector: + select: + options: + - "auto" + - "rain" + - "snow" + past_minutes: + selector: + number: + min: 0 + max: 180 + unit_of_measurement: min + future_minutes: + selector: + number: + min: 0 + max: 72 + unit_of_measurement: min + hourly_hours: + selector: + number: + min: 0 + max: 48 + unit_of_measurement: h + set_radar_type: target: entity: diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 746342e74a7606..06bef9c710591a 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -201,6 +201,32 @@ "description": "Retrieves the forecast from selected weather services.", "name": "Get forecasts" }, + "get_precipitation_forecast": { + "description": "Retrieves the precipitation forecast series from the selected weather service.", + "fields": { + "config_entry_id": { + "description": "The Environment Canada service to retrieve the precipitation forecast from.", + "name": "Environment Canada service" + }, + "future_minutes": { + "description": "How far ahead the short-interval series reaches.", + "name": "Future minutes" + }, + "hourly_hours": { + "description": "Length of the hourly series. Zero disables it.", + "name": "Hourly hours" + }, + "past_minutes": { + "description": "How far back the short-interval series reaches.", + "name": "Past minutes" + }, + "precip_type": { + "description": "Which precipitation type to report. Automatically detects it from radar.", + "name": "Precipitation type" + } + }, + "name": "Get precipitation forecast" + }, "set_radar_type": { "description": "Sets the type of radar image to retrieve.", "fields": { diff --git a/homeassistant/components/greencell/diagnostics.py b/homeassistant/components/greencell/diagnostics.py new file mode 100644 index 00000000000000..bcc1cd12c65d18 --- /dev/null +++ b/homeassistant/components/greencell/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for the Greencell integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .const import CONF_SERIAL_NUMBER +from .models import GreencellConfigEntry + +TO_REDACT = {CONF_SERIAL_NUMBER} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: GreencellConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + runtime_data = entry.runtime_data + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "access": { + "disabled": runtime_data.access.is_disabled(), + "can_execute": runtime_data.access.can_execute(), + }, + "current": asdict(runtime_data.current_data), + "voltage": asdict(runtime_data.voltage_data), + "power": asdict(runtime_data.power_data), + "state": asdict(runtime_data.state_data), + } diff --git a/homeassistant/components/greencell/quality_scale.yaml b/homeassistant/components/greencell/quality_scale.yaml index ee5d81a13a2955..06e0031a63ad37 100644 --- a/homeassistant/components/greencell/quality_scale.yaml +++ b/homeassistant/components/greencell/quality_scale.yaml @@ -42,7 +42,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: todo discovery: todo docs-data-update: done diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 9b4524cf9b0c23..a8ec6b70f5e478 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -457,20 +457,8 @@ def _timer_finished(self, timer_id: str) -> None: timer.finish() if timer.conversation_command: - from homeassistant.components.conversation import ( # noqa: PLC0415 - async_converse, - ) - self.hass.async_create_background_task( - async_converse( - self.hass, - timer.conversation_command, - conversation_id=None, - context=Context(), - language=timer.language, - agent_id=timer.conversation_agent_id, - device_id=timer.device_id, - ), + self._async_run_conversation_command(timer), "timer assist command", ) elif timer.device_id in self.handlers: @@ -483,6 +471,33 @@ def _timer_finished(self, timer_id: str) -> None: timer.device_id, ) + async def _async_run_conversation_command(self, timer: TimerInfo) -> None: + """Run the delayed command of a finished timer.""" + from homeassistant.components.conversation import ( # noqa: PLC0415 + async_converse, + ) + + assert timer.conversation_command is not None + + result = await async_converse( + self.hass, + timer.conversation_command, + conversation_id=None, + context=Context(), + language=timer.language, + agent_id=timer.conversation_agent_id, + device_id=timer.device_id, + ) + + # Nothing is listening to the response, so an error is only visible here. + if result.response.response_type is intent.IntentResponseType.ERROR: + _LOGGER.warning( + "Delayed command failed: command=%s, code=%s, response=%s", + timer.conversation_command, + result.response.error_code, + result.response.speech.get("plain", {}).get("speech", ""), + ) + def is_timer_device(self, device_id: str) -> bool: """Return True if device has been registered to handle timer events.""" return device_id in self.handlers diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index e76b5d3772cbc4..f5dd68f218c936 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -41,8 +41,9 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.const import CONF_ENTITY, CONF_GA_SENSOR -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import BinarySensorKnxConfig async def async_setup_entry( @@ -68,7 +69,9 @@ async def async_setup_entry( KnxYamlBinarySensor(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.BINARY_SENSOR): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.BINARY_SENSOR, BinarySensorKnxConfig + ): entities.extend( KnxUiBinarySensor(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -146,7 +149,10 @@ class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity): _device: XknxBinarySensor def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, + knx_module: KNXModule, + unique_id: str, + config: KnxEntityData[BinarySensorKnxConfig], ) -> None: """Initialize KNX binary sensor.""" super().__init__( @@ -154,18 +160,16 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = config[DOMAIN] self._device = XknxBinarySensor( xknx=knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR), - sync_state=knx_conf.get(CONF_SYNC_STATE), - invert=knx_conf.get(CONF_INVERT, default=False), - ignore_internal_state=knx_conf.get( - CONF_IGNORE_INTERNAL_STATE, default=False - ), - context_timeout=knx_conf.get(CONF_CONTEXT_TIMEOUT), - reset_after=knx_conf.get(CONF_RESET_AFTER), + group_address_state=knx_conf.ga_sensor.state_and_passive(), + sync_state=knx_conf.sync_state, + invert=knx_conf.invert, + ignore_internal_state=knx_conf.ignore_internal_state, + context_timeout=knx_conf.context_timeout, + reset_after=knx_conf.reset_after, always_callback=True, ) self._attr_force_update = self._device.ignore_internal_state diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 1ed7978ba5b655..53575c669c67e1 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -22,6 +22,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule +from .storage.config_store import KnxEntityData from .storage.const import CONF_DATA, CONF_ENTITY, CONF_GA_SEND from .storage.util import ConfigExtractor @@ -100,7 +101,7 @@ class KnxUiButton(_KnxButton, KnxUiEntity): _device: XknxRawValue | XknxExposeSensor def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize a KNX button.""" knx_conf = ConfigExtractor(config[DOMAIN]) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index d0f76b01c7163a..608ce1b1e6e7fb 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -50,6 +50,7 @@ ) from .knx_module import KNXModule from .schema import ClimateSchema +from .storage.config_store import KnxEntityData from .storage.const import ( CONF_ENTITY, CONF_GA_ACTIVE, @@ -698,7 +699,7 @@ class KnxUiClimate(_KnxClimate, KnxUiEntity): _device: XknxClimate def __init__( - self, knx_module: KNXModule, unique_id: str, config: ConfigType + self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize of a KNX climate device.""" super().__init__( diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 9270e4e2b8ce72..087129880d6fb1 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -39,6 +39,7 @@ ) from .knx_module import KNXModule from .schema import CoverSchema +from .storage.config_store import KnxEntityData from .storage.const import ( CONF_ENTITY, CONF_GA_ANGLE, @@ -306,7 +307,7 @@ class KnxUiCover(_KnxCover, KnxUiEntity): _device: XknxCover def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize KNX cover.""" super().__init__( diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index e6996d570a9a3f..cfaa00025a0a26 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -1,7 +1,7 @@ """Support for KNX date entities.""" from datetime import date as dt_date -from typing import Any, override +from typing import override from xknx.devices import DateDevice as XknxDateDevice from xknx.dpt.dpt_11 import KNXDate as XKNXDate @@ -32,8 +32,9 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.const import CONF_ENTITY, CONF_GA_DATE -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import DateKnxConfig async def async_setup_entry( @@ -59,7 +60,9 @@ async def async_setup_entry( KnxYamlDate(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.DATE): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.DATE, DateKnxConfig + ): entities.extend( KnxUiDate(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -125,7 +128,10 @@ class KnxUiDate(_KNXDate, KnxUiEntity): _device: XknxDateDevice def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, + knx_module: KNXModule, + unique_id: str, + config: KnxEntityData[DateKnxConfig], ) -> None: """Initialize KNX date.""" super().__init__( @@ -133,13 +139,13 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = config[DOMAIN] self._device = XknxDateDevice( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], localtime=False, - group_address=knx_conf.get_write(CONF_GA_DATE), - group_address_state=knx_conf.get_state_and_passive(CONF_GA_DATE), - respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), - sync_state=knx_conf.get(CONF_SYNC_STATE), + group_address=knx_conf.ga_date.write, + group_address_state=knx_conf.ga_date.state_and_passive(), + respond_to_read=knx_conf.respond_to_read, + sync_state=knx_conf.sync_state, ) diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 0b7964cd61c30e..dcd0e9d8550c88 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -1,7 +1,7 @@ """Support for KNX datetime entities.""" from datetime import datetime -from typing import Any, override +from typing import override from xknx.devices import DateTimeDevice as XknxDateTimeDevice from xknx.dpt.dpt_19 import KNXDateTime as XKNXDateTime @@ -33,8 +33,9 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.const import CONF_ENTITY, CONF_GA_DATETIME -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import DatetimeKnxConfig async def async_setup_entry( @@ -60,7 +61,9 @@ async def async_setup_entry( KnxYamlDateTime(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.DATETIME): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.DATETIME, DatetimeKnxConfig + ): entities.extend( KnxUiDateTime(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -130,7 +133,10 @@ class KnxUiDateTime(_KNXDateTime, KnxUiEntity): _device: XknxDateTimeDevice def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, + knx_module: KNXModule, + unique_id: str, + config: KnxEntityData[DatetimeKnxConfig], ) -> None: """Initialize KNX datetime.""" super().__init__( @@ -138,13 +144,13 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = config[DOMAIN] self._device = XknxDateTimeDevice( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], localtime=False, - group_address=knx_conf.get_write(CONF_GA_DATETIME), - group_address_state=knx_conf.get_state_and_passive(CONF_GA_DATETIME), - respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), - sync_state=knx_conf.get(CONF_SYNC_STATE), + group_address=knx_conf.ga_datetime.write, + group_address_state=knx_conf.ga_datetime.state_and_passive(), + respond_to_read=knx_conf.respond_to_read, + sync_state=knx_conf.sync_state, ) diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index 37571cfd95287d..bc43dceaede2a8 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -1,5 +1,6 @@ """Base classes for KNX entities.""" +from collections.abc import Callable from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, override @@ -26,7 +27,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry from .const import CONF_DEFAULT_ENTITY_ID, DOMAIN -from .storage.config_store import PlatformControllerBase +from .storage.config_store import KnxEntityData, PlatformControllerBase from .storage.const import CONF_DEVICE_INFO if TYPE_CHECKING: @@ -99,7 +100,7 @@ def __init__( self, knx_module: KNXModule, entity_platform: EntityPlatform, - entity_class: type[KnxUiEntity], + entity_class: Callable[[KNXModule, str, KnxEntityData[Any]], KnxUiEntity], ) -> None: """Initialize the UI platform.""" self._knx_module = knx_module @@ -107,7 +108,7 @@ def __init__( self._entity_class = entity_class @override - async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None: + async def create_entity(self, unique_id: str, config: KnxEntityData[Any]) -> None: """Add a new UI entity.""" await self._entity_platform.async_add_entities( [self._entity_class(self._knx_module, unique_id, config)] @@ -115,7 +116,7 @@ async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None: @override async def update_entity( - self, entity_entry: RegistryEntry, config: dict[str, Any] + self, entity_entry: RegistryEntry, config: KnxEntityData[Any] ) -> None: """Update an existing UI entities configuration.""" await self._entity_platform.async_remove_entity(entity_entry.entity_id) diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 36acb2d35facb1..a9235efb9f860d 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -33,6 +33,7 @@ ) from .knx_module import KNXModule from .schema import FanSchema +from .storage.config_store import KnxEntityData from .storage.const import ( CONF_ENTITY, CONF_GA_OSCILLATION, @@ -253,7 +254,7 @@ class KnxUiFan(_KnxFan, KnxUiEntity): _device: XknxFan def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize of KNX fan.""" knx_conf = ConfigExtractor(config[DOMAIN]) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index fa4455b7cd0967..d16c9f038ad38b 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -36,6 +36,7 @@ ) from .knx_module import KNXModule from .schema import LightSchema +from .storage.config_store import KnxEntityData from .storage.const import ( CONF_COLOR, CONF_COLOR_TEMP_MAX, @@ -603,7 +604,7 @@ class KnxUiLight(_KnxLight, KnxUiEntity): _device: XknxLight def __init__( - self, knx_module: KNXModule, unique_id: str, config: ConfigType + self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize of KNX light.""" super().__init__( diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 96e5ba7c137547..f427a6cb59c7d7 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -22,8 +22,9 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.const import CONF_ENTITY, CONF_GA_SEND -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import NotifyKnxConfig async def async_setup_entry( @@ -49,7 +50,9 @@ async def async_setup_entry( KnxYamlNotify(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.NOTIFY): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.NOTIFY, NotifyKnxConfig + ): entities.extend( KnxUiNotify(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -95,7 +98,10 @@ class KnxUiNotify(_KnxNotify, KnxUiEntity): _device: XknxNotification def __init__( - self, knx_module: KNXModule, unique_id: str, config: ConfigType + self, + knx_module: KNXModule, + unique_id: str, + config: KnxEntityData[NotifyKnxConfig], ) -> None: """Initialize a KNX notification.""" super().__init__( @@ -103,10 +109,10 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = config[DOMAIN] self._device = XknxNotification( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address=knx_conf.get_write(CONF_GA_SEND), - value_type=knx_conf.get_dpt(CONF_GA_SEND), + group_address=knx_conf.ga_send.write, + value_type=knx_conf.ga_send.dpt, ) diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 3f679243342f19..c95e4ce1232e0b 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -41,8 +41,9 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.const import CONF_ENTITY, CONF_GA_SENSOR -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import NumberKnxConfig async def async_setup_entry( @@ -68,7 +69,9 @@ async def async_setup_entry( KnxYamlNumber(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.NUMBER): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.NUMBER, NumberKnxConfig + ): entities.extend( KnxUiNumber(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -169,7 +172,7 @@ def __init__( self, knx_module: KNXModule, unique_id: str, - config: ConfigType, + config: KnxEntityData[NumberKnxConfig], ) -> None: """Initialize a KNX number.""" super().__init__( @@ -177,24 +180,24 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) - dpt_string = knx_conf.get_dpt(CONF_GA_SENSOR) + knx_conf = config[DOMAIN] + dpt_string = knx_conf.ga_sensor.dpt assert dpt_string is not None # required for number dpt_info = get_supported_dpts()[dpt_string] self._device = NumericValue( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address=knx_conf.get_write(CONF_GA_SENSOR), - group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR), - respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), - sync_state=knx_conf.get(CONF_SYNC_STATE), + group_address=knx_conf.ga_sensor.write, + group_address_state=knx_conf.ga_sensor.state_and_passive(), + respond_to_read=knx_conf.respond_to_read, + sync_state=knx_conf.sync_state, value_type=dpt_string, ) - if device_class_override := knx_conf.get(CONF_DEVICE_CLASS): + if knx_conf.device_class: self._attr_device_class = try_parse_enum( - NumberDeviceClass, device_class_override + NumberDeviceClass, knx_conf.device_class ) else: self._attr_device_class = try_parse_enum( @@ -204,21 +207,19 @@ def __init__( NumberDeviceClass, dpt_info["sensor_device_class"], ) - self._attr_mode = NumberMode(knx_conf.get(CONF_MODE)) - self._attr_native_max_value = knx_conf.get( - NumberConf.MAX, - default=self._device.sensor_value.dpt_class.value_max, + dpt_class = self._device.sensor_value.dpt_class + self._attr_mode = NumberMode(knx_conf.mode) + self._attr_native_max_value = ( + knx_conf.max if knx_conf.max is not None else dpt_class.value_max ) - self._attr_native_min_value = knx_conf.get( - NumberConf.MIN, - default=self._device.sensor_value.dpt_class.value_min, + self._attr_native_min_value = ( + knx_conf.min if knx_conf.min is not None else dpt_class.value_min ) - self._attr_native_step = knx_conf.get( - NumberConf.STEP, - default=self._device.sensor_value.dpt_class.resolution, + self._attr_native_step = ( + knx_conf.step if knx_conf.step is not None else dpt_class.resolution ) self._attr_native_unit_of_measurement = ( - knx_conf.get(CONF_UNIT_OF_MEASUREMENT) or dpt_info["unit"] + knx_conf.unit_of_measurement or dpt_info["unit"] ) self._device.sensor_value.value = max(0, self._attr_native_min_value) diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index ef95fec8dffbac..de525fea3ac036 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -14,7 +14,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, SceneConf +from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -24,8 +24,9 @@ ) from .knx_module import KNXModule from .schema import SceneSchema -from .storage.const import CONF_ENTITY, CONF_GA_SCENE -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import SceneKnxConfig async def async_setup_entry( @@ -51,7 +52,9 @@ async def async_setup_entry( KnxYamlScene(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.SCENE): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.SCENE, SceneKnxConfig + ): entities.extend( KnxUiScene(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -108,7 +111,7 @@ def __init__( self, knx_module: KNXModule, unique_id: str, - config: ConfigType, + config: KnxEntityData[SceneKnxConfig], ) -> None: """Initialize KNX scene.""" super().__init__( @@ -116,10 +119,10 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = config[DOMAIN] self._device = XknxScene( xknx=knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address=knx_conf.get_write(CONF_GA_SCENE), - scene_number=knx_conf.get(SceneConf.SCENE_NUMBER), + group_address=knx_conf.ga_scene.write, + scene_number=knx_conf.scene_number, ) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 31e50c7eb9a449..59983b35285fac 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -97,7 +97,15 @@ def _number_limit_sub_validator(config: dict) -> dict: """Validate min, max, and step values for a number entity.""" transcoder = DPTNumeric.parse_transcoder(config[CONF_TYPE]) assert transcoder is not None # already checked by numeric_type_validator - return validate_number_attributes(transcoder, config) + validate_number_attributes( + transcoder, + min_config=config.get(NumberConf.MIN), + max_config=config.get(NumberConf.MAX), + step_config=config.get(NumberConf.STEP), + device_class=config.get(CONF_DEVICE_CLASS), + unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT), + ) + return config def _max_payload_value(payload_length: int) -> int: @@ -167,7 +175,13 @@ def _sensor_attribute_sub_validator(config: dict) -> dict: config[CONF_TYPE] ) dpt_metadata = get_supported_dpts()[transcoder.dpt_number_str()] - return validate_sensor_attributes(dpt_metadata, config) + validate_sensor_attributes( + dpt_metadata, + state_class=config.get(CONF_SENSOR_STATE_CLASS), + device_class=config.get(CONF_DEVICE_CLASS), + unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT), + ) + return config ######### diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 350eb23495b3e5..3e45e4e9911edb 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -1,7 +1,7 @@ """Support for KNX select entities.""" import logging -from typing import override +from typing import Any, override from xknx.devices import RawValue from xknx.dpt import DPTBase, DPTEnum @@ -42,6 +42,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule +from .storage.config_store import KnxEntityData from .storage.const import CONF_ENTITY from .storage.util import ConfigExtractor @@ -202,7 +203,7 @@ class KnxUiSelect(_KNXSelect, KnxUiEntity): _device: RawValue def __init__( - self, knx_module: KNXModule, unique_id: str, config: ConfigType + self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize a KNX select.""" knx_conf = ConfigExtractor(config[DOMAIN]) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index f65922a99fdf9c..e65603ded37943 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial -from typing import Any, override +from typing import override from xknx.core.connection_state import XknxConnectionState, XknxConnectionType from xknx.devices import Device as XknxDevice, Sensor as XknxSensor @@ -47,8 +47,9 @@ ) from .knx_module import KNXModule from .schema import SensorSchema -from .storage.const import CONF_ALWAYS_CALLBACK, CONF_ENTITY, CONF_GA_SENSOR -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import SensorKnxConfig SCAN_INTERVAL = timedelta(seconds=10) @@ -156,7 +157,9 @@ async def async_setup_entry( KnxYamlSensor(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.SENSOR): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.SENSOR, SensorKnxConfig + ): entities.extend( KnxUiSensor(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -244,7 +247,10 @@ class KnxUiSensor(_KnxSensor, KnxUiEntity): _device: XknxSensor def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, + knx_module: KNXModule, + unique_id: str, + config: KnxEntityData[SensorKnxConfig], ) -> None: """Initialize KNX sensor.""" super().__init__( @@ -252,39 +258,39 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) - dpt_string = knx_conf.get_dpt(CONF_GA_SENSOR) + knx_conf = config[DOMAIN] + dpt_string = knx_conf.ga_sensor.dpt assert dpt_string is not None # required for sensor dpt_info = get_supported_dpts()[dpt_string] self._device = XknxSensor( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR), - sync_state=knx_conf.get(CONF_SYNC_STATE), + group_address_state=knx_conf.ga_sensor.state_and_passive(), + sync_state=knx_conf.sync_state, always_callback=True, value_type=dpt_string, ) - if device_class_override := knx_conf.get(CONF_DEVICE_CLASS): + if knx_conf.device_class: self._attr_device_class = try_parse_enum( - SensorDeviceClass, device_class_override + SensorDeviceClass, knx_conf.device_class ) else: self._attr_device_class = dpt_info["sensor_device_class"] - if state_class_override := knx_conf.get(CONF_STATE_CLASS): + if knx_conf.state_class: self._attr_state_class = try_parse_enum( - SensorStateClass, state_class_override + SensorStateClass, knx_conf.state_class ) else: self._attr_state_class = dpt_info["sensor_state_class"] self._attr_native_unit_of_measurement = ( - knx_conf.get(CONF_UNIT_OF_MEASUREMENT) or dpt_info["unit"] + knx_conf.unit_of_measurement or dpt_info["unit"] ) - self._attr_force_update = knx_conf.get(CONF_ALWAYS_CALLBACK, default=False) + self._attr_force_update = knx_conf.always_callback self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 35d4c422a8e705..cc3d7fb2a1bf33 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -1,8 +1,23 @@ """KNX entity configuration store.""" from abc import ABC, abstractmethod +from collections.abc import Callable +import dataclasses import logging -from typing import Any, Final, TypedDict, override +from typing import ( + Annotated, + Any, + Final, + TypedDict, + cast, + get_args, + get_origin, + get_type_hints, + overload, + override, +) + +from probatio import Key from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PLATFORM, Platform @@ -14,12 +29,13 @@ from ..const import DOMAIN, KNX_MODULE_KEY from ..repairs import async_create_entity_validation_issue from . import migration -from .const import CONF_DATA +from .const import CONF_DATA, CONF_ENTITY from .entity_store_validation import ( EntityStoreValidationException, validate_entity_data, ) from .expose_controller import KNXExposeStoreConfigModel, KNXExposeStoreModel +from .knx_selector import GroupAddressSelector, knx_selector_in from .time_server import KNXTimeServerStoreModel _LOGGER = logging.getLogger(__name__) @@ -42,16 +58,70 @@ class KNXConfigStoreModel(TypedDict): time_server: KNXTimeServerStoreModel +class KnxEntityData[KnxT](TypedDict): + """Validated entity data: the common `entity` and the platform `knx` part.""" + + entity: dict[str, Any] + knx: KnxT + + +def to_storage_dict(data: KnxEntityData[Any]) -> dict[str, Any]: + """Render validated entity data to its JSON serializable storage form.""" + knx_config = data[DOMAIN] + if isinstance(knx_config, dict): + return cast(dict[str, Any], data) # platform not yet migrated to a typed config + return { + CONF_ENTITY: data[CONF_ENTITY], + DOMAIN: { + name: encode(getattr(knx_config, name)) + for name, encode in _storage_encoders(type(knx_config)) + }, + } + + +def _unchanged(value: Any) -> Any: + return value + + +type _StorageEncoders = tuple[tuple[str, Callable[[Any], Any]], ...] +_STORAGE_ENCODERS: dict[type, _StorageEncoders] = {} + + +def _storage_encoders(config_type: type) -> _StorageEncoders: + """Return a storage encoder per field of a typed config. + + Section fields are dropped, group addresses are rendered by their selector. + """ + if (cached := _STORAGE_ENCODERS.get(config_type)) is not None: + return cached + hints = get_type_hints(config_type, include_extras=True) + encoders: list[tuple[str, Callable[[Any], Any]]] = [] + for dc_field in dataclasses.fields(config_type): + hint = hints[dc_field.name] + metadata = get_args(hint)[1:] if get_origin(hint) is Annotated else () + if any(isinstance(item, Key) and item.remove for item in metadata): + continue + field_selector = knx_selector_in(metadata) + encode = ( + field_selector.to_storage + if isinstance(field_selector, GroupAddressSelector) + else _unchanged + ) + encoders.append((dc_field.name, encode)) + _STORAGE_ENCODERS[config_type] = tuple(encoders) + return _STORAGE_ENCODERS[config_type] + + class PlatformControllerBase(ABC): """Entity platform controller base class.""" @abstractmethod - async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None: + async def create_entity(self, unique_id: str, config: KnxEntityData[Any]) -> None: """Create a new entity.""" @abstractmethod async def update_entity( - self, entity_entry: er.RegistryEntry, config: dict[str, Any] + self, entity_entry: er.RegistryEntry, config: KnxEntityData[Any] ) -> None: """Update an existing entities configuration.""" @@ -127,14 +197,26 @@ def add_platform( """Add platform controller.""" self._platform_controllers[platform] = controller + @overload + def get_entity_configs( + self, platform: Platform + ) -> dict[str, KnxEntityData[Any]]: ... + + @overload + def get_entity_configs[KnxT]( + self, platform: Platform, config_type: type[KnxT] + ) -> dict[str, KnxEntityData[KnxT]]: ... + @callback - def get_entity_configs(self, platform: Platform) -> KNXPlatformStoreModel: + def get_entity_configs( + self, platform: Platform, config_type: type | None = None + ) -> dict[str, KnxEntityData[Any]]: """Return validated entity configurations for a platform. Invalid configurations are reported as a repair issue and stay in `self.data` so they aren't dropped from storage. """ - validated: KNXPlatformStoreModel = {} + validated: dict[str, KnxEntityData[Any]] = {} invalid: list[str] = [] for unique_id, config in self.data["entities"].get(platform, {}).items(): try: @@ -143,21 +225,29 @@ def get_entity_configs(self, platform: Platform) -> KNXPlatformStoreModel: ) except EntityStoreValidationException: invalid.append(unique_id) - else: - validated[unique_id] = result[CONF_DATA] + continue + data: KnxEntityData[Any] = result[CONF_DATA] + if config_type is not None and not isinstance(data[DOMAIN], config_type): + raise TypeError( + f"{platform} schema yields {type(data[DOMAIN]).__name__}," + f" not {config_type.__name__}" + ) + validated[unique_id] = data if invalid: async_create_entity_validation_issue(self.hass, platform, invalid) return validated async def create_entity( - self, platform: Platform, data: dict[str, Any] + self, platform: Platform, data: KnxEntityData[Any] ) -> str | None: """Create a new entity.""" platform_controller = self._platform_controllers[platform] unique_id = f"knx_es_{ulid_now()}" await platform_controller.create_entity(unique_id, data) # store data after entity was added to be sure config didn't raise exceptions - self.data["entities"].setdefault(platform, {})[unique_id] = data + self.data["entities"].setdefault(platform, {})[unique_id] = to_storage_dict( + data + ) await self._store.async_save(self.data) entity_registry = er.async_get(self.hass) @@ -178,7 +268,7 @@ def get_entity_config(self, entity_id: str) -> dict[str, Any]: raise ConfigStoreException(f"Entity data not found: {entity_id}") from err async def update_entity( - self, platform: Platform, entity_id: str, data: dict[str, Any] + self, platform: Platform, entity_id: str, data: KnxEntityData[Any] ) -> None: """Update an existing entity.""" platform_controller = self._platform_controllers[platform] @@ -195,7 +285,7 @@ async def update_entity( ) await platform_controller.update_entity(entry, data) # store data after entity is added to make sure config doesn't raise exceptions - self.data["entities"][platform][unique_id] = data + self.data["entities"][platform][unique_id] = to_storage_dict(data) await self._store.async_save(self.data) async def delete_entity(self, entity_id: str) -> None: diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 933faf90375169..17ed93e2f951e7 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -1,9 +1,12 @@ """KNX entity store schema.""" from collections.abc import Hashable +from dataclasses import dataclass from enum import StrEnum, unique +from typing import Annotated import probatio +from probatio import Key from xknx.dpt import DPTBase, DPTBinary, DPTNumeric from xknx.exceptions import ConversionError @@ -13,31 +16,23 @@ NumberMode, ) from homeassistant.components.sensor import ( - CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, SensorDeviceClass, ) from homeassistant.components.text import TextMode from homeassistant.const import ( - CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_ENTITY_ID, - CONF_MODE, CONF_NAME, CONF_PAYLOAD, CONF_PLATFORM, - CONF_UNIT_OF_MEASUREMENT, Platform, ) from homeassistant.helpers import selector from homeassistant.helpers.typing import VolDictType from ..const import ( - CONF_CONTEXT_TIMEOUT, - CONF_IGNORE_INTERNAL_STATE, - CONF_INVERT, CONF_PAYLOAD_LENGTH, - CONF_RESET_AFTER, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, CONF_VALUE, @@ -48,8 +43,6 @@ CoverConf, FanConf, FanZeroMode, - NumberConf, - SceneConf, SelectConf, ) from ..dpt import get_supported_dpts, raw_payload_length @@ -59,7 +52,6 @@ validate_sensor_attributes, ) from .const import ( - CONF_ALWAYS_CALLBACK, CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, @@ -81,8 +73,6 @@ CONF_GA_COLOR_TEMP, CONF_GA_CONTROLLER_MODE, CONF_GA_CONTROLLER_STATUS, - CONF_GA_DATE, - CONF_GA_DATETIME, CONF_GA_DAY_NIGHT, CONF_GA_FAN_SPEED, CONF_GA_FAN_SWING, @@ -107,9 +97,7 @@ CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, - CONF_GA_SCENE, CONF_GA_SEND, - CONF_GA_SENSOR, CONF_GA_SETPOINT_SHIFT, CONF_GA_SPEED, CONF_GA_STEP, @@ -118,8 +106,6 @@ CONF_GA_TEMPERATURE, CONF_GA_TEMPERATURE_CURRENT, CONF_GA_TEMPERATURE_TARGET, - CONF_GA_TEXT, - CONF_GA_TIME, CONF_GA_UP_DOWN, CONF_GA_VALVE, CONF_GA_WHITE_BRIGHTNESS, @@ -135,14 +121,19 @@ from .knx_selector import ( AllSerializeFirst, GASelector, + GroupAddressConfig, GroupSelect, GroupSelectOption, KnxPayloadSelector, KNXSectionFlat, KnxSelectOptionsSelector, SyncStateSelector, + ga, ) +SyncState = Annotated[bool | str | int, SyncStateSelector()] +SyncStateAllowFalse = Annotated[bool | str | int, SyncStateSelector(allow_false=True)] + def base_entity_schema(platform: Platform) -> probatio.All: """Return the base entity schema for a platform.""" @@ -172,29 +163,42 @@ def base_entity_schema(platform: Platform) -> probatio.All: ) -BINARY_SENSOR_KNX_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_GA_SENSOR): GASelector( - write=False, state_required=True, valid_dpt="1" - ), - probatio.Optional(CONF_INVERT): selector.BooleanSelector(), - "section_advanced_options": KNXSectionFlat(collapsible=True), - probatio.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(), - probatio.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, max=10, step=0.1, unit_of_measurement="s" +@dataclass(kw_only=True, slots=True) +class BinarySensorKnxConfig: + """UI configuration of a KNX binary sensor.""" + + ga_sensor: Annotated[ + GroupAddressConfig, ga(write=False, state_required=True, valid_dpt="1") + ] + invert: Annotated[bool, selector.BooleanSelector()] = False + section_advanced_options: Annotated[ + None, Key(remove=True), KNXSectionFlat(collapsible=True) + ] = None + ignore_internal_state: Annotated[bool, selector.BooleanSelector()] = False + context_timeout: Annotated[ + float | None, + probatio.Maybe( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) ) ), - probatio.Optional(CONF_RESET_AFTER): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, max=600, step=0.1, unit_of_measurement="s" + ] = None + reset_after: Annotated[ + float | None, + probatio.Maybe( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=600, step=0.1, unit_of_measurement="s" + ) ) ), - probatio.Required(CONF_SYNC_STATE, default=True): SyncStateSelector( - allow_false=True - ), - }, -) + ] = None + sync_state: Annotated[SyncStateAllowFalse, Key(required=True)] = True + + +BINARY_SENSOR_KNX_SCHEMA = probatio.DataclassSchema(BinarySensorKnxConfig) def _button_data_sub_validator(config: dict) -> dict: @@ -306,29 +310,31 @@ def _button_data_sub_validator(config: dict) -> dict: ), ) -DATE_KNX_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_GA_DATE): GASelector( - write_required=True, valid_dpt="11.001" - ), - probatio.Optional( - CONF_RESPOND_TO_READ, default=False - ): selector.BooleanSelector(), - probatio.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), - } -) -DATETIME_KNX_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_GA_DATETIME): GASelector( - write_required=True, valid_dpt="19.001" - ), - probatio.Optional( - CONF_RESPOND_TO_READ, default=False - ): selector.BooleanSelector(), - probatio.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), - } -) +@dataclass(kw_only=True, slots=True) +class DateKnxConfig: + """UI configuration of a KNX date entity.""" + + ga_date: Annotated[GroupAddressConfig, ga(write_required=True, valid_dpt="11.001")] + respond_to_read: Annotated[bool, selector.BooleanSelector()] = False + sync_state: SyncState = True + + +DATE_KNX_SCHEMA = probatio.DataclassSchema(DateKnxConfig) + + +@dataclass(kw_only=True, slots=True) +class DatetimeKnxConfig: + """UI configuration of a KNX datetime entity.""" + + ga_datetime: Annotated[ + GroupAddressConfig, ga(write_required=True, valid_dpt="19.001") + ] + respond_to_read: Annotated[bool, selector.BooleanSelector()] = False + sync_state: SyncState = True + + +DATETIME_KNX_SCHEMA = probatio.DataclassSchema(DatetimeKnxConfig) FAN_KNX_SCHEMA = AllSerializeFirst( probatio.Schema( @@ -525,49 +531,54 @@ class LightColorMode(StrEnum): ) -NOTIFY_KNX_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_GA_SEND): GASelector( - state=False, passive=False, write_required=True, dpt=["string"] - ), - } -) +@dataclass(kw_only=True, slots=True) +class NotifyKnxConfig: + """UI configuration of a KNX notify entity.""" + ga_send: Annotated[ + GroupAddressConfig, + ga(state=False, passive=False, write_required=True, dpt=["string"]), + ] -def _number_limit_sub_validator(config: dict) -> dict: - """Validate min, max, and step values for a number entity.""" - dpt = config[CONF_GA_SENSOR][CONF_DPT] - transcoder = DPTNumeric.parse_transcoder(dpt) - assert transcoder is not None # already checked by GASelector - return validate_number_attributes(transcoder, config) +NOTIFY_KNX_SCHEMA = probatio.DataclassSchema(NotifyKnxConfig) -NUMBER_KNX_SCHEMA = AllSerializeFirst( - probatio.Schema( - { - probatio.Required(CONF_GA_SENSOR): GASelector( - write_required=True, dpt=["numeric"] - ), - probatio.Optional( - CONF_RESPOND_TO_READ, default=False - ): selector.BooleanSelector(), - "section_advanced_options": KNXSectionFlat(collapsible=True), - probatio.Required( - CONF_MODE, default=NumberMode.AUTO - ): selector.SelectSelector( - selector.SelectSelectorConfig( - options=list(NumberMode), - translation_key="component.knx.config_panel.entities.create.number.knx.mode", - ), + +@dataclass(kw_only=True, slots=True) +class NumberKnxConfig: + """UI configuration of a KNX number entity.""" + + ga_sensor: Annotated[GroupAddressConfig, ga(write_required=True, dpt=["numeric"])] + respond_to_read: Annotated[bool, selector.BooleanSelector()] = False + section_advanced_options: Annotated[ + None, Key(remove=True), KNXSectionFlat(collapsible=True) + ] = None + mode: Annotated[ + str, + Key(required=True), + selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(NumberMode), + translation_key="component.knx.config_panel.entities.create.number.knx.mode", ), - probatio.Optional(NumberConf.MIN): selector.NumberSelector(), - probatio.Optional(NumberConf.MAX): selector.NumberSelector(), - probatio.Optional(NumberConf.STEP): selector.NumberSelector( + ), + ] = NumberMode.AUTO + min: Annotated[float | None, probatio.Maybe(selector.NumberSelector())] = None + max: Annotated[float | None, probatio.Maybe(selector.NumberSelector())] = None + step: Annotated[ + float | None, + probatio.Maybe( + selector.NumberSelector( selector.NumberSelectorConfig( min=0, step="any", mode=selector.NumberSelectorMode.BOX ) - ), - probatio.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector( + ) + ), + ] = None + unit_of_measurement: Annotated[ + str | None, + probatio.Maybe( + selector.SelectSelector( selector.SelectSelectorConfig( options=sorted( { @@ -580,34 +591,67 @@ def _number_limit_sub_validator(config: dict) -> dict: mode=selector.SelectSelectorMode.DROPDOWN, custom_value=True, ), - ), - probatio.Optional(CONF_DEVICE_CLASS): selector.DeviceClassSelector( + ) + ), + ] = None + device_class: Annotated[ + str | None, + probatio.Maybe( + selector.DeviceClassSelector( selector.DeviceClassSelectorConfig(domain=Platform.NUMBER) - ), - probatio.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), - }, - ), + ) + ), + ] = None + sync_state: SyncState = True + + +def _number_limit_sub_validator(config: NumberKnxConfig) -> NumberKnxConfig: + """Validate min, max, and step values for a number entity.""" + assert config.ga_sensor.dpt is not None # required by the selector + transcoder = DPTNumeric.parse_transcoder(config.ga_sensor.dpt) + assert transcoder is not None # already checked by GASelector + validate_number_attributes( + transcoder, + min_config=config.min, + max_config=config.max, + step_config=config.step, + device_class=config.device_class, + unit_of_measurement=config.unit_of_measurement, + ) + return config + + +NUMBER_KNX_SCHEMA = AllSerializeFirst( + probatio.DataclassSchema(NumberKnxConfig), _number_limit_sub_validator, ) -SCENE_KNX_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_GA_SCENE): GASelector( + +@dataclass(kw_only=True, slots=True) +class SceneKnxConfig: + """UI configuration of a KNX scene.""" + + ga_scene: Annotated[ + GroupAddressConfig, + ga( state=False, passive=False, write_required=True, valid_dpt=["17.001", "18.001"], ), - probatio.Required(SceneConf.SCENE_NUMBER): AllSerializeFirst( - selector.NumberSelector( - selector.NumberSelectorConfig( - min=1, max=64, step=1, mode=selector.NumberSelectorMode.BOX - ) - ), - probatio.Coerce(int), + ] + scene_number: Annotated[ + int, + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=64, step=1, mode=selector.NumberSelectorMode.BOX + ) ), - }, -) + probatio.Coerce(int), + ] + + +SCENE_KNX_SCHEMA = probatio.DataclassSchema(SceneKnxConfig) def _select_options_sub_validator(config: dict) -> dict: @@ -730,48 +774,52 @@ def _select_options_sub_validator(config: dict) -> dict: _select_options_sub_validator, ) -SWITCH_KNX_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_GA_SWITCH): GASelector( - write_required=True, valid_dpt="1" - ), - probatio.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), - probatio.Optional( - CONF_RESPOND_TO_READ, default=False - ): selector.BooleanSelector(), - probatio.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), - }, -) -TEXT_KNX_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_GA_TEXT): GASelector( - write_required=True, dpt=["string"] - ), - probatio.Required(CONF_MODE, default=TextMode.TEXT): selector.SelectSelector( +@dataclass(kw_only=True, slots=True) +class SwitchKnxConfig: + """UI configuration of a KNX switch.""" + + ga_switch: Annotated[GroupAddressConfig, ga(write_required=True, valid_dpt="1")] + invert: Annotated[bool, selector.BooleanSelector()] = False + respond_to_read: Annotated[bool, selector.BooleanSelector()] = False + sync_state: SyncState = True + + +SWITCH_KNX_SCHEMA = probatio.DataclassSchema(SwitchKnxConfig) + + +@dataclass(kw_only=True, slots=True) +class TextKnxConfig: + """UI configuration of a KNX text entity.""" + + ga_text: Annotated[GroupAddressConfig, ga(write_required=True, dpt=["string"])] + mode: Annotated[ + str, + Key(required=True), + selector.SelectSelector( selector.SelectSelectorConfig( options=list(TextMode), translation_key="component.knx.config_panel.entities.create.text.knx.mode", ), ), - probatio.Optional( - CONF_RESPOND_TO_READ, default=False - ): selector.BooleanSelector(), - probatio.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), - }, -) + ] = TextMode.TEXT + respond_to_read: Annotated[bool, selector.BooleanSelector()] = False + sync_state: SyncState = True + + +TEXT_KNX_SCHEMA = probatio.DataclassSchema(TextKnxConfig) -TIME_KNX_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_GA_TIME): GASelector( - write_required=True, valid_dpt="10.001" - ), - probatio.Optional( - CONF_RESPOND_TO_READ, default=False - ): selector.BooleanSelector(), - probatio.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), - } -) + +@dataclass(kw_only=True, slots=True) +class TimeKnxConfig: + """UI configuration of a KNX time entity.""" + + ga_time: Annotated[GroupAddressConfig, ga(write_required=True, valid_dpt="10.001")] + respond_to_read: Annotated[bool, selector.BooleanSelector()] = False + sync_state: SyncState = True + + +TIME_KNX_SCHEMA = probatio.DataclassSchema(TimeKnxConfig) @unique @@ -920,21 +968,21 @@ class ConfClimateFanSpeedMode(StrEnum): ) -def _sensor_attribute_sub_validator(config: dict) -> dict: - """Validate state_class, device_class and unit compatibility.""" - dpt = config[CONF_GA_SENSOR][CONF_DPT] - dpt_metadata = get_supported_dpts()[dpt] - return validate_sensor_attributes(dpt_metadata, config) +@dataclass(kw_only=True, slots=True) +class SensorKnxConfig: + """UI configuration of a KNX sensor.""" - -SENSOR_KNX_SCHEMA = AllSerializeFirst( - probatio.Schema( - { - probatio.Required(CONF_GA_SENSOR): GASelector( - write=False, state_required=True, dpt=["numeric", "string"] - ), - "section_advanced_options": KNXSectionFlat(collapsible=True), - probatio.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector( + ga_sensor: Annotated[ + GroupAddressConfig, + ga(write=False, state_required=True, dpt=["numeric", "string"]), + ] + section_advanced_options: Annotated[ + None, Key(remove=True), KNXSectionFlat(collapsible=True) + ] = None + unit_of_measurement: Annotated[ + str | None, + probatio.Maybe( + selector.SelectSelector( selector.SelectSelectorConfig( options=sorted( { @@ -948,8 +996,13 @@ def _sensor_attribute_sub_validator(config: dict) -> dict: translation_key="component.knx.selector.sensor_unit_of_measurement", custom_value=True, ), - ), - probatio.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + ) + ), + ] = None + device_class: Annotated[ + str | None, + probatio.Maybe( + selector.SelectSelector( selector.SelectSelectorConfig( options=[ cls.value @@ -959,14 +1012,30 @@ def _sensor_attribute_sub_validator(config: dict) -> dict: translation_key="component.knx.selector.sensor_device_class", sort=True, ) - ), - probatio.Optional(CONF_SENSOR_STATE_CLASS): selector.StateClassSelector(), - probatio.Optional(CONF_ALWAYS_CALLBACK): selector.BooleanSelector(), - probatio.Required(CONF_SYNC_STATE, default=True): SyncStateSelector( - allow_false=True - ), - }, - ), + ) + ), + ] = None + state_class: Annotated[ + str | None, probatio.Maybe(selector.StateClassSelector()) + ] = None + always_callback: Annotated[bool, selector.BooleanSelector()] = False + sync_state: Annotated[SyncStateAllowFalse, Key(required=True)] = True + + +def _sensor_attribute_sub_validator(config: SensorKnxConfig) -> SensorKnxConfig: + """Validate state_class, device_class and unit compatibility.""" + assert config.ga_sensor.dpt is not None # required by the selector + validate_sensor_attributes( + get_supported_dpts()[config.ga_sensor.dpt], + state_class=config.state_class, + device_class=config.device_class, + unit_of_measurement=config.unit_of_measurement, + ) + return config + + +SENSOR_KNX_SCHEMA = AllSerializeFirst( + probatio.DataclassSchema(SensorKnxConfig), _sensor_attribute_sub_validator, ) diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index 49d6012d84d875..32184b73754b52 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -1,12 +1,15 @@ """Selectors for KNX.""" from collections.abc import Iterable +from dataclasses import dataclass, field from enum import Enum from typing import Any, override import probatio +from xknx.telegram.address import DeviceAddressableType from homeassistant.const import CONF_PAYLOAD +from homeassistant.helpers import selector from ..const import CONF_PAYLOAD_LENGTH, CONF_VALUE, SelectConf from ..dpt import HaDptClass, get_supported_dpts @@ -300,6 +303,99 @@ def _add_dpt(self, schema: dict[probatio.Marker, Any]) -> None: schema[probatio.Remove(CONF_DPT)] = object +@dataclass(kw_only=True, slots=True) +class GroupAddressConfig: + """Validated group address configuration of a `GASelector`.""" + + write: str | int | None = None + state: str | int | None = None + passive: list[str | int] = field(default_factory=list) + dpt: str | None = None + + def write_and_passive(self) -> list[DeviceAddressableType | None]: + """Return the write address followed by the passive addresses.""" + return [self.write, *self.passive] + + def state_and_passive(self) -> list[DeviceAddressableType | None]: + """Return the state address followed by the passive addresses.""" + return [self.state, *self.passive] + + +class GroupAddressSelector(GASelector): + """`GASelector` yielding a `GroupAddressConfig` instead of a dict. + + Temporary: fold into `GASelector` once all platforms use typed configs. + """ + + @override + def __call__(self, data: Any) -> GroupAddressConfig | None: + """Validate the passed data.""" + if data is None: # `Optional(key, default=None)` passes its default through + return None + return GroupAddressConfig(**self.schema(data)) + + def to_storage(self, value: GroupAddressConfig | None) -> dict[str, Any] | None: + """Render a validated value to exactly the keys its schema emits.""" + if value is None: + return None + data: dict[str, Any] = {} + if self.write: + data[CONF_GA_WRITE] = value.write + if self.state: + data[CONF_GA_STATE] = value.state + if self.passive: + data[CONF_GA_PASSIVE] = value.passive + if value.dpt is not None: + data[CONF_DPT] = value.dpt + return data + + +def ga( + write: bool = True, + state: bool = True, + passive: bool = True, + write_required: bool = False, + state_required: bool = False, + dpt: type[Enum] | list[HaDptClass] | None = None, + dpt_required: bool = True, + valid_dpt: str | Iterable[str] | None = None, +) -> probatio.Coerce: + """Annotate a dataclass field with a group address selector. + + `Coerce` makes probatio run the selector before the field type check, so + the selector receives the raw mapping instead of a constructed instance. + """ + return probatio.Coerce( + GroupAddressSelector( + write=write, + state=state, + passive=passive, + write_required=write_required, + state_required=state_required, + dpt=dpt, + dpt_required=dpt_required, + valid_dpt=valid_dpt, + ) + ) + + +def knx_selector_in( + nodes: Iterable[Any], +) -> KNXSelectorBase | selector.Selector | None: + """Return the first KNX or HA selector in `nodes`. + + Looks into `Coerce` and `Maybe`, the wrappers used in field annotations. + """ + for node in nodes: + if isinstance(node, probatio.Coerce): + node = node.type + elif isinstance(node, probatio.Maybe): + node = node.validator + if isinstance(node, (KNXSelectorBase, selector.Selector)): + return node + return None + + class SyncStateSelector(KNXSelectorBase): """Selector for knx sync state validation.""" diff --git a/homeassistant/components/knx/storage/serialize.py b/homeassistant/components/knx/storage/serialize.py index a632f19c4e471f..1315a3a0842008 100644 --- a/homeassistant/components/knx/storage/serialize.py +++ b/homeassistant/components/knx/storage/serialize.py @@ -2,13 +2,19 @@ from typing import Any, cast +import probatio from probatio import UNSUPPORTED, to_field_list from homeassistant.const import Platform from homeassistant.helpers import selector from .entity_store_schema import KNX_SCHEMA_FOR_PLATFORM -from .knx_selector import AllSerializeFirst, GroupSelectSchema, KNXSelectorBase +from .knx_selector import ( + AllSerializeFirst, + GroupSelectSchema, + KNXSelectorBase, + knx_selector_in, +) def knx_serializer(schema: Any) -> Any: @@ -30,6 +36,11 @@ def knx_serializer(schema: Any) -> Any: return result if isinstance(schema, AllSerializeFirst): return to_field_list(schema.validators[0], custom_serializer=knx_serializer) + if isinstance(schema, probatio.All): + # a dataclass field: the selector in `Annotated` metadata defines the + # field, the type annotation and `Coerce` are validation-only + if (field_selector := knx_selector_in(schema.validators)) is not None: + return knx_serializer(field_selector) if isinstance(schema, selector.Selector): return schema.serialize() | {"type": "ha_selector"} diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 200cc391d79ae8..03f328a8caa799 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -23,7 +23,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_INVERT, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, DOMAIN, @@ -38,8 +37,9 @@ ) from .knx_module import KNXModule from .schema import SwitchSchema -from .storage.const import CONF_ENTITY, CONF_GA_SWITCH -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import SwitchKnxConfig async def async_setup_entry( @@ -65,7 +65,9 @@ async def async_setup_entry( KnxYamlSwitch(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.SWITCH): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.SWITCH, SwitchKnxConfig + ): entities.extend( KnxUiSwitch(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -134,7 +136,10 @@ class KnxUiSwitch(_KnxSwitch, KnxUiEntity): _device: XknxSwitch def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, + knx_module: KNXModule, + unique_id: str, + config: KnxEntityData[SwitchKnxConfig], ) -> None: """Initialize KNX switch.""" super().__init__( @@ -142,13 +147,13 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = config[DOMAIN] self._device = XknxSwitch( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address=knx_conf.get_write(CONF_GA_SWITCH), - group_address_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH), - respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), - sync_state=knx_conf.get(CONF_SYNC_STATE), - invert=knx_conf.get(CONF_INVERT), + group_address=knx_conf.ga_switch.write, + group_address_state=knx_conf.ga_switch.state_and_passive(), + respond_to_read=knx_conf.respond_to_read, + sync_state=knx_conf.sync_state, + invert=knx_conf.invert, ) diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index f035740aa6077c..6d3d7870940f4b 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -39,8 +39,9 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.const import CONF_ENTITY, CONF_GA_TEXT -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import TextKnxConfig async def async_setup_entry( @@ -66,7 +67,9 @@ async def async_setup_entry( KnxYamlText(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.TEXT): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.TEXT, TextKnxConfig + ): entities.extend( KnxUiText(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -146,7 +149,7 @@ def __init__( self, knx_module: KNXModule, unique_id: str, - config: ConfigType, + config: KnxEntityData[TextKnxConfig], ) -> None: """Initialize a KNX text.""" super().__init__( @@ -154,17 +157,17 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = config[DOMAIN] self._device = XknxNotification( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address=knx_conf.get_write(CONF_GA_TEXT), - group_address_state=knx_conf.get_state_and_passive(CONF_GA_TEXT), - respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), - sync_state=knx_conf.get(CONF_SYNC_STATE), - value_type=knx_conf.get_dpt(CONF_GA_TEXT), + group_address=knx_conf.ga_text.write, + group_address_state=knx_conf.ga_text.state_and_passive(), + respond_to_read=knx_conf.respond_to_read, + sync_state=knx_conf.sync_state, + value_type=knx_conf.ga_text.dpt, ) - self._attr_mode = TextMode(knx_conf.get(CONF_MODE)) + self._attr_mode = TextMode(knx_conf.mode) self._attr_native_max_length = ( self._device.remote_value.dpt_class.payload_length ) diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 19b962e4eb430a..1e73bc49c3ac26 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -1,7 +1,7 @@ """Support for KNX time entities.""" from datetime import time as dt_time -from typing import Any, override +from typing import override from xknx.devices import TimeDevice as XknxTimeDevice from xknx.dpt.dpt_10 import KNXTime as XknxTime @@ -32,8 +32,9 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.const import CONF_ENTITY, CONF_GA_TIME -from .storage.util import ConfigExtractor +from .storage.config_store import KnxEntityData +from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import TimeKnxConfig async def async_setup_entry( @@ -59,7 +60,9 @@ async def async_setup_entry( KnxYamlTime(knx_module, entity_config) for entity_config in yaml_platform_config ) - if ui_config := knx_module.config_store.get_entity_configs(Platform.TIME): + if ui_config := knx_module.config_store.get_entity_configs( + Platform.TIME, TimeKnxConfig + ): entities.extend( KnxUiTime(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -125,7 +128,10 @@ class KnxUiTime(_KNXTime, KnxUiEntity): _device: XknxTimeDevice def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, + knx_module: KNXModule, + unique_id: str, + config: KnxEntityData[TimeKnxConfig], ) -> None: """Initialize KNX time.""" super().__init__( @@ -133,13 +139,13 @@ def __init__( unique_id=unique_id, entity_config=config[CONF_ENTITY], ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = config[DOMAIN] self._device = XknxTimeDevice( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], localtime=False, - group_address=knx_conf.get_write(CONF_GA_TIME), - group_address_state=knx_conf.get_state_and_passive(CONF_GA_TIME), - respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), - sync_state=knx_conf.get(CONF_SYNC_STATE), + group_address=knx_conf.ga_time.write, + group_address_state=knx_conf.ga_time.state_and_passive(), + respond_to_read=knx_conf.respond_to_read, + sync_state=knx_conf.sync_state, ) diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index a7221c05b782a4..ff15edcf8df418 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -1,10 +1,10 @@ """Validation helpers for KNX config schemas.""" -from collections.abc import Callable +from collections.abc import Callable, Mapping from enum import Enum import ipaddress import math -from typing import Any +from typing import Any, cast import probatio from xknx.dpt import DPTBase, DPTNumeric, DPTString @@ -31,6 +31,15 @@ from .const import PLATFORMS_WITHOUT_CONFIG_CATEGORY, NumberConf from .dpt import DPTInfo, get_supported_dpts +# Device and state classes arrive as plain strings from the UI selectors and as +# their StrEnum from YAML - both look up the same StrEnum keyed entries. +_NUMBER_DEVICE_CLASS_UNITS = cast(Mapping[str, set[Any]], NUMBER_DEVICE_CLASS_UNITS) +_SENSOR_DEVICE_CLASS_UNITS = cast(Mapping[str, set[Any]], DEVICE_CLASS_UNITS) +_SENSOR_DEVICE_CLASS_STATE_CLASSES = cast( + Mapping[str, set[Any]], DEVICE_CLASS_STATE_CLASSES +) +_SENSOR_STATE_CLASS_UNITS = cast(Mapping[str, set[Any]], STATE_CLASS_UNITS) + def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: """Validate that value is parsable as given sensor type.""" @@ -210,16 +219,18 @@ def _string_transform(value: Any) -> str: def validate_number_attributes( - transcoder: type[DPTNumeric], config: dict[str, Any] -) -> dict[str, Any]: + transcoder: type[DPTNumeric], + *, + min_config: float | None, + max_config: float | None, + step_config: float | None, + device_class: str | None, + unit_of_measurement: str | None, +) -> None: """Validate a number entity configurations dependent on configured value type. - Works for both, UI and YAML configuration schema since they - share same names for all tested attributes. + Works for both, UI and YAML configuration schema. `None` means not configured. """ - min_config: float | None = config.get(NumberConf.MIN) - max_config: float | None = config.get(NumberConf.MAX) - step_config: float | None = config.get(NumberConf.STEP) _dpt_error_str = f"DPT {transcoder.dpt_number_str()} '{transcoder.value_type}'" # Infinity is not supported by Home Assistant frontend so user defined @@ -255,101 +266,105 @@ def validate_number_attributes( # Validate device class and unit of measurement compatibility dpt_metadata = get_supported_dpts()[transcoder.dpt_number_str()] - - device_class = config.get( - CONF_DEVICE_CLASS, - dpt_metadata["sensor_device_class"], + effective_device_class = ( + device_class + if device_class is not None + else dpt_metadata["sensor_device_class"] ) - unit_of_measurement = config.get( - CONF_UNIT_OF_MEASUREMENT, - dpt_metadata["unit"], + effective_unit = ( + unit_of_measurement if unit_of_measurement is not None else dpt_metadata["unit"] ) if ( - device_class - and (d_c_units := NUMBER_DEVICE_CLASS_UNITS.get(device_class)) is not None - and unit_of_measurement not in d_c_units + effective_device_class + and (d_c_units := _NUMBER_DEVICE_CLASS_UNITS.get(effective_device_class)) + is not None + and effective_unit not in d_c_units ): _options = ", ".join(sorted(map(str, d_c_units), key=str.casefold)) raise probatio.Invalid( - f"Unit of measurement '{unit_of_measurement}'" + f"Unit of measurement '{effective_unit}'" f" is not valid for device class" - f" '{device_class}'." + f" '{effective_device_class}'." f" Valid options are: {_options}", path=( [CONF_DEVICE_CLASS] - if CONF_DEVICE_CLASS in config + if device_class is not None else [CONF_UNIT_OF_MEASUREMENT] ), ) - return config - def validate_sensor_attributes( - dpt_info: DPTInfo, config: dict[str, Any] -) -> dict[str, Any]: + dpt_info: DPTInfo, + *, + state_class: str | None, + device_class: str | None, + unit_of_measurement: str | None, +) -> None: """Validate state_class, device_class and unit compatibility. - Works for both, UI and YAML configuration schema since they - share same names for all tested attributes. + Works for both, UI and YAML configuration schema. `None` means not configured. """ - state_class = config.get( - CONF_SENSOR_STATE_CLASS, - dpt_info["sensor_state_class"], + effective_state_class = ( + state_class if state_class is not None else dpt_info["sensor_state_class"] ) - device_class = config.get( - CONF_DEVICE_CLASS, - dpt_info["sensor_device_class"], + effective_device_class = ( + device_class if device_class is not None else dpt_info["sensor_device_class"] ) - unit_of_measurement = config.get( - CONF_UNIT_OF_MEASUREMENT, - dpt_info["unit"], + effective_unit = ( + unit_of_measurement if unit_of_measurement is not None else dpt_info["unit"] ) if ( - state_class - and device_class - and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None - and state_class not in state_classes + effective_state_class + and effective_device_class + and ( + state_classes := _SENSOR_DEVICE_CLASS_STATE_CLASSES.get( + effective_device_class + ) + ) + is not None + and effective_state_class not in state_classes ): _options = ", ".join(sorted(map(str, state_classes), key=str.casefold)) raise probatio.Invalid( - f"State class '{state_class}' is not valid" - f" for device class '{device_class}'." + f"State class '{effective_state_class}' is not valid" + f" for device class '{effective_device_class}'." f" Valid options are: {_options}", path=[CONF_SENSOR_STATE_CLASS], ) if ( - device_class - and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None - and unit_of_measurement not in d_c_units + effective_device_class + and (d_c_units := _SENSOR_DEVICE_CLASS_UNITS.get(effective_device_class)) + is not None + and effective_unit not in d_c_units ): _options = ", ".join(sorted(map(str, d_c_units), key=str.casefold)) raise probatio.Invalid( - f"Unit of measurement '{unit_of_measurement}'" + f"Unit of measurement '{effective_unit}'" f" is not valid for device class" - f" '{device_class}'." + f" '{effective_device_class}'." f" Valid options are: {_options}", path=( [CONF_DEVICE_CLASS] - if CONF_DEVICE_CLASS in config + if device_class is not None else [CONF_UNIT_OF_MEASUREMENT] ), ) if ( - state_class - and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None - and unit_of_measurement not in s_c_units + effective_state_class + and (s_c_units := _SENSOR_STATE_CLASS_UNITS.get(effective_state_class)) + is not None + and effective_unit not in s_c_units ): _options = ", ".join(sorted(map(str, s_c_units), key=str.casefold)) raise probatio.Invalid( - f"Unit of measurement '{unit_of_measurement}'" + f"Unit of measurement '{effective_unit}'" f" is not valid for state class" - f" '{state_class}'." + f" '{effective_state_class}'." f" Valid options are: {_options}", path=( [CONF_SENSOR_STATE_CLASS] - if CONF_SENSOR_STATE_CLASS in config + if state_class is not None else [CONF_UNIT_OF_MEASUREMENT] ), ) - return config diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 03315f8efd7ec9..db149debedfcfe 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -1,6 +1,6 @@ """Support for KNX weather entities.""" -from typing import override +from typing import Any, override from xknx.devices import Weather as XknxWeather @@ -29,6 +29,7 @@ ) from .knx_module import KNXModule from .schema import WeatherSchema +from .storage.config_store import KnxEntityData from .storage.const import ( CONF_ENTITY, CONF_GA_AIR_PRESSURE, @@ -190,7 +191,7 @@ class KnxUiWeather(_KnxWeather, KnxUiEntity): _device: XknxWeather def __init__( - self, knx_module: KNXModule, unique_id: str, config: ConfigType + self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize of a KNX weather device.""" super().__init__( diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 25dc9a6f89b822..2386ffa153db5c 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -55,8 +55,15 @@ def _format_tool( mcp_schema["required"] = required return types.Tool( name=tool.name, + title=tool.title, description=tool.description or "", inputSchema=mcp_schema, + annotations=types.ToolAnnotations( + readOnlyHint=tool.annotations.read_only, + destructiveHint=tool.annotations.destructive, + idempotentHint=tool.annotations.idempotent, + openWorldHint=tool.annotations.open_world, + ), ) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index a89c97e2be2ebd..0d1b96f654df86 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -1,5 +1,6 @@ """Support for Tuya Alarm.""" +from dataclasses import dataclass from typing import override from tuya_device_handlers.definition.alarm_control_panel import ( @@ -24,14 +25,22 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription -ALARM: dict[DeviceCategory, AlarmControlPanelEntityDescription] = { - DeviceCategory.MAL: AlarmControlPanelEntityDescription( + +@dataclass(frozen=True) +class TuyaAlarmControlPanelEntityDescription( + TuyaEntityDescription, AlarmControlPanelEntityDescription +): + """Describes a Tuya alarm control panel entity.""" + + +ALARM: dict[DeviceCategory, TuyaAlarmControlPanelEntityDescription] = { + DeviceCategory.MAL: TuyaAlarmControlPanelEntityDescription( key=DPCode.MASTER_MODE, name="Alarm", ), - DeviceCategory.WG2: AlarmControlPanelEntityDescription( + DeviceCategory.WG2: TuyaAlarmControlPanelEntityDescription( key=DPCode.MASTER_MODE, name="Alarm", ), @@ -92,7 +101,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: AlarmControlPanelEntityDescription, + description: TuyaAlarmControlPanelEntityDescription, definition: AlarmControlPanelDefinition, ) -> None: """Init Tuya Alarm.""" diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index af5feb3eda2590..895ecaaac441e7 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -21,11 +21,13 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription @dataclass(frozen=True) -class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): +class TuyaBinarySensorEntityDescription( + TuyaEntityDescription, BinarySensorEntityDescription +): """Describes a Tuya binary sensor.""" # DPCode, to use. If None, the key will be used as DPCode diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 3d9c5148f20d0a..a873e71b6d6fc5 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -1,5 +1,6 @@ """Support for Tuya buttons.""" +from dataclasses import dataclass from typing import override from tuya_device_handlers.definition.button import ( @@ -20,57 +21,63 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription -BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = { + +@dataclass(frozen=True) +class TuyaButtonEntityDescription(TuyaEntityDescription, ButtonEntityDescription): + """Describes a Tuya button entity.""" + + +BUTTONS: dict[DeviceCategory, tuple[TuyaButtonEntityDescription, ...]] = { DeviceCategory.HXD: ( - ButtonEntityDescription( + TuyaButtonEntityDescription( key=DPCode.SWITCH_USB6, translation_key="snooze", ), ), DeviceCategory.MSP: ( - ButtonEntityDescription( + TuyaButtonEntityDescription( key=DPCode.FACTORY_RESET, translation_key="factory_reset", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - ButtonEntityDescription( + TuyaButtonEntityDescription( key=DPCode.MANUAL_CLEAN, translation_key="manual_clean", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SD: ( - ButtonEntityDescription( + TuyaButtonEntityDescription( key=DPCode.RESET_DUSTER_CLOTH, translation_key="reset_duster_cloth", entity_category=EntityCategory.CONFIG, ), - ButtonEntityDescription( + TuyaButtonEntityDescription( key=DPCode.RESET_EDGE_BRUSH, translation_key="reset_edge_brush", entity_category=EntityCategory.CONFIG, ), - ButtonEntityDescription( + TuyaButtonEntityDescription( key=DPCode.RESET_FILTER, translation_key="reset_filter", entity_category=EntityCategory.CONFIG, ), - ButtonEntityDescription( + TuyaButtonEntityDescription( key=DPCode.RESET_MAP, translation_key="reset_map", entity_category=EntityCategory.CONFIG, ), - ButtonEntityDescription( + TuyaButtonEntityDescription( key=DPCode.RESET_ROLL_BRUSH, translation_key="reset_roll_brush", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SP: ( - ButtonEntityDescription( + TuyaButtonEntityDescription( key=DPCode.DEVICE_RESTART, device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, @@ -116,7 +123,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: ButtonEntityDescription, + description: TuyaButtonEntityDescription, definition: ButtonDefinition, ) -> None: """Init Tuya button.""" diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 277fa2b17cd46e..7fa7c74ceef818 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -1,5 +1,6 @@ """Support for Tuya cameras.""" +from dataclasses import dataclass from typing import override from tuya_device_handlers.definition.camera import ( @@ -20,11 +21,17 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription -CAMERAS: dict[DeviceCategory, CameraEntityDescription] = { - DeviceCategory.DGHSXJ: CameraEntityDescription(key=""), - DeviceCategory.SP: CameraEntityDescription(key=""), + +@dataclass(frozen=True) +class TuyaCameraEntityDescription(TuyaEntityDescription, CameraEntityDescription): + """Describes a Tuya camera entity.""" + + +CAMERAS: dict[DeviceCategory, TuyaCameraEntityDescription] = { + DeviceCategory.DGHSXJ: TuyaCameraEntityDescription(key=""), + DeviceCategory.SP: TuyaCameraEntityDescription(key=""), } @@ -69,7 +76,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: CameraEntityDescription, + description: TuyaCameraEntityDescription, definition: CameraDefinition, ) -> None: """Init Tuya Camera.""" diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 4de024e52787e1..1b7039341d82bf 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -33,7 +33,7 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription from .util import get_temperature_unit _TUYA_TO_HA_HVACMODE_MAPPINGS = { @@ -63,7 +63,7 @@ @dataclass(frozen=True, kw_only=True) -class TuyaClimateEntityDescription(ClimateEntityDescription): +class TuyaClimateEntityDescription(TuyaEntityDescription, ClimateEntityDescription): """Describe an Tuya climate entity.""" switch_only_hvac_mode: HVACMode diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index b834b438c94ef7..d1b0b9753fbf83 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -35,11 +35,11 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription @dataclass(frozen=True) -class TuyaCoverEntityDescription(CoverEntityDescription): +class TuyaCoverEntityDescription(TuyaEntityDescription, CoverEntityDescription): """Describe a Tuya cover entity.""" current_state: DPCode | tuple[DPCode, ...] | None = None diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index fb5326038388d5..a98cdbf9813f12 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -1,5 +1,6 @@ """Tuya Home Assistant Base Device Model.""" +from dataclasses import dataclass from typing import Any, override from tuya_device_handlers.device_wrapper import DeviceWrapper @@ -12,6 +13,11 @@ from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY +@dataclass(frozen=True) +class TuyaEntityDescription(EntityDescription): + """Describes a Tuya entity.""" + + class TuyaEntity(Entity): """Tuya base device.""" @@ -22,7 +28,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: EntityDescription, + description: TuyaEntityDescription, ) -> None: """Init TuyaEntity.""" self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device.id)}) diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index b863f9db1761a5..94f3af59923c19 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -26,11 +26,11 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription @dataclass(frozen=True) -class TuyaEventEntityDescription(EventEntityDescription): +class TuyaEventEntityDescription(TuyaEntityDescription, EventEntityDescription): """Describe a Tuya Event entity.""" wrapper_class: type[DPCodeTypeInformationWrapper] = SimpleEventEnumWrapper @@ -150,13 +150,13 @@ def async_discover_device(device_ids: list[str]) -> None: class TuyaEventEntity(TuyaEntity, EventEntity): """Tuya Event Entity.""" - entity_description: EventEntityDescription + entity_description: TuyaEventEntityDescription def __init__( self, device: CustomerDevice, device_manager: Manager, - description: EventEntityDescription, + description: TuyaEventEntityDescription, definition: EventDefinition, ) -> None: """Init Tuya event entity.""" diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index d1701593733255..20d5265cbe7a71 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -1,5 +1,6 @@ """Support for Tuya Fan.""" +from dataclasses import dataclass from typing import Any, override from tuya_device_handlers.definition.fan import FanDefinition, get_default_definition @@ -19,15 +20,21 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity - -FANS: dict[DeviceCategory, FanEntityDescription] = { - DeviceCategory.CS: FanEntityDescription(key=""), - DeviceCategory.FS: FanEntityDescription(key=""), - DeviceCategory.FSD: FanEntityDescription(key=""), - DeviceCategory.FSKG: FanEntityDescription(key=""), - DeviceCategory.KJ: FanEntityDescription(key=""), - DeviceCategory.KS: FanEntityDescription(key=""), +from .entity import TuyaEntity, TuyaEntityDescription + + +@dataclass(frozen=True) +class TuyaFanEntityDescription(TuyaEntityDescription, FanEntityDescription): + """Describes a Tuya fan entity.""" + + +FANS: dict[DeviceCategory, TuyaFanEntityDescription] = { + DeviceCategory.CS: TuyaFanEntityDescription(key=""), + DeviceCategory.FS: TuyaFanEntityDescription(key=""), + DeviceCategory.FSD: TuyaFanEntityDescription(key=""), + DeviceCategory.FSKG: TuyaFanEntityDescription(key=""), + DeviceCategory.KJ: TuyaFanEntityDescription(key=""), + DeviceCategory.KS: TuyaFanEntityDescription(key=""), } _TUYA_TO_HA_DIRECTION_MAPPINGS = { @@ -75,7 +82,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: FanEntityDescription, + description: TuyaFanEntityDescription, definition: FanDefinition, ) -> None: """Init Tuya Fan Device.""" diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index c76fd341423410..42aff1b4d780fe 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,12 +21,14 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription from .util import ActionDPCodeNotFoundError @dataclass(frozen=True) -class TuyaHumidifierEntityDescription(HumidifierEntityDescription): +class TuyaHumidifierEntityDescription( + TuyaEntityDescription, HumidifierEntityDescription +): """Describe an Tuya (de)humidifier entity.""" # DPCode, to use. If None, the key will be used as DPCode diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 49525b980fc2a6..35a3cb5fafecd4 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -29,11 +29,11 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, WorkMode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription @dataclass(frozen=True) -class TuyaLightEntityDescription(LightEntityDescription): +class TuyaLightEntityDescription(TuyaEntityDescription, LightEntityDescription): """Describe an Tuya light entity.""" brightness_max: DPCode | None = None diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index e16e4a622dc820..5b9b59ddf500b8 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -1,5 +1,6 @@ """Support for Tuya number.""" +from dataclasses import dataclass from typing import override from tuya_device_handlers.definition.number import ( @@ -27,50 +28,56 @@ DPCode, ) from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription from .util import get_device_temp_unit_convert -NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { + +@dataclass(frozen=True) +class TuyaNumberEntityDescription(TuyaEntityDescription, NumberEntityDescription): + """Describes a Tuya number entity.""" + + +NUMBERS: dict[DeviceCategory, tuple[TuyaNumberEntityDescription, ...]] = { DeviceCategory.BH: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP_SET_F, translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP_BOILING_C, translation_key="temperature_after_boiling", device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP_BOILING_F, translation_key="temperature_after_boiling", device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.WARM_TIME, translation_key="heat_preservation_time", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.BZYD: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.VOLUME_SET, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.CO2BJ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.ALARM_TIME, translation_key="alarm_duration", native_unit_of_measurement=UnitOfTime.SECONDS, @@ -79,25 +86,25 @@ ), ), DeviceCategory.CWWSQ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.MANUAL_FEED, translation_key="feed", ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.VOICE_TIMES, translation_key="voice_times", ), ), DeviceCategory.CZ: ( # Two-channel current transformer meters warn above these thresholds - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.WARN_POWER1, translation_key="indexed_power_warning_threshold", translation_placeholders={"index": "1"}, device_class=NumberDeviceClass.POWER, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.WARN_POWER2, translation_key="indexed_power_warning_threshold", translation_placeholders={"index": "2"}, @@ -106,93 +113,93 @@ ), ), DeviceCategory.DGNBJ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.ALARM_TIME, translation_key="time", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.FS: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP, translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, ), ), DeviceCategory.HPS: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.SENSITIVITY, translation_key="sensitivity", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.NEAR_DETECTION, translation_key="near_detection", device_class=NumberDeviceClass.DISTANCE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.FAR_DETECTION, translation_key="far_detection", device_class=NumberDeviceClass.DISTANCE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TARGET_DIS_CLOSEST, translation_key="target_dis_closest", device_class=NumberDeviceClass.DISTANCE, ), ), DeviceCategory.JSQ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP_SET_F, translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, ), ), DeviceCategory.KFJ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.WATER_SET, translation_key="water_level", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.WARM_TIME, translation_key="heat_preservation_time", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.POWDER_SET, translation_key="powder", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.MAL: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.DELAY_SET, # This setting is called "Arm Delay" in the official Tuya app translation_key="arm_delay", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.ALARM_DELAY_TIME, translation_key="alarm_delay", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.ALARM_TIME, # This setting is called "Siren Duration" in the official Tuya app translation_key="siren_duration", @@ -201,7 +208,7 @@ ), ), DeviceCategory.MSP: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.DELAY_CLEAN_TIME, translation_key="delay_clean_time", device_class=NumberDeviceClass.DURATION, @@ -209,37 +216,37 @@ ), ), DeviceCategory.MZJ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COOK_TEMPERATURE, translation_key="cook_temperature", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COOK_TIME, translation_key="cook_time", native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.CLOUD_RECIPE_NUMBER, translation_key="cloud_recipe", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.QCCDZ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.CHARGE_CUR_SET, translation_key="charging_current", device_class=NumberDeviceClass.CURRENT, ), ), DeviceCategory.SWTZ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COOK_TEMPERATURE, translation_key="cook_temperature", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COOK_TEMPERATURE_2, translation_key="indexed_cook_temperature", translation_placeholders={"index": "2"}, @@ -247,7 +254,7 @@ ), ), DeviceCategory.SD: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.VOLUME_SET, translation_key="volume", entity_category=EntityCategory.CONFIG, @@ -255,63 +262,63 @@ ), DeviceCategory.SFKZQ: ( # Controls the irrigation duration for indexed water valves - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COUNTDOWN, translation_key="irrigation_duration", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), # Controls the irrigation duration for indexed water valves - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COUNTDOWN_1, translation_key="indexed_irrigation_duration", translation_placeholders={"index": "1"}, device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COUNTDOWN_2, translation_key="indexed_irrigation_duration", translation_placeholders={"index": "2"}, device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COUNTDOWN_3, translation_key="indexed_irrigation_duration", translation_placeholders={"index": "3"}, device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COUNTDOWN_4, translation_key="indexed_irrigation_duration", translation_placeholders={"index": "4"}, device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COUNTDOWN_5, translation_key="indexed_irrigation_duration", translation_placeholders={"index": "5"}, device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COUNTDOWN_6, translation_key="indexed_irrigation_duration", translation_placeholders={"index": "6"}, device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COUNTDOWN_7, translation_key="indexed_irrigation_duration", translation_placeholders={"index": "7"}, device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.COUNTDOWN_8, translation_key="indexed_irrigation_duration", translation_placeholders={"index": "8"}, @@ -320,85 +327,85 @@ ), ), DeviceCategory.SGBJ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.ALARM_TIME, translation_key="time", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SP: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BASIC_DEVICE_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.IPC_BRIGHT, translation_key="video_brightness", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.IPC_CONTRAST, translation_key="video_contrast", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.IPC_SHARP, translation_key="video_sharpness", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SZJQR: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.ARM_DOWN_PERCENT, translation_key="move_down", native_unit_of_measurement=UnitOfRatio.PERCENTAGE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.ARM_UP_PERCENT, translation_key="move_up", native_unit_of_measurement=UnitOfRatio.PERCENTAGE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.CLICK_SUSTAIN_TIME, translation_key="down_delay", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.TGKG: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, translation_key="indexed_minimum_brightness", translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, translation_key="indexed_maximum_brightness", translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, translation_key="indexed_minimum_brightness", translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, translation_key="indexed_maximum_brightness", translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_3, translation_key="indexed_minimum_brightness", translation_placeholders={"index": "3"}, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_3, translation_key="indexed_maximum_brightness", translation_placeholders={"index": "3"}, @@ -406,25 +413,25 @@ ), ), DeviceCategory.TGQ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, translation_key="indexed_minimum_brightness", translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, translation_key="indexed_maximum_brightness", translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, translation_key="indexed_minimum_brightness", translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, translation_key="indexed_maximum_brightness", translation_placeholders={"index": "2"}, @@ -432,20 +439,20 @@ ), ), DeviceCategory.WG2: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.DELAY_SET, # This setting is called "Arm Delay" in the official Tuya app translation_key="arm_delay", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.ALARM_DELAY_TIME, translation_key="alarm_delay", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.ALARM_TIME, # This setting is called "Siren Duration" in the official Tuya app translation_key="siren_duration", @@ -454,19 +461,19 @@ ), ), DeviceCategory.WK: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP_CORRECTION, translation_key="temp_correction", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.XNYJCN: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.BACKUP_RESERVE, translation_key="battery_backup_reserve", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.OUTPUT_POWER_LIMIT, translation_key="inverter_output_power_limit", device_class=NumberDeviceClass.POWER, @@ -474,23 +481,23 @@ ), ), DeviceCategory.YWCGQ: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.MAX_SET, translation_key="alarm_maximum", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.MINI_SET, translation_key="alarm_minimum", entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.INSTALLATION_HEIGHT, translation_key="installation_height", device_class=NumberDeviceClass.DISTANCE, entity_category=EntityCategory.CONFIG, ), - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.LIQUID_DEPTH_MAX, translation_key="maximum_liquid_depth", device_class=NumberDeviceClass.DISTANCE, @@ -498,14 +505,14 @@ ), ), DeviceCategory.ZD: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.SENSITIVITY, translation_key="sensitivity", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.ZNRB: ( - NumberEntityDescription( + TuyaNumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, @@ -554,7 +561,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: NumberEntityDescription, + description: TuyaNumberEntityDescription, definition: NumberDefinition, ) -> None: """Initialize a Tuya number entity.""" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 43cb4a509eebd2..e6acaf1f4418c1 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -1,5 +1,6 @@ """Support for Tuya select.""" +from dataclasses import dataclass from typing import override from tuya_device_handlers.definition.select import ( @@ -16,81 +17,87 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription + # All descriptions can be found here. Mostly the Enum data types in the # default instructions set of each category end up being a select. -SELECTS: dict[DeviceCategory, tuple[SelectEntityDescription, ...]] = { +@dataclass(frozen=True) +class TuyaSelectEntityDescription(TuyaEntityDescription, SelectEntityDescription): + """Describes a Tuya select entity.""" + + +SELECTS: dict[DeviceCategory, tuple[TuyaSelectEntityDescription, ...]] = { DeviceCategory.BH: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.TEMP_SETTING_QUICK_C, entity_category=EntityCategory.CONFIG, translation_key="quick_heat_temperature", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.WORK_TYPE, entity_category=EntityCategory.CONFIG, translation_key="kettle_work_mode", ), ), DeviceCategory.CL: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.CONTROL_BACK_MODE, entity_category=EntityCategory.CONFIG, translation_key="curtain_motor_mode", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.MODE, entity_category=EntityCategory.CONFIG, translation_key="curtain_mode", ), ), DeviceCategory.CO2BJ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.ALARM_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.CS: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.COUNTDOWN_SET, entity_category=EntityCategory.CONFIG, translation_key="countdown", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.DEHUMIDITY_SET_ENUM, translation_key="target_humidity", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.CWJWQ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.WORK_MODE, entity_category=EntityCategory.CONFIG, translation_key="odor_elimination_mode", ), ), DeviceCategory.DGNBJ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.ALARM_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.DR: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LEVEL, icon="mdi:thermometer-lines", translation_key="blanket_level", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LEVEL_1, icon="mdi:thermometer-lines", translation_key="indexed_blanket_level", translation_placeholders={"index": "1"}, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LEVEL_2, icon="mdi:thermometer-lines", translation_key="indexed_blanket_level", @@ -98,122 +105,122 @@ ), ), DeviceCategory.FS: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.FAN_VERTICAL, entity_category=EntityCategory.CONFIG, translation_key="vertical_fan_angle", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.FAN_HORIZONTAL, entity_category=EntityCategory.CONFIG, translation_key="horizontal_fan_angle", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.COUNTDOWN, entity_category=EntityCategory.CONFIG, translation_key="countdown", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.COUNTDOWN_SET, entity_category=EntityCategory.CONFIG, translation_key="countdown", ), ), DeviceCategory.JSQ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.SPRAY_MODE, entity_category=EntityCategory.CONFIG, translation_key="humidifier_spray_mode", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LEVEL, entity_category=EntityCategory.CONFIG, translation_key="humidifier_level", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.MOODLIGHTING, entity_category=EntityCategory.CONFIG, translation_key="humidifier_moodlighting", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.COUNTDOWN, entity_category=EntityCategory.CONFIG, translation_key="countdown", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.COUNTDOWN_SET, entity_category=EntityCategory.CONFIG, translation_key="countdown", ), ), DeviceCategory.KFJ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.CUP_NUMBER, translation_key="cups", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.CONCENTRATION_SET, translation_key="concentration", entity_category=EntityCategory.CONFIG, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.MATERIAL, translation_key="material", entity_category=EntityCategory.CONFIG, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.MODE, translation_key="mode", ), ), DeviceCategory.KG: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.RELAY_STATUS, entity_category=EntityCategory.CONFIG, translation_key="relay_status", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LIGHT_MODE, entity_category=EntityCategory.CONFIG, translation_key="light_mode", ), ), DeviceCategory.KJ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.COUNTDOWN, entity_category=EntityCategory.CONFIG, translation_key="countdown", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.COUNTDOWN_SET, entity_category=EntityCategory.CONFIG, translation_key="countdown", ), ), DeviceCategory.QCCDZ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.WORK_MODE, translation_key="charger_work_mode", ), ), DeviceCategory.QN: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LEVEL, translation_key="temperature_level", ), ), DeviceCategory.SD: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.CISTERN, entity_category=EntityCategory.CONFIG, translation_key="vacuum_cistern", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.COLLECTION_MODE, entity_category=EntityCategory.CONFIG, translation_key="vacuum_collection", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.MODE, entity_category=EntityCategory.CONFIG, translation_key="vacuum_mode", @@ -221,116 +228,116 @@ ), DeviceCategory.SFKZQ: ( # Irrigation will not be run within this set delay period - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.WEATHER_DELAY, translation_key="weather_delay", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SGBJ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.ALARM_STATE, translation_key="siren_mode", entity_category=EntityCategory.CONFIG, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.ALARM_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.BRIGHT_STATE, translation_key="brightness", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SJZ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LEVEL, translation_key="desk_level", entity_category=EntityCategory.CONFIG, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.UP_DOWN, translation_key="desk_up_down", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SP: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.IPC_WORK_MODE, entity_category=EntityCategory.CONFIG, translation_key="ipc_work_mode", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.DECIBEL_SENSITIVITY, entity_category=EntityCategory.CONFIG, translation_key="decibel_sensitivity", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.RECORD_MODE, entity_category=EntityCategory.CONFIG, translation_key="record_mode", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.BASIC_NIGHTVISION, entity_category=EntityCategory.CONFIG, translation_key="basic_nightvision", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.BASIC_ANTI_FLICKER, entity_category=EntityCategory.CONFIG, translation_key="basic_anti_flicker", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.MOTION_SENSITIVITY, entity_category=EntityCategory.CONFIG, translation_key="motion_sensitivity", ), ), DeviceCategory.SZJQR: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.MODE, entity_category=EntityCategory.CONFIG, translation_key="fingerbot_mode", ), ), DeviceCategory.TDQ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.RELAY_STATUS, entity_category=EntityCategory.CONFIG, translation_key="relay_status", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LIGHT_MODE, entity_category=EntityCategory.CONFIG, translation_key="light_mode", ), ), DeviceCategory.TGKG: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.RELAY_STATUS, entity_category=EntityCategory.CONFIG, translation_key="relay_status", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LIGHT_MODE, entity_category=EntityCategory.CONFIG, translation_key="light_mode", ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, translation_key="indexed_led_type", translation_placeholders={"index": "1"}, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LED_TYPE_2, entity_category=EntityCategory.CONFIG, translation_key="indexed_led_type", translation_placeholders={"index": "2"}, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LED_TYPE_3, entity_category=EntityCategory.CONFIG, translation_key="indexed_led_type", @@ -338,13 +345,13 @@ ), ), DeviceCategory.TGQ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, translation_key="indexed_led_type", translation_placeholders={"index": "1"}, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LED_TYPE_2, entity_category=EntityCategory.CONFIG, translation_key="indexed_led_type", @@ -352,19 +359,19 @@ ), ), DeviceCategory.XNYJCN: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.WORK_MODE, translation_key="inverter_work_mode", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.ZNJDQ: ( - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.RELAY_STATUS, translation_key="relay_status", entity_category=EntityCategory.CONFIG, ), - SelectEntityDescription( + TuyaSelectEntityDescription( key=DPCode.LIGHT_MODE, translation_key="light_mode", entity_category=EntityCategory.CONFIG, @@ -419,7 +426,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: SelectEntityDescription, + description: TuyaSelectEntityDescription, definition: SelectDefinition, ) -> None: """Initialize a Tuya select entity.""" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 6650449929132f..d4212f1fdd2b9d 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -59,12 +59,12 @@ DPCode, ) from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription from .util import get_device_temp_unit_convert @dataclass(frozen=True) -class TuyaSensorEntityDescription(SensorEntityDescription): +class TuyaSensorEntityDescription(TuyaEntityDescription, SensorEntityDescription): """Describes Tuya sensor entity.""" dpcode: DPCode | None = None diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index e43f4d56fa314a..ed06cb477d04c7 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -1,5 +1,6 @@ """Support for Tuya siren.""" +from dataclasses import dataclass from typing import Any, override from tuya_device_handlers.definition.siren import ( @@ -20,30 +21,36 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription -SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = { + +@dataclass(frozen=True) +class TuyaSirenEntityDescription(TuyaEntityDescription, SirenEntityDescription): + """Describes a Tuya siren entity.""" + + +SIRENS: dict[DeviceCategory, tuple[TuyaSirenEntityDescription, ...]] = { DeviceCategory.CO2BJ: ( - SirenEntityDescription( + TuyaSirenEntityDescription( key=DPCode.ALARM_SWITCH, entity_category=EntityCategory.CONFIG, translation_key="siren", ), ), DeviceCategory.DGNBJ: ( - SirenEntityDescription( + TuyaSirenEntityDescription( key=DPCode.ALARM_SWITCH, translation_key="siren", ), ), DeviceCategory.SGBJ: ( - SirenEntityDescription( + TuyaSirenEntityDescription( key=DPCode.ALARM_SWITCH, name=None, ), ), DeviceCategory.SP: ( - SirenEntityDescription( + TuyaSirenEntityDescription( key=DPCode.SIREN_SWITCH, translation_key="siren", ), @@ -93,7 +100,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: SirenEntityDescription, + description: TuyaSirenEntityDescription, definition: SirenDefinition, ) -> None: """Init Tuya Siren.""" diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 5d1215f68f55ec..7264f24023b2e5 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -1,5 +1,6 @@ """Support for Tuya switches.""" +from dataclasses import dataclass from typing import Any, override from tuya_device_handlers.definition.switch import ( @@ -20,41 +21,47 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription + # All descriptions can be found here. Mostly the Boolean data types in the # default instruction set of each category end up being a Switch. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SWITCHES: dict[DeviceCategory, tuple[SwitchEntityDescription, ...]] = { +@dataclass(frozen=True) +class TuyaSwitchEntityDescription(TuyaEntityDescription, SwitchEntityDescription): + """Describes a Tuya switch entity.""" + + +SWITCHES: dict[DeviceCategory, tuple[TuyaSwitchEntityDescription, ...]] = { DeviceCategory.BH: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.START, translation_key="start", ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.WARM, translation_key="heat_preservation", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.BZYD: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, name=None, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_MUSIC, translation_key="music", icon="mdi:music", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SNOOZE, translation_key="snooze", icon="mdi:alarm-snooze", @@ -62,63 +69,63 @@ ), ), DeviceCategory.CJKG: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", translation_placeholders={"index": "1"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_2, translation_key="indexed_switch", translation_placeholders={"index": "2"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_3, translation_key="indexed_switch", translation_placeholders={"index": "3"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_4, translation_key="indexed_switch", translation_placeholders={"index": "4"}, ), ), DeviceCategory.CL: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CONTROL_BACK, translation_key="reverse", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.OPPOSITE, translation_key="reverse", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.CN: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.DISINFECTION, translation_key="disinfection", ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.WATER, translation_key="water", ), ), DeviceCategory.CS: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", icon="mdi:atom", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.FILTER_RESET, translation_key="filter_reset", icon="mdi:filter", @@ -126,39 +133,39 @@ ), ), DeviceCategory.CWJWQ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), DeviceCategory.CWWSQ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SLOW_FEED, translation_key="slow_feed", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.CWYSJ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.FILTER_RESET, translation_key="filter_reset", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.PUMP_RESET, translation_key="water_pump_reset", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="power", ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.WATER_RESET, translation_key="reset_of_water_usage_days", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.UV, translation_key="uv_sterilization", entity_category=EntityCategory.CONFIG, @@ -168,54 +175,54 @@ # There are sockets available with an RGB light # that advertise as `dj`, but provide an additional # switch to control the plug. - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="plug", ), ), DeviceCategory.DLQ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), DeviceCategory.DR: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, name="Power", icon="mdi:power", device_class=SwitchDeviceClass.SWITCH, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, name="Side A Power", icon="mdi:alpha-a", device_class=SwitchDeviceClass.SWITCH, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_2, name="Side B Power", icon="mdi:alpha-b", device_class=SwitchDeviceClass.SWITCH, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.PREHEAT, name="Preheat", icon="mdi:radiator", device_class=SwitchDeviceClass.SWITCH, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.PREHEAT_1, name="Side A Preheat", icon="mdi:radiator", device_class=SwitchDeviceClass.SWITCH, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.PREHEAT_2, name="Side B Preheat", icon="mdi:radiator", @@ -223,284 +230,284 @@ ), ), DeviceCategory.FS: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.ANION, translation_key="anion", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.HUMIDIFIER, translation_key="humidification", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.OXYGEN, translation_key="oxygen_bar", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.FAN_COOL, translation_key="natural_wind", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.FAN_BEEP, translation_key="sound", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.FSD: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.FAN_BEEP, translation_key="sound", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.GGQ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", translation_placeholders={"index": "1"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_2, translation_key="indexed_switch", translation_placeholders={"index": "2"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_3, translation_key="indexed_switch", translation_placeholders={"index": "3"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_4, translation_key="indexed_switch", translation_placeholders={"index": "4"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_5, translation_key="indexed_switch", translation_placeholders={"index": "5"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_6, translation_key="indexed_switch", translation_placeholders={"index": "6"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_7, translation_key="indexed_switch", translation_placeholders={"index": "7"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_8, translation_key="indexed_switch", translation_placeholders={"index": "8"}, ), ), DeviceCategory.HXD: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="radio", ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_2, translation_key="indexed_alarm", translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_3, translation_key="indexed_alarm", translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_4, translation_key="indexed_alarm", translation_placeholders={"index": "3"}, entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_5, translation_key="indexed_alarm", translation_placeholders={"index": "4"}, entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_6, translation_key="sleep_aid", ), ), DeviceCategory.JSQ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_SOUND, translation_key="voice", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SLEEP, translation_key="sleep", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.STERILIZATION, translation_key="sterilization", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.KG: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_2, translation_key="indexed_switch", translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_3, translation_key="indexed_switch", translation_placeholders={"index": "3"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_4, translation_key="indexed_switch", translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_5, translation_key="indexed_switch", translation_placeholders={"index": "5"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_6, translation_key="indexed_switch", translation_placeholders={"index": "6"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_7, translation_key="indexed_switch", translation_placeholders={"index": "7"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_8, translation_key="indexed_switch", translation_placeholders={"index": "8"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB1, translation_key="indexed_usb", translation_placeholders={"index": "1"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB2, translation_key="indexed_usb", translation_placeholders={"index": "2"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB3, translation_key="indexed_usb", translation_placeholders={"index": "3"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB4, translation_key="indexed_usb", translation_placeholders={"index": "4"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB5, translation_key="indexed_usb", translation_placeholders={"index": "5"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB6, translation_key="indexed_usb", translation_placeholders={"index": "6"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", device_class=SwitchDeviceClass.OUTLET, ), ), DeviceCategory.KJ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.FILTER_RESET, translation_key="filter_cartridge_reset", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="power", ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.WET, translation_key="humidification", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.UV, translation_key="uv_sterilization", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.KT: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.KS: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", ), ), DeviceCategory.MAL: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_ALARM_SOUND, # This switch is called "Arm Beep" in the official Tuya app translation_key="arm_beep", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_ALARM_LIGHT, # This switch is called "Siren" in the official Tuya app translation_key="siren", @@ -508,305 +515,305 @@ ), ), DeviceCategory.MSP: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.AUTO_CLEAN, translation_key="auto_clean", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.MZJ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.START, translation_key="start", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.PC: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_socket", translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_2, translation_key="indexed_socket", translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_3, translation_key="indexed_socket", translation_placeholders={"index": "3"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_4, translation_key="indexed_socket", translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_5, translation_key="indexed_socket", translation_placeholders={"index": "5"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_6, translation_key="indexed_socket", translation_placeholders={"index": "6"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB1, translation_key="indexed_usb", translation_placeholders={"index": "1"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB2, translation_key="indexed_usb", translation_placeholders={"index": "2"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB3, translation_key="indexed_usb", translation_placeholders={"index": "3"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB4, translation_key="indexed_usb", translation_placeholders={"index": "4"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB5, translation_key="indexed_usb", translation_placeholders={"index": "5"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_USB6, translation_key="indexed_usb", translation_placeholders={"index": "6"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="socket", device_class=SwitchDeviceClass.OUTLET, ), ), DeviceCategory.QCCDZ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), DeviceCategory.QJDCZ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="switch", ), ), DeviceCategory.QN: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.QXJ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", device_class=SwitchDeviceClass.OUTLET, ), ), DeviceCategory.SD: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_DISTURB, translation_key="do_not_disturb", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.VOICE_SWITCH, translation_key="mute_voice", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SGBJ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.MUFFLING, translation_key="mute", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SJZ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SP: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.WIRELESS_BATTERYLOCK, translation_key="battery_lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CRY_DETECTION_SWITCH, translation_key="cry_detection", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.DECIBEL_SWITCH, translation_key="sound_detection", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.RECORD_SWITCH, translation_key="video_recording", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.MOTION_RECORD, translation_key="motion_recording", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.BASIC_PRIVATE, translation_key="privacy_mode", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.BASIC_FLIP, translation_key="flip", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.BASIC_OSD, translation_key="time_watermark", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.BASIC_WDR, translation_key="wide_dynamic_range", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.MOTION_TRACKING, translation_key="motion_tracking", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.MOTION_SWITCH, translation_key="motion_alarm", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.MOTION_AREA_SWITCH, translation_key="motion_detection_zone", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.IPC_AUTO_SIREN, translation_key="auto_siren", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.SZ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="power", ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.PUMP, translation_key="pump", ), ), DeviceCategory.SZJQR: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), DeviceCategory.TDQ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_2, translation_key="indexed_switch", translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_3, translation_key="indexed_switch", translation_placeholders={"index": "3"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_4, translation_key="indexed_switch", translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_5, translation_key="indexed_switch", translation_placeholders={"index": "5"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_6, translation_key="indexed_switch", translation_placeholders={"index": "6"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.TYNDJ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_SAVE_ENERGY, translation_key="energy_saving", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.WG2: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.MUFFLING, translation_key="mute", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_KB_SOUND, # Poorly translated as "Key tone switch of host" in the official Tuya app translation_key="keypad_sound", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_ALARM_PROPEL, # Poorly translated as "alarm push switch" in the official Tuya app translation_key="alarm_push_notifications", @@ -814,25 +821,25 @@ ), ), DeviceCategory.WK: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.FROST, translation_key="frost_protection", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.WKCZ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_2, translation_key="indexed_switch", translation_placeholders={"index": "2"}, @@ -840,79 +847,79 @@ ), ), DeviceCategory.WKF: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.WINDOW_CHECK, translation_key="open_window_detection", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.WNYKQ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, name=None, ), ), DeviceCategory.WSDCG: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", device_class=SwitchDeviceClass.OUTLET, ), ), DeviceCategory.XDD: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.DO_NOT_DISTURB, translation_key="do_not_disturb", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.XNYJCN: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.FEEDIN_POWER_LIMIT_ENABLE, translation_key="output_power_limit", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.XXJ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="power", ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_SPRAY, translation_key="spray", ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_VOICE, translation_key="voice", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.YWBJ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.MUFFLING, translation_key="mute", entity_category=EntityCategory.CONFIG, ), ), DeviceCategory.ZNDB: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), DeviceCategory.ZNJDQ: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", translation_placeholders={"index": "1"}, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", icon="mdi:account-lock", @@ -920,19 +927,19 @@ ), ), DeviceCategory.ZNJXS: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), DeviceCategory.ZNRB: ( - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), - SwitchEntityDescription( + TuyaSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), @@ -983,7 +990,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: SwitchEntityDescription, + description: TuyaSwitchEntityDescription, definition: SwitchDefinition, ) -> None: """Init TuyaHaSwitch.""" diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index ba75ccce13e770..213825e97b0ac4 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -1,5 +1,6 @@ """Support for Tuya Vacuums.""" +from dataclasses import dataclass from typing import Any, override from tuya_device_handlers.definition.vacuum import ( @@ -24,7 +25,7 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription _TUYA_TO_HA_ACTIVITY_MAPPINGS = { TuyaVacuumActivity.CLEANING: VacuumActivity.CLEANING, @@ -35,8 +36,14 @@ TuyaVacuumActivity.ERROR: VacuumActivity.ERROR, } -VACUUMS: dict[DeviceCategory, StateVacuumEntityDescription] = { - DeviceCategory.SD: StateVacuumEntityDescription(key=""), + +@dataclass(frozen=True) +class TuyaVacuumEntityDescription(TuyaEntityDescription, StateVacuumEntityDescription): + """Describes a Tuya vacuum entity.""" + + +VACUUMS: dict[DeviceCategory, TuyaVacuumEntityDescription] = { + DeviceCategory.SD: TuyaVacuumEntityDescription(key=""), } @@ -78,7 +85,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: StateVacuumEntityDescription, + description: TuyaVacuumEntityDescription, definition: VacuumDefinition, ) -> None: """Init Tuya vacuum.""" diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py index 6aa10bb51c9979..32fd08d0b2242d 100644 --- a/homeassistant/components/tuya/valve.py +++ b/homeassistant/components/tuya/valve.py @@ -1,5 +1,6 @@ """Support for Tuya valves.""" +from dataclasses import dataclass from typing import override from tuya_device_handlers.definition.valve import ( @@ -20,58 +21,64 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .coordinator import TuyaConfigEntry -from .entity import TuyaEntity +from .entity import TuyaEntity, TuyaEntityDescription -VALVES: dict[DeviceCategory, tuple[ValveEntityDescription, ...]] = { + +@dataclass(frozen=True) +class TuyaValveEntityDescription(TuyaEntityDescription, ValveEntityDescription): + """Describes a Tuya valve entity.""" + + +VALVES: dict[DeviceCategory, tuple[TuyaValveEntityDescription, ...]] = { DeviceCategory.SFKZQ: ( - ValveEntityDescription( + TuyaValveEntityDescription( key=DPCode.SWITCH, translation_key="valve", device_class=ValveDeviceClass.WATER, ), - ValveEntityDescription( + TuyaValveEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_valve", translation_placeholders={"index": "1"}, device_class=ValveDeviceClass.WATER, ), - ValveEntityDescription( + TuyaValveEntityDescription( key=DPCode.SWITCH_2, translation_key="indexed_valve", translation_placeholders={"index": "2"}, device_class=ValveDeviceClass.WATER, ), - ValveEntityDescription( + TuyaValveEntityDescription( key=DPCode.SWITCH_3, translation_key="indexed_valve", translation_placeholders={"index": "3"}, device_class=ValveDeviceClass.WATER, ), - ValveEntityDescription( + TuyaValveEntityDescription( key=DPCode.SWITCH_4, translation_key="indexed_valve", translation_placeholders={"index": "4"}, device_class=ValveDeviceClass.WATER, ), - ValveEntityDescription( + TuyaValveEntityDescription( key=DPCode.SWITCH_5, translation_key="indexed_valve", translation_placeholders={"index": "5"}, device_class=ValveDeviceClass.WATER, ), - ValveEntityDescription( + TuyaValveEntityDescription( key=DPCode.SWITCH_6, translation_key="indexed_valve", translation_placeholders={"index": "6"}, device_class=ValveDeviceClass.WATER, ), - ValveEntityDescription( + TuyaValveEntityDescription( key=DPCode.SWITCH_7, translation_key="indexed_valve", translation_placeholders={"index": "7"}, device_class=ValveDeviceClass.WATER, ), - ValveEntityDescription( + TuyaValveEntityDescription( key=DPCode.SWITCH_8, translation_key="indexed_valve", translation_placeholders={"index": "8"}, @@ -120,7 +127,7 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: ValveEntityDescription, + description: TuyaValveEntityDescription, definition: ValveDefinition, ) -> None: """Init TuyaValveEntity.""" diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 185675c3564fbc..c3a6b6c33b16cb 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==16.12.0"] + "requirements": ["uiprotect==16.13.0"] } diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 77f9405ac74e09..128c1eee3fc05f 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -163,12 +163,29 @@ class ToolResult: error: bool = False +@dataclass(frozen=True, slots=True, kw_only=True) +class ToolAnnotations: + """Properties describing how a tool behaves. + + The defaults describe the least safe case, so a tool that declares nothing + is taken to write, to be destructive, and to reach outside Home Assistant. + """ + + read_only: bool = False + destructive: bool = True + idempotent: bool = False + open_world: bool = True + + class Tool: """LLM Tool base class.""" name: str + title: str | None = None description: str | None = None parameters: probatio.Schema = probatio.Schema({}) + annotations: ToolAnnotations = ToolAnnotations() + integration: str | None = None @abstractmethod async def async_call( @@ -259,9 +276,16 @@ def __init__( self, name: str, intent_handler: intent.IntentHandler, + *, + title: str | None = None, + integration: str | None = None, + annotations: ToolAnnotations = ToolAnnotations(), ) -> None: """Init the class.""" self.name = name + self.title = title + self.integration = integration + self.annotations = annotations self.intent_type = intent_handler.intent_type self.description = ( intent_handler.description @@ -357,8 +381,11 @@ def __init__(self, namespace: str, tool: Tool) -> None: """Init the class.""" self.namespace = namespace self.name = f"{namespace}__{tool.name}" + self.title = tool.title self.description = tool.description self.parameters = tool.parameters + self.annotations = tool.annotations + self.integration = tool.integration self.tool = tool @override @@ -664,6 +691,7 @@ def __init__( self._domain = domain self._action = action self.name = f"{domain}__{action}" + self.integration = domain # Note: _get_cached_action_parameters only works for services which # add their description directly to the service description cache. # This is not the case for most services, but it is for scripts. diff --git a/requirements_all.txt b/requirements_all.txt index 77bcf3e99e0707..8d3bbe845bf5c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3347,7 +3347,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==16.12.0 +uiprotect==16.13.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 diff --git a/tests/components/environment_canada/snapshots/test_services.ambr b/tests/components/environment_canada/snapshots/test_services.ambr index 259b8a5a968773..f0b09fc4dc33a4 100644 --- a/tests/components/environment_canada/snapshots/test_services.ambr +++ b/tests/components/environment_canada/snapshots/test_services.ambr @@ -37,3 +37,32 @@ ]), }) # --- +# name: test_get_precipitation_forecast + dict({ + 'hourly': list([ + dict({ + 'amount': 1.726, + 'conditional_amount': 0.909, + 'expected_amount': 0.518, + 'label': '0.5 - 1 mm', + 'precip_type': 'Rain', + 'probability': 57, + 'timestamp': '2022-10-04T13:00:00+00:00', + }), + ]), + 'metadata': dict({ + 'attribution': 'Data provided by Environment Canada', + 'timestamp': '2022-10-04T12:00:00+00:00', + }), + 'nowcast': list([ + dict({ + 'forecast': False, + 'label': '1.0 - 2.0 (mm/h)', + 'precip_type': 'rain', + 'rate': 1.2391, + 'timestamp': '2022-10-04T12:00:00+00:00', + 'unit': 'mm/h', + }), + ]), + }) +# --- diff --git a/tests/components/environment_canada/test_services.py b/tests/components/environment_canada/test_services.py index 4e0df908d18001..c4481280497e5e 100644 --- a/tests/components/environment_canada/test_services.py +++ b/tests/components/environment_canada/test_services.py @@ -1,17 +1,54 @@ """Tests for the Environment Canada services.""" +from datetime import UTC, datetime from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +import probatio import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.environment_canada.const import DOMAIN +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import init_integration +from . import FIXTURE_USER_INPUT, init_integration SERVICE_GET_ALERTS = "get_alerts" +SERVICE_GET_PRECIPITATION_FORECAST = "get_precipitation_forecast" + + +def _precip_mock() -> MagicMock: + """Build an ECPrecipForecast constructor mock returning sample data.""" + instance = MagicMock() + instance.update = AsyncMock() + instance.nowcast = [ + { + "timestamp": datetime(2022, 10, 4, 12, 0, tzinfo=UTC), + "rate": 1.2391, + "unit": "mm/h", + "label": "1.0 - 2.0 (mm/h)", + "precip_type": "rain", + "forecast": False, + } + ] + instance.hourly = [ + { + "timestamp": datetime(2022, 10, 4, 13, 0, tzinfo=UTC), + "amount": 1.726, + "probability": 57, + "conditional_amount": 0.909, + "expected_amount": 0.518, + "precip_type": "Rain", + "label": "0.5 - 1 mm", + } + ] + instance.metadata = { + "attribution": "Data provided by Environment Canada", + "timestamp": "2022-10-04T12:00:00+00:00", + } + return MagicMock(return_value=instance) async def test_get_alerts( @@ -45,3 +82,116 @@ async def test_get_alerts_not_connected( blocking=True, return_response=True, ) + + +async def test_get_precipitation_forecast( + hass: HomeAssistant, snapshot: SnapshotAssertion, ec_data: dict[str, Any] +) -> None: + """Test the get_precipitation_forecast service returns the series.""" + config_entry = await init_integration(hass, ec_data) + constructor = _precip_mock() + + with patch( + "homeassistant.components.environment_canada.services.ECPrecipForecast", + constructor, + ): + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRECIPITATION_FORECAST, + {"config_entry_id": config_entry.entry_id}, + blocking=True, + return_response=True, + ) + + assert response == snapshot + constructor.assert_called_once_with( + coordinates=( + FIXTURE_USER_INPUT[CONF_LATITUDE], + FIXTURE_USER_INPUT[CONF_LONGITUDE], + ), + language=FIXTURE_USER_INPUT[CONF_LANGUAGE].lower(), + ) + constructor.return_value.update.assert_awaited_once() + + +async def test_get_precipitation_forecast_options( + hass: HomeAssistant, ec_data: dict[str, Any] +) -> None: + """Test the get_precipitation_forecast service passes requested options.""" + config_entry = await init_integration(hass, ec_data) + constructor = _precip_mock() + + with patch( + "homeassistant.components.environment_canada.services.ECPrecipForecast", + constructor, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRECIPITATION_FORECAST, + { + "config_entry_id": config_entry.entry_id, + "precip_type": "snow", + "past_minutes": 30, + "future_minutes": 15, + "hourly_hours": 6, + }, + blocking=True, + return_response=True, + ) + + constructor.assert_called_once_with( + coordinates=( + FIXTURE_USER_INPUT[CONF_LATITUDE], + FIXTURE_USER_INPUT[CONF_LONGITUDE], + ), + language=FIXTURE_USER_INPUT[CONF_LANGUAGE].lower(), + precip_type="snow", + past_minutes=30, + future_minutes=15, + hourly_hours=6, + ) + + +async def test_get_precipitation_forecast_options_do_not_leak( + hass: HomeAssistant, ec_data: dict[str, Any] +) -> None: + """Test that options from one call are not carried over to the next.""" + config_entry = await init_integration(hass, ec_data) + constructor = _precip_mock() + + with patch( + "homeassistant.components.environment_canada.services.ECPrecipForecast", + constructor, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRECIPITATION_FORECAST, + {"config_entry_id": config_entry.entry_id, "precip_type": "snow"}, + blocking=True, + return_response=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRECIPITATION_FORECAST, + {"config_entry_id": config_entry.entry_id}, + blocking=True, + return_response=True, + ) + + assert "precip_type" not in constructor.call_args_list[1].kwargs + + +async def test_get_precipitation_forecast_invalid_option( + hass: HomeAssistant, ec_data: dict[str, Any] +) -> None: + """Test the get_precipitation_forecast service rejects out-of-range options.""" + config_entry = await init_integration(hass, ec_data) + + with pytest.raises(probatio.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRECIPITATION_FORECAST, + {"config_entry_id": config_entry.entry_id, "hourly_hours": 100}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/greencell/conftest.py b/tests/components/greencell/conftest.py index 931010ea09328e..e666f22a52e822 100644 --- a/tests/components/greencell/conftest.py +++ b/tests/components/greencell/conftest.py @@ -5,15 +5,19 @@ import pytest +from homeassistant.components import mqtt as real_mqtt from homeassistant.components.greencell.const import ( CONF_SERIAL_NUMBER, DOMAIN, GREENCELL_BROADCAST_TOPIC, GREENCELL_DISC_TOPIC, ) +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from tests.common import MockConfigEntry +from tests.typing import MqttMockHAClient # Test constants TEST_SERIAL_NUMBER = "EVGC021A22750001ZM0001" @@ -101,3 +105,42 @@ def mock_setup_entry(): return_value=True, ) as mock_setup: yield mock_setup + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mqtt_mock: MqttMockHAClient, +) -> MockConfigEntry: + """Set up the greencell integration with device-ready fired synchronously.""" + + mock_config_entry.add_to_hass(hass) + real_async_subscribe = real_mqtt.async_subscribe + + async def _mock_init_subscribe(hass_arg, topic, msg_callback, *args, **kwargs): + """Fire discovery payload immediately, pass everything else through.""" + if topic == GREENCELL_DISC_TOPIC: + msg_callback( + ReceiveMessage( + topic=GREENCELL_DISC_TOPIC, + payload=f'{{"id": "{TEST_SERIAL_NUMBER}"}}', + qos=0, + retain=False, + subscribed_topic=GREENCELL_DISC_TOPIC, + timestamp=time.time(), + ) + ) + return lambda: None + return await real_async_subscribe( + hass_arg, topic, msg_callback, *args, **kwargs + ) + + with patch( + "homeassistant.components.greencell.mqtt.async_subscribe", + side_effect=_mock_init_subscribe, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/greencell/snapshots/test_diagnostics.ambr b/tests/components/greencell/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..a367e0d550dafa --- /dev/null +++ b/tests/components/greencell/snapshots/test_diagnostics.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'access': dict({ + 'can_execute': True, + 'disabled': False, + }), + 'current': dict({ + 'l1': 2000, + 'l2': 2500, + 'l3': 3000, + }), + 'entry_data': dict({ + 'serial_number': '**REDACTED**', + }), + 'power': dict({ + 'value': 1500.5, + }), + 'state': dict({ + 'value': 'CHARGING', + }), + 'voltage': dict({ + 'l1': 230.0, + 'l2': 229.7, + 'l3': 232.5, + }), + }) +# --- diff --git a/tests/components/greencell/test_diagnostics.py b/tests/components/greencell/test_diagnostics.py new file mode 100644 index 00000000000000..5621e5e86132ea --- /dev/null +++ b/tests/components/greencell/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Tests for the Greencell diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from .conftest import ( + TEST_CURRENT_PAYLOAD_3PHASE, + TEST_CURRENT_TOPIC, + TEST_POWER_PAYLOAD_CHARGING, + TEST_POWER_TOPIC, + TEST_STATUS_PAYLOAD_CHARGING, + TEST_STATUS_TOPIC, + TEST_VOLTAGE_PAYLOAD_NORMAL, + TEST_VOLTAGE_TOPIC, +) + +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + async_fire_mqtt_message(hass, TEST_CURRENT_TOPIC, TEST_CURRENT_PAYLOAD_3PHASE) + async_fire_mqtt_message(hass, TEST_VOLTAGE_TOPIC, TEST_VOLTAGE_PAYLOAD_NORMAL) + async_fire_mqtt_message(hass, TEST_POWER_TOPIC, TEST_POWER_PAYLOAD_CHARGING) + async_fire_mqtt_message(hass, TEST_STATUS_TOPIC, TEST_STATUS_PAYLOAD_CHARGING) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, setup_integration) + == snapshot + ) diff --git a/tests/components/greencell/test_sensor.py b/tests/components/greencell/test_sensor.py index 32858964e80ce0..10909a47da6fad 100644 --- a/tests/components/greencell/test_sensor.py +++ b/tests/components/greencell/test_sensor.py @@ -1,17 +1,9 @@ """Real integration tests for Greencell EVSE sensors.""" -import time -from unittest.mock import patch - import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import mqtt as real_mqtt -from homeassistant.components.greencell.const import ( - GREENCELL_DISC_TOPIC, - GREENCELL_HABU_DEN, -) -from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.components.greencell.const import GREENCELL_HABU_DEN from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity @@ -39,46 +31,6 @@ ) from tests.common import MockConfigEntry, async_fire_mqtt_message -from tests.typing import MqttMockHAClient - - -@pytest.fixture -async def setup_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mqtt_mock: MqttMockHAClient, -): - """Set up the greencell integration with device-ready fired synchronously.""" - - mock_config_entry.add_to_hass(hass) - real_async_subscribe = real_mqtt.async_subscribe - - async def _mock_init_subscribe(hass_arg, topic, msg_callback, *args, **kwargs): - """Fire discovery payload immediately, pass everything else through.""" - if topic == GREENCELL_DISC_TOPIC: - msg_callback( - ReceiveMessage( - topic=GREENCELL_DISC_TOPIC, - payload=f'{{"id": "{TEST_SERIAL_NUMBER}"}}', - qos=0, - retain=False, - subscribed_topic=GREENCELL_DISC_TOPIC, - timestamp=time.time(), - ) - ) - return lambda: None - return await real_async_subscribe( - hass_arg, topic, msg_callback, *args, **kwargs - ) - - with patch( - "homeassistant.components.greencell.mqtt.async_subscribe", - side_effect=_mock_init_subscribe, - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - return mock_config_entry async def test_sensor_states_and_snapshots( diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index ea3d60a5be1b2d..3f2ce7e7c0d8d9 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -1,6 +1,8 @@ """Tests for intent timers.""" import asyncio +from collections.abc import Callable +import logging from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -32,6 +34,9 @@ from tests.common import MockConfigEntry +DELAYED_COMMAND = "turn on the lights" +DELAYED_COMMAND_ERROR = "Sorry, I am not aware of any device called lights" + @pytest.fixture async def init_components(hass: HomeAssistant) -> None: @@ -1526,6 +1531,75 @@ async def test_start_timer_with_conversation_command( assert mock_converse.call_args.args[1] == test_command +def _delayed_command_acted() -> intent.IntentResponse: + """Return the response of a delayed command that acted.""" + return intent.IntentResponse(language="en") + + +def _delayed_command_failed() -> intent.IntentResponse: + """Return the response of a delayed command that could not act.""" + response = intent.IntentResponse(language="en") + response.async_set_error( + intent.IntentResponseErrorCode.NO_VALID_TARGETS, DELAYED_COMMAND_ERROR + ) + return response + + +@pytest.mark.usefixtures("init_components") +@pytest.mark.parametrize( + ("make_response", "expected_warnings"), + [ + pytest.param(_delayed_command_acted, [], id="command_acted"), + pytest.param( + _delayed_command_failed, + [ + f"Delayed command failed: command={DELAYED_COMMAND}," + f" code=no_valid_targets, response={DELAYED_COMMAND_ERROR}" + ], + id="command_failed", + ), + ], +) +async def test_start_timer_conversation_command_result_logged( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + make_response: Callable[[], intent.IntentResponse], + expected_warnings: list[str], +) -> None: + """Test that a delayed command which could not act is logged. + + Nothing listens to the response of a delayed command, so an error is + otherwise invisible. + """ + with patch( + "homeassistant.components.conversation.async_converse", + return_value=conversation.ConversationResult(response=make_response()), + ): + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "seconds": {"value": 0}, + "conversation_command": {"value": DELAYED_COMMAND}, + }, + device_id="test_device", + conversation_agent_id="test_agent", + ) + + assert result.response_type is intent.IntentResponseType.ACTION_DONE + + # The delayed command runs in a background task + await hass.async_block_till_done(wait_background_tasks=True) + + assert [ + record.getMessage() + for record in caplog.records + if record.name == "homeassistant.components.intent.timers" + and record.levelno == logging.WARNING + ] == expected_warnings + + async def test_start_timer_with_sentence_trigger_validation( hass: HomeAssistant, init_components ) -> None: diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index f5edfa9f0cf6c2..26cb2e3af7bec3 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -62,6 +62,7 @@ 'type': 'knx_group_address', }), dict({ + 'default': False, 'name': 'invert', 'optional': True, 'required': False, @@ -78,6 +79,7 @@ 'type': 'knx_section_flat', }), dict({ + 'default': False, 'name': 'ignore_internal_state', 'optional': True, 'required': False, @@ -88,6 +90,7 @@ 'type': 'ha_selector', }), dict({ + 'default': None, 'name': 'context_timeout', 'optional': True, 'required': False, @@ -103,6 +106,7 @@ 'type': 'ha_selector', }), dict({ + 'default': None, 'name': 'reset_after', 'optional': True, 'required': False, @@ -1731,6 +1735,7 @@ 'type': 'ha_selector', }), dict({ + 'default': None, 'name': 'min', 'optional': True, 'required': False, @@ -1743,6 +1748,7 @@ 'type': 'ha_selector', }), dict({ + 'default': None, 'name': 'max', 'optional': True, 'required': False, @@ -1755,6 +1761,7 @@ 'type': 'ha_selector', }), dict({ + 'default': None, 'name': 'step', 'optional': True, 'required': False, @@ -1768,6 +1775,7 @@ 'type': 'ha_selector', }), dict({ + 'default': None, 'name': 'unit_of_measurement', 'optional': True, 'required': False, @@ -1961,6 +1969,7 @@ 'type': 'ha_selector', }), dict({ + 'default': None, 'name': 'device_class', 'optional': True, 'required': False, @@ -2147,6 +2156,7 @@ 'type': 'knx_section_flat', }), dict({ + 'default': None, 'name': 'unit_of_measurement', 'optional': True, 'required': False, @@ -2341,6 +2351,7 @@ 'type': 'ha_selector', }), dict({ + 'default': None, 'name': 'device_class', 'optional': True, 'required': False, @@ -2418,6 +2429,7 @@ 'type': 'ha_selector', }), dict({ + 'default': None, 'name': 'state_class', 'optional': True, 'required': False, @@ -2429,6 +2441,7 @@ 'type': 'ha_selector', }), dict({ + 'default': False, 'name': 'always_callback', 'optional': True, 'required': False, diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 4036571d2d479e..5f09a62f413aaf 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -1,5 +1,7 @@ """Test KNX config store.""" +import dataclasses +import json from typing import Any import pytest @@ -11,8 +13,26 @@ ) from homeassistant.components.knx.storage.config_store import ( STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, + to_storage_dict, ) -from homeassistant.const import EntityCategory, Platform +from homeassistant.components.knx.storage.const import CONF_DATA +from homeassistant.components.knx.storage.entity_store_schema import ( + BinarySensorKnxConfig, + DateKnxConfig, + DatetimeKnxConfig, + NotifyKnxConfig, + NumberKnxConfig, + SceneKnxConfig, + SensorKnxConfig, + SwitchKnxConfig, + TextKnxConfig, + TimeKnxConfig, +) +from homeassistant.components.knx.storage.entity_store_validation import ( + validate_entity_data, +) +from homeassistant.components.knx.storage.serialize import get_serialized_schema +from homeassistant.const import CONF_PLATFORM, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -811,3 +831,180 @@ async def test_migration_2_4_to_2_5( # entities that could not be set up before are now created assert hass.states.get("sensor.test_sensor") assert hass.states.get("binary_sensor.test_binary_sensor") + + +TYPED_CONFIG_CASES = [ + pytest.param( + Platform.SWITCH, + SwitchKnxConfig, + {"ga_switch": {"write": "1/2/3"}}, + { + "ga_switch": {"write": "1/2/3", "state": None, "passive": []}, + "invert": False, + "respond_to_read": False, + "sync_state": True, + }, + id="switch", + ), + pytest.param( + Platform.DATE, + DateKnxConfig, + { + "ga_date": {"write": "1/2/3", "passive": ["1/2/4"]}, + "sync_state": "expire 60", + }, + { + "ga_date": {"write": "1/2/3", "state": None, "passive": ["1/2/4"]}, + "respond_to_read": False, + "sync_state": "expire 60", + }, + id="date", + ), + pytest.param( + Platform.DATETIME, + DatetimeKnxConfig, + {"ga_datetime": {"write": "1/2/3", "state": "1/2/4"}, "respond_to_read": True}, + { + "ga_datetime": {"write": "1/2/3", "state": "1/2/4", "passive": []}, + "respond_to_read": True, + "sync_state": True, + }, + id="datetime", + ), + pytest.param( + Platform.TIME, + TimeKnxConfig, + {"ga_time": {"write": "1/2/3"}}, + { + "ga_time": {"write": "1/2/3", "state": None, "passive": []}, + "respond_to_read": False, + "sync_state": True, + }, + id="time", + ), + pytest.param( + Platform.NOTIFY, + NotifyKnxConfig, + {"ga_send": {"write": "1/2/3", "dpt": "16.000"}}, + {"ga_send": {"write": "1/2/3", "dpt": "16.000"}}, + id="notify", + ), + pytest.param( + Platform.SCENE, + SceneKnxConfig, + {"ga_scene": {"write": "1/2/3"}, "scene_number": 4.0}, + {"ga_scene": {"write": "1/2/3"}, "scene_number": 4}, + id="scene", + ), + pytest.param( + Platform.BINARY_SENSOR, + BinarySensorKnxConfig, + {"ga_sensor": {"state": "1/2/3"}, "context_timeout": 1.5}, + { + "ga_sensor": {"state": "1/2/3", "passive": []}, + "invert": False, + "ignore_internal_state": False, + "context_timeout": 1.5, + "reset_after": None, + "sync_state": True, + }, + id="binary_sensor", + ), + pytest.param( + Platform.SENSOR, + SensorKnxConfig, + {"ga_sensor": {"state": "1/2/3", "dpt": "9.001"}, "sync_state": False}, + { + "ga_sensor": {"state": "1/2/3", "passive": [], "dpt": "9.001"}, + "unit_of_measurement": None, + "device_class": None, + "state_class": None, + "always_callback": False, + "sync_state": False, + }, + id="sensor", + ), + pytest.param( + Platform.NUMBER, + NumberKnxConfig, + {"ga_sensor": {"write": "1/2/3", "dpt": "9.001"}, "max": 50}, + { + "ga_sensor": { + "write": "1/2/3", + "state": None, + "passive": [], + "dpt": "9.001", + }, + "respond_to_read": False, + "mode": "auto", + "min": None, + "max": 50, + "step": None, + "unit_of_measurement": None, + "device_class": None, + "sync_state": True, + }, + id="number", + ), + pytest.param( + Platform.TEXT, + TextKnxConfig, + {"ga_text": {"write": "1/2/3", "dpt": "16.000"}}, + { + "ga_text": { + "write": "1/2/3", + "state": None, + "passive": [], + "dpt": "16.000", + }, + "mode": "text", + "respond_to_read": False, + "sync_state": True, + }, + id="text", + ), +] + + +@pytest.mark.parametrize( + ("platform", "config_type", "knx_input", "knx_stored"), TYPED_CONFIG_CASES +) +def test_typed_config_storage_roundtrip( + platform: Platform, + config_type: type, + knx_input: dict[str, Any], + knx_stored: dict[str, Any], +) -> None: + """Test typed configs render to the stored shape and load back unchanged.""" + entity_input = {"name": "test"} + validated = validate_entity_data( + {CONF_PLATFORM: platform, CONF_DATA: {"entity": entity_input, "knx": knx_input}} + )[CONF_DATA] + assert isinstance(validated[DOMAIN], config_type) + + stored = to_storage_dict(validated) + assert stored["knx"] == knx_stored + assert json.loads(json.dumps(stored)) == stored # storage is JSON + + reloaded = validate_entity_data({CONF_PLATFORM: platform, CONF_DATA: stored})[ + CONF_DATA + ] + assert reloaded[DOMAIN] == validated[DOMAIN] + assert to_storage_dict(reloaded) == stored + + +@pytest.mark.parametrize( + ("platform", "config_type", "knx_input", "knx_stored"), TYPED_CONFIG_CASES +) +def test_typed_config_field_order_is_ui_order( + platform: Platform, + config_type: type, + knx_input: dict[str, Any], + knx_stored: dict[str, Any], +) -> None: + """Test the serialized schema lists fields in dataclass declaration order.""" + serialized = get_serialized_schema(platform) + assert serialized is not None + assert [field["name"] for field in serialized] == [ + field.name for field in dataclasses.fields(config_type) + ] diff --git a/tests/components/knx/test_knx_selectors.py b/tests/components/knx/test_knx_selectors.py index 7b7fdb39a52a2b..a11a7064c5c945 100644 --- a/tests/components/knx/test_knx_selectors.py +++ b/tests/components/knx/test_knx_selectors.py @@ -9,11 +9,15 @@ from homeassistant.components.knx.storage.knx_selector import ( AllSerializeFirst, GASelector, + GroupAddressConfig, + GroupAddressSelector, GroupSelect, GroupSelectOption, KNXSection, KNXSectionFlat, SyncStateSelector, + ga, + knx_selector_in, ) from homeassistant.components.knx.storage.serialize import knx_serializer from homeassistant.helpers import selector @@ -21,64 +25,64 @@ INVALID = "invalid" -@pytest.mark.parametrize( - ("selector_config", "data", "expected"), - [ - # valid data - ( - {}, - {"write": "1/2/3"}, - {"write": "1/2/3", "state": None, "passive": []}, - ), - ( - {}, - {"state": "1/2/3"}, - {"write": None, "state": "1/2/3", "passive": []}, - ), - ( - {}, - {"passive": ["1/2/3"]}, - {"write": None, "state": None, "passive": ["1/2/3"]}, - ), - ( - {}, - {"write": "1", "state": 2, "passive": ["1/2/3"]}, - {"write": "1", "state": 2, "passive": ["1/2/3"]}, - ), - ( - {"write": False}, - {"state": "1/2/3"}, - {"state": "1/2/3", "passive": []}, - ), - ( - {"write": False}, - {"passive": ["1/2/3"]}, - {"state": None, "passive": ["1/2/3"]}, - ), - ( - {"passive": False}, - {"write": "1/2/3"}, - {"write": "1/2/3", "state": None}, - ), - # required keys - ( - {"write_required": True}, - {"write": "1/2/3"}, - {"write": "1/2/3", "state": None, "passive": []}, - ), - ( - {"state_required": True}, - {"state": "1/2/3"}, - {"write": None, "state": "1/2/3", "passive": []}, - ), - # dpt key - ( - {"dpt": ColorTempModes}, - {"write": "1/2/3", "dpt": "7.600"}, - {"write": "1/2/3", "state": None, "passive": [], "dpt": "7.600"}, - ), - ], -) +GA_SELECTOR_CASES = [ + # valid data + ( + {}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None, "passive": []}, + ), + ( + {}, + {"state": "1/2/3"}, + {"write": None, "state": "1/2/3", "passive": []}, + ), + ( + {}, + {"passive": ["1/2/3"]}, + {"write": None, "state": None, "passive": ["1/2/3"]}, + ), + ( + {}, + {"write": "1", "state": 2, "passive": ["1/2/3"]}, + {"write": "1", "state": 2, "passive": ["1/2/3"]}, + ), + ( + {"write": False}, + {"state": "1/2/3"}, + {"state": "1/2/3", "passive": []}, + ), + ( + {"write": False}, + {"passive": ["1/2/3"]}, + {"state": None, "passive": ["1/2/3"]}, + ), + ( + {"passive": False}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None}, + ), + # required keys + ( + {"write_required": True}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None, "passive": []}, + ), + ( + {"state_required": True}, + {"state": "1/2/3"}, + {"write": None, "state": "1/2/3", "passive": []}, + ), + # dpt key + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3", "dpt": "7.600"}, + {"write": "1/2/3", "state": None, "passive": [], "dpt": "7.600"}, + ), +] + + +@pytest.mark.parametrize(("selector_config", "data", "expected"), GA_SELECTOR_CASES) def test_ga_selector( selector_config: dict[str, Any], data: dict[str, Any], @@ -90,6 +94,50 @@ def test_ga_selector( assert result == expected +@pytest.mark.parametrize(("selector_config", "data", "expected"), GA_SELECTOR_CASES) +def test_group_address_selector( + selector_config: dict[str, Any], + data: dict[str, Any], + expected: dict[str, Any], +) -> None: + """Test GroupAddressSelector yields a typed value that renders back to storage.""" + selector = GroupAddressSelector(**selector_config) + result = selector(data) + assert isinstance(result, GroupAddressConfig) + assert result.write == expected.get("write") + assert result.state == expected.get("state") + assert result.passive == expected.get("passive", []) + assert result.dpt == expected.get("dpt") + # storage form is what the dict based GASelector produces + assert selector.to_storage(result) == expected + + +def test_group_address_selector_none() -> None: + """Test GroupAddressSelector passes an absent optional value through.""" + selector = GroupAddressSelector() + assert selector(None) is None + assert selector.to_storage(None) is None + + +def test_group_address_config_address_lists() -> None: + """Test GroupAddressConfig combines write / state with passive addresses.""" + config = GroupAddressConfig(write="1/2/3", state="1/2/4", passive=["1/2/5"]) + assert config.write_and_passive() == ["1/2/3", "1/2/5"] + assert config.state_and_passive() == ["1/2/4", "1/2/5"] + assert GroupAddressConfig(write="1/2/3").write_and_passive() == ["1/2/3"] + assert GroupAddressConfig().state_and_passive() == [None] + + +def test_knx_selector_in() -> None: + """Test finding a selector in annotation metadata.""" + ga_selector = knx_selector_in([str, ga(write_required=True)]) + assert isinstance(ga_selector, GroupAddressSelector) + assert ga_selector.write_required is True + ha_selector = selector.BooleanSelector() + assert knx_selector_in([bool, ha_selector]) is ha_selector + assert knx_selector_in([bool, probatio.Coerce(int)]) is None + + @pytest.mark.parametrize( ("selector_config", "data", "error_str"), [ diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 391eedac5b942c..ab0aa337b00575 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -631,6 +631,13 @@ async def test_mcp_tools_list( assert tool.inputSchema.get("type") == "object" properties = tool.inputSchema.get("properties") assert properties.get("name") == {"type": "string"} + # A tool that declares no annotations is advertised as unsafe. + assert tool.annotations == mcp.types.ToolAnnotations( + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=True, + ) @pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID]) @@ -680,6 +687,47 @@ async def test_mcp_tools_list_required_parameters( assert tool.inputSchema.get("required") == expected_required +@pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID]) +async def test_mcp_tools_list_metadata( + hass: HomeAssistant, + setup_integration: None, + mcp_url: str, + mcp_client: MCPClientFactory, + hass_supervisor_access_token: str, +) -> None: + """Test the tools list advertises the tool title and annotations.""" + + class _AnnotatedTool(_StubTool): + """Tool that declares it only reads.""" + + title = "Test tool" + annotations = llm.ToolAnnotations( + read_only=True, destructive=False, idempotent=True, open_world=False + ) + + llm.async_register_api( + hass, + MockLLMAPI( + hass=hass, + id=TEST_LLM_API_ID, + name="Test API", + tools=[_AnnotatedTool(probatio.Schema({}))], + ), + ) + + async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: + result = await session.list_tools() + + tool = next(iter(tool for tool in result.tools if tool.name == "test_tool")) + assert tool.title == "Test tool" + assert tool.annotations == mcp.types.ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ) + + @pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tool_call( hass: HomeAssistant, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 3539f34efc129e..9be07fe4322f2e 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -230,6 +230,74 @@ async def async_call( assert "returns a JSON object from a tool" in caplog.text +def test_tool_metadata_defaults() -> None: + """Test a tool that declares no metadata is taken to be unsafe.""" + + class MyTool(llm.Tool): + name = "test_tool" + + async def async_call( + self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext + ) -> llm.ToolResult: + return llm.ToolResult(data={}) + + tool = MyTool() + assert tool.title is None + assert tool.integration is None + assert tool.annotations == llm.ToolAnnotations( + read_only=False, destructive=True, idempotent=False, open_world=True + ) + + +def test_intent_tool_metadata() -> None: + """Test an intent tool takes the metadata of the integration exposing it.""" + + class MyIntentHandler(intent.IntentHandler): + intent_type = "test_intent" + + annotations = llm.ToolAnnotations(read_only=True, open_world=False) + tool = llm.IntentTool( + "test_tool", + MyIntentHandler(), + title="Test tool", + integration="my_integration", + annotations=annotations, + ) + + assert tool.title == "Test tool" + assert tool.integration == "my_integration" + assert tool.annotations == annotations + + # An intent tool that declares nothing keeps the unsafe defaults. + tool = llm.IntentTool("test_tool", MyIntentHandler()) + assert tool.title is None + assert tool.integration is None + assert tool.annotations == llm.ToolAnnotations() + + +def test_namespaced_tool_keeps_metadata() -> None: + """Test a namespaced tool carries the metadata of the tool it wraps.""" + + class MyTool(llm.Tool): + name = "test_tool" + title = "Test tool" + annotations = llm.ToolAnnotations(read_only=True, open_world=False) + integration = "my_integration" + + async def async_call( + self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext + ) -> llm.ToolResult: + return llm.ToolResult(data={}) + + tool = MyTool() + namespaced = llm.NamespacedTool("test_api", tool) + + assert namespaced.name == "test_api__test_tool" + assert namespaced.title == tool.title + assert namespaced.annotations == tool.annotations + assert namespaced.integration == tool.integration + + @pytest.mark.parametrize("namespaced", [False, True]) async def test_intent_tool_omits_blank_arguments( hass: HomeAssistant, llm_context: llm.LLMContext, namespaced: bool