From 816977dd75a6145420877a64707593582f8aada1 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 02:26:34 -0400 Subject: [PATCH 01/36] Refactor async_setup_platform for template platforms (#147379) --- .../template/alarm_control_panel.py | 91 +---- .../components/template/binary_sensor.py | 107 +----- homeassistant/components/template/button.py | 50 +-- homeassistant/components/template/config.py | 14 +- homeassistant/components/template/cover.py | 84 +---- homeassistant/components/template/fan.py | 84 +---- homeassistant/components/template/helpers.py | 174 ++++++++- homeassistant/components/template/image.py | 47 +-- homeassistant/components/template/light.py | 77 +--- homeassistant/components/template/lock.py | 62 +--- homeassistant/components/template/number.py | 50 +-- homeassistant/components/template/select.py | 47 +-- homeassistant/components/template/sensor.py | 95 +---- homeassistant/components/template/switch.py | 92 +---- .../components/template/template_entity.py | 38 -- homeassistant/components/template/vacuum.py | 86 +---- homeassistant/components/template/weather.py | 76 +--- .../components/template/test_binary_sensor.py | 2 +- tests/components/template/test_helpers.py | 344 ++++++++++++++++++ tests/components/template/test_light.py | 123 ------- tests/components/template/test_switch.py | 33 -- 21 files changed, 711 insertions(+), 1065 deletions(-) create mode 100644 tests/components/template/test_helpers.py diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index bac3f03afb8..a308d55e443 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -45,12 +45,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TemplateEntity, - make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -88,7 +84,7 @@ class TemplateCodeFormat(Enum): text = CodeFormat.TEXT -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -161,54 +157,6 @@ ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy alarm control panel configuration definitions to modern ones.""" - alarm_control_panels = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - alarm_control_panels.append(entity_conf) - - return alarm_control_panels - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template alarm control panels.""" - alarm_control_panels = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - alarm_control_panels.append( - AlarmControlPanelTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(alarm_control_panels) - - def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: """Rewrite option configuration to modern configuration.""" option_config = {**option_config} @@ -231,7 +179,7 @@ async def async_setup_entry( validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) async_add_entities( [ - AlarmControlPanelTemplate( + StateAlarmControlPanelEntity( hass, validated_config, config_entry.entry_id, @@ -247,27 +195,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_ALARM_CONTROL_PANELS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerAlarmControlPanelEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + ALARM_CONTROL_PANEL_DOMAIN, + config, + StateAlarmControlPanelEntity, + TriggerAlarmControlPanelEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_ALARM_CONTROL_PANELS, ) @@ -414,7 +351,7 @@ class AbstractTemplateAlarmControlPanel( ) -class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPanel): +class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlPanel): """Representation of a templated Alarm Control Panel.""" _attr_should_poll = False diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index b3bbf37712f..6d41a5804b6 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -24,9 +24,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, - CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, CONF_SENSORS, @@ -53,18 +51,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import ( - CONF_ATTRIBUTES, - CONF_AVAILABILITY, - CONF_AVAILABILITY_TEMPLATE, - CONF_OBJECT_ID, - CONF_PICTURE, -) -from .template_entity import ( - TEMPLATE_ENTITY_COMMON_SCHEMA, - TemplateEntity, - rewrite_common_legacy_to_modern_conf, -) +from .const import CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .helpers import async_setup_template_platform +from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity CONF_DELAY_ON = "delay_on" @@ -73,12 +62,7 @@ CONF_AUTO_OFF = "auto_off" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_FRIENDLY_NAME: CONF_NAME, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -121,27 +105,6 @@ LEGACY_BINARY_SENSOR_SCHEMA = vol.All( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, cfg: dict[str, dict] -) -> list[dict]: - """Rewrite legacy binary sensor definitions to modern ones.""" - sensors = [] - - for object_id, entity_cfg in cfg.items(): - entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - - entity_cfg = rewrite_common_legacy_to_modern_conf( - hass, entity_cfg, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_cfg: - entity_cfg[CONF_NAME] = template.Template(object_id, hass) - - sensors.append(entity_cfg) - - return sensors - - PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( @@ -151,33 +114,6 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( ) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template binary sensors.""" - sensors = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - sensors.append( - BinarySensorTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(sensors) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -185,27 +121,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template binary sensors.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerBinarySensorEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + BINARY_SENSOR_DOMAIN, + config, + StateBinarySensorEntity, + TriggerBinarySensorEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SENSORS, ) @@ -219,20 +144,20 @@ async def async_setup_entry( _options.pop("template_type") validated_config = BINARY_SENSOR_CONFIG_SCHEMA(_options) async_add_entities( - [BinarySensorTemplate(hass, validated_config, config_entry.entry_id)] + [StateBinarySensorEntity(hass, validated_config, config_entry.entry_id)] ) @callback def async_create_preview_binary_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> BinarySensorTemplate: +) -> StateBinarySensorEntity: """Create a preview sensor.""" validated_config = BINARY_SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return BinarySensorTemplate(hass, validated_config, None) + return StateBinarySensorEntity(hass, validated_config, None) -class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): +class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity): """A virtual binary sensor that triggers from another sensor.""" _attr_should_poll = False diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 07aa41b3811..c52e2dae5a0 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -3,20 +3,17 @@ from __future__ import annotations import logging -from typing import Any import voluptuous as vol -from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_DEVICE_ID, - CONF_NAME, - CONF_UNIQUE_ID, +from homeassistant.components.button import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN as BUTTON_DOMAIN, + ButtonEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( @@ -26,6 +23,7 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN +from .helpers import async_setup_template_platform from .template_entity import TemplateEntity, make_template_entity_common_modern_schema _LOGGER = logging.getLogger(__name__) @@ -50,19 +48,6 @@ CONFIG_BUTTON_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateButtonEntity]: - """Create the Template button.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateButtonEntity(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -70,15 +55,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template button.""" - if not discovery_info or "coordinator" in discovery_info: - raise PlatformNotReady( - "The template button platform doesn't support trigger entities" - ) - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + BUTTON_DOMAIN, + config, + StateButtonEntity, + None, + async_add_entities, + discovery_info, ) @@ -92,11 +76,11 @@ async def async_setup_entry( _options.pop("template_type") validated_config = CONFIG_BUTTON_SCHEMA(_options) async_add_entities( - [TemplateButtonEntity(hass, validated_config, config_entry.entry_id)] + [StateButtonEntity(hass, validated_config, config_entry.entry_id)] ) -class TemplateButtonEntity(TemplateEntity, ButtonEntity): +class StateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" _attr_should_poll = False diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 86769a0d22a..1b3e9986d36 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -65,7 +65,7 @@ from . import ( weather as weather_platform, ) from .const import DOMAIN, PLATFORMS, TemplateConfig -from .helpers import async_get_blueprints +from .helpers import async_get_blueprints, rewrite_legacy_to_modern_configs PACKAGE_MERGE_HINT = "list" @@ -249,16 +249,16 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf legacy_warn_printed = False - for old_key, new_key, transform in ( + for old_key, new_key, legacy_fields in ( ( CONF_SENSORS, DOMAIN_SENSOR, - sensor_platform.rewrite_legacy_to_modern_conf, + sensor_platform.LEGACY_FIELDS, ), ( CONF_BINARY_SENSORS, DOMAIN_BINARY_SENSOR, - binary_sensor_platform.rewrite_legacy_to_modern_conf, + binary_sensor_platform.LEGACY_FIELDS, ), ): if old_key not in template_config: @@ -276,7 +276,11 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf definitions = ( list(template_config[new_key]) if new_key in template_config else [] ) - definitions.extend(transform(hass, template_config[old_key])) + definitions.extend( + rewrite_legacy_to_modern_configs( + hass, template_config[old_key], legacy_fields + ) + ) template_config = TemplateConfig({**template_config, new_key: definitions}) config_sections.append(template_config) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 68645c718b2..9d6391d80c9 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -39,12 +39,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -85,7 +84,7 @@ TILT_FEATURES = ( | CoverEntityFeature.SET_TILT_POSITION ) -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, CONF_POSITION_TEMPLATE: CONF_POSITION, CONF_TILT_TEMPLATE: CONF_TILT, @@ -140,54 +139,6 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - covers = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - covers.append(entity_conf) - - return covers - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - covers = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - covers.append( - CoverTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(covers) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -195,27 +146,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_COVERS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerCoverEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + COVER_DOMAIN, + config, + StateCoverEntity, + TriggerCoverEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_COVERS, ) @@ -445,7 +385,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): self.async_write_ha_state() -class CoverTemplate(TemplateEntity, AbstractTemplateCover): +class StateCoverEntity(TemplateEntity, AbstractTemplateCover): """Representation of a Template cover.""" _attr_should_poll = False diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index f7b0b57cf27..95086375f4b 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -41,12 +41,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -73,7 +72,7 @@ CONF_OSCILLATING = "oscillating" CONF_PERCENTAGE = "percentage" CONF_PRESET_MODE = "preset_mode" -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_DIRECTION_TEMPLATE: CONF_DIRECTION, CONF_OSCILLATING_TEMPLATE: CONF_OSCILLATING, CONF_PERCENTAGE_TEMPLATE: CONF_PERCENTAGE, @@ -132,54 +131,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy fan configuration definitions to modern ones.""" - fans = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - fans.append(entity_conf) - - return fans - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template fans.""" - fans = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - fans.append( - TemplateFan( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(fans) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -187,27 +138,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_FANS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerFanEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + FAN_DOMAIN, + config, + StateFanEntity, + TriggerFanEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_FANS, ) @@ -484,7 +424,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): ) -class TemplateFan(TemplateEntity, AbstractTemplateFan): +class StateFanEntity(TemplateEntity, AbstractTemplateFan): """A template fan component.""" _attr_should_poll = False diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 2cd587de5a1..514255f417a 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -1,19 +1,60 @@ """Helpers for template integration.""" +from collections.abc import Callable +import itertools import logging +from typing import Any from homeassistant.components import blueprint -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import ( + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME, + CONF_ICON, + CONF_ICON_TEMPLATE, + CONF_NAME, + CONF_UNIQUE_ID, + SERVICE_RELOAD, +) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import template +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_platforms, +) from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import ( + CONF_ATTRIBUTE_TEMPLATES, + CONF_ATTRIBUTES, + CONF_AVAILABILITY, + CONF_AVAILABILITY_TEMPLATE, + CONF_OBJECT_ID, + CONF_PICTURE, + DOMAIN, +) from .entity import AbstractTemplateEntity +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity DATA_BLUEPRINTS = "template_blueprints" -LOGGER = logging.getLogger(__name__) +LEGACY_FIELDS = { + CONF_ICON_TEMPLATE: CONF_ICON, + CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, + CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, + CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, + CONF_FRIENDLY_NAME: CONF_NAME, +} + +_LOGGER = logging.getLogger(__name__) + +type CreateTemplateEntitiesCallback = Callable[ + [type[TemplateEntity], AddEntitiesCallback, HomeAssistant, list[dict], str | None], + None, +] @callback @@ -59,8 +100,131 @@ def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: return blueprint.DomainBlueprints( hass, DOMAIN, - LOGGER, + _LOGGER, _blueprint_in_use, _reload_blueprint_templates, TEMPLATE_BLUEPRINT_SCHEMA, ) + + +def rewrite_legacy_to_modern_config( + hass: HomeAssistant, + entity_cfg: dict[str, Any], + extra_legacy_fields: dict[str, str], +) -> dict[str, Any]: + """Rewrite legacy config.""" + entity_cfg = {**entity_cfg} + + for from_key, to_key in itertools.chain( + LEGACY_FIELDS.items(), extra_legacy_fields.items() + ): + if from_key not in entity_cfg or to_key in entity_cfg: + continue + + val = entity_cfg.pop(from_key) + if isinstance(val, str): + val = template.Template(val, hass) + entity_cfg[to_key] = val + + if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): + entity_cfg[CONF_NAME] = template.Template(entity_cfg[CONF_NAME], hass) + + return entity_cfg + + +def rewrite_legacy_to_modern_configs( + hass: HomeAssistant, + entity_cfg: dict[str, dict], + extra_legacy_fields: dict[str, str], +) -> list[dict]: + """Rewrite legacy configuration definitions to modern ones.""" + entities = [] + for object_id, entity_conf in entity_cfg.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_legacy_to_modern_config( + hass, entity_conf, extra_legacy_fields + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + entities.append(entity_conf) + + return entities + + +@callback +def async_create_template_tracking_entities( + entity_cls: type[Entity], + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template tracking entities.""" + entities: list[Entity] = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + entities.append(entity_cls(hass, definition, unique_id)) # type: ignore[call-arg] + async_add_entities(entities) + + +async def async_setup_template_platform( + hass: HomeAssistant, + domain: str, + config: ConfigType, + state_entity_cls: type[TemplateEntity], + trigger_entity_cls: type[TriggerEntity] | None, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None, + legacy_fields: dict[str, str] | None = None, + legacy_key: str | None = None, +) -> None: + """Set up the Template platform.""" + if discovery_info is None: + # Legacy Configuration + if legacy_fields is not None: + if legacy_key: + configs = rewrite_legacy_to_modern_configs( + hass, config[legacy_key], legacy_fields + ) + else: + configs = [rewrite_legacy_to_modern_config(hass, config, legacy_fields)] + async_create_template_tracking_entities( + state_entity_cls, + async_add_entities, + hass, + configs, + None, + ) + else: + _LOGGER.warning( + "Template %s entities can only be configured under template:", domain + ) + return + + # Trigger Configuration + if "coordinator" in discovery_info: + if trigger_entity_cls: + entities = [ + trigger_entity_cls(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ] + async_add_entities(entities) + else: + raise PlatformNotReady( + f"The template {domain} platform doesn't support trigger entities" + ) + return + + # Modern Configuration + async_create_template_tracking_entities( + state_entity_cls, + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index d286a2f6b4d..5f7f06faf4f 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -9,13 +9,7 @@ import voluptuous as vol from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_NAME, - CONF_UNIQUE_ID, - CONF_URL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector @@ -29,6 +23,7 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE +from .helpers import async_setup_template_platform from .template_entity import ( TemplateEntity, make_template_entity_common_modern_attributes_schema, @@ -59,19 +54,6 @@ IMAGE_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[StateImageEntity]: - """Create the template image.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(StateImageEntity(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -79,23 +61,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template image.""" - if discovery_info is None: - _LOGGER.warning( - "Template image entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerImageEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + IMAGE_DOMAIN, + config, + StateImageEntity, + TriggerImageEntity, + async_add_entities, + discovery_info, ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 10870462bc9..438c295ecd5 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -51,12 +51,11 @@ from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -103,7 +102,7 @@ CONF_WHITE_VALUE_TEMPLATE = "white_value_template" DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_COLOR_ACTION: CONF_HS_ACTION, CONF_COLOR_TEMPLATE: CONF_HS, CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST, @@ -193,47 +192,6 @@ PLATFORM_SCHEMA = vol.All( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - lights = [] - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - lights.append(entity_conf) - - return lights - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the Template Lights.""" - lights = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - lights.append(LightTemplate(hass, entity_conf, unique_id)) - - async_add_entities(lights) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -241,27 +199,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template lights.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_LIGHTS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerLightEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + LIGHT_DOMAIN, + config, + StateLightEntity, + TriggerLightEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_LIGHTS, ) @@ -934,7 +881,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): self._attr_supported_features |= LightEntityFeature.TRANSITION -class LightTemplate(TemplateEntity, AbstractTemplateLight): +class StateLightEntity(TemplateEntity, AbstractTemplateLight): """Representation of a templated Light, including dimmable.""" _attr_should_poll = False diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 4e3f3ed8ccc..20bc098d130 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -31,12 +31,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PICTURE, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -49,7 +48,7 @@ CONF_OPEN = "open" DEFAULT_NAME = "Template Lock" DEFAULT_OPTIMISTIC = False -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_CODE_FORMAT_TEMPLATE: CONF_CODE_FORMAT, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -83,33 +82,6 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template fans.""" - fans = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - fans.append( - TemplateLock( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(fans) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -117,27 +89,15 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - [rewrite_common_legacy_to_modern_conf(hass, config, LEGACY_FIELDS)], - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerLockEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + LOCK_DOMAIN, + config, + StateLockEntity, + TriggerLockEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, ) @@ -311,7 +271,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): ) -class TemplateLock(TemplateEntity, AbstractTemplateLock): +class StateLockEntity(TemplateEntity, AbstractTemplateLock): """Representation of a template lock.""" _attr_should_poll = False diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 4d9eaff0b2d..fa1e2790a9d 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -21,7 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, - CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback @@ -35,6 +34,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN +from .helpers import async_setup_template_platform from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity @@ -70,19 +70,6 @@ NUMBER_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateNumber]: - """Create the Template number.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateNumber(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -90,23 +77,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template number.""" - if discovery_info is None: - _LOGGER.warning( - "Template number entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerNumberEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + NUMBER_DOMAIN, + config, + StateNumberEntity, + TriggerNumberEntity, + async_add_entities, + discovery_info, ) @@ -119,19 +97,21 @@ async def async_setup_entry( _options = dict(config_entry.options) _options.pop("template_type") validated_config = NUMBER_CONFIG_SCHEMA(_options) - async_add_entities([TemplateNumber(hass, validated_config, config_entry.entry_id)]) + async_add_entities( + [StateNumberEntity(hass, validated_config, config_entry.entry_id)] + ) @callback def async_create_preview_number( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> TemplateNumber: +) -> StateNumberEntity: """Create a preview number.""" validated_config = NUMBER_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return TemplateNumber(hass, validated_config, None) + return StateNumberEntity(hass, validated_config, None) -class TemplateNumber(TemplateEntity, NumberEntity): +class StateNumberEntity(TemplateEntity, NumberEntity): """Representation of a template number.""" _attr_should_poll = False diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 256955e70a8..55b5c7375f8 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -14,13 +14,7 @@ from homeassistant.components.select import ( SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_NAME, - CONF_OPTIMISTIC, - CONF_STATE, - CONF_UNIQUE_ID, -) +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id @@ -33,6 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity @@ -65,19 +60,6 @@ SELECT_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateSelect]: - """Create the Template select.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateSelect(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -85,23 +67,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template select.""" - if discovery_info is None: - _LOGGER.warning( - "Template select entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSelectEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + SELECT_DOMAIN, + config, + TemplateSelect, + TriggerSelectEntity, + async_add_entities, + discovery_info, ) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index c25a2a0e3cb..11fe279fdfb 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -56,16 +56,12 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID -from .template_entity import ( - TEMPLATE_ENTITY_COMMON_SCHEMA, - TemplateEntity, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity LEGACY_FIELDS = { CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_FRIENDLY_NAME: CONF_NAME, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -142,27 +138,6 @@ def extra_validation_checks(val): return val -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, cfg: dict[str, dict] -) -> list[dict]: - """Rewrite legacy sensor definitions to modern ones.""" - sensors = [] - - for object_id, entity_cfg in cfg.items(): - entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - - entity_cfg = rewrite_common_legacy_to_modern_conf( - hass, entity_cfg, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_cfg: - entity_cfg[CONF_NAME] = template.Template(object_id, hass) - - sensors.append(entity_cfg) - - return sensors - - PLATFORM_SCHEMA = vol.All( SENSOR_PLATFORM_SCHEMA.extend( { @@ -177,33 +152,6 @@ PLATFORM_SCHEMA = vol.All( _LOGGER = logging.getLogger(__name__) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template sensors.""" - sensors = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - sensors.append( - SensorTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(sensors) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -211,27 +159,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template sensors.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSensorEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + SENSOR_DOMAIN, + config, + StateSensorEntity, + TriggerSensorEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SENSORS, ) @@ -244,19 +181,21 @@ async def async_setup_entry( _options = dict(config_entry.options) _options.pop("template_type") validated_config = SENSOR_CONFIG_SCHEMA(_options) - async_add_entities([SensorTemplate(hass, validated_config, config_entry.entry_id)]) + async_add_entities( + [StateSensorEntity(hass, validated_config, config_entry.entry_id)] + ) @callback def async_create_preview_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> SensorTemplate: +) -> StateSensorEntity: """Create a preview sensor.""" validated_config = SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return SensorTemplate(hass, validated_config, None) + return StateSensorEntity(hass, validated_config, None) -class SensorTemplate(TemplateEntity, SensorEntity): +class StateSensorEntity(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 677686ea8d8..e2ccb5a8a82 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -41,18 +41,17 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -96,27 +95,6 @@ SWITCH_CONFIG_SCHEMA = vol.Schema( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - switches = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - switches.append(entity_conf) - - return switches - - def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: """Rewrite option configuration to modern configuration.""" option_config = {**option_config} @@ -127,33 +105,6 @@ def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, return option_config -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - switches = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - switches.append( - SwitchTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(switches) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -161,27 +112,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template switches.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SWITCHES]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSwitchEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + SWITCH_DOMAIN, + config, + StateSwitchEntity, + TriggerSwitchEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SWITCHES, ) @@ -195,20 +135,22 @@ async def async_setup_entry( _options.pop("template_type") _options = rewrite_options_to_modern_conf(_options) validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)]) + async_add_entities( + [StateSwitchEntity(hass, validated_config, config_entry.entry_id)] + ) @callback def async_create_preview_switch( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> SwitchTemplate: +) -> StateSwitchEntity: """Create a preview switch.""" updated_config = rewrite_options_to_modern_conf(config) validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name}) - return SwitchTemplate(hass, validated_config, None) + return StateSwitchEntity(hass, validated_config, None) -class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): +class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" _attr_should_poll = False diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 3157a60347e..e404821e651 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping import contextlib -import itertools import logging from typing import Any, cast @@ -14,7 +13,6 @@ import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, @@ -137,42 +135,6 @@ TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY = vol.Schema( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, - CONF_FRIENDLY_NAME: CONF_NAME, -} - - -def rewrite_common_legacy_to_modern_conf( - hass: HomeAssistant, - entity_cfg: dict[str, Any], - extra_legacy_fields: dict[str, str] | None = None, -) -> dict[str, Any]: - """Rewrite legacy config.""" - entity_cfg = {**entity_cfg} - if extra_legacy_fields is None: - extra_legacy_fields = {} - - for from_key, to_key in itertools.chain( - LEGACY_FIELDS.items(), extra_legacy_fields.items() - ): - if from_key not in entity_cfg or to_key in entity_cfg: - continue - - val = entity_cfg.pop(from_key) - if isinstance(val, str): - val = Template(val, hass) - entity_cfg[to_key] = val - - if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): - entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME], hass) - - return entity_cfg - - class _TemplateAttribute: """Attribute value linked to template result.""" diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1fb5b89ead2..d9c416f4863 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -41,13 +41,12 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_attributes_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -72,7 +71,7 @@ _VALID_STATES = [ VacuumActivity.ERROR, ] -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_BATTERY_LEVEL_TEMPLATE: CONF_BATTERY_LEVEL, CONF_FAN_SPEED_TEMPLATE: CONF_FAN_SPEED, CONF_VALUE_TEMPLATE: CONF_STATE, @@ -125,82 +124,23 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - vacuums = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - vacuums.append(entity_conf) - - return vacuums - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - vacuums = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - vacuums.append( - TemplateVacuum( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(vacuums) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_VACUUMS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerVacuumEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + """Set up the Template vacuum.""" + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + VACUUM_DOMAIN, + config, + TemplateStateVacuumEntity, + TriggerVacuumEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_VACUUMS, ) @@ -350,7 +290,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): self._attr_fan_speed = None -class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): +class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): """A template vacuum component.""" _attr_should_poll = False diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index ee834e757a3..66ead388d5d 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -31,12 +31,7 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import ( - CONF_TEMPERATURE_UNIT, - CONF_UNIQUE_ID, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template @@ -52,11 +47,8 @@ from homeassistant.util.unit_conversion import ( ) from .coordinator import TriggerUpdateCoordinator -from .template_entity import ( - TemplateEntity, - make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( @@ -138,33 +130,6 @@ WEATHER_SCHEMA = vol.Schema( PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the weather entities.""" - entities = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - entities.append( - WeatherTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(entities) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -172,36 +137,19 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" - if discovery_info is None: - config = rewrite_common_legacy_to_modern_conf(hass, config) - unique_id = config.get(CONF_UNIQUE_ID) - async_add_entities( - [ - WeatherTemplate( - hass, - config, - unique_id, - ) - ] - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerWeatherEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + WEATHER_DOMAIN, + config, + StateWeatherEntity, + TriggerWeatherEntity, + async_add_entities, + discovery_info, + {}, ) -class WeatherTemplate(TemplateEntity, WeatherEntity): +class StateWeatherEntity(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 75a9e2c9689..b30051a52d2 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -559,7 +559,7 @@ def setup_mock() -> Generator[Mock]: """Do setup of sensor mock.""" with patch( "homeassistant.components.template.binary_sensor." - "BinarySensorTemplate._update_state" + "StateBinarySensorEntity._update_state" ) as _update_state: yield _update_state diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py new file mode 100644 index 00000000000..574c764ba28 --- /dev/null +++ b/tests/components/template/test_helpers.py @@ -0,0 +1,344 @@ +"""The tests for template helpers.""" + +import pytest + +from homeassistant.components.template.alarm_control_panel import ( + LEGACY_FIELDS as ALARM_CONTROL_PANEL_LEGACY_FIELDS, +) +from homeassistant.components.template.binary_sensor import ( + LEGACY_FIELDS as BINARY_SENSOR_LEGACY_FIELDS, +) +from homeassistant.components.template.button import StateButtonEntity +from homeassistant.components.template.cover import LEGACY_FIELDS as COVER_LEGACY_FIELDS +from homeassistant.components.template.fan import LEGACY_FIELDS as FAN_LEGACY_FIELDS +from homeassistant.components.template.helpers import ( + async_setup_template_platform, + rewrite_legacy_to_modern_config, + rewrite_legacy_to_modern_configs, +) +from homeassistant.components.template.light import LEGACY_FIELDS as LIGHT_LEGACY_FIELDS +from homeassistant.components.template.lock import LEGACY_FIELDS as LOCK_LEGACY_FIELDS +from homeassistant.components.template.sensor import ( + LEGACY_FIELDS as SENSOR_LEGACY_FIELDS, +) +from homeassistant.components.template.switch import ( + LEGACY_FIELDS as SWITCH_LEGACY_FIELDS, +) +from homeassistant.components.template.vacuum import ( + LEGACY_FIELDS as VACUUM_LEGACY_FIELDS, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.template import Template + + +@pytest.mark.parametrize( + ("legacy_fields", "old_attr", "new_attr", "attr_template"), + [ + ( + LOCK_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + LOCK_LEGACY_FIELDS, + "code_format_template", + "code_format", + "{{ 'some format' }}", + ), + ], +) +async def test_legacy_to_modern_config( + hass: HomeAssistant, + legacy_fields, + old_attr: str, + new_attr: str, + attr_template: str, +) -> None: + """Test the conversion of single legacy template to modern template.""" + config = { + "friendly_name": "foo bar", + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + } + altered_configs = rewrite_legacy_to_modern_config(hass, config, legacy_fields) + + assert { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + new_attr: Template(attr_template, hass), + } == altered_configs + + +@pytest.mark.parametrize( + ("legacy_fields", "old_attr", "new_attr", "attr_template"), + [ + ( + ALARM_CONTROL_PANEL_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + BINARY_SENSOR_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + COVER_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + COVER_LEGACY_FIELDS, + "position_template", + "position", + "{{ 100 }}", + ), + ( + COVER_LEGACY_FIELDS, + "tilt_template", + "tilt", + "{{ 100 }}", + ), + ( + FAN_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + FAN_LEGACY_FIELDS, + "direction_template", + "direction", + "{{ 1 == 1 }}", + ), + ( + FAN_LEGACY_FIELDS, + "oscillating_template", + "oscillating", + "{{ True }}", + ), + ( + FAN_LEGACY_FIELDS, + "percentage_template", + "percentage", + "{{ 100 }}", + ), + ( + FAN_LEGACY_FIELDS, + "preset_mode_template", + "preset_mode", + "{{ 'foo' }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgb_template", + "rgb", + "{{ (255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgbw_template", + "rgbw", + "{{ (255,255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgbww_template", + "rgbww", + "{{ (255,255,255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "effect_list_template", + "effect_list", + "{{ ['a', 'b'] }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "effect_template", + "effect", + "{{ 'a' }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "level_template", + "level", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "max_mireds_template", + "max_mireds", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "min_mireds_template", + "min_mireds", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "supports_transition_template", + "supports_transition", + "{{ True }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "temperature_template", + "temperature", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "white_value_template", + "white_value", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "hs_template", + "hs", + "{{ (255, 255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "color_template", + "hs", + "{{ (255, 255) }}", + ), + ( + SENSOR_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + SWITCH_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "battery_level_template", + "battery_level", + "{{ 100 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "fan_speed_template", + "fan_speed", + "{{ 7 }}", + ), + ], +) +async def test_legacy_to_modern_configs( + hass: HomeAssistant, + legacy_fields, + old_attr: str, + new_attr: str, + attr_template: str, +) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + } + } + altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + new_attr: Template(attr_template, hass), + } + ] == altered_configs + + +@pytest.mark.parametrize( + "legacy_fields", + [ + BINARY_SENSOR_LEGACY_FIELDS, + SENSOR_LEGACY_FIELDS, + ], +) +async def test_friendly_name_template_legacy_to_modern_configs( + hass: HomeAssistant, + legacy_fields, +) -> None: + """Test the conversion of friendly_name_tempalte in legacy template to modern template.""" + config = { + "foo": { + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + "friendly_name_template": "{{ 'foo bar' }}", + } + } + altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + "name": Template("{{ 'foo bar' }}", hass), + } + ] == altered_configs + + +async def test_platform_not_ready( + hass: HomeAssistant, +) -> None: + """Test async_setup_template_platform raises PlatformNotReady when trigger object is None.""" + with pytest.raises(PlatformNotReady): + await async_setup_template_platform( + hass, + "button", + {}, + StateButtonEntity, + None, + None, + {"coordinator": None, "entities": []}, + ) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index eaa1708aea7..bfffd0911a9 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -17,7 +17,6 @@ from homeassistant.components.light import ( ColorMode, LightEntityFeature, ) -from homeassistant.components.template.light import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -29,7 +28,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from .conftest import ConfigurationStyle @@ -289,127 +287,6 @@ TEST_UNIQUE_ID_CONFIG = { } -@pytest.mark.parametrize( - ("old_attr", "new_attr", "attr_template"), - [ - ( - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "rgb_template", - "rgb", - "{{ (255,255,255) }}", - ), - ( - "rgbw_template", - "rgbw", - "{{ (255,255,255,255) }}", - ), - ( - "rgbww_template", - "rgbww", - "{{ (255,255,255,255,255) }}", - ), - ( - "effect_list_template", - "effect_list", - "{{ ['a', 'b'] }}", - ), - ( - "effect_template", - "effect", - "{{ 'a' }}", - ), - ( - "level_template", - "level", - "{{ 255 }}", - ), - ( - "max_mireds_template", - "max_mireds", - "{{ 255 }}", - ), - ( - "min_mireds_template", - "min_mireds", - "{{ 255 }}", - ), - ( - "supports_transition_template", - "supports_transition", - "{{ True }}", - ), - ( - "temperature_template", - "temperature", - "{{ 255 }}", - ), - ( - "white_value_template", - "white_value", - "{{ 255 }}", - ), - ( - "hs_template", - "hs", - "{{ (255, 255) }}", - ), - ( - "color_template", - "hs", - "{{ (255, 255) }}", - ), - ], -) -async def test_legacy_to_modern_config( - hass: HomeAssistant, old_attr: str, new_attr: str, attr_template: str -) -> None: - """Test the conversion of legacy template to modern template.""" - config = { - "foo": { - "friendly_name": "foo bar", - "unique_id": "foo-bar-light", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - old_attr: attr_template, - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - } - } - altered_configs = rewrite_legacy_to_modern_conf(hass, config) - - assert len(altered_configs) == 1 - - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "object_id": "foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "turn_off": { - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, - "service": "test.automation", - }, - "turn_on": { - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, - "service": "test.automation", - }, - "unique_id": "foo-bar-light", - new_attr: Template(attr_template, hass), - } - ] == altered_configs - - async def async_setup_legacy_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] ) -> None: diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index c6ed303af7b..2e2fb5e8093 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -7,7 +7,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -18,7 +17,6 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from .conftest import ConfigurationStyle, async_get_flow_preview_state @@ -306,37 +304,6 @@ async def setup_single_attribute_optimistic_switch( ) -async def test_legacy_to_modern_config(hass: HomeAssistant) -> None: - """Test the conversion of legacy template to modern template.""" - config = { - "foo": { - "friendly_name": "foo bar", - "value_template": "{{ 1 == 1 }}", - "unique_id": "foo-bar-switch", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - **SWITCH_ACTIONS, - } - } - altered_configs = rewrite_legacy_to_modern_conf(hass, config) - - assert len(altered_configs) == 1 - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "object_id": "foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "turn_off": SWITCH_TURN_OFF, - "turn_on": SWITCH_TURN_ON, - "unique_id": "foo-bar-switch", - "state": Template("{{ 1 == 1 }}", hass), - } - ] == altered_configs - - @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ True }}")]) @pytest.mark.parametrize( "style", From e2cc51f21def72ef5dbf9872119298147d7e0f41 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 15 Jul 2025 08:51:08 +0200 Subject: [PATCH 02/36] Allow AI Task to handle camera attachments (#148753) --- homeassistant/components/ai_task/entity.py | 7 +- .../components/ai_task/manifest.json | 1 + homeassistant/components/ai_task/task.py | 95 +++++++++++++++---- .../components/conversation/chat_log.py | 3 - tests/components/ai_task/test_init.py | 1 - tests/components/ai_task/test_task.py | 88 +++++++++++++++++ 6 files changed, 167 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index 420777ce5c3..4c5cd186943 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -13,7 +13,7 @@ from homeassistant.components.conversation import ( ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.helpers import llm -from homeassistant.helpers.chat_session import async_get_chat_session +from homeassistant.helpers.chat_session import ChatSession from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -56,12 +56,12 @@ class AITaskEntity(RestoreEntity): @contextlib.asynccontextmanager async def _async_get_ai_task_chat_log( self, + session: ChatSession, task: GenDataTask, ) -> AsyncGenerator[ChatLog]: """Context manager used to manage the ChatLog used during an AI Task.""" # pylint: disable-next=contextmanager-generator-missing-cleanup with ( - async_get_chat_session(self.hass) as session, async_get_chat_log( self.hass, session, @@ -88,12 +88,13 @@ class AITaskEntity(RestoreEntity): @final async def internal_async_generate_data( self, + session: ChatSession, task: GenDataTask, ) -> GenDataTaskResult: """Run a gen data task.""" self.__last_activity = dt_util.utcnow().isoformat() self.async_write_ha_state() - async with self._async_get_ai_task_chat_log(task) as chat_log: + async with self._async_get_ai_task_chat_log(session, task) as chat_log: return await self._async_generate_data(task, chat_log) async def _async_generate_data( diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json index c3e33e7d411..ea377ffa671 100644 --- a/homeassistant/components/ai_task/manifest.json +++ b/homeassistant/components/ai_task/manifest.json @@ -1,6 +1,7 @@ { "domain": "ai_task", "name": "AI Task", + "after_dependencies": ["camera"], "codeowners": ["@home-assistant/core"], "dependencies": ["conversation", "media_source"], "documentation": "https://www.home-assistant.io/integrations/ai_task", diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index bb57a89203e..3cc43f8c07a 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -3,17 +3,32 @@ from __future__ import annotations from dataclasses import dataclass +import mimetypes +from pathlib import Path +import tempfile from typing import Any import voluptuous as vol -from homeassistant.components import conversation, media_source -from homeassistant.core import HomeAssistant +from homeassistant.components import camera, conversation, media_source +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.chat_session import async_get_chat_session from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature +def _save_camera_snapshot(image: camera.Image) -> Path: + """Save camera snapshot to temp file.""" + with tempfile.NamedTemporaryFile( + mode="wb", + suffix=mimetypes.guess_extension(image.content_type, False), + delete=False, + ) as temp_file: + temp_file.write(image.content) + return Path(temp_file.name) + + async def async_generate_data( hass: HomeAssistant, *, @@ -40,41 +55,79 @@ async def async_generate_data( ) # Resolve attachments - resolved_attachments: list[conversation.Attachment] | None = None + resolved_attachments: list[conversation.Attachment] = [] + created_files: list[Path] = [] - if attachments: - if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features: - raise HomeAssistantError( - f"AI Task entity {entity_id} does not support attachments" + if ( + attachments + and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features + ): + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support attachments" + ) + + for attachment in attachments or []: + media_content_id = attachment["media_content_id"] + + # Special case for camera media sources + if media_content_id.startswith("media-source://camera/"): + # Extract entity_id from the media content ID + entity_id = media_content_id.removeprefix("media-source://camera/") + + # Get snapshot from camera + image = await camera.async_get_image(hass, entity_id) + + temp_filename = await hass.async_add_executor_job( + _save_camera_snapshot, image ) + created_files.append(temp_filename) - resolved_attachments = [] - - for attachment in attachments: - media = await media_source.async_resolve_media( - hass, attachment["media_content_id"], None + resolved_attachments.append( + conversation.Attachment( + media_content_id=media_content_id, + mime_type=image.content_type, + path=temp_filename, + ) ) + else: + # Handle regular media sources + media = await media_source.async_resolve_media(hass, media_content_id, None) if media.path is None: raise HomeAssistantError( "Only local attachments are currently supported" ) resolved_attachments.append( conversation.Attachment( - media_content_id=attachment["media_content_id"], - url=media.url, + media_content_id=media_content_id, mime_type=media.mime_type, path=media.path, ) ) - return await entity.internal_async_generate_data( - GenDataTask( - name=task_name, - instructions=instructions, - structure=structure, - attachments=resolved_attachments, + with async_get_chat_session(hass) as session: + if created_files: + + def cleanup_files() -> None: + """Cleanup temporary files.""" + for file in created_files: + file.unlink(missing_ok=True) + + @callback + def cleanup_files_callback() -> None: + """Cleanup temporary files.""" + hass.async_add_executor_job(cleanup_files) + + session.async_on_cleanup(cleanup_files_callback) + + return await entity.internal_async_generate_data( + session, + GenDataTask( + name=task_name, + instructions=instructions, + structure=structure, + attachments=resolved_attachments or None, + ), ) - ) @dataclass(slots=True) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index e8ec66afa76..8d739b6267d 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -147,9 +147,6 @@ class Attachment: media_content_id: str """Media content ID of the attachment.""" - url: str - """URL of the attachment.""" - mime_type: str """MIME type of the attachment.""" diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 19f73045532..09ee926c187 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -117,7 +117,6 @@ async def test_generate_data_service( for msg_attachment, attachment in zip( msg_attachments, task.attachments or [], strict=False ): - assert attachment.url == "http://example.com/media.mp4" assert attachment.mime_type == "video/mp4" assert attachment.media_content_id == msg_attachment["media_content_id"] assert attachment.path == Path("media.mp4") diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index b11d96823cc..7eb75b62bb0 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -1,18 +1,26 @@ """Test tasks for the AI Task integration.""" +from datetime import timedelta +from pathlib import Path +from unittest.mock import patch + from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import media_source from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_data +from homeassistant.components.camera import Image from homeassistant.components.conversation import async_get_chat_log from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session +from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_ID, MockAITaskEntity +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @@ -154,3 +162,83 @@ async def test_generate_data_attachments_not_supported( } ], ) + + +async def test_generate_data_mixed_attachments( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test generating data with both camera and regular media source attachments.""" + with ( + patch( + "homeassistant.components.camera.async_get_image", + return_value=Image(content_type="image/jpeg", content=b"fake_camera_jpeg"), + ) as mock_get_image, + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=media_source.PlayMedia( + url="http://example.com/test.mp4", + mime_type="video/mp4", + path=Path("/media/test.mp4"), + ), + ) as mock_resolve_media, + ): + await async_generate_data( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Analyze these files", + attachments=[ + { + "media_content_id": "media-source://camera/camera.front_door", + "media_content_type": "image/jpeg", + }, + { + "media_content_id": "media-source://media_player/video.mp4", + "media_content_type": "video/mp4", + }, + ], + ) + + # Verify both methods were called + mock_get_image.assert_called_once_with(hass, "camera.front_door") + mock_resolve_media.assert_called_once_with( + hass, "media-source://media_player/video.mp4", None + ) + + # Check attachments + assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 + task = mock_ai_task_entity.mock_generate_data_tasks[0] + assert task.attachments is not None + assert len(task.attachments) == 2 + + # Check camera attachment + camera_attachment = task.attachments[0] + assert ( + camera_attachment.media_content_id == "media-source://camera/camera.front_door" + ) + assert camera_attachment.mime_type == "image/jpeg" + assert isinstance(camera_attachment.path, Path) + assert camera_attachment.path.suffix == ".jpg" + + # Verify camera snapshot content + assert camera_attachment.path.exists() + content = await hass.async_add_executor_job(camera_attachment.path.read_bytes) + assert content == b"fake_camera_jpeg" + + # Trigger clean up + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1), + ) + await hass.async_block_till_done() + + # Verify the temporary file cleaned up + assert not camera_attachment.path.exists() + + # Check regular media attachment + media_attachment = task.attachments[1] + assert media_attachment.media_content_id == "media-source://media_player/video.mp4" + assert media_attachment.mime_type == "video/mp4" + assert media_attachment.path == Path("/media/test.mp4") From 5e883cfb129859f06b54fb282756abbdadd50557 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jul 2025 21:03:49 -1000 Subject: [PATCH 03/36] Fix flaky nuki tests by preventing teardown race condition (#148795) --- tests/components/nuki/__init__.py | 51 +++++++++++---------- tests/components/nuki/conftest.py | 13 ++++++ tests/components/nuki/test_binary_sensor.py | 4 +- tests/components/nuki/test_lock.py | 4 +- tests/components/nuki/test_sensor.py | 4 +- 5 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 tests/components/nuki/conftest.py diff --git a/tests/components/nuki/__init__.py b/tests/components/nuki/__init__.py index 4f5728003fc..307ff080d71 100644 --- a/tests/components/nuki/__init__.py +++ b/tests/components/nuki/__init__.py @@ -14,28 +14,33 @@ from tests.common import ( ) -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, mock_nuki_requests: requests_mock.Mocker +) -> MockConfigEntry: """Mock integration setup.""" - with requests_mock.Mocker() as mock: - # Mocking authentication endpoint - mock.get("http://1.1.1.1:8080/info", json=MOCK_INFO) - mock.get( - "http://1.1.1.1:8080/list", - json=await async_load_json_array_fixture(hass, "list.json", DOMAIN), - ) - mock.get( - "http://1.1.1.1:8080/callback/list", - json=await async_load_json_object_fixture( - hass, "callback_list.json", DOMAIN - ), - ) - mock.get( - "http://1.1.1.1:8080/callback/add", - json=await async_load_json_object_fixture( - hass, "callback_add.json", DOMAIN - ), - ) - entry = await setup_nuki_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + # Mocking authentication endpoint + mock_nuki_requests.get("http://1.1.1.1:8080/info", json=MOCK_INFO) + mock_nuki_requests.get( + "http://1.1.1.1:8080/list", + json=await async_load_json_array_fixture(hass, "list.json", DOMAIN), + ) + callback_list_data = await async_load_json_object_fixture( + hass, "callback_list.json", DOMAIN + ) + mock_nuki_requests.get( + "http://1.1.1.1:8080/callback/list", + json=callback_list_data, + ) + mock_nuki_requests.get( + "http://1.1.1.1:8080/callback/add", + json=await async_load_json_object_fixture(hass, "callback_add.json", DOMAIN), + ) + # Mock the callback remove endpoint for teardown + mock_nuki_requests.delete( + requests_mock.ANY, + json={"success": True}, + ) + entry = await setup_nuki_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/nuki/conftest.py b/tests/components/nuki/conftest.py new file mode 100644 index 00000000000..624a5cafb9e --- /dev/null +++ b/tests/components/nuki/conftest.py @@ -0,0 +1,13 @@ +"""Fixtures for nuki tests.""" + +from collections.abc import Generator + +import pytest +import requests_mock + + +@pytest.fixture +def mock_nuki_requests() -> Generator[requests_mock.Mocker]: + """Mock nuki HTTP requests.""" + with requests_mock.Mocker() as mock: + yield mock diff --git a/tests/components/nuki/test_binary_sensor.py b/tests/components/nuki/test_binary_sensor.py index 11507100aae..20551a66307 100644 --- a/tests/components/nuki/test_binary_sensor.py +++ b/tests/components/nuki/test_binary_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -19,9 +20,10 @@ async def test_binary_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test binary sensors.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.BINARY_SENSOR]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_lock.py b/tests/components/nuki/test_lock.py index fc2d9d1cba8..6d8c3cc43fc 100644 --- a/tests/components/nuki/test_lock.py +++ b/tests/components/nuki/test_lock.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -17,9 +18,10 @@ async def test_locks( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test locks.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.LOCK]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_sensor.py b/tests/components/nuki/test_sensor.py index 69a0aec56f7..d03fe7f0da6 100644 --- a/tests/components/nuki/test_sensor.py +++ b/tests/components/nuki/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -17,9 +18,10 @@ async def test_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test sensors.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.SENSOR]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From 7d7767c93a35c580ff145d5a50f62855b94264e8 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 15 Jul 2025 17:21:00 +1000 Subject: [PATCH 04/36] Bump Tesla Fleet API to 1.2.2 (#148776) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 4c92e0bd222..cf86fbeb4f9 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.2.0"] + "requirements": ["tesla-fleet-api==1.2.2"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index f58783e04a4..d12cf278d59 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.2.0", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.2", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index c0cbc2ea431..26f26990d58 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.0"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 53bc939f588..ee5e5b1e5df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2907,7 +2907,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a18908ffe97..f7d07254799 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2393,7 +2393,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From f6aa4aa788165bfad08c792cb1fd9c927d44c134 Mon Sep 17 00:00:00 2001 From: Max Velitchko Date: Tue, 15 Jul 2025 01:14:26 -0700 Subject: [PATCH 05/36] Bump amcrest to 1.9.9 (#148769) --- homeassistant/components/amcrest/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 7d8f8f9e6c8..85e37b0df64 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["amcrest"], "quality_scale": "legacy", - "requirements": ["amcrest==1.9.8"] + "requirements": ["amcrest==1.9.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee5e5b1e5df..10abfedaad0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -471,7 +471,7 @@ altruistclient==0.1.1 amberelectric==2.0.12 # homeassistant.components.amcrest -amcrest==1.9.8 +amcrest==1.9.9 # homeassistant.components.androidtv androidtv[async]==0.0.75 From 41e261096aa30160ff7348045ed3984da4530910 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:34:16 +0200 Subject: [PATCH 06/36] Use suggested unit of measurement in Tuya (#148599) --- homeassistant/components/tuya/const.py | 11 ----- homeassistant/components/tuya/number.py | 8 ++-- homeassistant/components/tuya/sensor.py | 47 +++++++++++++++---- .../tuya/snapshots/test_sensor.ambr | 12 +++++ 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index f9377114e47..61da1239554 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum import logging @@ -417,8 +416,6 @@ class UnitOfMeasurement: device_classes: set[str] aliases: set[str] = field(default_factory=set) - conversion_unit: str | None = None - conversion_fn: Callable[[float], float] | None = None # A tuple of available units of measurements we can work with. @@ -458,8 +455,6 @@ UNITS = ( SensorDeviceClass.CO, SensorDeviceClass.CO2, }, - conversion_unit=CONCENTRATION_PARTS_PER_MILLION, - conversion_fn=lambda x: x / 1000, ), UnitOfMeasurement( unit=UnitOfElectricCurrent.AMPERE, @@ -470,8 +465,6 @@ UNITS = ( unit=UnitOfElectricCurrent.MILLIAMPERE, aliases={"ma", "milliampere"}, device_classes={SensorDeviceClass.CURRENT}, - conversion_unit=UnitOfElectricCurrent.AMPERE, - conversion_fn=lambda x: x / 1000, ), UnitOfMeasurement( unit=UnitOfEnergy.WATT_HOUR, @@ -527,8 +520,6 @@ UNITS = ( SensorDeviceClass.SULPHUR_DIOXIDE, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, }, - conversion_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - conversion_fn=lambda x: x * 1000, ), UnitOfMeasurement( unit=UnitOfPower.WATT, @@ -596,8 +587,6 @@ UNITS = ( unit=UnitOfElectricPotential.MILLIVOLT, aliases={"mv", "millivolt"}, device_classes={SensorDeviceClass.VOLTAGE}, - conversion_unit=UnitOfElectricPotential.VOLT, - conversion_fn=lambda x: x / 1000, ), ) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index b5b8437ea8b..cb248d42739 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -382,20 +382,18 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): return uoms = DEVICE_CLASS_UNITS[self.device_class] - self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + uom = uoms.get(self.native_unit_of_measurement) or uoms.get( self.native_unit_of_measurement.lower() ) # Unknown unit of measurement, device class should not be used. - if self._uom is None: + if uom is None: self._attr_device_class = None return # Found unit of measurement, use the standardized Unit # Use the target conversion unit (if set) - self._attr_native_unit_of_measurement = ( - self._uom.conversion_unit or self._uom.unit - ) + self._attr_native_unit_of_measurement = uom.unit @property def native_value(self) -> float | None: diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index b45b8214bff..9caf642d403 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -14,6 +14,8 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, EntityCategory, UnitOfElectricCurrent, @@ -98,6 +100,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -112,6 +115,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -164,6 +168,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -181,6 +186,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -192,6 +198,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_monoxide", device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), *BATTERY_SENSORS, ), @@ -278,18 +285,21 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CO_VALUE, translation_key="carbon_monoxide", device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -418,6 +428,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -432,6 +443,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -472,6 +484,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -489,12 +502,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.PM10, translation_key="pm10", device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -506,6 +521,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, @@ -518,6 +534,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, @@ -583,6 +600,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -597,6 +615,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -613,6 +632,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.TEMP, @@ -637,6 +657,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="concentration_carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.TOTAL_TIME, @@ -685,6 +706,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), *BATTERY_SENSORS, ), @@ -724,6 +746,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -747,6 +770,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, @@ -759,12 +783,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm1", device_class=SensorDeviceClass.PM1, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.PM10, translation_key="pm10", device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -945,6 +971,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -959,6 +986,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -1004,12 +1032,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -1057,6 +1087,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -1071,6 +1102,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -1097,6 +1129,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -1113,6 +1146,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -1415,20 +1449,18 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): return uoms = DEVICE_CLASS_UNITS[self.device_class] - self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + uom = uoms.get(self.native_unit_of_measurement) or uoms.get( self.native_unit_of_measurement.lower() ) # Unknown unit of measurement, device class should not be used. - if self._uom is None: + if uom is None: self._attr_device_class = None return # Found unit of measurement, use the standardized Unit # Use the target conversion unit (if set) - self._attr_native_unit_of_measurement = ( - self._uom.conversion_unit or self._uom.unit - ) + self._attr_native_unit_of_measurement = uom.unit @property def native_value(self) -> StateType: @@ -1450,10 +1482,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): # Scale integer/float value if isinstance(self._type_data, IntegerTypeData): - scaled_value = self._type_data.scale_value(value) - if self._uom and self._uom.conversion_fn is not None: - return self._uom.conversion_fn(scaled_value) - return scaled_value + return self._type_data.scale_value(value) # Unexpected enum value if ( diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 3704aa4f067..f63c75567ef 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -387,6 +387,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -499,6 +502,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -555,6 +561,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -667,6 +676,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, From e1f15dac3950ddbf50ea794ed9df33e58a1bf436 Mon Sep 17 00:00:00 2001 From: nasWebio <140073814+nasWebio@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:39:53 +0200 Subject: [PATCH 07/36] Add Sensor platform to NASweb integration (#133063) Co-authored-by: Erik Montnemery --- homeassistant/components/nasweb/__init__.py | 2 +- homeassistant/components/nasweb/const.py | 1 + .../components/nasweb/coordinator.py | 20 +- homeassistant/components/nasweb/icons.json | 15 ++ homeassistant/components/nasweb/sensor.py | 189 ++++++++++++++++++ homeassistant/components/nasweb/strings.json | 12 ++ 6 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/nasweb/icons.json create mode 100644 homeassistant/components/nasweb/sensor.py diff --git a/homeassistant/components/nasweb/__init__.py b/homeassistant/components/nasweb/__init__.py index 1992cc41c75..43998ef43b3 100644 --- a/homeassistant/components/nasweb/__init__.py +++ b/homeassistant/components/nasweb/__init__.py @@ -19,7 +19,7 @@ from .const import DOMAIN, MANUFACTURER, SUPPORT_EMAIL from .coordinator import NASwebCoordinator from .nasweb_data import NASwebData -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] NASWEB_CONFIG_URL = "https://{host}/page" diff --git a/homeassistant/components/nasweb/const.py b/homeassistant/components/nasweb/const.py index ec750c90c8c..9150785d3bb 100644 --- a/homeassistant/components/nasweb/const.py +++ b/homeassistant/components/nasweb/const.py @@ -1,6 +1,7 @@ """Constants for the NASweb integration.""" DOMAIN = "nasweb" +KEY_TEMP_SENSOR = "temp_sensor" MANUFACTURER = "chomtech.pl" STATUS_UPDATE_MAX_TIME_INTERVAL = 60 SUPPORT_EMAIL = "support@chomtech.eu" diff --git a/homeassistant/components/nasweb/coordinator.py b/homeassistant/components/nasweb/coordinator.py index 90dca0f3022..2865bffe9a5 100644 --- a/homeassistant/components/nasweb/coordinator.py +++ b/homeassistant/components/nasweb/coordinator.py @@ -11,16 +11,19 @@ from typing import Any from aiohttp.web import Request, Response from webio_api import WebioAPI -from webio_api.const import KEY_DEVICE_SERIAL, KEY_OUTPUTS, KEY_TYPE, TYPE_STATUS_UPDATE +from webio_api.const import KEY_DEVICE_SERIAL, KEY_TYPE, TYPE_STATUS_UPDATE from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol -from .const import STATUS_UPDATE_MAX_TIME_INTERVAL +from .const import KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL _LOGGER = logging.getLogger(__name__) +KEY_INPUTS = "inputs" +KEY_OUTPUTS = "outputs" + class NotificationCoordinator: """Coordinator redirecting push notifications for this integration to appropriate NASwebCoordinator.""" @@ -96,8 +99,11 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): self._job = HassJob(self._handle_max_update_interval, job_name) self._unsub_last_update_check: CALLBACK_TYPE | None = None self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} - data: dict[str, Any] = {} - data[KEY_OUTPUTS] = self.webio_api.outputs + data: dict[str, Any] = { + KEY_OUTPUTS: self.webio_api.outputs, + KEY_INPUTS: self.webio_api.inputs, + KEY_TEMP_SENSOR: self.webio_api.temp_sensor, + } self.async_set_updated_data(data) def is_connection_confirmed(self) -> bool: @@ -187,5 +193,9 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): async def process_status_update(self, new_status: dict) -> None: """Process status update from NASweb.""" self.webio_api.update_device_status(new_status) - new_data = {KEY_OUTPUTS: self.webio_api.outputs} + new_data = { + KEY_OUTPUTS: self.webio_api.outputs, + KEY_INPUTS: self.webio_api.inputs, + KEY_TEMP_SENSOR: self.webio_api.temp_sensor, + } self.async_set_updated_data(new_data) diff --git a/homeassistant/components/nasweb/icons.json b/homeassistant/components/nasweb/icons.json new file mode 100644 index 00000000000..0055bf2296a --- /dev/null +++ b/homeassistant/components/nasweb/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "sensor_input": { + "default": "mdi:help-circle-outline", + "state": { + "tamper": "mdi:lock-alert", + "active": "mdi:alert", + "normal": "mdi:shield-check-outline", + "problem": "mdi:alert-circle" + } + } + } + } +} diff --git a/homeassistant/components/nasweb/sensor.py b/homeassistant/components/nasweb/sensor.py new file mode 100644 index 00000000000..eb342d7ce92 --- /dev/null +++ b/homeassistant/components/nasweb/sensor.py @@ -0,0 +1,189 @@ +"""Platform for NASweb sensors.""" + +from __future__ import annotations + +import logging +import time + +from webio_api import Input as NASwebInput, TempSensor + +from homeassistant.components.sensor import ( + DOMAIN as DOMAIN_SENSOR, + SensorDeviceClass, + SensorEntity, + SensorStateClass, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + BaseCoordinatorEntity, + BaseDataUpdateCoordinatorProtocol, +) + +from . import NASwebConfigEntry +from .const import DOMAIN, KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL + +SENSOR_INPUT_TRANSLATION_KEY = "sensor_input" +STATE_UNDEFINED = "undefined" +STATE_TAMPER = "tamper" +STATE_ACTIVE = "active" +STATE_NORMAL = "normal" +STATE_PROBLEM = "problem" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config: NASwebConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up Sensor platform.""" + coordinator = config.runtime_data + current_inputs: set[int] = set() + + @callback + def _check_entities() -> None: + received_inputs: dict[int, NASwebInput] = { + entry.index: entry for entry in coordinator.webio_api.inputs + } + added = {i for i in received_inputs if i not in current_inputs} + removed = {i for i in current_inputs if i not in received_inputs} + entities_to_add: list[InputStateSensor] = [] + for index in added: + webio_input = received_inputs[index] + if not isinstance(webio_input, NASwebInput): + _LOGGER.error("Cannot create InputStateSensor without NASwebInput") + continue + new_input = InputStateSensor(coordinator, webio_input) + entities_to_add.append(new_input) + current_inputs.add(index) + async_add_entities(entities_to_add) + entity_registry = er.async_get(hass) + for index in removed: + unique_id = f"{DOMAIN}.{config.unique_id}.input.{index}" + if entity_id := entity_registry.async_get_entity_id( + DOMAIN_SENSOR, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + current_inputs.remove(index) + else: + _LOGGER.warning("Failed to remove old input: no entity_id") + + coordinator.async_add_listener(_check_entities) + _check_entities() + + nasweb_temp_sensor = coordinator.data[KEY_TEMP_SENSOR] + temp_sensor = TemperatureSensor(coordinator, nasweb_temp_sensor) + async_add_entities([temp_sensor]) + + +class BaseSensorEntity(SensorEntity, BaseCoordinatorEntity): + """Base class providing common functionality.""" + + def __init__(self, coordinator: BaseDataUpdateCoordinatorProtocol) -> None: + """Initialize base sensor.""" + super().__init__(coordinator) + self._attr_available = False + self._attr_has_entity_name = True + self._attr_should_poll = False + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + def _set_attr_available( + self, entity_last_update: float, available: bool | None + ) -> None: + if ( + self.coordinator.last_update is None + or time.time() - entity_last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL + ): + self._attr_available = False + else: + self._attr_available = available if available is not None else False + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + """ + + +class InputStateSensor(BaseSensorEntity): + """Entity representing NASweb input.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_options: list[str] = [ + STATE_UNDEFINED, + STATE_TAMPER, + STATE_ACTIVE, + STATE_NORMAL, + STATE_PROBLEM, + ] + _attr_translation_key = SENSOR_INPUT_TRANSLATION_KEY + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_input: NASwebInput, + ) -> None: + """Initialize InputStateSensor entity.""" + super().__init__(coordinator) + self._input = nasweb_input + self._attr_native_value: str | None = None + self._attr_translation_placeholders = {"index": f"{nasweb_input.index:2d}"} + self._attr_unique_id = ( + f"{DOMAIN}.{self._input.webio_serial}.input.{self._input.index}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._input.webio_serial)}, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._input.state is None or self._input.state in self._attr_options: + self._attr_native_value = self._input.state + else: + _LOGGER.warning("Received unrecognized input state: %s", self._input.state) + self._attr_native_value = None + self._set_attr_available(self._input.last_update, self._input.available) + self.async_write_ha_state() + + +class TemperatureSensor(BaseSensorEntity): + """Entity representing NASweb temperature sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_temp_sensor: TempSensor, + ) -> None: + """Initialize TemperatureSensor entity.""" + super().__init__(coordinator) + self._temp_sensor = nasweb_temp_sensor + self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._temp_sensor.webio_serial)} + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self._temp_sensor.value + self._set_attr_available( + self._temp_sensor.last_update, self._temp_sensor.available + ) + self.async_write_ha_state() diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json index 8b93ea10d79..2e1ea55ffcb 100644 --- a/homeassistant/components/nasweb/strings.json +++ b/homeassistant/components/nasweb/strings.json @@ -45,6 +45,18 @@ "switch_output": { "name": "Relay Switch {index}" } + }, + "sensor": { + "sensor_input": { + "name": "Input {index}", + "state": { + "undefined": "Undefined", + "tamper": "Tamper", + "active": "Active", + "normal": "Normal", + "problem": "Problem" + } + } } } } From 4f938d032d02265a5a464ddf6b3b16de89b6a4d6 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 15 Jul 2025 02:45:55 -0600 Subject: [PATCH 08/36] Bump elevenlabs to 2.3.0 (#147224) --- .../components/elevenlabs/__init__.py | 3 +- .../components/elevenlabs/config_flow.py | 20 ++-- homeassistant/components/elevenlabs/const.py | 2 - .../components/elevenlabs/manifest.json | 2 +- .../components/elevenlabs/strings.json | 5 +- homeassistant/components/elevenlabs/tts.py | 15 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/elevenlabs/conftest.py | 57 +++++++++++- .../components/elevenlabs/test_config_flow.py | 91 ++++++++++++++++++- tests/components/elevenlabs/test_tts.py | 87 ++++++++++-------- 11 files changed, 209 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index e5807fec67c..a930dea43ed 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -25,7 +25,8 @@ PLATFORMS: list[Platform] = [Platform.TTS] async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None: """Get ElevenLabs model from their API by the model_id.""" - models = await client.models.get_all() + models = await client.models.list() + for maybe_model in models: if maybe_model.model_id == model_id: return maybe_model diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 227749bf82c..fc248235834 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -23,14 +23,12 @@ from . import ElevenLabsConfigEntry from .const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, DEFAULT_MODEL, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -51,7 +49,8 @@ async def get_voices_models( httpx_client = get_async_client(hass) client = AsyncElevenLabs(api_key=api_key, httpx_client=httpx_client) voices = (await client.voices.get_all()).voices - models = await client.models.get_all() + models = await client.models.list() + voices_dict = { voice.voice_id: voice.name for voice in sorted(voices, key=lambda v: v.name or "") @@ -78,8 +77,13 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: voices, _ = await get_voices_models(self.hass, user_input[CONF_API_KEY]) - except ApiError: - errors["base"] = "invalid_api_key" + except ApiError as exc: + errors["base"] = "unknown" + details = getattr(exc, "body", {}).get("detail", {}) + if details: + status = details.get("status") + if status == "invalid_api_key": + errors["base"] = "invalid_api_key" else: return self.async_create_entry( title="ElevenLabs", @@ -206,12 +210,6 @@ class ElevenLabsOptionsFlow(OptionsFlow): vol.Coerce(float), vol.Range(min=0, max=1), ), - vol.Optional( - CONF_OPTIMIZE_LATENCY, - default=self.config_entry.options.get( - CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY - ), - ): vol.All(int, vol.Range(min=0, max=4)), vol.Optional( CONF_STYLE, default=self.config_entry.options.get(CONF_STYLE, DEFAULT_STYLE), diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index 1de92f95e43..2629e62d2fc 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -7,7 +7,6 @@ CONF_MODEL = "model" CONF_CONFIGURE_VOICE = "configure_voice" CONF_STABILITY = "stability" CONF_SIMILARITY = "similarity" -CONF_OPTIMIZE_LATENCY = "optimize_streaming_latency" CONF_STYLE = "style" CONF_USE_SPEAKER_BOOST = "use_speaker_boost" DOMAIN = "elevenlabs" @@ -15,6 +14,5 @@ DOMAIN = "elevenlabs" DEFAULT_MODEL = "eleven_multilingual_v2" DEFAULT_STABILITY = 0.5 DEFAULT_SIMILARITY = 0.75 -DEFAULT_OPTIMIZE_LATENCY = 0 DEFAULT_STYLE = 0 DEFAULT_USE_SPEAKER_BOOST = True diff --git a/homeassistant/components/elevenlabs/manifest.json b/homeassistant/components/elevenlabs/manifest.json index eb6df09149a..f36a2383576 100644 --- a/homeassistant/components/elevenlabs/manifest.json +++ b/homeassistant/components/elevenlabs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["elevenlabs"], - "requirements": ["elevenlabs==1.9.0"] + "requirements": ["elevenlabs==2.3.0"] } diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json index 8b0205a9e9a..eb497f1a7a6 100644 --- a/homeassistant/components/elevenlabs/strings.json +++ b/homeassistant/components/elevenlabs/strings.json @@ -11,7 +11,8 @@ } }, "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "options": { @@ -32,14 +33,12 @@ "data": { "stability": "Stability", "similarity": "Similarity", - "optimize_streaming_latency": "Latency", "style": "Style", "use_speaker_boost": "Speaker boost" }, "data_description": { "stability": "Stability of the generated audio. Higher values lead to less emotional audio.", "similarity": "Similarity of the generated audio to the original voice. Higher values may result in more similar audio, but may also introduce background noise.", - "optimize_streaming_latency": "Optimize the model for streaming. This may reduce the quality of the generated audio.", "style": "Style of the generated audio. Recommended to keep at 0 for most almost all use cases.", "use_speaker_boost": "Use speaker boost to increase the similarity of the generated audio to the original voice." } diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index 61850837075..fc1a950d4b9 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -25,13 +25,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElevenLabsConfigEntry from .const import ( ATTR_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -75,9 +73,6 @@ async def async_setup_entry( config_entry.entry_id, config_entry.title, voice_settings, - config_entry.options.get( - CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY - ), ) ] ) @@ -98,7 +93,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): entry_id: str, title: str, voice_settings: VoiceSettings, - latency: int = 0, ) -> None: """Init ElevenLabs TTS service.""" self._client = client @@ -115,7 +109,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): if voice_indices: self._voices.insert(0, self._voices.pop(voice_indices[0])) self._voice_settings = voice_settings - self._latency = latency # Entity attributes self._attr_unique_id = entry_id @@ -144,14 +137,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): voice_id = options.get(ATTR_VOICE, self._default_voice_id) model = options.get(ATTR_MODEL, self._model.model_id) try: - audio = await self._client.generate( + audio = self._client.text_to_speech.convert( text=message, - voice=voice_id, - optimize_streaming_latency=self._latency, + voice_id=voice_id, voice_settings=self._voice_settings, - model=model, + model_id=model, ) bytes_combined = b"".join([byte_seg async for byte_seg in audio]) + except ApiError as exc: _LOGGER.warning( "Error during processing of TTS request %s", exc, exc_info=True diff --git a/requirements_all.txt b/requirements_all.txt index 10abfedaad0..140932f5f52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -845,7 +845,7 @@ eheimdigital==1.3.0 electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs -elevenlabs==1.9.0 +elevenlabs==2.3.0 # homeassistant.components.elgato elgato==5.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7d07254799..da9d5047723 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -736,7 +736,7 @@ eheimdigital==1.3.0 electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs -elevenlabs==1.9.0 +elevenlabs==2.3.0 # homeassistant.components.elgato elgato==5.1.2 diff --git a/tests/components/elevenlabs/conftest.py b/tests/components/elevenlabs/conftest.py index 1c261e2947a..c47017b88e9 100644 --- a/tests/components/elevenlabs/conftest.py +++ b/tests/components/elevenlabs/conftest.py @@ -28,7 +28,8 @@ def mock_setup_entry() -> Generator[AsyncMock]: def _client_mock(): client_mock = AsyncMock() client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) - client_mock.models.get_all.return_value = MOCK_MODELS + client_mock.models.list.return_value = MOCK_MODELS + return client_mock @@ -44,6 +45,10 @@ def mock_async_client() -> Generator[AsyncMock]: "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", new=mock_async_client, ), + patch( + "homeassistant.components.elevenlabs.tts.AsyncElevenLabs", + new=mock_async_client, + ), ): yield mock_async_client @@ -52,8 +57,12 @@ def mock_async_client() -> Generator[AsyncMock]: def mock_async_client_api_error() -> Generator[AsyncMock]: """Override async ElevenLabs client with ApiError side effect.""" client_mock = _client_mock() - client_mock.models.get_all.side_effect = ApiError - client_mock.voices.get_all.side_effect = ApiError + api_error = ApiError() + api_error.body = { + "detail": {"status": "invalid_api_key", "message": "API key is invalid"} + } + client_mock.models.list.side_effect = api_error + client_mock.voices.get_all.side_effect = api_error with ( patch( @@ -68,11 +77,51 @@ def mock_async_client_api_error() -> Generator[AsyncMock]: yield mock_async_client +@pytest.fixture +def mock_async_client_voices_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + api_error = ApiError() + api_error.body = { + "detail": { + "status": "voices_unauthorized", + "message": "API is unauthorized for voices", + } + } + client_mock.voices.get_all.side_effect = api_error + + with patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client: + yield mock_async_client + + +@pytest.fixture +def mock_async_client_models_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + api_error = ApiError() + api_error.body = { + "detail": { + "status": "models_unauthorized", + "message": "API is unauthorized for models", + } + } + client_mock.models.list.side_effect = api_error + + with patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client: + yield mock_async_client + + @pytest.fixture def mock_async_client_connect_error() -> Generator[AsyncMock]: """Override async ElevenLabs client.""" client_mock = _client_mock() - client_mock.models.get_all.side_effect = ConnectError("Unknown") + client_mock.models.list.side_effect = ConnectError("Unknown") client_mock.voices.get_all.side_effect = ConnectError("Unknown") with ( patch( diff --git a/tests/components/elevenlabs/test_config_flow.py b/tests/components/elevenlabs/test_config_flow.py index 7eeb0a6eb46..eccd5d49d92 100644 --- a/tests/components/elevenlabs/test_config_flow.py +++ b/tests/components/elevenlabs/test_config_flow.py @@ -7,14 +7,12 @@ import pytest from homeassistant.components.elevenlabs.const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, DEFAULT_MODEL, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -101,6 +99,94 @@ async def test_invalid_api_key( mock_setup_entry.assert_called_once() +async def test_voices_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_voices_error: AsyncMock, + request: pytest.FixtureRequest, +) -> None: + """Test user step with invalid api key.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_setup_entry.assert_not_called() + + # Use a working client + request.getfixturevalue("mock_async_client") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + + +async def test_models_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_models_error: AsyncMock, + request: pytest.FixtureRequest, +) -> None: + """Test user step with invalid api key.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_setup_entry.assert_not_called() + + # Use a working client + request.getfixturevalue("mock_async_client") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + + async def test_options_flow_init( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -166,7 +252,6 @@ async def test_options_flow_voice_settings_default( assert mock_entry.options == { CONF_MODEL: "model1", CONF_VOICE: "voice1", - CONF_OPTIMIZE_LATENCY: DEFAULT_OPTIMIZE_LATENCY, CONF_SIMILARITY: DEFAULT_SIMILARITY, CONF_STABILITY: DEFAULT_STABILITY, CONF_STYLE: DEFAULT_STYLE, diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index a63672cc85d..f25a03f2824 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -15,13 +15,11 @@ from homeassistant.components import tts from homeassistant.components.elevenlabs.const import ( ATTR_MODEL, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -44,6 +42,19 @@ from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator +class FakeAudioGenerator: + """Mock audio generator for ElevenLabs TTS.""" + + def __aiter__(self): + """Mock async iterator for audio parts.""" + + async def _gen(): + yield b"audio-part-1" + yield b"audio-part-2" + + return _gen() + + @pytest.fixture(autouse=True) def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: """Mock writing tags.""" @@ -74,12 +85,6 @@ def mock_similarity(): return DEFAULT_SIMILARITY / 2 -@pytest.fixture -def mock_latency(): - """Mock latency.""" - return (DEFAULT_OPTIMIZE_LATENCY + 1) % 5 # 0, 1, 2, 3, 4 - - @pytest.fixture(name="setup") async def setup_fixture( hass: HomeAssistant, @@ -98,6 +103,7 @@ async def setup_fixture( raise RuntimeError("Invalid setup fixture") await hass.async_block_till_done() + return mock_async_client @@ -114,10 +120,9 @@ def config_options_fixture() -> dict[str, Any]: @pytest.fixture(name="config_options_voice") -def config_options_voice_fixture(mock_similarity, mock_latency) -> dict[str, Any]: +def config_options_voice_fixture(mock_similarity) -> dict[str, Any]: """Return config options.""" return { - CONF_OPTIMIZE_LATENCY: mock_latency, CONF_SIMILARITY: mock_similarity, CONF_STABILITY: DEFAULT_STABILITY, CONF_STYLE: DEFAULT_STYLE, @@ -144,7 +149,7 @@ async def mock_config_entry_setup( config_entry.add_to_hass(hass) client_mock = AsyncMock() client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) - client_mock.models.get_all.return_value = MOCK_MODELS + client_mock.models.list.return_value = MOCK_MODELS with patch( "homeassistant.components.elevenlabs.AsyncElevenLabs", return_value=client_mock ): @@ -217,7 +222,10 @@ async def test_tts_service_speak( ) -> None: """Test tts service.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) + assert tts_entity._voice_settings == VoiceSettings( stability=DEFAULT_STABILITY, similarity_boost=DEFAULT_SIMILARITY, @@ -240,12 +248,11 @@ async def test_tts_service_speak( voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "voice1") model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, "model1") - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice=voice_id, - model=model_id, + voice_id=voice_id, + model_id=model_id, voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -287,7 +294,9 @@ async def test_tts_service_speak_lang_config( ) -> None: """Test service call say with other langcodes in the config.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) await hass.services.async_call( tts.DOMAIN, @@ -302,12 +311,11 @@ async def test_tts_service_speak_lang_config( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - model="model1", + voice_id="voice1", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -337,8 +345,10 @@ async def test_tts_service_speak_error( ) -> None: """Test service call say with http response 400.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() - tts_entity._client.generate.side_effect = ApiError + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) + tts_entity._client.text_to_speech.convert.side_effect = ApiError await hass.services.async_call( tts.DOMAIN, @@ -353,12 +363,11 @@ async def test_tts_service_speak_error( == HTTPStatus.INTERNAL_SERVER_ERROR ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - model="model1", + voice_id="voice1", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -396,18 +405,18 @@ async def test_tts_service_speak_voice_settings( tts_service: str, service_data: dict[str, Any], mock_similarity: float, - mock_latency: int, ) -> None: """Test tts service.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) assert tts_entity._voice_settings == VoiceSettings( stability=DEFAULT_STABILITY, similarity_boost=mock_similarity, style=DEFAULT_STYLE, use_speaker_boost=DEFAULT_USE_SPEAKER_BOOST, ) - assert tts_entity._latency == mock_latency await hass.services.async_call( tts.DOMAIN, @@ -422,12 +431,11 @@ async def test_tts_service_speak_voice_settings( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice2", - model="model1", + voice_id="voice2", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -457,7 +465,9 @@ async def test_tts_service_speak_without_options( ) -> None: """Test service call say with http response 200.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) await hass.services.async_call( tts.DOMAIN, @@ -472,12 +482,11 @@ async def test_tts_service_speak_without_options( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - optimize_streaming_latency=0, + voice_id="voice1", voice_settings=VoiceSettings( stability=0.5, similarity_boost=0.75, style=0.0, use_speaker_boost=True ), - model="model1", + model_id="model1", ) From db45f46c8a4f9473f15334bd1553aa0dd159902e Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:14:47 +0200 Subject: [PATCH 09/36] Fan support in WiZ (#146440) --- CODEOWNERS | 4 +- homeassistant/components/wiz/__init__.py | 1 + homeassistant/components/wiz/fan.py | 139 +++++++++++ homeassistant/components/wiz/manifest.json | 2 +- tests/components/wiz/__init__.py | 26 +++ tests/components/wiz/snapshots/test_fan.ambr | 61 +++++ tests/components/wiz/test_fan.py | 232 +++++++++++++++++++ 7 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/wiz/fan.py create mode 100644 tests/components/wiz/snapshots/test_fan.ambr create mode 100644 tests/components/wiz/test_fan.py diff --git a/CODEOWNERS b/CODEOWNERS index a6ab083e07d..c0bed7f100a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1758,8 +1758,8 @@ build.json @home-assistant/supervisor /homeassistant/components/wirelesstag/ @sergeymaysak /homeassistant/components/withings/ @joostlek /tests/components/withings/ @joostlek -/homeassistant/components/wiz/ @sbidy -/tests/components/wiz/ @sbidy +/homeassistant/components/wiz/ @sbidy @arturpragacz +/tests/components/wiz/ @sbidy @arturpragacz /homeassistant/components/wled/ @frenck /tests/components/wled/ @frenck /homeassistant/components/wmspro/ @mback2k diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 43a9b863d20..39be4d9a387 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -37,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.FAN, Platform.LIGHT, Platform.NUMBER, Platform.SENSOR, diff --git a/homeassistant/components/wiz/fan.py b/homeassistant/components/wiz/fan.py new file mode 100644 index 00000000000..f826ee80b8b --- /dev/null +++ b/homeassistant/components/wiz/fan.py @@ -0,0 +1,139 @@ +"""WiZ integration fan platform.""" + +from __future__ import annotations + +import math +from typing import Any, ClassVar + +from pywizlight.bulblibrary import BulbType, Features + +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + FanEntity, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from . import WizConfigEntry +from .entity import WizEntity +from .models import WizData + +PRESET_MODE_BREEZE = "breeze" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WizConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the WiZ Platform from config_flow.""" + if entry.runtime_data.bulb.bulbtype.features.fan: + async_add_entities([WizFanEntity(entry.runtime_data, entry.title)]) + + +class WizFanEntity(WizEntity, FanEntity): + """Representation of WiZ Light bulb.""" + + _attr_name = None + + # We want the implementation of is_on to be the same as in ToggleEntity, + # but it is being overridden in FanEntity, so we need to restore it here. + is_on: ClassVar = ToggleEntity.is_on + + def __init__(self, wiz_data: WizData, name: str) -> None: + """Initialize a WiZ fan.""" + super().__init__(wiz_data, name) + bulb_type: BulbType = self._device.bulbtype + features: Features = bulb_type.features + + supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + if features.fan_reverse: + supported_features |= FanEntityFeature.DIRECTION + if features.fan_breeze_mode: + supported_features |= FanEntityFeature.PRESET_MODE + self._attr_preset_modes = [PRESET_MODE_BREEZE] + + self._attr_supported_features = supported_features + self._attr_speed_count = bulb_type.fan_speed_range + + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + state = self._device.state + + self._attr_is_on = state.get_fan_state() > 0 + self._attr_percentage = ranged_value_to_percentage( + (1, self.speed_count), state.get_fan_speed() + ) + if FanEntityFeature.PRESET_MODE in self.supported_features: + fan_mode = state.get_fan_mode() + self._attr_preset_mode = PRESET_MODE_BREEZE if fan_mode == 2 else None + if FanEntityFeature.DIRECTION in self.supported_features: + fan_reverse = state.get_fan_reverse() + self._attr_current_direction = None + if fan_reverse == 0: + self._attr_current_direction = DIRECTION_FORWARD + elif fan_reverse == 1: + self._attr_current_direction = DIRECTION_REVERSE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + # preset_mode == PRESET_MODE_BREEZE + await self._device.fan_set_state(mode=2) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: + await self.async_turn_off() + return + + speed = math.ceil(percentage_to_ranged_value((1, self.speed_count), percentage)) + await self._device.fan_set_state(mode=1, speed=speed) + await self.coordinator.async_request_refresh() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + mode: int | None = None + speed: int | None = None + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + if preset_mode == PRESET_MODE_BREEZE: + mode = 2 + if percentage is not None: + speed = math.ceil( + percentage_to_ranged_value((1, self.speed_count), percentage) + ) + if mode is None: + mode = 1 + await self._device.fan_turn_on(mode=mode, speed=speed) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self._device.fan_turn_off(**kwargs) + await self.coordinator.async_request_refresh() + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + reverse = 1 if direction == DIRECTION_REVERSE else 0 + await self._device.fan_set_state(reverse=reverse) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 2ae78a8af92..57671ecd007 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -1,7 +1,7 @@ { "domain": "wiz", "name": "WiZ", - "codeowners": ["@sbidy"], + "codeowners": ["@sbidy", "@arturpragacz"], "config_flow": true, "dependencies": ["network"], "dhcp": [ diff --git a/tests/components/wiz/__init__.py b/tests/components/wiz/__init__.py index d84074e37d3..037b6a1dfbd 100644 --- a/tests/components/wiz/__init__.py +++ b/tests/components/wiz/__init__.py @@ -33,6 +33,10 @@ FAKE_STATE = PilotParser( "c": 0, "w": 0, "dimming": 100, + "fanState": 0, + "fanMode": 1, + "fanSpeed": 1, + "fanRevrs": 0, } ) FAKE_IP = "1.1.1.1" @@ -173,6 +177,25 @@ FAKE_OLD_FIRMWARE_DIMMABLE_BULB = BulbType( white_channels=1, white_to_color_ratio=80, ) +FAKE_DIMMABLE_FAN = BulbType( + bulb_type=BulbClass.FANDIM, + name="ESP03_FANDIMS_31", + features=Features( + color=False, + color_tmp=False, + effect=True, + brightness=True, + dual_head=False, + fan=True, + fan_breeze_mode=True, + fan_reverse=True, + ), + kelvin_range=KelvinRange(max=2700, min=2700), + fw_version="1.31.32", + white_channels=1, + white_to_color_ratio=20, + fan_speed_range=6, +) async def setup_integration(hass: HomeAssistant) -> MockConfigEntry: @@ -220,6 +243,9 @@ def _mocked_wizlight( bulb.async_close = AsyncMock() bulb.set_speed = AsyncMock() bulb.set_ratio = AsyncMock() + bulb.fan_set_state = AsyncMock() + bulb.fan_turn_on = AsyncMock() + bulb.fan_turn_off = AsyncMock() bulb.diagnostics = { "mocked": "mocked", "roomId": 123, diff --git a/tests/components/wiz/snapshots/test_fan.ambr b/tests/components/wiz/snapshots/test_fan.ambr new file mode 100644 index 00000000000..2c6b235e78b --- /dev/null +++ b/tests/components/wiz/snapshots/test_fan.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_entity[fan.mock_title-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'breeze', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.mock_title', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'wiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'abcabcabcabc', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[fan.mock_title-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'forward', + 'friendly_name': 'Mock Title', + 'percentage': 16, + 'percentage_step': 16.666666666666668, + 'preset_mode': None, + 'preset_modes': list([ + 'breeze', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.mock_title', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/wiz/test_fan.py b/tests/components/wiz/test_fan.py new file mode 100644 index 00000000000..d15f083d431 --- /dev/null +++ b/tests/components/wiz/test_fan.py @@ -0,0 +1,232 @@ +"""Tests for fan platform.""" + +from typing import Any +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.components.wiz.fan import PRESET_MODE_BREEZE +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import FAKE_DIMMABLE_FAN, FAKE_MAC, async_push_update, async_setup_integration + +from tests.common import snapshot_platform + +ENTITY_ID = "fan.mock_title" + +INITIAL_PARAMS = { + "mac": FAKE_MAC, + "fanState": 0, + "fanMode": 1, + "fanSpeed": 1, + "fanRevrs": 0, +} + + +@patch("homeassistant.components.wiz.PLATFORMS", [Platform.FAN]) +async def test_entity( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test the fan entity.""" + entry = (await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN))[1] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +def _update_params( + params: dict[str, Any], + state: int | None = None, + mode: int | None = None, + speed: int | None = None, + reverse: int | None = None, +) -> dict[str, Any]: + """Get the parameters for the update.""" + if state is not None: + params["fanState"] = state + if mode is not None: + params["fanMode"] = mode + if speed is not None: + params["fanSpeed"] = speed + if reverse is not None: + params["fanRevrs"] = reverse + return params + + +async def test_turn_on_off(hass: HomeAssistant) -> None: + """Test turning the fan on and off.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": None, "speed": None} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE}, + blocking=True, + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 2, "speed": None} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 1, "speed": 3} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes[ATTR_PRESET_MODE] is None + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + calls = device.fan_turn_off.mock_calls + assert len(calls) == 1 + await async_push_update(hass, device, _update_params(params, state=0)) + device.fan_turn_off.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + + +async def test_fan_set_preset_mode(hass: HomeAssistant) -> None: + """Test setting the fan preset mode.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 2} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE + + +async def test_fan_set_percentage(hass: HomeAssistant) -> None: + """Test setting the fan percentage.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 1, "speed": 3} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 0}, + blocking=True, + ) + calls = device.fan_turn_off.mock_calls + assert len(calls) == 1 + await async_push_update(hass, device, _update_params(params, state=0)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 50 + + +async def test_fan_set_direction(hass: HomeAssistant) -> None: + """Test setting the fan direction.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_REVERSE}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"reverse": 1} + await async_push_update(hass, device, _update_params(params, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_DIRECTION] == DIRECTION_REVERSE + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_FORWARD}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"reverse": 0} + await async_push_update(hass, device, _update_params(params, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD From 3d74d0270423c961a78a5e23aad2a3dd510fdc4f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:15:06 +0200 Subject: [PATCH 10/36] Update pytouchlinesl to 0.4.0 (#148801) --- homeassistant/components/touchline_sl/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index ab07ae770fd..5140584f7ff 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.3.0"] + "requirements": ["pytouchlinesl==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 140932f5f52..0b3e1361e81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2538,7 +2538,7 @@ pytomorrowio==0.3.6 pytouchline_extended==0.4.5 # homeassistant.components.touchline_sl -pytouchlinesl==0.3.0 +pytouchlinesl==0.4.0 # homeassistant.components.traccar # homeassistant.components.traccar_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da9d5047723..b822277d8c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2099,7 +2099,7 @@ pytile==2024.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.3.0 +pytouchlinesl==0.4.0 # homeassistant.components.traccar # homeassistant.components.traccar_server From a6e1d968526e7ad1f6d5e0a4f77e98a58efdabc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 15 Jul 2025 11:21:54 +0200 Subject: [PATCH 11/36] Update aioairzone-cloud to v0.6.13 (#148798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index ecc9634f36a..e185ed89106 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.12"] + "requirements": ["aioairzone-cloud==0.6.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b3e1361e81..742deadc2f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.12 +aioairzone-cloud==0.6.13 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b822277d8c6..1a3d5730ec5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.12 +aioairzone-cloud==0.6.13 # homeassistant.components.airzone aioairzone==1.0.0 From b522bd5ef20746c5a516e1ecac75ff4ed0a3d848 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 15 Jul 2025 12:07:57 +0200 Subject: [PATCH 12/36] Get media player features elsewhere for jellyfin (#148805) --- homeassistant/components/jellyfin/media_player.py | 12 ++++++++++-- tests/components/jellyfin/fixtures/sessions.json | 2 +- .../jellyfin/snapshots/test_diagnostics.ambr | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index e0fcc8a559b..b71c0bf93c9 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from homeassistant.components.media_player import ( @@ -21,6 +22,8 @@ from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator from .entity import JellyfinClientEntity +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -177,10 +180,15 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" commands: list[str] = self.capabilities.get("SupportedCommands", []) - controllable = self.capabilities.get("SupportsMediaControl", False) + _LOGGER.debug( + "Supported commands for device %s, client %s, %s", + self.device_name, + self.client_name, + commands, + ) features = MediaPlayerEntityFeature(0) - if controllable: + if "PlayMediaSource" in commands: features |= ( MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json index db2b691dff0..9a8f93dc5bd 100644 --- a/tests/components/jellyfin/fixtures/sessions.json +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -21,7 +21,7 @@ ], "Capabilities": { "PlayableMediaTypes": ["Video"], - "SupportedCommands": ["VolumeSet", "Mute"], + "SupportedCommands": ["VolumeSet", "Mute", "PlayMediaSource"], "SupportsMediaControl": true, "SupportsContentUploading": true, "MessageCallbackUrl": "string", diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr index 9d73ee6397c..0100c7618b7 100644 --- a/tests/components/jellyfin/snapshots/test_diagnostics.ambr +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -182,6 +182,7 @@ 'SupportedCommands': list([ 'VolumeSet', 'Mute', + 'PlayMediaSource', ]), 'SupportsContentUploading': True, 'SupportsMediaControl': True, From 1cb278966c9b05a5588d784031d185287b0da80b Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:15:19 +0200 Subject: [PATCH 13/36] Handle connection issues after websocket reconnected in homematicip_cloud (#147731) --- .../components/homematicip_cloud/hap.py | 63 ++++++++++++------- .../homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homematicip_cloud/test_device.py | 11 +++- .../components/homematicip_cloud/test_hap.py | 61 ++++++++++++++++-- 6 files changed, 107 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index c42ebff200d..d66594da390 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -113,9 +113,7 @@ class HomematicipHAP: self._ws_close_requested = False self._ws_connection_closed = asyncio.Event() - self._retry_task: asyncio.Task | None = None - self._tries = 0 - self._accesspoint_connected = True + self._get_state_task: asyncio.Task | None = None self.hmip_device_by_entity_id: dict[str, Any] = {} self.reset_connection_listener: Callable | None = None @@ -161,17 +159,8 @@ class HomematicipHAP: """ if not self.home.connected: _LOGGER.error("HMIP access point has lost connection with the cloud") - self._accesspoint_connected = False + self._ws_connection_closed.set() self.set_all_to_unavailable() - elif not self._accesspoint_connected: - # Now the HOME_CHANGED event has fired indicating the access - # point has reconnected to the cloud again. - # Explicitly getting an update as entity states might have - # changed during access point disconnect.""" - - job = self.hass.async_create_task(self.get_state()) - job.add_done_callback(self.get_state_finished) - self._accesspoint_connected = True @callback def async_create_entity(self, *args, **kwargs) -> None: @@ -185,20 +174,43 @@ class HomematicipHAP: await asyncio.sleep(30) await self.hass.config_entries.async_reload(self.config_entry.entry_id) + async def _try_get_state(self) -> None: + """Call get_state in a loop until no error occurs, using exponential backoff on error.""" + + # Wait until WebSocket connection is established. + while not self.home.websocket_is_connected(): + await asyncio.sleep(2) + + delay = 8 + max_delay = 1500 + while True: + try: + await self.get_state() + break + except HmipConnectionError as err: + _LOGGER.warning( + "Get_state failed, retrying in %s seconds: %s", delay, err + ) + await asyncio.sleep(delay) + delay = min(delay * 2, max_delay) + async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" await self.home.get_current_state_async() self.update_all() def get_state_finished(self, future) -> None: - """Execute when get_state coroutine has finished.""" + """Execute when try_get_state coroutine has finished.""" try: future.result() - except HmipConnectionError: - # Somehow connection could not recover. Will disconnect and - # so reconnect loop is taking over. - _LOGGER.error("Updating state after HMIP access point reconnect failed") - self.hass.async_create_task(self.home.disable_events()) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error updating state after HMIP access point reconnect: %s", err + ) + else: + _LOGGER.info( + "Updating state after HMIP access point reconnect finished successfully", + ) def set_all_to_unavailable(self) -> None: """Set all devices to unavailable and tell Home Assistant.""" @@ -222,8 +234,8 @@ class HomematicipHAP: async def async_reset(self) -> bool: """Close the websocket connection.""" self._ws_close_requested = True - if self._retry_task is not None: - self._retry_task.cancel() + if self._get_state_task is not None: + self._get_state_task.cancel() await self.home.disable_events_async() _LOGGER.debug("Closed connection to HomematicIP cloud server") await self.hass.config_entries.async_unload_platforms( @@ -247,7 +259,9 @@ class HomematicipHAP: """Handle websocket connected.""" _LOGGER.info("Websocket connection to HomematicIP Cloud established") if self._ws_connection_closed.is_set(): - await self.get_state() + self._get_state_task = self.hass.async_create_task(self._try_get_state()) + self._get_state_task.add_done_callback(self.get_state_finished) + self._ws_connection_closed.clear() async def ws_disconnected_handler(self) -> None: @@ -256,11 +270,12 @@ class HomematicipHAP: self._ws_connection_closed.set() async def ws_reconnected_handler(self, reason: str) -> None: - """Handle websocket reconnection.""" + """Handle websocket reconnection. Is called when Websocket tries to reconnect.""" _LOGGER.info( - "Websocket connection to HomematicIP Cloud re-established due to reason: %s", + "Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s", reason, ) + self._ws_connection_closed.set() async def get_hap( diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index d5af2859873..036ffa286a3 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.6"] + "requirements": ["homematicip==2.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 742deadc2f8..8fe43a3198c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.0.7 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a3d5730ec5..d7e3da48a19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.0.7 # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index aff698cd3d9..9dd537848fe 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -195,9 +195,14 @@ async def test_hap_reconnected( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_UNAVAILABLE - mock_hap._accesspoint_connected = False - await async_manipulate_test_data(hass, mock_hap.home, "connected", True) - await hass.async_block_till_done() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await async_manipulate_test_data(hass, mock_hap.home, "connected", True) + await mock_hap.ws_connected_handler() + await hass.async_block_till_done() + ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index ae094f7dded..69078beafaf 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -1,6 +1,6 @@ """Test HomematicIP Cloud accesspoint.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext @@ -242,7 +242,14 @@ async def test_get_state_after_disconnect( hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch.object(hap, "get_state") as mock_get_state: + simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True) + hap.home = simple_mock_home + hap.home.websocket_is_connected = Mock(side_effect=[False, True]) + + with ( + patch("asyncio.sleep", new=AsyncMock()) as mock_sleep, + patch.object(hap, "get_state") as mock_get_state, + ): assert not hap._ws_connection_closed.is_set() await hap.ws_connected_handler() @@ -250,8 +257,54 @@ async def test_get_state_after_disconnect( await hap.ws_disconnected_handler() assert hap._ws_connection_closed.is_set() - await hap.ws_connected_handler() - mock_get_state.assert_called_once() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await hap.ws_connected_handler() + mock_get_state.assert_called_once() + + assert not hap._ws_connection_closed.is_set() + hap.home.websocket_is_connected.assert_called() + mock_sleep.assert_awaited_with(2) + + +async def test_try_get_state_exponential_backoff() -> None: + """Test _try_get_state waits for websocket connection.""" + + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + hap.home.websocket_is_connected = Mock(return_value=True) + + hap.get_state = AsyncMock( + side_effect=[HmipConnectionError, HmipConnectionError, True] + ) + + with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep: + await hap._try_get_state() + + assert mock_sleep.mock_calls[0].args[0] == 8 + assert mock_sleep.mock_calls[1].args[0] == 16 + assert hap.get_state.call_count == 3 + + +async def test_try_get_state_handle_exception() -> None: + """Test _try_get_state handles exceptions.""" + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + + expected_exception = Exception("Connection error") + future = AsyncMock() + future.result = Mock(side_effect=expected_exception) + + with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as mock_logger: + hap.get_state_finished(future) + + mock_logger.error.assert_called_once_with( + "Error updating state after HMIP access point reconnect: %s", expected_exception + ) async def test_async_connect( From ab187f39c2b63e434013a587e37517186bdef4fb Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:16:07 +0200 Subject: [PATCH 14/36] Add support for HmIP-RGBW and HmIP-LSC in homematicip_cloud integration (#148639) --- .../components/homematicip_cloud/light.py | 77 +++- .../fixtures/homematicip_cloud.json | 370 ++++++++++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_light.py | 76 ++++ 4 files changed, 523 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index d5175e6e647..1e602cd09c2 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -2,13 +2,20 @@ from __future__ import annotations +import logging from typing import Any -from homematicip.base.enums import DeviceType, OpticalSignalBehaviour, RGBColorState +from homematicip.base.enums import ( + DeviceType, + FunctionalChannelType, + OpticalSignalBehaviour, + RGBColorState, +) from homematicip.base.functionalChannels import NotificationLightChannel from homematicip.device import ( BrandDimmer, BrandSwitchNotificationLight, + Device, Dimmer, DinRailDimmer3, FullFlushDimmer, @@ -34,6 +41,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP +_logger = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -43,6 +52,14 @@ async def async_setup_entry( """Set up the HomematicIP Cloud lights from a config entry.""" hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] + + entities.extend( + HomematicipLightHS(hap, d, ch.index) + for d in hap.home.devices + for ch in d.functionalChannels + if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL + ) + for device in hap.home.devices: if ( isinstance(device, SwitchMeasuring) @@ -104,6 +121,64 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): await self._device.turn_off_async() +class HomematicipLightHS(HomematicipGenericEntity, LightEntity): + """Representation of the HomematicIP light with HS color mode.""" + + _attr_color_mode = ColorMode.HS + _attr_supported_color_modes = {ColorMode.HS} + + def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None: + """Initialize the light entity.""" + super().__init__(hap, device, channel=channel_index, is_multi_channel=True) + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.functional_channel.on + + @property + def brightness(self) -> int | None: + """Return the current brightness.""" + return int(self.functional_channel.dimLevel * 255.0) + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hue and saturation color value [float, float].""" + if ( + self.functional_channel.hue is None + or self.functional_channel.saturationLevel is None + ): + return None + return ( + self.functional_channel.hue, + self.functional_channel.saturationLevel * 100.0, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + + hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0)) + hue = hs_color[0] % 360.0 + saturation = hs_color[1] / 100.0 + dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2) + + if ATTR_HS_COLOR not in kwargs: + hue = self.functional_channel.hue + saturation = self.functional_channel.saturationLevel + + if ATTR_BRIGHTNESS not in kwargs: + # If no brightness is set, use the current brightness + dim_level = self.functional_channel.dimLevel or 1.0 + + await self.functional_channel.set_hue_saturation_dim_level_async( + hue=hue, saturation_level=saturation, dim_level=dim_level + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.functional_channel.set_switch_state_async(on=False) + + class HomematicipLightMeasuring(HomematicipLight): """Representation of the HomematicIP measuring light.""" diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index c378190d00c..c9eab0cf4f5 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8566,6 +8566,376 @@ "serializedGlobalTradeItemNumber": "3014F71100000000000SVCTH", "type": "TEMPERATURE_HUMIDITY_SENSOR_COMPACT", "updateState": "UP_TO_DATE" + }, + "3014F71100000000000RGBW2": { + "availableFirmwareVersion": "1.0.62", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "fastColorChangeSupported": true, + "firmwareVersion": "1.0.62", + "firmwareVersionInteger": 65598, + "functionalChannels": { + "0": { + "altitude": null, + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "dataDecodingFailedError": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F71100000000000RGBW2", + "deviceOperationMode": "UNIVERSAL_LIGHT_1_RGB", + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "frostProtectionError": null, + "frostProtectionErrorAcknowledged": null, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000056"], + "index": 0, + "inputLayoutMode": null, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": null, + "mountingModuleError": null, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "noDataFromLinkyError": null, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -50, + "rssiPeerValue": null, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDataDecodingFailedError": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceMountingModuleError": false, + "IFeatureDeviceOverheated": true, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTempSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeatureNoDataFromLinkyError": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IFeatureTicVersionError": false, + "IOptionalFeatureAltitude": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceFrostProtectionError": false, + "IOptionalFeatureDeviceInputLayoutMode": false, + "IOptionalFeatureDeviceOperationMode": true, + "IOptionalFeatureDeviceSwitchChannelMode": false, + "IOptionalFeatureDeviceValveError": false, + "IOptionalFeatureDeviceWaterError": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureLowBat": false, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": false + }, + "switchChannelMode": null, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "temperatureSensorError": null, + "ticVersionError": null, + "unreach": false, + "valveFlowError": null, + "valveWaterError": null + }, + "1": { + "channelActive": true, + "channelRole": "UNIVERSAL_LIGHT_ACTUATOR", + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": false, + "dimLevel": 0.68, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000061"], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": 120, + "humanCentricLightActive": false, + "index": 1, + "label": "", + "lampFailure": null, + "lightSceneId": 1, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": true, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": 0.8, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLightGroupActuatorChannel": true, + "IFeatureLightProfileActuatorChannel": true, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": true, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": true, + "IOptionalFeatureLightScene": true, + "IOptionalFeatureLightSceneWithShortTimes": true, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": true, + "IOptionalFeaturePowerUpHueSaturationValue": true, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "2": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 2, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "3": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 3, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "4": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 4, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F71100000000000RGBW2", + "label": "RGBW Controller", + "lastStatusUpdate": 1749973334235, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 1, + "measuredAttributes": {}, + "modelId": 462, + "modelType": "HmIP-RGBW", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F71100000000000RGBW2", + "type": "RGBW_DIMMER", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 9dd537848fe..4fb9f9eede8 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -22,7 +22,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 331 + assert len(mock_hap.hmip_device_by_entity_id) == 335 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index b929bd337cc..85106f2d987 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -600,3 +600,79 @@ async def test_hmip_din_rail_dimmer_3_channel3( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF assert not ha_state.attributes.get(ATTR_BRIGHTNESS) + + +async def test_hmip_light_hs( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipLight with HS color mode.""" + entity_id = "light.rgbw_controller_channel1" + entity_name = "RGBW Controller Channel1" + device_model = "HmIP-RGBW" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["RGBW Controller"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == STATE_ON + assert ha_state.attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_HS_COLOR: [240.0, 100.0]}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": 240.0, + "saturation_level": 1.0, + "dim_level": 0.68, + } + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_HS_COLOR: [220.0, 80.0], ATTR_BRIGHTNESS: 123}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": 220.0, + "saturation_level": 0.8, + "dim_level": 0.48, + } + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_BRIGHTNESS: 40}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": hmip_device.functionalChannels[1].hue, + "saturation_level": hmip_device.functionalChannels[1].saturationLevel, + "dim_level": 0.16, + } From 8256401f7f91a52d4d92c512267fb769eed75dc9 Mon Sep 17 00:00:00 2001 From: wuede Date: Tue, 15 Jul 2025 12:16:59 +0200 Subject: [PATCH 15/36] expose schedule id as an extra state attribute in Netatmo (#147076) --- homeassistant/components/netatmo/climate.py | 21 +++++++++++++------ homeassistant/components/netatmo/const.py | 1 + .../netatmo/snapshots/test_climate.ambr | 4 ++++ tests/components/netatmo/test_climate.py | 13 ++++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index f8f89ffd06b..a74ed630a4b 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -38,6 +38,7 @@ from .const import ( ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, + ATTR_SELECTED_SCHEDULE_ID, ATTR_TARGET_TEMPERATURE, ATTR_TIME_PERIOD, DATA_SCHEDULES, @@ -251,16 +252,22 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): if data["event_type"] == EVENT_TYPE_SCHEDULE: # handle schedule change if "schedule_id" in data: + selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][ + self.home.entity_id + ].get(data["schedule_id"]) self._selected_schedule = getattr( - self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get( - data["schedule_id"] - ), + selected_schedule, "name", None, ) self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( self._selected_schedule ) + + self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr( + selected_schedule, "entity_id", None + ) + self.async_write_ha_state() self.data_handler.async_force_update(self._signal_name) # ignore other schedule events @@ -420,12 +427,14 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._attr_hvac_mode = HVAC_MAP_NETATMO[self._attr_preset_mode] self._away = self._attr_hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] - self._selected_schedule = getattr( - self.home.get_selected_schedule(), "name", None - ) + selected_schedule = self.home.get_selected_schedule() + self._selected_schedule = getattr(selected_schedule, "name", None) self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( self._selected_schedule ) + self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr( + selected_schedule, "entity_id", None + ) if self.device_type == NA_VALVE: self._attr_extra_state_attributes[ATTR_HEATING_POWER_REQUEST] = ( diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index d69a62f37f9..d8ecc72ada7 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -95,6 +95,7 @@ ATTR_PSEUDO = "pseudo" ATTR_SCHEDULE_ID = "schedule_id" ATTR_SCHEDULE_NAME = "schedule_name" ATTR_SELECTED_SCHEDULE = "selected_schedule" +ATTR_SELECTED_SCHEDULE_ID = "selected_schedule_id" ATTR_TARGET_TEMPERATURE = "target_temperature" ATTR_TIME_PERIOD = "time_period" diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr index 22a50213306..e5d5f477d34 100644 --- a/tests/components/netatmo/snapshots/test_climate.ambr +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -147,6 +147,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, @@ -229,6 +230,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 22, @@ -312,6 +314,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, @@ -396,6 +399,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 12, diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index f38e21021dc..0344ec8a7c1 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -681,6 +681,13 @@ async def test_service_schedule_thermostats( webhook_id = config_entry.data[CONF_WEBHOOK_ID] climate_entity_livingroom = "climate.livingroom" + assert ( + hass.states.get(climate_entity_livingroom).attributes.get( + "selected_schedule_id" + ) + == "591b54a2764ff4d50d8b5795" + ) + # Test setting a valid schedule with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_schedule: await hass.services.async_call( @@ -707,6 +714,12 @@ async def test_service_schedule_thermostats( hass.states.get(climate_entity_livingroom).attributes["selected_schedule"] == "Winter" ) + assert ( + hass.states.get(climate_entity_livingroom).attributes.get( + "selected_schedule_id" + ) + == "b1b54a2f45795764f59d50d8" + ) # Test setting an invalid schedule with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_home_schedule: From c7aadcdd20544aa3842091b5b5c032bd8fa553b2 Mon Sep 17 00:00:00 2001 From: Alex Leversen <91166616+leversonic@users.noreply.github.com> Date: Tue, 15 Jul 2025 06:35:20 -0400 Subject: [PATCH 16/36] Add file name/size sensors to OctoPrint integration (#148636) --- homeassistant/components/octoprint/sensor.py | 62 +++++++++++++- tests/components/octoprint/__init__.py | 16 +++- tests/components/octoprint/test_sensor.py | 82 ++++++++++++++----- .../{test_servics.py => test_services.py} | 0 4 files changed, 137 insertions(+), 23 deletions(-) rename tests/components/octoprint/{test_servics.py => test_services.py} (100%) diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 71db1d804c5..5594de48ff5 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import PERCENTAGE, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -84,6 +84,8 @@ async def async_setup_entry( OctoPrintJobPercentageSensor(coordinator, device_id), OctoPrintEstimatedFinishTimeSensor(coordinator, device_id), OctoPrintStartTimeSensor(coordinator, device_id), + OctoPrintFileNameSensor(coordinator, device_id), + OctoPrintFileSizeSensor(coordinator, device_id), ] async_add_entities(entities) @@ -262,3 +264,61 @@ class OctoPrintTemperatureSensor(OctoPrintSensorBase): def available(self) -> bool: """Return if entity is available.""" return self.coordinator.last_update_success and self.coordinator.data["printer"] + + +class OctoPrintFileNameSensor(OctoPrintSensorBase): + """Representation of an OctoPrint sensor.""" + + def __init__( + self, + coordinator: OctoprintDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize a new OctoPrint sensor.""" + super().__init__(coordinator, "Current File", device_id) + + @property + def native_value(self) -> str | None: + """Return sensor state.""" + job: OctoprintJobInfo = self.coordinator.data["job"] + + return job.job.file.name or None + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + job: OctoprintJobInfo = self.coordinator.data["job"] + return job and job.job.file.name + + +class OctoPrintFileSizeSensor(OctoPrintSensorBase): + """Representation of an OctoPrint sensor.""" + + _attr_device_class = SensorDeviceClass.DATA_SIZE + _attr_native_unit_of_measurement = UnitOfInformation.BYTES + _attr_suggested_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__( + self, + coordinator: OctoprintDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize a new OctoPrint sensor.""" + super().__init__(coordinator, "Current File Size", device_id) + + @property + def native_value(self) -> int | None: + """Return sensor state.""" + job: OctoprintJobInfo = self.coordinator.data["job"] + + return job.job.file.size or None + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + job: OctoprintJobInfo = self.coordinator.data["job"] + return job and job.job.file.size diff --git a/tests/components/octoprint/__init__.py b/tests/components/octoprint/__init__.py index 3ddae7de587..3755b84a6f9 100644 --- a/tests/components/octoprint/__init__.py +++ b/tests/components/octoprint/__init__.py @@ -21,7 +21,21 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry DEFAULT_JOB = { - "job": {"file": {}}, + "job": { + "averagePrintTime": None, + "estimatedPrintTime": None, + "filament": None, + "file": { + "date": None, + "display": None, + "name": None, + "origin": None, + "path": None, + "size": None, + }, + "lastPrintTime": None, + "user": None, + }, "progress": {"completion": 50}, } diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index 87485e46807..3b0ed2ded0b 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime from freezegun.api import FrozenDateTimeFactory +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,11 +24,7 @@ async def test_sensors( }, "temperature": {"tool1": {"actual": 18.83136, "target": 37.83136}}, } - job = { - "job": {"file": {}}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Printing", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC)) await init_integration(hass, "sensor", printer=printer, job=job) @@ -80,6 +77,21 @@ async def test_sensors( entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" + state = hass.states.get("sensor.octoprint_current_file") + assert state is not None + assert state.state == "Test_File_Name.gcode" + assert state.name == "OctoPrint Current File" + entry = entity_registry.async_get("sensor.octoprint_current_file") + assert entry.unique_id == "Current File-uuid" + + state = hass.states.get("sensor.octoprint_current_file_size") + assert state is not None + assert state.state == "123.456789" + assert state.attributes.get("unit_of_measurement") == UnitOfInformation.MEGABYTES + assert state.name == "OctoPrint Current File Size" + entry = entity_registry.async_get("sensor.octoprint_current_file_size") + assert entry.unique_id == "Current File Size-uuid" + async def test_sensors_no_target_temp( hass: HomeAssistant, @@ -106,11 +118,25 @@ async def test_sensors_no_target_temp( state = hass.states.get("sensor.octoprint_target_tool1_temp") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint target tool1 temp" entry = entity_registry.async_get("sensor.octoprint_target_tool1_temp") assert entry.unique_id == "target tool1 temp-uuid" + state = hass.states.get("sensor.octoprint_current_file") + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert state.name == "OctoPrint Current File" + entry = entity_registry.async_get("sensor.octoprint_current_file") + assert entry.unique_id == "Current File-uuid" + + state = hass.states.get("sensor.octoprint_current_file_size") + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert state.name == "OctoPrint Current File Size" + entry = entity_registry.async_get("sensor.octoprint_current_file_size") + assert entry.unique_id == "Current File Size-uuid" + async def test_sensors_paused( hass: HomeAssistant, @@ -125,24 +151,20 @@ async def test_sensors_paused( }, "temperature": {"tool1": {"actual": 18.83136, "target": None}}, } - job = { - "job": {"file": {}}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Paused", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) await init_integration(hass, "sensor", printer=printer, job=job) state = hass.states.get("sensor.octoprint_start_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Start Time" entry = entity_registry.async_get("sensor.octoprint_start_time") assert entry.unique_id == "Start Time-uuid" state = hass.states.get("sensor.octoprint_estimated_finish_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" @@ -154,11 +176,7 @@ async def test_sensors_printer_disconnected( entity_registry: er.EntityRegistry, ) -> None: """Test the underlying sensors.""" - job = { - "job": {"file": {}}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Paused", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) await init_integration(hass, "sensor", printer=None, job=job) @@ -171,21 +189,43 @@ async def test_sensors_printer_disconnected( state = hass.states.get("sensor.octoprint_current_state") assert state is not None - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE assert state.name == "OctoPrint Current State" entry = entity_registry.async_get("sensor.octoprint_current_state") assert entry.unique_id == "Current State-uuid" state = hass.states.get("sensor.octoprint_start_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Start Time" entry = entity_registry.async_get("sensor.octoprint_start_time") assert entry.unique_id == "Start Time-uuid" state = hass.states.get("sensor.octoprint_estimated_finish_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" + + +def __standard_job(): + return { + "job": { + "averagePrintTime": 6500, + "estimatedPrintTime": 6000, + "filament": {"tool0": {"length": 3000, "volume": 7}}, + "file": { + "date": 1577836800, + "display": "Test File Name", + "name": "Test_File_Name.gcode", + "origin": "local", + "path": "Folder1/Folder2/Test_File_Name.gcode", + "size": 123456789, + }, + "lastPrintTime": 12345.678, + "user": "testUser", + }, + "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, + "state": "Printing", + } diff --git a/tests/components/octoprint/test_servics.py b/tests/components/octoprint/test_services.py similarity index 100% rename from tests/components/octoprint/test_servics.py rename to tests/components/octoprint/test_services.py From ee4325a927426f8208210502f88c09c40c356819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 15 Jul 2025 12:40:48 +0200 Subject: [PATCH 17/36] Replace deprecated battery property on Miele vacuum with sensor (#148765) --- homeassistant/components/miele/sensor.py | 10 + homeassistant/components/miele/vacuum.py | 6 - .../miele/snapshots/test_sensor.ambr | 375 ++++++++++++++++++ .../miele/snapshots/test_vacuum.ambr | 12 +- tests/components/miele/test_sensor.py | 15 + 5 files changed, 404 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index ff72b791735..a0daf462c7b 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -539,6 +539,16 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( options=sorted(StateDryingStep.keys()), ), ), + MieleSensorDefinition( + types=(MieleAppliance.ROBOT_VACUUM_CLEANER,), + description=MieleSensorDescription( + key="state_battery", + value_fn=lambda value: value.state_battery_level, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + ), + ), ) diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py index 29a89e39bdb..999ceac5cce 100644 --- a/homeassistant/components/miele/vacuum.py +++ b/homeassistant/components/miele/vacuum.py @@ -87,7 +87,6 @@ class MieleVacuumStateCode(MieleEnum): SUPPORTED_FEATURES = ( VacuumEntityFeature.STATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.START | VacuumEntityFeature.STOP @@ -174,11 +173,6 @@ class MieleVacuum(MieleEntity, StateVacuumEntity): MieleVacuumStateCode(self.device.state_program_phase).value ) - @property - def battery_level(self) -> int | None: - """Return the battery level.""" - return self.device.state_battery_level - @property def fan_speed(self) -> str | None: """Return the fan speed.""" diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index dfc12a52c08..e37af02bf26 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -2920,3 +2920,378 @@ 'state': '0.0', }) # --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_cleaner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:robot-vacuum', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Vacuum_1-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner', + 'icon': 'mdi:robot-vacuum', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Vacuum_1-state_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum cleaner Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_elapsed_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'Dummy_Vacuum_1-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Robot vacuum cleaner Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'no_program', + 'silent', + 'spot', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_cleaner_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'Dummy_Vacuum_1-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner Program', + 'options': list([ + 'auto', + 'no_program', + 'silent', + 'spot', + 'turbo', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'Dummy_Vacuum_1-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal_operation_mode', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'Dummy_Vacuum_1-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Robot vacuum cleaner Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/miele/snapshots/test_vacuum.ambr b/tests/components/miele/snapshots/test_vacuum.ambr index 9f96db7b05a..3b808ad9cd2 100644 --- a/tests/components/miele/snapshots/test_vacuum.ambr +++ b/tests/components/miele/snapshots/test_vacuum.ambr @@ -34,7 +34,7 @@ 'platform': 'miele', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': 'Dummy_Vacuum_1-vacuum', 'unit_of_measurement': None, @@ -43,8 +43,6 @@ # name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-60', - 'battery_level': 65, 'fan_speed': 'normal', 'fan_speed_list': list([ 'normal', @@ -52,7 +50,7 @@ 'silent', ]), 'friendly_name': 'Robot vacuum cleaner', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.robot_vacuum_cleaner', @@ -97,7 +95,7 @@ 'platform': 'miele', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': 'Dummy_Vacuum_1-vacuum', 'unit_of_measurement': None, @@ -106,8 +104,6 @@ # name: test_vacuum_states_api_push[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-60', - 'battery_level': 65, 'fan_speed': 'normal', 'fan_speed_list': list([ 'normal', @@ -115,7 +111,7 @@ 'silent', ]), 'friendly_name': 'Robot vacuum cleaner', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.robot_vacuum_cleaner', diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 47e101c6636..3f66f36f556 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -54,3 +54,18 @@ async def test_hob_sensor_states( """Test sensor state.""" await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_vacuum_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test robot vacuum cleaner sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) From 7d06aec8dabac85f999aa3f51b5d922e665054da Mon Sep 17 00:00:00 2001 From: Andrea Turri Date: Tue, 15 Jul 2025 12:50:28 +0200 Subject: [PATCH 18/36] Discovery of Miele temperature sensors (#144585) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/miele/entity.py | 7 +- homeassistant/components/miele/sensor.py | 204 +- .../components/miele/fixtures/4_actions.json | 15 + .../components/miele/fixtures/4_devices.json | 124 + .../miele/fixtures/fridge_freezer.json | 109 + tests/components/miele/fixtures/oven.json | 142 ++ .../miele/snapshots/test_binary_sensor.ambr | 582 +++++ .../miele/snapshots/test_button.ambr | 192 ++ .../miele/snapshots/test_diagnostics.ambr | 168 ++ .../miele/snapshots/test_light.ambr | 114 + .../miele/snapshots/test_sensor.ambr | 2145 +++++++++++++++++ .../miele/snapshots/test_switch.ambr | 96 + tests/components/miele/test_init.py | 8 +- tests/components/miele/test_sensor.py | 189 +- 14 files changed, 4015 insertions(+), 80 deletions(-) create mode 100644 tests/components/miele/fixtures/fridge_freezer.json create mode 100644 tests/components/miele/fixtures/oven.json diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py index f9ed4f0bf48..4c6e61f6ea5 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -16,6 +16,11 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): _attr_has_entity_name = True + @staticmethod + def get_unique_id(device_id: str, description: EntityDescription) -> str: + """Generate a unique ID for the entity.""" + return f"{device_id}-{description.key}" + def __init__( self, coordinator: MieleDataUpdateCoordinator, @@ -26,7 +31,7 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): super().__init__(coordinator) self._device_id = device_id self.entity_description = description - self._attr_unique_id = f"{device_id}-{description.key}" + self._attr_unique_id = MieleEntity.get_unique_id(device_id, description) device = self.device appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type)) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index a0daf462c7b..216b91ca68e 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass import logging from typing import Final, cast -from pymiele import MieleDevice +from pymiele import MieleDevice, MieleTemperature from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,10 +25,13 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( + DISABLED_TEMP_ENTITIES, + DOMAIN, STATE_PROGRAM_ID, STATE_PROGRAM_PHASE, STATE_STATUS_TAGS, @@ -45,8 +48,6 @@ PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) -DISABLED_TEMPERATURE = -32768 - DEFAULT_PLATE_COUNT = 4 PLATE_COUNT = { @@ -75,12 +76,25 @@ def _convert_duration(value_list: list[int]) -> int | None: return value_list[0] * 60 + value_list[1] if value_list else None +def _convert_temperature( + value_list: list[MieleTemperature], index: int +) -> float | None: + """Convert temperature object to readable value.""" + if index >= len(value_list): + return None + raw_value = cast(int, value_list[index].temperature) / 100.0 + if raw_value in DISABLED_TEMP_ENTITIES: + return None + return raw_value + + @dataclass(frozen=True, kw_only=True) class MieleSensorDescription(SensorEntityDescription): """Class describing Miele sensor entities.""" value_fn: Callable[[MieleDevice], StateType] - zone: int = 1 + zone: int | None = None + unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None @dataclass @@ -404,32 +418,20 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( ), description=MieleSensorDescription( key="state_temperature_1", + zone=1, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: cast(int, value.state_temperatures[0].temperature) - / 100.0, + value_fn=lambda value: _convert_temperature(value.state_temperatures, 0), ), ), MieleSensorDefinition( types=( - MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, - MieleAppliance.OVEN, - MieleAppliance.OVEN_MICROWAVE, - MieleAppliance.DISH_WARMER, - MieleAppliance.STEAM_OVEN, - MieleAppliance.MICROWAVE, - MieleAppliance.FRIDGE, - MieleAppliance.FREEZER, MieleAppliance.FRIDGE_FREEZER, - MieleAppliance.STEAM_OVEN_COMBI, MieleAppliance.WINE_CABINET, MieleAppliance.WINE_CONDITIONING_UNIT, MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, - MieleAppliance.STEAM_OVEN_MICRO, - MieleAppliance.DIALOG_OVEN, MieleAppliance.WINE_CABINET_FREEZER, - MieleAppliance.STEAM_OVEN_MK2, ), description=MieleSensorDescription( key="state_temperature_2", @@ -438,7 +440,24 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( translation_key="temperature_zone_2", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: value.state_temperatures[1].temperature / 100.0, # type: ignore [operator] + value_fn=lambda value: _convert_temperature(value.state_temperatures, 1), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleSensorDescription( + key="state_temperature_3", + zone=3, + device_class=SensorDeviceClass.TEMPERATURE, + translation_key="temperature_zone_3", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: _convert_temperature(value.state_temperatures, 2), ), ), MieleSensorDefinition( @@ -454,11 +473,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast( - int, value.state_core_target_temperature[0].temperature - ) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_core_target_temperature, 0 ), ), ), @@ -479,9 +495,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast(int, value.state_target_temperature[0].temperature) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_target_temperature, 0 ), ), ), @@ -497,9 +512,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast(int, value.state_core_temperature[0].temperature) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_core_temperature, 0 ), ), ), @@ -518,6 +532,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.ENUM, options=sorted(PlatePowerStep.keys()), value_fn=lambda value: None, + unique_id_fn=lambda device_id, + description: f"{device_id}-{description.key}-{description.zone}", ), ) for i in range(1, 7) @@ -559,10 +575,52 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data - added_devices: set[str] = set() + added_devices: set[str] = set() # device_id + added_entities: set[str] = set() # unique_id - def _async_add_new_devices() -> None: - nonlocal added_devices + def _get_entity_class(definition: MieleSensorDefinition) -> type[MieleSensor]: + """Get the entity class for the sensor.""" + return { + "state_status": MieleStatusSensor, + "state_program_id": MieleProgramIdSensor, + "state_program_phase": MielePhaseSensor, + "state_plate_step": MielePlateSensor, + }.get(definition.description.key, MieleSensor) + + def _is_entity_registered(unique_id: str) -> bool: + """Check if the entity is already registered.""" + entity_registry = er.async_get(hass) + return any( + entry.platform == DOMAIN and entry.unique_id == unique_id + for entry in entity_registry.entities.values() + ) + + def _is_sensor_enabled( + definition: MieleSensorDefinition, + device: MieleDevice, + unique_id: str, + ) -> bool: + """Check if the sensor is enabled.""" + if ( + definition.description.device_class == SensorDeviceClass.TEMPERATURE + and definition.description.value_fn(device) is None + and definition.description.zone != 1 + ): + # all appliances supporting temperature have at least zone 1, for other zones + # don't create entity if API signals that datapoint is disabled, unless the sensor + # already appeared in the past (= it provided a valid value) + return _is_entity_registered(unique_id) + if ( + definition.description.key == "state_plate_step" + and definition.description.zone is not None + and definition.description.zone > _get_plate_count(device.tech_type) + ): + # don't create plate entity if not expected by the appliance tech type + return False + return True + + def _async_add_devices() -> None: + nonlocal added_devices, added_entities entities: list = [] entity_class: type[MieleSensor] new_devices_set, current_devices = coordinator.async_add_devices(added_devices) @@ -570,40 +628,35 @@ async def async_setup_entry( for device_id, device in coordinator.data.devices.items(): for definition in SENSOR_TYPES: - if ( - device_id in new_devices_set - and device.device_type in definition.types - ): - match definition.description.key: - case "state_status": - entity_class = MieleStatusSensor - case "state_program_id": - entity_class = MieleProgramIdSensor - case "state_program_phase": - entity_class = MielePhaseSensor - case "state_plate_step": - entity_class = MielePlateSensor - case _: - entity_class = MieleSensor - if ( - definition.description.device_class - == SensorDeviceClass.TEMPERATURE - and definition.description.value_fn(device) - == DISABLED_TEMPERATURE / 100 - ) or ( - definition.description.key == "state_plate_step" - and definition.description.zone - > _get_plate_count(device.tech_type) - ): - # Don't create entity if API signals that datapoint is disabled - continue - entities.append( - entity_class(coordinator, device_id, definition.description) + # device is not supported, skip + if device.device_type not in definition.types: + continue + + entity_class = _get_entity_class(definition) + unique_id = ( + definition.description.unique_id_fn( + device_id, definition.description ) + if definition.description.unique_id_fn is not None + else MieleEntity.get_unique_id(device_id, definition.description) + ) + + # entity was already added, skip + if device_id not in new_devices_set and unique_id in added_entities: + continue + + # sensors is not enabled, skip + if not _is_sensor_enabled(definition, device, unique_id): + continue + + added_entities.add(unique_id) + entities.append( + entity_class(coordinator, device_id, definition.description) + ) async_add_entities(entities) - config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) - _async_add_new_devices() + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_devices)) + _async_add_devices() APPLIANCE_ICONS = { @@ -641,6 +694,17 @@ class MieleSensor(MieleEntity, SensorEntity): entity_description: MieleSensorDescription + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id, description) + if description.unique_id_fn is not None: + self._attr_unique_id = description.unique_id_fn(device_id, description) + @property def native_value(self) -> StateType: """Return the state of the sensor.""" @@ -652,16 +716,6 @@ class MielePlateSensor(MieleSensor): entity_description: MieleSensorDescription - def __init__( - self, - coordinator: MieleDataUpdateCoordinator, - device_id: str, - description: MieleSensorDescription, - ) -> None: - """Initialize the plate sensor.""" - super().__init__(coordinator, device_id, description) - self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}" - @property def native_value(self) -> StateType: """Return the state of the plate sensor.""" @@ -672,7 +726,7 @@ class MielePlateSensor(MieleSensor): cast( int, self.device.state_plate_step[ - self.entity_description.zone - 1 + cast(int, self.entity_description.zone) - 1 ].value_raw, ) ).name diff --git a/tests/components/miele/fixtures/4_actions.json b/tests/components/miele/fixtures/4_actions.json index 6a89fb4604a..903a075df3c 100644 --- a/tests/components/miele/fixtures/4_actions.json +++ b/tests/components/miele/fixtures/4_actions.json @@ -82,5 +82,20 @@ "colors": [], "modes": [], "runOnTime": [] + }, + "DummyAppliance_12": { + "processAction": [], + "light": [2], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [], + "deviceName": true, + "powerOn": false, + "powerOff": true, + "colors": [], + "modes": [], + "runOnTime": [] } } diff --git a/tests/components/miele/fixtures/4_devices.json b/tests/components/miele/fixtures/4_devices.json index b63c60ff4d3..7d6ee9a7173 100644 --- a/tests/components/miele/fixtures/4_devices.json +++ b/tests/components/miele/fixtures/4_devices.json @@ -466,5 +466,129 @@ "ecoFeedback": null, "batteryLevel": null } + }, + "DummyAppliance_12": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 356, + "value_localized": "Defrost", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 1, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 3073, + "value_localized": "Heating-up phase", + "key_localized": "Program phase" + }, + "remainingTime": [0, 5], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 2500, + "value_localized": 25.0, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": 1954, + "value_localized": 19.54, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": 2200, + "value_localized": 22.0, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": true + }, + "ambientLight": null, + "light": 1, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } } } diff --git a/tests/components/miele/fixtures/fridge_freezer.json b/tests/components/miele/fixtures/fridge_freezer.json new file mode 100644 index 00000000000..5d091b9c74e --- /dev/null +++ b/tests/components/miele/fixtures/fridge_freezer.json @@ -0,0 +1,109 @@ +{ + "DummyAppliance_Fridge_Freezer": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 21, + "value_localized": "Fridge freezer" + }, + "deviceName": "", + "protocolVersion": 203, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KFN 7734 C", + "matNumber": "12336150", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK037LHBM", + "releaseVersion": "32.33" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 400, + "value_localized": 4.0, + "unit": "Celsius" + }, + { + "value_raw": -1800, + "value_localized": -18.0, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": 400, + "value_localized": 4.0, + "unit": "Celsius" + }, + { + "value_raw": -1800, + "value_localized": -18.0, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/oven.json b/tests/components/miele/fixtures/oven.json new file mode 100644 index 00000000000..dbf14d4546c --- /dev/null +++ b/tests/components/miele/fixtures/oven.json @@ -0,0 +1,142 @@ +{ + "DummyOven": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [ + "6166", + "25211", + "25210", + "4860", + "25245", + "6153", + "6050", + "25300", + "25307", + "25247", + "20570", + "25223", + "5640", + "20366", + "20462" + ] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/snapshots/test_binary_sensor.ambr b/tests/components/miele/snapshots/test_binary_sensor.ambr index f102c925c98..9a3de2ddd49 100644 --- a/tests/components/miele/snapshots/test_binary_sensor.ambr +++ b/tests/components/miele/snapshots/test_binary_sensor.ambr @@ -532,6 +532,297 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_12-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_12-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_12-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_12-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1647,6 +1938,297 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_12-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_12-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_12-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_12-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_states_api_push[platforms0][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_button.ambr b/tests/components/miele/snapshots/test_button.ambr index 6e6f3cbb72d..e4eb80587c9 100644 --- a/tests/components/miele/snapshots/test_button.ambr +++ b/tests/components/miele/snapshots/test_button.ambr @@ -47,6 +47,102 @@ 'state': 'unknown', }) # --- +# name: test_button_states[platforms0][button.oven_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'DummyAppliance_12-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.oven_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Start', + }), + 'context': , + 'entity_id': 'button.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_12-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_button_states[platforms0][button.washing_machine_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -239,6 +335,102 @@ 'state': 'unavailable', }) # --- +# name: test_button_states_api_push[platforms0][button.oven_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'DummyAppliance_12-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Start', + }), + 'context': , + 'entity_id': 'button.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_12-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_button_states_api_push[platforms0][button.washing_machine_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_diagnostics.ambr b/tests/components/miele/snapshots/test_diagnostics.ambr index 8fa40755888..54f6083a74c 100644 --- a/tests/components/miele/snapshots/test_diagnostics.ambr +++ b/tests/components/miele/snapshots/test_diagnostics.ambr @@ -144,6 +144,39 @@ 'ventilationStep': list([ ]), }), + '**REDACTED_e7bc6793e305bf53': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + dict({ + 'max': 28, + 'min': -28, + 'zone': 1, + }), + ]), + 'ventilationStep': list([ + ]), + }), }), 'devices': dict({ '**REDACTED_019aa577ad1c330d': dict({ @@ -661,6 +694,141 @@ }), }), }), + '**REDACTED_e7bc6793e305bf53': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '16', + 'fabNumber': '**REDACTED**', + 'matNumber': '11120960', + 'swids': list([ + ]), + 'techType': 'H7660BP', + }), + 'deviceName': '', + 'protocolVersion': 4, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Oven', + 'value_raw': 12, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '08.32', + 'techType': 'EK057', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': 'Defrost', + 'value_raw': 356, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'coreTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 22.0, + 'value_raw': 2200, + }), + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + 0, + 0, + ]), + 'light': 1, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': 'Heating-up phase', + 'value_raw': 3073, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': 'Program', + 'value_raw': 1, + }), + 'remainingTime': list([ + 0, + 5, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': True, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 25.0, + 'value_raw': 2500, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 19.54, + 'value_raw': 1954, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), }), 'missing_code_warnings': list([ 'None', diff --git a/tests/components/miele/snapshots/test_light.ambr b/tests/components/miele/snapshots/test_light.ambr index 8c4a4f4bff9..243536fc997 100644 --- a/tests/components/miele/snapshots/test_light.ambr +++ b/tests/components/miele/snapshots/test_light.ambr @@ -113,6 +113,63 @@ 'state': 'on', }) # --- +# name: test_light_states[platforms0][light.oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_12-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states[platforms0][light.oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Oven Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_light_states_api_push[platforms0][light.hood_ambient_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -227,3 +284,60 @@ 'state': 'on', }) # --- +# name: test_light_states_api_push[platforms0][light.oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_12-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states_api_push[platforms0][light.oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Oven Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index e37af02bf26..915eda4d361 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -1,4 +1,207 @@ # serializer version: 1 +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fridge_freezer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Fridge freezer', + 'icon': 'mdi:fridge-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fridge_freezer_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Fridge freezer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fridge_freezer_temperature_zone_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature zone 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_zone_2', + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Fridge freezer Temperature zone 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_temperature_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -808,6 +1011,921 @@ 'state': 'off', }) # --- +# name: test_sensor_states[platforms0][sensor.oven-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:chef-hat', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_12-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven', + 'icon': 'mdi:chef-hat', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_core_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_core_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'core_temperature', + 'unique_id': 'DummyAppliance_12-state_core_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_core_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Core temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_elapsed_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'DummyAppliance_12-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'DummyAppliance_12-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program', + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'defrost', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'DummyAppliance_12-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program phase', + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating_up', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'DummyAppliance_12-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'own_program', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'DummyAppliance_12-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_start_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_start_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'DummyAppliance_12-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'DummyAppliance_12-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.54', + }) +# --- # name: test_sensor_states[platforms0][sensor.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1640,6 +2758,62 @@ 'state': '0.0', }) # --- +# name: test_sensor_states[platforms0][sensor.washing_machine_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'Dummy_Appliance_3-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Washing machine Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_states[platforms0][sensor.washing_machine_water_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1983,6 +3157,921 @@ 'state': 'off', }) # --- +# name: test_sensor_states_api_push[platforms0][sensor.oven-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:chef-hat', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_12-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven', + 'icon': 'mdi:chef-hat', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_core_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_core_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'core_temperature', + 'unique_id': 'DummyAppliance_12-state_core_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_core_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Core temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_elapsed_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'DummyAppliance_12-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'DummyAppliance_12-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program', + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'defrost', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'DummyAppliance_12-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program phase', + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating_up', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'DummyAppliance_12-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'own_program', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'DummyAppliance_12-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_start_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_start_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'DummyAppliance_12-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'DummyAppliance_12-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.54', + }) +# --- # name: test_sensor_states_api_push[platforms0][sensor.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2815,6 +4904,62 @@ 'state': '0.0', }) # --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'Dummy_Appliance_3-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Washing machine Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_switch.ambr b/tests/components/miele/snapshots/test_switch.ambr index c8ca88c5b59..769b08271a5 100644 --- a/tests/components/miele/snapshots/test_switch.ambr +++ b/tests/components/miele/snapshots/test_switch.ambr @@ -95,6 +95,54 @@ 'state': 'off', }) # --- +# name: test_switch_states[platforms0][switch.oven_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.oven_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_12-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.oven_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Power', + }), + 'context': , + 'entity_id': 'switch.oven_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_states[platforms0][switch.refrigerator_supercooling-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -287,6 +335,54 @@ 'state': 'off', }) # --- +# name: test_switch_states_api_push[platforms0][switch.oven_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.oven_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_12-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states_api_push[platforms0][switch.oven_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Power', + }), + 'context': , + 'entity_id': 'switch.oven_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_states_api_push[platforms0][switch.refrigerator_supercooling-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py index dd3f3b95d02..cdf1a39b421 100644 --- a/tests/components/miele/test_init.py +++ b/tests/components/miele/test_init.py @@ -109,7 +109,7 @@ async def test_devices_multiple_created_count( """Test that multiple devices are created.""" await setup_integration(hass, mock_config_entry) - assert len(device_registry.devices) == 4 + assert len(device_registry.devices) == 5 async def test_device_info( @@ -200,11 +200,13 @@ async def test_setup_all_platforms( ) freezer.tick(timedelta(seconds=130)) + prev_devices = len(device_registry.devices) + async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(device_registry.devices) == 6 + assert len(device_registry.devices) == prev_devices + 2 # Check a sample sensor for each new device assert hass.states.get("sensor.dishwasher").state == "in_use" - assert hass.states.get("sensor.oven_temperature").state == "175.0" + assert hass.states.get("sensor.oven_temperature_2").state == "175.0" diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 3f66f36f556..f35404a665b 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -1,15 +1,24 @@ """Tests for miele sensor module.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pymiele import MieleDevices import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.miele.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, + snapshot_platform, +) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @@ -56,6 +65,184 @@ async def test_hob_sensor_states( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.parametrize("load_device_file", ["fridge_freezer.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_fridge_freezer_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_oven_temperatures_scenario( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + mock_config_entry: MockConfigEntry, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Parametrized test for verifying temperature sensors for oven devices.""" + + # Initial state when the oven is and created for the first time - don't know if it supports core temperature (probe) + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 0) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 0) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 0) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 0) + + # Simulate temperature settings, no probe temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = 8000 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + 80.0 + ) + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = 2150 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = 21.5 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "21.5", 1) + check_sensor_state(hass, "sensor.oven_target_temperature", "80.0", 1) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 1) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 1) + + # Simulate unsetting temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + None + ) + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 2) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 2) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 2) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 2) + + # Simulate temperature settings with probe temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = 8000 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + 80.0 + ) + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0]["value_raw"] = 3000 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = 30.0 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = 2183 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = 21.83 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "21.83", 3) + check_sensor_state(hass, "sensor.oven_target_temperature", "80.0", 3) + check_sensor_state(hass, "sensor.oven_core_temperature", "22.0", 2) + check_sensor_state(hass, "sensor.oven_core_target_temperature", "30.0", 3) + + # Simulate unsetting temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + None + ) + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_raw" + ] = -32768 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = None + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = None + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_core_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_core_target_temperature", "unknown", 4) + + +def check_sensor_state( + hass: HomeAssistant, + sensor_entity: str, + expected: str, + step: int, +): + """Check the state of sensor matches the expected state.""" + + state = hass.states.get(sensor_entity) + + if expected is None: + assert state is None, ( + f"[{sensor_entity}] Step {step + 1}: got {state.state}, expected nothing" + ) + else: + assert state is not None, f"Missing entity: {sensor_entity}" + assert state.state == expected, ( + f"[{sensor_entity}] Step {step + 1}: got {state.state}, expected {expected}" + ) + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_temperature_sensor_registry_lookup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_miele_client: MagicMock, + setup_platform: None, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that core temperature sensor is provided by the integration after looking up in entity registry.""" + + # Initial state, the oven is showing core temperature (probe) + freezer.tick(timedelta(seconds=130)) + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_id = "sensor.oven_core_temperature" + + assert hass.states.get(entity_id) is not None + assert hass.states.get(entity_id).state == "22.0" + + # reload device when turned off, reporting the invalid value + mock_miele_client.get_devices.return_value = await async_load_json_object_fixture( + hass, "oven.json", DOMAIN + ) + + # unload config entry and reload to make sure that the entity is still provided + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unavailable" + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unknown" + + @pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") From 0acfb81d500ee049ab11b8bdd0d77ba84d78ef35 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 15 Jul 2025 19:53:29 +0800 Subject: [PATCH 19/36] Clean up YoLink entities on startup (#148718) --- homeassistant/components/yolink/__init__.py | 14 ++++ tests/components/yolink/conftest.py | 77 +++++++++++++++++++++ tests/components/yolink/test_init.py | 38 ++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/components/yolink/conftest.py create mode 100644 tests/components/yolink/test_init.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7132fd6a414..96db2ab555a 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -165,6 +165,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = YoLinkHomeStore( yolink_home, device_coordinators ) + + # Clean up yolink devices which are not associated to the account anymore. + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + for identifier in device_entry.identifiers: + if ( + identifier[0] == DOMAIN + and device_coordinators.get(identifier[1]) is None + ): + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def async_yolink_unload(event) -> None: diff --git a/tests/components/yolink/conftest.py b/tests/components/yolink/conftest.py new file mode 100644 index 00000000000..2090cd57f2f --- /dev/null +++ b/tests/components/yolink/conftest.py @@ -0,0 +1,77 @@ +"""Provide common fixtures for the YoLink integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from yolink.home_manager import YoLinkHome + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.yolink.api import ConfigEntryAuth +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "12345" +CLIENT_SECRET = "6789" +DOMAIN = "yolink" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="mock_auth_manager") +def mock_auth_manager() -> Generator[MagicMock]: + """Mock the authentication manager.""" + with patch( + "homeassistant.components.yolink.api.ConfigEntryAuth", autospec=True + ) as mock_auth: + mock_auth.return_value = MagicMock(spec=ConfigEntryAuth) + yield mock_auth + + +@pytest.fixture(name="mock_yolink_home") +def mock_yolink_home() -> Generator[AsyncMock]: + """Mock YoLink home instance.""" + with patch( + "homeassistant.components.yolink.YoLinkHome", autospec=True + ) as mock_home: + mock_home.return_value = AsyncMock(spec=YoLinkHome) + yield mock_home + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock a config entry for YoLink.""" + config_entry = MockConfigEntry( + unique_id=DOMAIN, + domain=DOMAIN, + title="yolink", + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "create", + }, + }, + options={}, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/yolink/test_init.py b/tests/components/yolink/test_init.py new file mode 100644 index 00000000000..11d0528dcce --- /dev/null +++ b/tests/components/yolink/test_init.py @@ -0,0 +1,38 @@ +"""Tests for the yolink integration.""" + +import pytest + +from homeassistant.components.yolink import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("setup_credentials", "mock_auth_manager", "mock_yolink_home") +async def test_device_remove_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can only remove a device that no longer exists.""" + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "stale_device_id")}, + ) + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + device_entry = device_entries[0] + assert device_entry.identifiers == {(DOMAIN, "stale_device_id")} + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 0 From cd94685b7d41afcd993bff39810864e1e7ded91a Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:55:13 +0800 Subject: [PATCH 20/36] Add Fan platform to Switchbot cloud (#148304) Co-authored-by: Joost Lekkerkerker --- .../components/switchbot_cloud/__init__.py | 13 +- .../components/switchbot_cloud/fan.py | 120 +++++++++++ .../components/switchbot_cloud/sensor.py | 1 + tests/components/switchbot_cloud/test_fan.py | 187 ++++++++++++++++++ 4 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/switchbot_cloud/fan.py create mode 100644 tests/components/switchbot_cloud/test_fan.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index b87a569abda..482c5c4a9e6 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -29,6 +29,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.FAN, Platform.LOCK, Platform.SENSOR, Platform.SWITCH, @@ -51,6 +52,7 @@ class SwitchbotDevices: sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -96,7 +98,6 @@ async def make_switchbot_devices( for device in devices ] ) - return devices_data @@ -177,6 +178,16 @@ async def make_device_data( else: devices_data.switches.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in [ + "Battery Circulator Fan", + "Circulator Fan", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.fans.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py new file mode 100644 index 00000000000..d7cf82520ec --- /dev/null +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -0,0 +1,120 @@ +"""Support for the Switchbot Battery Circulator fan.""" + +import asyncio +from typing import Any + +from switchbot_api import ( + BatteryCirculatorFanCommands, + BatteryCirculatorFanMode, + CommonCommands, +) + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudFan(data.api, device, coordinator) + for device, coordinator in data.devices.fans + ) + + +class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): + """Representation of a SwitchBot Battery Circulator Fan.""" + + _attr_name = None + + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + _attr_preset_modes = list(BatteryCirculatorFanMode) + + _attr_is_on: bool | None = None + + @property + def is_on(self) -> bool | None: + """Return true if the entity is on.""" + return self._attr_is_on + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + + power: str = self.coordinator.data["power"] + mode: str = self.coordinator.data["mode"] + fan_speed: str = self.coordinator.data["fanSpeed"] + self._attr_is_on = power == "on" + self._attr_preset_mode = mode + self._attr_percentage = int(fan_speed) + self._attr_supported_features = ( + FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + if self.is_on and self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + self._attr_supported_features |= FanEntityFeature.SET_SPEED + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + await self.send_api_command(CommonCommands.ON) + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=str(self.preset_mode), + ) + if self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_SPEED, + parameters=str(self.percentage), + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=str(BatteryCirculatorFanMode.DIRECT.value), + ) + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_SPEED, + parameters=str(percentage), + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=preset_mode, + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index f93df234289..75e994b484e 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -91,6 +91,7 @@ CO2_DESCRIPTION = SensorEntityDescription( SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Bot": (BATTERY_DESCRIPTION,), + "Battery Circulator Fan": (BATTERY_DESCRIPTION,), "Meter": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, diff --git a/tests/components/switchbot_cloud/test_fan.py b/tests/components/switchbot_cloud/test_fan.py new file mode 100644 index 00000000000..4a9eb527818 --- /dev/null +++ b/tests/components/switchbot_cloud/test_fan.py @@ -0,0 +1,187 @@ +"""Test for the Switchbot Battery Circulator Fan.""" + +from unittest.mock import patch + +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_coordinator_data_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + None, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_UNKNOWN + + +async def test_turn_on(hass: HomeAssistant, mock_list_devices, mock_get_status) -> None: + """Test turning on the fan.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +async def test_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test turning off the fan.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +async def test_set_percentage( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set percentage.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "5"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 5}, + blocking=True, + ) + mock_send_command.assert_called() + + +async def test_set_preset_mode( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set preset mode.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "baby", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "baby"}, + blocking=True, + ) + mock_send_command.assert_called_once() From b89b248b4c7ceaabbfadeca24b05ea39d72bc124 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:18:14 +0200 Subject: [PATCH 21/36] Add tuya snapshots for qxj category (#148802) --- tests/components/tuya/__init__.py | 8 + .../qxj_temp_humidity_external_probe.json | 65 +++ .../tuya/fixtures/qxj_weather_station.json | 412 +++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 479 ++++++++++++++++++ 4 files changed, 964 insertions(+) create mode 100644 tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json create mode 100644 tests/components/tuya/fixtures/qxj_weather_station.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 09606c7e116..c8f54fa275d 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -75,6 +75,14 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "qxj_temp_humidity_external_probe": [ + # https://github.com/home-assistant/core/issues/136472 + Platform.SENSOR, + ], + "qxj_weather_station": [ + # https://github.com/orgs/home-assistant/discussions/318 + Platform.SENSOR, + ], "rqbj_gas_sensor": [ # https://github.com/orgs/home-assistant/discussions/100 Platform.BINARY_SENSOR, diff --git a/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json b/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json new file mode 100644 index 00000000000..caccb0b9234 --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json @@ -0,0 +1,65 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1708196692712PHOeqy", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bff00f6abe0563b284t77p", + "name": "Frysen", + "category": "qxj", + "product_id": "is2indt9nlth6esa", + "product_name": "T & H Sensor with external probe", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-27T15:19:27+00:00", + "create_time": "2025-01-27T15:19:27+00:00", + "update_time": "2025-01-27T15:19:27+00:00", + "function": {}, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "temp_current": 222, + "humidity_value": 38, + "battery_state": "high", + "temp_current_external": -130 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qxj_weather_station.json b/tests/components/tuya/fixtures/qxj_weather_station.json new file mode 100644 index 00000000000..c52086213fd --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_weather_station.json @@ -0,0 +1,412 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1751921699759JsVujI", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf84c743a84eb2c8abeurz", + "name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "category": "qxj", + "product_id": "fsea1lat3vuktbt6", + "product_name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-07T17:43:41+00:00", + "create_time": "2025-07-07T17:43:41+00:00", + "update_time": "2025-07-07T17:43:41+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + } + }, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + }, + "fault_type": { + "type": "Enum", + "value": { + "range": [ + "normal", + "ch1_offline", + "ch2_offline", + "ch3_offline", + "offline" + ] + } + }, + "battery_status": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_1": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_2": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_3": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_1": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_1": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_2": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_3": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_3": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "atmospheric_pressture": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 3000, + "max": 12000, + "scale": 1, + "step": 1 + } + }, + "pressure_drop": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 0, + "max": 15, + "scale": 0, + "step": 1 + } + }, + "windspeed_avg": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "windspeed_gust": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "wind_direct": { + "type": "Enum", + "value": { + "range": [ + "north", + "north_north_east", + "north_east", + "east_north_east", + "east", + "east_south_east", + "south_east", + "south_south_east", + "south", + "south_south_west", + "south_west", + "west_south_west", + "west", + "west_north_west", + "north_west", + "north_north_west" + ] + } + }, + "rain_24h": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 1000000, + "scale": 3, + "step": 1 + } + }, + "rain_rate": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 999999, + "scale": 3, + "step": 1 + } + }, + "uv_index": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 180, + "scale": 1, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 238000, + "scale": 0, + "step": 100 + } + }, + "dew_point_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 800, + "scale": 1, + "step": 1 + } + }, + "feellike_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "heat_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 260, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "windchill_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "com_index": { + "type": "Enum", + "value": { + "range": ["moist", "dry", "comfortable"] + } + } + }, + "status": { + "temp_current": 240, + "humidity_value": 52, + "battery_state": "high", + "temp_unit_convert": "c", + "windspeed_unit_convert": "m_s", + "pressure_unit_convert": "hpa", + "rain_unit_convert": "mm", + "bright_unit_convert": "lux", + "fault_type": "normal", + "battery_status": "low", + "battery_state_1": "high", + "battery_state_2": "high", + "battery_state_3": "low", + "temp_current_external": -400, + "humidity_outdoor": 0, + "temp_current_external_1": 193, + "humidity_outdoor_1": 99, + "temp_current_external_2": 252, + "humidity_outdoor_2": 0, + "temp_current_external_3": -400, + "humidity_outdoor_3": 0, + "atmospheric_pressture": 10040, + "pressure_drop": 0, + "windspeed_avg": 0, + "windspeed_gust": 0, + "wind_direct": "none", + "rain_24h": 0, + "rain_rate": 0, + "uv_index": 0, + "bright_value": 0, + "dew_point_temp": -400, + "feellike_temp": -650, + "heat_index": 260, + "windchill_index": -650, + "com_index": "none" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index f63c75567ef..8cf51062a73 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1265,6 +1265,485 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frysen_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bff00f6abe0563b284t77pbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frysen Battery state', + }), + 'context': , + 'entity_id': 'sensor.frysen_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frysen_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bff00f6abe0563b284t77phumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Frysen Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.frysen_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frysen_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-13.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frysen_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Battery state', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbright_value', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- # name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From c0585611623798a82e6542a4540cbcfbe7494cfe Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 09:53:01 -0400 Subject: [PATCH 22/36] Add initalize for abstract template entities (#147504) --- .../template/alarm_control_panel.py | 22 ++--------- .../components/template/binary_sensor.py | 16 ++------ homeassistant/components/template/button.py | 10 ++++- homeassistant/components/template/cover.py | 11 ++---- homeassistant/components/template/entity.py | 22 ++++++++++- homeassistant/components/template/fan.py | 11 ++---- homeassistant/components/template/image.py | 10 ++++- homeassistant/components/template/light.py | 11 ++---- homeassistant/components/template/lock.py | 5 ++- homeassistant/components/template/number.py | 16 ++++---- homeassistant/components/template/select.py | 11 ++---- homeassistant/components/template/sensor.py | 16 ++------ homeassistant/components/template/switch.py | 23 +++--------- .../components/template/template_entity.py | 37 ++++++------------- .../components/template/trigger_entity.py | 2 +- homeassistant/components/template/vacuum.py | 11 ++---- homeassistant/components/template/weather.py | 9 ++--- tests/components/template/test_entity.py | 2 +- tests/components/template/test_sensor.py | 4 +- .../template/test_template_entity.py | 2 +- 20 files changed, 106 insertions(+), 145 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index a308d55e443..97896e08a68 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -32,8 +32,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -42,7 +40,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -213,6 +211,8 @@ class AbstractTemplateAlarmControlPanel( ): """Representation of a templated Alarm Control Panel features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -363,12 +363,8 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP unique_id: str | None, ) -> None: """Initialize the panel.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateAlarmControlPanel.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -379,11 +375,6 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -434,11 +425,6 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 6d41a5804b6..caac43712a7 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -39,8 +39,6 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -51,7 +49,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .const import CONF_AVAILABILITY_TEMPLATE from .helpers import async_setup_template_platform from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity @@ -161,6 +159,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) """A virtual binary sensor that triggers from another sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -169,11 +168,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) unique_id: str | None, ) -> None: """Initialize the Template binary sensor.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + TemplateEntity.__init__(self, hass, config, unique_id) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._template = config[CONF_STATE] @@ -182,10 +177,6 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) self._delay_on_raw = config.get(CONF_DELAY_ON) self._delay_off = None self._delay_off_raw = config.get(CONF_DELAY_OFF) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_added_to_hass(self) -> None: """Restore state.""" @@ -258,6 +249,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = BINARY_SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index c52e2dae5a0..397fc5f4174 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -3,12 +3,14 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.components.button import ( DEVICE_CLASSES_SCHEMA, DOMAIN as BUTTON_DOMAIN, + ENTITY_ID_FORMAT, ButtonEntity, ) from homeassistant.config_entries import ConfigEntry @@ -84,6 +86,7 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -92,8 +95,11 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): unique_id: str | None, ) -> None: """Initialize the button.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None + TemplateEntity.__init__(self, hass, config, unique_id) + + if TYPE_CHECKING: + assert self._attr_name is not None + # Scripts can be an empty list, therefore we need to check for None if (action := config.get(CONF_PRESS)) is not None: self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 9d6391d80c9..bceac7811f4 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -32,12 +32,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( @@ -162,6 +161,8 @@ async def async_setup_platform( class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): """Representation of a template cover features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -397,12 +398,8 @@ class StateCoverEntity(TemplateEntity, AbstractTemplateCover): unique_id, ) -> None: """Initialize the Template cover.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateCover.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 3617d9acdee..a97a5ac6571 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -3,21 +3,39 @@ from collections.abc import Sequence from typing import Any +from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.script import Script, _VarsType from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_OBJECT_ID class AbstractTemplateEntity(Entity): """Actions linked to a template entity.""" - def __init__(self, hass: HomeAssistant) -> None: + _entity_id_format: str + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Initialize the entity.""" self.hass = hass self._action_scripts: dict[str, Script] = {} + if self.hass: + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + self._entity_id_format, object_id, hass=self.hass + ) + + self._attr_device_info = async_device_info_to_link_from_device_id( + self.hass, + config.get(CONF_DEVICE_ID), + ) + @property def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 95086375f4b..34faba353d0 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -34,11 +34,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -154,6 +153,8 @@ async def async_setup_platform( class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): """Representation of a template fan features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -436,12 +437,8 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan): unique_id, ) -> None: """Initialize the fan.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateFan.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 5f7f06faf4f..ed7093cfcdb 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -7,7 +7,11 @@ from typing import Any import voluptuous as vol -from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity +from homeassistant.components.image import ( + DOMAIN as IMAGE_DOMAIN, + ENTITY_ID_FORMAT, + ImageEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback @@ -91,6 +95,7 @@ class StateImageEntity(TemplateEntity, ImageEntity): _attr_should_poll = False _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -99,7 +104,7 @@ class StateImageEntity(TemplateEntity, ImageEntity): unique_id: str | None, ) -> None: """Initialize the image.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] self._attr_device_info = async_device_info_to_link_from_device_id( @@ -135,6 +140,7 @@ class TriggerImageEntity(TriggerEntity, ImageEntity): """Image entity based on trigger data.""" _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT domain = IMAGE_DOMAIN extra_template_keys = (CONF_URL,) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 438c295ecd5..fb97d95db3d 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -43,13 +43,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( @@ -215,6 +214,8 @@ async def async_setup_platform( class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__( # pylint: disable=super-init-not-called @@ -893,12 +894,8 @@ class StateLightEntity(TemplateEntity, AbstractTemplateLight): unique_id: str | None, ) -> None: """Initialize the light.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLight.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 20bc098d130..581a037c3d7 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, + ENTITY_ID_FORMAT, PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, @@ -104,6 +105,8 @@ async def async_setup_platform( class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Representation of a template lock features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -283,7 +286,7 @@ class StateLockEntity(TemplateEntity, AbstractTemplateLock): unique_id: str | None, ) -> None: """Initialize the lock.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLock.__init__(self, config) name = self._attr_name if TYPE_CHECKING: diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index fa1e2790a9d..e0b8e7594ce 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -13,6 +13,7 @@ from homeassistant.components.number import ( DEFAULT_MIN_VALUE, DEFAULT_STEP, DOMAIN as NUMBER_DOMAIN, + ENTITY_ID_FORMAT, NumberEntity, ) from homeassistant.config_entries import ConfigEntry @@ -25,7 +26,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -115,6 +115,7 @@ class StateNumberEntity(TemplateEntity, NumberEntity): """Representation of a template number.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -123,8 +124,10 @@ class StateNumberEntity(TemplateEntity, NumberEntity): unique_id: str | None, ) -> None: """Initialize the number.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None + TemplateEntity.__init__(self, hass, config, unique_id) + if TYPE_CHECKING: + assert self._attr_name is not None + self._value_template = config[CONF_STATE] self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN) @@ -136,10 +139,6 @@ class StateNumberEntity(TemplateEntity, NumberEntity): self._attr_native_step = DEFAULT_STEP self._attr_native_min_value = DEFAULT_MIN_VALUE self._attr_native_max_value = DEFAULT_MAX_VALUE - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @callback def _async_setup_templates(self) -> None: @@ -188,6 +187,7 @@ class StateNumberEntity(TemplateEntity, NumberEntity): class TriggerNumberEntity(TriggerEntity, NumberEntity): """Number entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = NUMBER_DOMAIN extra_template_keys = ( CONF_STATE, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 55b5c7375f8..d5abf7033a9 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -11,13 +11,13 @@ from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, + ENTITY_ID_FORMAT, SelectEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -93,6 +93,8 @@ async def async_setup_entry( class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): """Representation of a template select features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -132,7 +134,7 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect): unique_id: str | None, ) -> None: """Initialize the select.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateSelect.__init__(self, config) name = self._attr_name @@ -142,11 +144,6 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect): if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - @callback def _async_setup_templates(self) -> None: """Set up templates.""" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 11fe279fdfb..6fc0588d9c7 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -44,8 +44,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -55,7 +53,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE from .helpers import async_setup_template_platform from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity @@ -199,6 +197,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -207,7 +206,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + super().__init__(hass, config, unique_id) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS) @@ -215,14 +214,6 @@ class StateSensorEntity(TemplateEntity, SensorEntity): self._attr_last_reset_template: template.Template | None = config.get( ATTR_LAST_RESET ) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) @callback def _async_setup_templates(self) -> None: @@ -266,6 +257,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index e2ccb5a8a82..7c1abd6d852 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -30,8 +30,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -40,7 +38,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, @@ -154,6 +152,7 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -162,11 +161,8 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + super().__init__(hass, config, unique_id) + name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -180,10 +176,6 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): self._state: bool | None = False self._attr_assumed_state = self._template is None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @callback def _update_state(self, result): @@ -246,6 +238,7 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): """Switch entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = SWITCH_DOMAIN def __init__( @@ -256,6 +249,7 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) self._template = config.get(CONF_STATE) if on_action := config.get(CONF_TURN_ON): @@ -268,11 +262,6 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): self._to_render_simple.append(CONF_STATE) self._parse_result.add(CONF_STATE) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index e404821e651..b5081189cf3 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -240,17 +240,11 @@ class TemplateEntity(AbstractTemplateEntity): def __init__( self, hass: HomeAssistant, - *, - availability_template: Template | None = None, - icon_template: Template | None = None, - entity_picture_template: Template | None = None, - attribute_templates: dict[str, Template] | None = None, - config: ConfigType | None = None, - fallback_name: str | None = None, - unique_id: str | None = None, + config: ConfigType, + unique_id: str | None, ) -> None: """Template Entity.""" - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} @@ -269,22 +263,13 @@ class TemplateEntity(AbstractTemplateEntity): | None ) = None self._run_variables: ScriptVariables | dict - if config is None: - self._attribute_templates = attribute_templates - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template - self._friendly_name_template = None - self._run_variables = {} - self._blueprint_inputs = None - else: - self._attribute_templates = config.get(CONF_ATTRIBUTES) - self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) - self._run_variables = config.get(CONF_VARIABLES, {}) - self._blueprint_inputs = config.get("raw_blueprint_inputs") + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) + self._friendly_name_template = config.get(CONF_NAME) + self._run_variables = config.get(CONF_VARIABLES, {}) + self._blueprint_inputs = config.get("raw_blueprint_inputs") class DummyState(State): """None-state for template entities not yet added to the state machine.""" @@ -302,7 +287,7 @@ class TemplateEntity(AbstractTemplateEntity): variables = {"this": DummyState()} # Try to render the name as it can influence the entity ID - self._attr_name = fallback_name + self._attr_name = None if self._friendly_name_template: with contextlib.suppress(TemplateError): self._attr_name = self._friendly_name_template.async_render( diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 4565e86843a..66c57eb2aab 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -30,7 +30,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Initialize the entity.""" CoordinatorEntity.__init__(self, coordinator) TriggerBaseEntity.__init__(self, hass, config) - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._state_render_error = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index d9c416f4863..143eb837bb5 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -34,11 +34,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -147,6 +146,8 @@ async def async_setup_platform( class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -302,12 +303,8 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): unique_id, ) -> None: """Initialize the vacuum.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateVacuum.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 66ead388d5d..671a2ad0bac 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -35,7 +35,6 @@ from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -153,6 +152,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -161,9 +161,8 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): unique_id: str | None, ) -> None: """Initialize the Template weather.""" - super().__init__(hass, config=config, unique_id=unique_id) + super().__init__(hass, config, unique_id) - name = self._attr_name self._condition_template = config[CONF_CONDITION_TEMPLATE] self._temperature_template = config[CONF_TEMPERATURE_TEMPLATE] self._humidity_template = config[CONF_HUMIDITY_TEMPLATE] @@ -191,8 +190,6 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) - self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) - self._condition = None self._temperature = None self._humidity = None @@ -486,6 +483,7 @@ class WeatherExtraStoredData(ExtraStoredData): class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = WEATHER_DOMAIN extra_template_keys = ( CONF_CONDITION_TEMPLATE, @@ -501,6 +499,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): ) -> None: """Initialize.""" super().__init__(hass, coordinator, config) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 67a85839982..4a6940c2813 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: """Test abstract template entity raises not implemented error.""" - entity = abstract_entity.AbstractTemplateEntity(None) + entity = abstract_entity.AbstractTemplateEntity(None, {}) with pytest.raises(NotImplementedError): _ = entity.referenced_blueprint diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index e89e98601d6..9aba8511192 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1141,7 +1141,7 @@ async def test_duplicate_templates(hass: HomeAssistant) -> None: "unique_id": "listening-test-event", "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "unique_id": "hello_name-id", "device_class": "battery", @@ -1360,7 +1360,7 @@ async def test_trigger_conditional_entity_invalid_condition( { "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "value_template": "{{ trigger.event.data.beer }}", "entity_picture_template": "{{ '/local/dogs.png' }}", diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index d66fc2710c9..b743f7e2d9f 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(None) + entity = template_entity.TemplateEntity(None, {}, "something_unique") with pytest.raises(ValueError, match="^hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello")) From 087a938a7d2194f9c34c3c8bce1a26b1040cfe18 Mon Sep 17 00:00:00 2001 From: Myles Eftos Date: Wed, 16 Jul 2025 00:32:59 +1000 Subject: [PATCH 23/36] Add forecast service to amberelectric (#144848) Co-authored-by: G Johansson --- .../components/amberelectric/__init__.py | 13 +- .../components/amberelectric/const.py | 12 +- .../components/amberelectric/coordinator.py | 25 +- .../components/amberelectric/helpers.py | 25 ++ .../components/amberelectric/icons.json | 5 + .../components/amberelectric/sensor.py | 8 +- .../components/amberelectric/services.py | 121 ++++++++++ .../components/amberelectric/services.yaml | 16 ++ .../components/amberelectric/strings.json | 58 ++++- tests/components/amberelectric/__init__.py | 12 + tests/components/amberelectric/conftest.py | 179 +++++++++++++- tests/components/amberelectric/helpers.py | 150 +++++++++++- .../amberelectric/test_coordinator.py | 28 +-- .../components/amberelectric/test_helpers.py | 17 ++ tests/components/amberelectric/test_sensor.py | 225 +++++++----------- .../components/amberelectric/test_services.py | 202 ++++++++++++++++ 16 files changed, 879 insertions(+), 217 deletions(-) create mode 100644 homeassistant/components/amberelectric/helpers.py create mode 100644 homeassistant/components/amberelectric/services.py create mode 100644 homeassistant/components/amberelectric/services.yaml create mode 100644 tests/components/amberelectric/test_helpers.py create mode 100644 tests/components/amberelectric/test_services.py diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py index 9eab6f42ad3..06641327946 100644 --- a/homeassistant/components/amberelectric/__init__.py +++ b/homeassistant/components/amberelectric/__init__.py @@ -2,11 +2,22 @@ import amberelectric +from homeassistant.components.sensor import ConfigType from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv -from .const import CONF_SITE_ID, PLATFORMS +from .const import CONF_SITE_ID, DOMAIN, PLATFORMS from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .services import setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Amber component.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool: diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 56324628ed6..bdb9aa3186c 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -1,14 +1,24 @@ """Amber Electric Constants.""" import logging +from typing import Final from homeassistant.const import Platform -DOMAIN = "amberelectric" +DOMAIN: Final = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_CHANNEL_TYPE = "channel_type" + ATTRIBUTION = "Data provided by Amber Electric" LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] + +SERVICE_GET_FORECASTS = "get_forecasts" + +GENERAL_CHANNEL = "general" +CONTROLLED_LOAD_CHANNEL = "controlled_load" +FEED_IN_CHANNEL = "feed_in" diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 1edf64ba0d6..a1efef26aae 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -10,7 +10,6 @@ from amberelectric.models.actual_interval import ActualInterval from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.rest import ApiException from homeassistant.config_entries import ConfigEntry @@ -18,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +from .helpers import normalize_descriptor type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator] @@ -49,27 +49,6 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> return interval.channel_type == ChannelType.FEEDIN -def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: - """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" - if descriptor is None: - return None - if descriptor.value == "spike": - return "spike" - if descriptor.value == "high": - return "high" - if descriptor.value == "neutral": - return "neutral" - if descriptor.value == "low": - return "low" - if descriptor.value == "veryLow": - return "very_low" - if descriptor.value == "extremelyLow": - return "extremely_low" - if descriptor.value == "negative": - return "negative" - return None - - class AmberUpdateCoordinator(DataUpdateCoordinator): """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" @@ -103,7 +82,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator): "grid": {}, } try: - data = self._api.get_current_prices(self.site_id, next=48) + data = self._api.get_current_prices(self.site_id, next=288) intervals = [interval.actual_instance for interval in data] except ApiException as api_exception: raise UpdateFailed("Missing price data, skipping update") from api_exception diff --git a/homeassistant/components/amberelectric/helpers.py b/homeassistant/components/amberelectric/helpers.py new file mode 100644 index 00000000000..c383c21f276 --- /dev/null +++ b/homeassistant/components/amberelectric/helpers.py @@ -0,0 +1,25 @@ +"""Formatting helpers used to convert things.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +DESCRIPTOR_MAP: dict[str, str] = { + PriceDescriptor.SPIKE: "spike", + PriceDescriptor.HIGH: "high", + PriceDescriptor.NEUTRAL: "neutral", + PriceDescriptor.LOW: "low", + PriceDescriptor.VERYLOW: "very_low", + PriceDescriptor.EXTREMELYLOW: "extremely_low", + PriceDescriptor.NEGATIVE: "negative", +} + + +def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: + """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" + if descriptor in DESCRIPTOR_MAP: + return DESCRIPTOR_MAP[descriptor] + return None + + +def format_cents_to_dollars(cents: float) -> float: + """Return a formatted conversion from cents to dollars.""" + return round(cents / 100, 2) diff --git a/homeassistant/components/amberelectric/icons.json b/homeassistant/components/amberelectric/icons.json index 7dd6ae3217c..a2d0a0a5486 100644 --- a/homeassistant/components/amberelectric/icons.json +++ b/homeassistant/components/amberelectric/icons.json @@ -22,5 +22,10 @@ } } } + }, + "services": { + "get_forecasts": { + "service": "mdi:transmission-tower" + } } } diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 7276ddb26a5..f7a61bea5a5 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -23,16 +23,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION -from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor +from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .helpers import format_cents_to_dollars, normalize_descriptor UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}" -def format_cents_to_dollars(cents: float) -> float: - """Return a formatted conversion from cents to dollars.""" - return round(cents / 100, 2) - - def friendly_channel_type(channel_type: str) -> str: """Return a human readable version of the channel type.""" if channel_type == "controlled_load": diff --git a/homeassistant/components/amberelectric/services.py b/homeassistant/components/amberelectric/services.py new file mode 100644 index 00000000000..074a2f0ac88 --- /dev/null +++ b/homeassistant/components/amberelectric/services.py @@ -0,0 +1,121 @@ +"""Amber Electric Service class.""" + +from amberelectric.models.channel import ChannelType +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.util.json import JsonValueType + +from .const import ( + ATTR_CHANNEL_TYPE, + ATTR_CONFIG_ENTRY_ID, + CONTROLLED_LOAD_CHANNEL, + DOMAIN, + FEED_IN_CHANNEL, + GENERAL_CHANNEL, + SERVICE_GET_FORECASTS, +) +from .coordinator import AmberConfigEntry +from .helpers import format_cents_to_dollars, normalize_descriptor + +GET_FORECASTS_SCHEMA = vol.Schema( + { + ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}), + ATTR_CHANNEL_TYPE: vol.In( + [GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL] + ), + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry: + """Get the Amber config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": config_entry_id}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return entry + + +def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]: + """Return an array of forecasts.""" + results: list[JsonValueType] = [] + + if channel_type not in data["forecasts"]: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="channel_not_found", + translation_placeholders={"channel_type": channel_type}, + ) + + intervals = data["forecasts"][channel_type] + + for interval in intervals: + datum = {} + datum["duration"] = interval.duration + datum["date"] = interval.var_date.isoformat() + datum["nem_date"] = interval.nem_time.isoformat() + datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh) + if interval.channel_type == ChannelType.FEEDIN: + datum["per_kwh"] = datum["per_kwh"] * -1 + datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) + datum["start_time"] = interval.start_time.isoformat() + datum["end_time"] = interval.end_time.isoformat() + datum["renewables"] = round(interval.renewables) + datum["spike_status"] = interval.spike_status.value + datum["descriptor"] = normalize_descriptor(interval.descriptor) + + if interval.range is not None: + datum["range_min"] = format_cents_to_dollars(interval.range.min) + datum["range_max"] = format_cents_to_dollars(interval.range.max) + + if interval.advanced_price is not None: + multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1 + datum["advanced_price_low"] = multiplier * format_cents_to_dollars( + interval.advanced_price.low + ) + datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars( + interval.advanced_price.predicted + ) + datum["advanced_price_high"] = multiplier * format_cents_to_dollars( + interval.advanced_price.high + ) + + results.append(datum) + + return results + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Amber integration.""" + + async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse: + channel_type = call.data[ATTR_CHANNEL_TYPE] + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + coordinator = entry.runtime_data + forecasts = get_forecasts(channel_type, coordinator.data) + return {"forecasts": forecasts} + + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECASTS, + handle_get_forecasts, + GET_FORECASTS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/amberelectric/services.yaml b/homeassistant/components/amberelectric/services.yaml new file mode 100644 index 00000000000..89a7027fee0 --- /dev/null +++ b/homeassistant/components/amberelectric/services.yaml @@ -0,0 +1,16 @@ +get_forecasts: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: amberelectric + channel_type: + required: true + selector: + select: + options: + - general + - controlled_load + - feed_in + translation_key: channel_type diff --git a/homeassistant/components/amberelectric/strings.json b/homeassistant/components/amberelectric/strings.json index 684a5a2a0cc..f9eba4a1f27 100644 --- a/homeassistant/components/amberelectric/strings.json +++ b/homeassistant/components/amberelectric/strings.json @@ -1,25 +1,61 @@ { "config": { + "error": { + "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", + "no_site": "No site provided", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + }, "step": { + "site": { + "data": { + "site_id": "Site NMI", + "site_name": "Site name" + }, + "description": "Select the NMI of the site you would like to add" + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", "site_id": "Site ID" }, "description": "Go to {api_url} to generate an API key" - }, - "site": { - "data": { - "site_id": "Site NMI", - "site_name": "Site Name" - }, - "description": "Select the NMI of the site you would like to add" } + } + }, + "services": { + "get_forecasts": { + "name": "Get price forecasts", + "description": "Retrieves price forecasts from Amber Electric for a site.", + "fields": { + "config_entry_id": { + "description": "The config entry of the site to get forecasts for.", + "name": "Config entry" + }, + "channel_type": { + "name": "Channel type", + "description": "The channel to get forecasts for." + } + } + } + }, + "exceptions": { + "integration_not_found": { + "message": "Config entry \"{target}\" not found in registry." }, - "error": { - "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", - "no_site": "No site provided", - "unknown_error": "[%key:common::config_flow::error::unknown%]" + "not_loaded": { + "message": "{target} is not loaded." + }, + "channel_not_found": { + "message": "There is no {channel_type} channel at this site." + } + }, + "selector": { + "channel_type": { + "options": { + "general": "General", + "controlled_load": "Controlled load", + "feed_in": "Feed-in" + } } } } diff --git a/tests/components/amberelectric/__init__.py b/tests/components/amberelectric/__init__.py index 9eae18c65aa..8ee603cee14 100644 --- a/tests/components/amberelectric/__init__.py +++ b/tests/components/amberelectric/__init__.py @@ -1 +1,13 @@ """Tests for the amberelectric integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/amberelectric/conftest.py b/tests/components/amberelectric/conftest.py index ce4073db71b..57f93074883 100644 --- a/tests/components/amberelectric/conftest.py +++ b/tests/components/amberelectric/conftest.py @@ -1,10 +1,59 @@ """Provide common Amber fixtures.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, Mock, patch +from amberelectric.models.interval import Interval import pytest +from homeassistant.components.amberelectric.const import ( + CONF_SITE_ID, + CONF_SITE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_API_TOKEN + +from .helpers import ( + CONTROLLED_LOAD_CHANNEL, + FEED_IN_CHANNEL, + FORECASTS, + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_CHANNEL, + GENERAL_CHANNEL_WITH_RANGE, + GENERAL_FORECASTS, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + +MOCK_API_TOKEN = "psk_0000000000000000" + + +def create_amber_config_entry( + site_id: str, entry_id: str, name: str +) -> MockConfigEntry: + """Create an Amber config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_NAME: name, + CONF_SITE_ID: site_id, + }, + entry_id=entry_id, + ) + + +@pytest.fixture +def mock_amber_client() -> Generator[AsyncMock]: + """Mock the Amber API client.""" + with patch( + "homeassistant.components.amberelectric.amberelectric.AmberApi", + autospec=True, + ) as mock_client: + yield mock_client + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +62,129 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.amberelectric.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +async def general_channel_config_entry(): + """Generate the default Amber config entry.""" + return create_amber_config_entry(GENERAL_ONLY_SITE_ID, GENERAL_ONLY_SITE_ID, "home") + + +@pytest.fixture +async def general_channel_and_controlled_load_config_entry(): + """Generate the default Amber config entry for site with controlled load.""" + return create_amber_config_entry( + GENERAL_AND_CONTROLLED_SITE_ID, GENERAL_AND_CONTROLLED_SITE_ID, "home" + ) + + +@pytest.fixture +async def general_channel_and_feed_in_config_entry(): + """Generate the default Amber config entry for site with feed in.""" + return create_amber_config_entry( + GENERAL_AND_FEED_IN_SITE_ID, GENERAL_AND_FEED_IN_SITE_ID, "home" + ) + + +@pytest.fixture +def general_channel_prices() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL + + +@pytest.fixture +def general_channel_prices_with_range() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL_WITH_RANGE + + +@pytest.fixture +def controlled_load_channel_prices() -> list[Interval]: + """List containing controlled load channel prices.""" + return CONTROLLED_LOAD_CHANNEL + + +@pytest.fixture +def feed_in_channel_prices() -> list[Interval]: + """List containing feed in channel prices.""" + return FEED_IN_CHANNEL + + +@pytest.fixture +def forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return FORECASTS + + +@pytest.fixture +def general_forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return GENERAL_FORECASTS + + +@pytest.fixture +def mock_amber_client_general_channel( + mock_amber_client: AsyncMock, general_channel_prices: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_channel_with_range( + mock_amber_client: AsyncMock, general_channel_prices_with_range: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices with a range.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices_with_range + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_and_controlled_load( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + controlled_load_channel_prices: list[Interval], +) -> Generator[AsyncMock]: + """Fake general channel and controlled load channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + controlled_load_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_and_feed_in( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + feed_in_channel_prices: list[Interval], +) -> AsyncGenerator[Mock]: + """Set up general channel and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + feed_in_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_forecasts( + mock_amber_client: AsyncMock, forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel, controlled load and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = forecast_prices + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_forecasts( + mock_amber_client: AsyncMock, general_forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel only.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_forecast_prices + return mock_amber_client diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py index 971f3690a0d..d4f968f01d1 100644 --- a/tests/components/amberelectric/helpers.py +++ b/tests/components/amberelectric/helpers.py @@ -3,11 +3,13 @@ from datetime import datetime, timedelta from amberelectric.models.actual_interval import ActualInterval +from amberelectric.models.advanced_price import AdvancedPrice from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval from amberelectric.models.interval import Interval from amberelectric.models.price_descriptor import PriceDescriptor +from amberelectric.models.range import Range from amberelectric.models.spike_status import SpikeStatus from dateutil import parser @@ -15,12 +17,16 @@ from dateutil import parser def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> Interval: """Generate a mock actual interval.""" start_time = end_time - timedelta(minutes=30) + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 return Interval( ActualInterval( type="ActualInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -34,16 +40,23 @@ def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> I def generate_current_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, + end_time: datetime, + range=False, ) -> Interval: """Generate a mock current price.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( CurrentInterval( type="CurrentInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -56,18 +69,28 @@ def generate_current_interval( ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + + return interval + def generate_forecast_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, end_time: datetime, range=False, advanced_price=False ) -> Interval: """Generate a mock forecast interval.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( ForecastInterval( type="ForecastInterval", duration=30, spot_per_kwh=1.1, - per_kwh=8.8, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -79,12 +102,20 @@ def generate_forecast_interval( estimate=True, ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + if advanced_price: + interval.actual_instance.advanced_price = AdvancedPrice( + low=6.7, predicted=9.0, high=10.2 + ) + return interval GENERAL_ONLY_SITE_ID = "01FG2K6V5TB6X9W0EWPPMZD6MJ" GENERAL_AND_CONTROLLED_SITE_ID = "01FG2MC8RF7GBC4KJXP3YFZ162" GENERAL_AND_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW84VP50S" GENERAL_AND_CONTROLLED_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW847S50S" +GENERAL_FOR_FAIL = "01JVCEYVSD5HGJG0KT7RNM91GG" GENERAL_CHANNEL = [ generate_current_interval( @@ -101,6 +132,21 @@ GENERAL_CHANNEL = [ ), ] +GENERAL_CHANNEL_WITH_RANGE = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:00:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T10:00:00+10:00"), range=True + ), +] + CONTROLLED_LOAD_CHANNEL = [ generate_current_interval( ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") @@ -131,3 +177,93 @@ FEED_IN_CHANNEL = [ ChannelType.FEEDIN, parser.parse("2021-09-21T10:00:00+10:00") ), ] + +GENERAL_FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] + +FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.FEEDIN, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 6faabc924b4..0e82d81f4e8 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -9,7 +9,6 @@ from unittest.mock import Mock, patch from amberelectric import ApiException from amberelectric.models.channel import Channel, ChannelType from amberelectric.models.interval import Interval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.models.site import Site from amberelectric.models.site_status import SiteStatus from amberelectric.models.spike_status import SpikeStatus @@ -17,10 +16,7 @@ from dateutil import parser import pytest from homeassistant.components.amberelectric.const import CONF_SITE_ID, CONF_SITE_NAME -from homeassistant.components.amberelectric.coordinator import ( - AmberUpdateCoordinator, - normalize_descriptor, -) +from homeassistant.components.amberelectric.coordinator import AmberUpdateCoordinator from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -98,18 +94,6 @@ def mock_api_current_price() -> Generator: yield instance -def test_normalize_descriptor() -> None: - """Test normalizing descriptors works correctly.""" - assert normalize_descriptor(None) is None - assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" - assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" - assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" - assert normalize_descriptor(PriceDescriptor.LOW) == "low" - assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" - assert normalize_descriptor(PriceDescriptor.HIGH) == "high" - assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" - - async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None: """Test fetching a site with only a general channel.""" @@ -120,7 +104,7 @@ async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -152,7 +136,7 @@ async def test_fetch_no_general_site( await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) @@ -166,7 +150,7 @@ async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -217,7 +201,7 @@ async def test_fetch_general_and_controlled_load_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_CONTROLLED_SITE_ID, next=48 + GENERAL_AND_CONTROLLED_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -257,7 +241,7 @@ async def test_fetch_general_and_feed_in_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_FEED_IN_SITE_ID, next=48 + GENERAL_AND_FEED_IN_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance diff --git a/tests/components/amberelectric/test_helpers.py b/tests/components/amberelectric/test_helpers.py new file mode 100644 index 00000000000..958c60fd1b3 --- /dev/null +++ b/tests/components/amberelectric/test_helpers.py @@ -0,0 +1,17 @@ +"""Test formatters.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +from homeassistant.components.amberelectric.helpers import normalize_descriptor + + +def test_normalize_descriptor() -> None: + """Test normalizing descriptors works correctly.""" + assert normalize_descriptor(None) is None + assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" + assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" + assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" + assert normalize_descriptor(PriceDescriptor.LOW) == "low" + assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" + assert normalize_descriptor(PriceDescriptor.HIGH) == "high" + assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index 203b65d6df6..0d979a2021c 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -1,119 +1,26 @@ """Test the Amber Electric Sensors.""" -from collections.abc import AsyncGenerator -from unittest.mock import Mock, patch - -from amberelectric.models.current_interval import CurrentInterval -from amberelectric.models.interval import Interval -from amberelectric.models.range import Range import pytest -from homeassistant.components.amberelectric.const import ( - CONF_SITE_ID, - CONF_SITE_NAME, - DOMAIN, -) -from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .helpers import ( - CONTROLLED_LOAD_CHANNEL, - FEED_IN_CHANNEL, - GENERAL_AND_CONTROLLED_SITE_ID, - GENERAL_AND_FEED_IN_SITE_ID, - GENERAL_CHANNEL, - GENERAL_ONLY_SITE_ID, -) - -from tests.common import MockConfigEntry - -MOCK_API_TOKEN = "psk_0000000000000000" +from . import MockConfigEntry, setup_integration -@pytest.fixture -async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_SITE_NAME: "mock_title", - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_ONLY_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_controlled_load( - hass: HomeAssistant, -) -> AsyncGenerator[Mock]: - """Set up general channel and controller load channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_CONTROLLED_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel and feed in channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_FEED_IN_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price - assert price.state == "0.08" + assert price.state == "0.09" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.09 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -126,32 +33,36 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> assert attributes.get("range_min") is None assert attributes.get("range_max") is None - with_range: list[CurrentInterval] = GENERAL_CHANNEL - with_range[0].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_price_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Price sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price attributes = price.attributes - assert attributes.get("range_min") == 0.08 - assert attributes.get("range_max") == 0.12 + assert attributes.get("range_min") == 0.07 + assert attributes.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Price sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price") assert price - assert price.state == "0.08" + assert price.state == "0.04" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.04 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -163,17 +74,20 @@ async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> assert attributes["attribution"] == "Data provided by Amber Electric" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price") assert price - assert price.state == "-0.08" + assert price.state == "-0.01" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == -0.08 + assert attributes["per_kwh"] == -0.01 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -185,10 +99,12 @@ async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: assert attributes["attribution"] == "Data provided by Amber Electric" +@pytest.mark.usefixtures("mock_amber_client_general_channel") async def test_general_forecast_sensor( - hass: HomeAssistant, setup_general: Mock + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry ) -> None: """Test the General Forecast sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price @@ -212,29 +128,33 @@ async def test_general_forecast_sensor( assert first_forecast.get("range_min") is None assert first_forecast.get("range_max") is None - with_range: list[Interval] = GENERAL_CHANNEL - with_range[1].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_forecast_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Forecast sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price attributes = price.attributes first_forecast = attributes["forecasts"][0] - assert first_forecast.get("range_min") == 0.08 - assert first_forecast.get("range_max") == 0.12 + assert first_forecast.get("range_min") == 0.07 + assert first_forecast.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_controlled_load_forecast_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Load Forecast sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_forecast") assert price - assert price.state == "0.09" + assert price.state == "0.04" attributes = price.attributes assert attributes["channel_type"] == "controlledLoad" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -242,7 +162,7 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == 0.09 + assert first_forecast["per_kwh"] == 0.04 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -252,13 +172,16 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_feed_in_forecast_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Forecast sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_forecast") assert price - assert price.state == "-0.09" + assert price.state == "-0.01" attributes = price.attributes assert attributes["channel_type"] == "feedIn" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -266,7 +189,7 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == -0.09 + assert first_forecast["per_kwh"] == -0.01 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -276,38 +199,52 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general") -def test_renewable_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_renewable_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Testing the creation of the Amber renewables sensor.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 sensor = hass.states.get("sensor.mock_title_renewables") assert sensor assert sensor.state == "51" -@pytest.mark.usefixtures("setup_general") -def test_general_price_descriptor_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_descriptor_descriptor_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price Descriptor sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_controlled_load") -def test_general_and_controlled_load_price_descriptor_sensor( +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_descriptor_sensor( hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, ) -> None: """Test the Controlled Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -def test_general_and_feed_in_price_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_descriptor_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price_descriptor") assert price diff --git a/tests/components/amberelectric/test_services.py b/tests/components/amberelectric/test_services.py new file mode 100644 index 00000000000..7ef895a5d88 --- /dev/null +++ b/tests/components/amberelectric/test_services.py @@ -0,0 +1,202 @@ +"""Test the Amber Service object.""" + +import re + +import pytest +import voluptuous as vol + +from homeassistant.components.amberelectric.const import DOMAIN, SERVICE_GET_FORECASTS +from homeassistant.components.amberelectric.services import ( + ATTR_CHANNEL_TYPE, + ATTR_CONFIG_ENTRY_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_integration +from .helpers import ( + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_general_forecasts( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.09 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_controlled_load_forecasts( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_CONTROLLED_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.04 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_feed_in_forecasts( + hass: HomeAssistant, + general_channel_and_feed_in_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_FEED_IN_SITE_ID, + ATTR_CHANNEL_TYPE: "feed_in", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == -0.01 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_incorrect_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is incorrect.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + vol.error.MultipleInvalid, + match=re.escape( + "value must be one of ['controlled_load', 'feed_in', 'general'] for dictionary value @ data['channel_type']" + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "incorrect", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_general_forecasts") +async def test_unavailable_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is not found.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + ServiceValidationError, match="There is no controlled_load channel at this site" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_service_entry_availability( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + general_channel_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(general_channel_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id, + ATTR_CHANNEL_TYPE: "general", + }, + blocking=True, + return_response=True, + ) + + with pytest.raises( + ServiceValidationError, + match='Config entry "bad-config_id" not found in registry', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id", ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) From fd10fa1fba8f8ab16a5ad96eb356e7716c978e27 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:49:08 +0200 Subject: [PATCH 24/36] Add reauthentication flow to Uptime Kuma (#148772) --- .../components/uptime_kuma/config_flow.py | 47 +++++++++++++ .../components/uptime_kuma/coordinator.py | 4 +- .../components/uptime_kuma/quality_scale.yaml | 2 +- .../components/uptime_kuma/strings.json | 13 +++- .../uptime_kuma/test_config_flow.py | 70 +++++++++++++++++++ tests/components/uptime_kuma/test_init.py | 29 +++++++- 6 files changed, 160 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py index 9866f08bef3..30f9d7ae9ba 100644 --- a/homeassistant/components/uptime_kuma/config_flow.py +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -38,6 +39,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Optional(CONF_API_KEY, default=""): str, } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_API_KEY, default=""): str}) class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): @@ -77,3 +79,48 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + session = async_get_clientsession(self.hass, entry.data[CONF_VERIFY_SSL]) + uptime_kuma = UptimeKuma( + session, + entry.data[CONF_URL], + user_input[CONF_API_KEY], + ) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 788d37cfb84..297bd83e7c8 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -16,7 +16,7 @@ from pythonkuma import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -59,7 +59,7 @@ class UptimeKumaDataUpdateCoordinator( try: metrics = await self.api.metrics() except UptimeKumaAuthenticationException as e: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="auth_failed_exception", ) from e diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index 145cbf58448..c3d88f7e3c8 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -38,7 +38,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 8cd361cccea..0321db1c221 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -13,6 +13,16 @@ "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to an Uptime Kuma instance using a self-signed certificate or via IP address", "api_key": "Enter an API key. To create a new API key navigate to **Settings → API Keys** and select **Add API Key**" } + }, + "reauth_confirm": { + "title": "Re-authenticate with Uptime Kuma: {name}", + "description": "The API key for **{name}** is invalid. To re-authenticate with Uptime Kuma provide a new API key below", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -21,7 +31,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py index b70cb9d353c..3c1bf902ce8 100644 --- a/tests/components/uptime_kuma/test_config_flow.py +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -120,3 +120,73 @@ async def test_form_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test reauth flow errors and recover.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_pythonkuma.metrics.side_effect = raise_error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/uptime_kuma/test_init.py b/tests/components/uptime_kuma/test_init.py index 57390da60d5..6e2ef43b14d 100644 --- a/tests/components/uptime_kuma/test_init.py +++ b/tests/components/uptime_kuma/test_init.py @@ -5,7 +5,8 @@ from unittest.mock import AsyncMock import pytest from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaException -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -50,3 +51,29 @@ async def test_config_entry_not_ready( await hass.async_block_till_done() assert config_entry.state is state + + +async def test_config_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, +) -> None: + """Test config entry auth error starts reauth flow.""" + + mock_pythonkuma.metrics.side_effect = UptimeKumaAuthenticationException + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id From e5fe243a8633785a2a3ac2c2a26556ab8a7cad81 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:03:47 -0400 Subject: [PATCH 25/36] Remove device id references from button and image (#148826) --- homeassistant/components/template/button.py | 5 ----- homeassistant/components/template/image.py | 5 ----- 2 files changed, 10 deletions(-) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 397fc5f4174..26d339b7e33 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -17,7 +17,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -105,10 +104,6 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index ed7093cfcdb..57e7c6ffc55 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -17,7 +17,6 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -107,10 +106,6 @@ class StateImageEntity(TemplateEntity, ImageEntity): TemplateEntity.__init__(self, hass, config, unique_id) ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @property def entity_picture(self) -> str | None: From 35097602d77e4d7813af80353398a5898da2a12f Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:04:31 -0400 Subject: [PATCH 26/36] Remove unnecessary hass if check in AbstractTemplateEntity (#148828) --- homeassistant/components/template/entity.py | 22 +++++++++---------- tests/components/template/test_entity.py | 8 ++----- .../template/test_template_entity.py | 6 +---- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index a97a5ac6571..481db182713 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -1,5 +1,6 @@ """Template entity base class.""" +from abc import abstractmethod from collections.abc import Sequence from typing import Any @@ -25,26 +26,25 @@ class AbstractTemplateEntity(Entity): self.hass = hass self._action_scripts: dict[str, Script] = {} - if self.hass: - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - self._entity_id_format, object_id, hass=self.hass - ) - - self._attr_device_info = async_device_info_to_link_from_device_id( - self.hass, - config.get(CONF_DEVICE_ID), + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + self._entity_id_format, object_id, hass=self.hass ) + self._attr_device_info = async_device_info_to_link_from_device_id( + self.hass, + config.get(CONF_DEVICE_ID), + ) + @property + @abstractmethod def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" - raise NotImplementedError @callback + @abstractmethod def _render_script_variables(self) -> dict: """Render configured variables.""" - raise NotImplementedError def add_script( self, diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 4a6940c2813..8e98d8c94a7 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -9,9 +9,5 @@ from homeassistant.core import HomeAssistant async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: """Test abstract template entity raises not implemented error.""" - entity = abstract_entity.AbstractTemplateEntity(None, {}) - with pytest.raises(NotImplementedError): - _ = entity.referenced_blueprint - - with pytest.raises(NotImplementedError): - entity._render_script_variables() + with pytest.raises(TypeError): + _ = abstract_entity.AbstractTemplateEntity(hass, {}) diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index b743f7e2d9f..7fe3870ae1e 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,12 +9,8 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(None, {}, "something_unique") + entity = template_entity.TemplateEntity(hass, {}, "something_unique") - with pytest.raises(ValueError, match="^hass cannot be None"): - entity.add_template_attribute("_hello", template.Template("Hello")) - - entity.hass = object() with pytest.raises(ValueError, match="^template.hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello", None)) From 2c2ac4b6692fb9c8ecac342d416f7cbf2130ed78 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 15 Jul 2025 08:08:19 -0700 Subject: [PATCH 27/36] Throw an error from reload_themes if themes are invalid (#148827) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/frontend/__init__.py | 7 +++++ tests/components/frontend/test_init.py | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9694c299b23..2f2a8e93b1e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,6 +26,7 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted @@ -543,6 +544,12 @@ async def _async_setup_themes( """Reload themes.""" config = await async_hass_config_yaml(hass) new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {}) + + try: + THEME_SCHEMA(new_themes) + except vol.Invalid as err: + raise HomeAssistantError(f"Failed to reload themes: {err}") from err + hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index f28742cdd0a..a6c35513dc3 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -26,6 +26,7 @@ from homeassistant.components.frontend import ( ) from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component @@ -408,6 +409,35 @@ async def test_themes_reload_themes( assert msg["result"]["default_theme"] == "default" +@pytest.mark.usefixtures("frontend") +async def test_themes_reload_invalid( + hass: HomeAssistant, themes_ws_client: MockHAClientWebSocket +) -> None: + """Test frontend.reload_themes service with an invalid theme.""" + + with patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "pink"}}}}, + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + with ( + patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"sad": "blue"}}}, + ), + pytest.raises(HomeAssistantError, match="Failed to reload themes"), + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + + msg = await themes_ws_client.receive_json() + + assert msg["result"]["themes"] == {"happy": {"primary-color": "pink"}} + assert msg["result"]["default_theme"] == "default" + + async def test_missing_themes(ws_client: MockHAClientWebSocket) -> None: """Test that themes API works when themes are not defined.""" await ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) From 5b29d6bbdfbf46306b74696d0bf40c7226a76dc9 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 15 Jul 2025 17:25:22 +0200 Subject: [PATCH 28/36] Set icon for off state for light domain (#148749) --- homeassistant/components/light/icons.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index 6218c733f4c..c0b478e895d 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -2,6 +2,9 @@ "entity_component": { "_": { "default": "mdi:lightbulb", + "state": { + "off": "mdi:lightbulb-off" + }, "state_attributes": { "effect": { "default": "mdi:circle-medium", From 8bd51a7fd1470495a87674ec08404f92b36268f3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 15 Jul 2025 17:38:19 +0200 Subject: [PATCH 29/36] Use ffmpeg for generic cameras in go2rtc (#148818) --- homeassistant/components/go2rtc/__init__.py | 5 ++++ tests/components/go2rtc/test_init.py | 29 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 4e15b93330c..8d3e988dd14 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -306,6 +306,11 @@ class WebRTCProvider(CameraWebRTCProvider): await self.teardown() raise HomeAssistantError("Camera has no stream source") + if camera.platform.platform_name == "generic": + # This is a workaround to use ffmpeg for generic cameras + # A proper fix will be added in the future together with supporting multiple streams per camera + stream_source = "ffmpeg:" + stream_source + if not self.async_is_supported(stream_source): await self.teardown() raise HomeAssistantError("Stream source is not supported by go2rtc") diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 2abdf724f61..dcbcb629d11 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -670,3 +670,32 @@ async def test_async_get_image( HomeAssistantError, match="Stream source is not supported by go2rtc" ): await async_get_image(hass, camera.entity_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_generic_workaround( + hass: HomeAssistant, + init_test_integration: MockCamera, + rest_client: AsyncMock, +) -> None: + """Test workaround for generic integration cameras.""" + camera = init_test_integration + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) + + rest_client.get_jpeg_snapshot.return_value = image_bytes + camera.set_stream_source("https://my_stream_url.m3u8") + + with patch.object(camera.platform, "platform_name", "generic"): + image = await async_get_image(hass, camera.entity_id) + assert image.content == image_bytes + + rest_client.streams.add.assert_called_once_with( + camera.entity_id, + [ + "ffmpeg:https://my_stream_url.m3u8", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", + ], + ) From 3e0628cec2c365a0276a3fdea03fcba030af58dd Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:58:42 +0200 Subject: [PATCH 30/36] Fix entity and device selectors (#148580) --- .../components/ai_task/services.yaml | 7 +-- .../components/assist_satellite/services.yaml | 7 +-- homeassistant/helpers/selector.py | 44 ++++++++++++++++--- tests/helpers/test_selector.py | 21 ++++++++- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 194c0e07bc3..feefa70a30b 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -15,9 +15,10 @@ generate_data: required: false selector: entity: - domain: ai_task - supported_features: - - ai_task.AITaskEntityFeature.GENERATE_DATA + filter: + domain: ai_task + supported_features: + - ai_task.AITaskEntityFeature.GENERATE_DATA structure: advanced: true required: false diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 8433eb6102d..ed292e1626c 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -68,9 +68,10 @@ ask_question: required: true selector: entity: - domain: assist_satellite - supported_features: - - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + filter: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION question: required: false example: "What kind of music would you like to play?" diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index bc24113251c..83524fac24c 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -160,6 +160,22 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( ) +# Legacy entity selector config schema used directly under entity selectors +# is provided for backwards compatibility and remains feature frozen. +# New filtering features should be added under the `filter` key instead. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration that provided the entity + vol.Optional("integration"): str, + # Domain the entity belongs to + vol.Optional("domain"): vol.All(cv.ensure_list, [str]), + # Device class of the entity + vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), + } +) + + class EntityFilterSelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" @@ -179,10 +195,22 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("model"): str, # Model ID of device vol.Optional("model_id"): str, - # Device has to contain entities matching this selector - vol.Optional("entity"): vol.All( - cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] - ), + } +) + + +# Legacy device selector config schema used directly under device selectors +# is provided for backwards compatibility and remains feature frozen. +# New filtering features should be added under the `filter` key instead. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration linked to it with a config entry + vol.Optional("integration"): str, + # Manufacturer of device + vol.Optional("manufacturer"): str, + # Model of device + vol.Optional("model"): str, } ) @@ -714,9 +742,13 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA.schema ).extend( { + # Device has to contain entities matching this selector + vol.Optional("entity"): vol.All( + cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] + ), vol.Optional("multiple", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, @@ -794,7 +826,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA.schema ).extend( { vol.Optional("exclude_entities"): [str], diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 0e68992d0e4..159f295ab2f 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -88,7 +88,6 @@ def _test_selector( ({"integration": "zha"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf"}, ("abc123",), (None,)), ({"model": "mock-model"}, ("abc123",), (None,)), - ({"model_id": "mock-model_id"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf", "model": "mock-model"}, ("abc123",), (None,)), ( {"integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model"}, @@ -128,6 +127,7 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", } }, ("abc123",), @@ -140,11 +140,13 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", }, { "integration": "matter", "manufacturer": "other-mock-manuf", "model": "other-mock-model", + "model_id": "other-mock-model_id", }, ] }, @@ -158,6 +160,19 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("device", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + "schema", + [ + # model_id should be used under the filter key + {"model_id": "mock-model_id"}, + ], +) +def test_device_selector_schema_error(schema) -> None: + """Test device selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"device": schema}) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), [ @@ -290,10 +305,12 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"filter": [{"supported_features": ["light.FooEntityFeature.blah"]}]}, # Unknown feature enum member {"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]}, + # supported_features should be used under the filter key + {"supported_features": ["light.LightEntityFeature.EFFECT"]}, ], ) def test_entity_selector_schema_error(schema) -> None: - """Test number selector.""" + """Test entity selector.""" with pytest.raises(vol.Invalid): selector.validate_selector({"entity": schema}) From 36156d9c544c6e713ae84668247f61e138785bb9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:43:44 +0200 Subject: [PATCH 31/36] Update orjson to 3.11.0 (#148840) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9e21c5830e4..f56c44d494a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.18 +orjson==3.11.0 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 diff --git a/pyproject.toml b/pyproject.toml index 860b4af379d..6946993e6af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dependencies = [ "Pillow==11.3.0", "propcache==0.3.2", "pyOpenSSL==25.1.0", - "orjson==3.10.18", + "orjson==3.11.0", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index 118d2bedfa6..896ff44a3c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ cryptography==45.0.3 Pillow==11.3.0 propcache==0.3.2 pyOpenSSL==25.1.0 -orjson==3.10.18 +orjson==3.11.0 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From e89ae021d83a053d9f7ecfe5814ffe28503d48a3 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:10:16 +0200 Subject: [PATCH 32/36] Clean up validate_supported_features in selector helper (#148843) --- homeassistant/helpers/selector.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 83524fac24c..0fa5403ad2b 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -117,11 +117,8 @@ def _validate_supported_feature(supported_feature: str) -> int: raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc -def _validate_supported_features(supported_features: int | list[str]) -> int: - """Validate a supported feature and resolve an enum string to its value.""" - - if isinstance(supported_features, int): - return supported_features +def _validate_supported_features(supported_features: list[str]) -> int: + """Validate supported features and resolve enum strings to their value.""" feature_mask = 0 From 9caf46c68b2533a86f17810c81ce84fe76339ca3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 15 Jul 2025 20:17:54 +0200 Subject: [PATCH 33/36] Bump `imgw_pib` library to version 1.4.0 (#148831) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/conftest.py | 3 ++- tests/components/imgw_pib/snapshots/test_diagnostics.ambr | 7 +++++++ 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 631bce3fbc9..a24e5d23907 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.2.0"] + "requirements": ["imgw_pib==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8fe43a3198c..486bd1242f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.2.0 +imgw_pib==1.4.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7e3da48a19..f6f6af12452 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.2.0 +imgw_pib==1.4.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index e0b091e5ff3..ad5ad992688 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, patch -from imgw_pib import HydrologicalData, SensorData +from imgw_pib import NO_ALERT, Alert, HydrologicalData, SensorData import pytest from homeassistant.components.imgw_pib.const import DOMAIN @@ -25,6 +25,7 @@ HYDROLOGICAL_DATA = HydrologicalData( water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), water_flow=SensorData(name="Water Flow", value=123.45), water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), + alert=Alert(value=NO_ALERT), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 08f3690136e..1521bc8320a 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -22,6 +22,13 @@ 'version': 1, }), 'hydrological_data': dict({ + 'alert': dict({ + 'level': None, + 'probability': None, + 'valid_from': None, + 'valid_to': None, + 'value': 'no_alert', + }), 'flood_alarm': None, 'flood_alarm_level': dict({ 'name': 'Flood Alarm Level', From d14a0e01911358055a545812abb3ec1d53c36059 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:18:47 +0200 Subject: [PATCH 34/36] Bump pythonkuma to v0.3.1 (#148834) --- homeassistant/components/uptime_kuma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index 6f20d4ae20f..42fac89a976 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "bronze", - "requirements": ["pythonkuma==0.3.0"] + "requirements": ["pythonkuma==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 486bd1242f1..b09aff3f4bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2526,7 +2526,7 @@ python-vlc==3.0.18122 pythonegardia==1.0.52 # homeassistant.components.uptime_kuma -pythonkuma==0.3.0 +pythonkuma==0.3.1 # homeassistant.components.tile pytile==2024.12.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6f6af12452..081a7d6ed5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2090,7 +2090,7 @@ python-technove==2.0.0 python-telegram-bot[socks]==21.5 # homeassistant.components.uptime_kuma -pythonkuma==0.3.0 +pythonkuma==0.3.1 # homeassistant.components.tile pytile==2024.12.0 From 648dce2fa39ca63cbf5d53ec29892f4aac515961 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:19:14 +0200 Subject: [PATCH 35/36] Add diagnostics platform to Uptime Kuma (#148835) --- .../components/uptime_kuma/diagnostics.py | 23 +++++++++++ .../components/uptime_kuma/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 41 +++++++++++++++++++ .../uptime_kuma/test_diagnostics.py | 28 +++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/uptime_kuma/diagnostics.py create mode 100644 tests/components/uptime_kuma/snapshots/test_diagnostics.ambr create mode 100644 tests/components/uptime_kuma/test_diagnostics.py diff --git a/homeassistant/components/uptime_kuma/diagnostics.py b/homeassistant/components/uptime_kuma/diagnostics.py new file mode 100644 index 00000000000..48e23adc40d --- /dev/null +++ b/homeassistant/components/uptime_kuma/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics platform for Uptime Kuma.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import UptimeKumaConfigEntry + +TO_REDACT = {"monitor_url", "monitor_hostname"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: UptimeKumaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return async_redact_data( + {k: asdict(v) for k, v in entry.runtime_data.data.items()}, TO_REDACT + ) diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index c3d88f7e3c8..469ecad8d7b 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -43,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: is not locally discoverable diff --git a/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..97e40e821da --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + '1': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 1, + 'monitor_name': 'Monitor 1', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 1, + 'monitor_type': 'http', + 'monitor_url': '**REDACTED**', + }), + '2': dict({ + 'monitor_cert_days_remaining': 0, + 'monitor_cert_is_valid': 0, + 'monitor_hostname': None, + 'monitor_id': 2, + 'monitor_name': 'Monitor 2', + 'monitor_port': None, + 'monitor_response_time': 28, + 'monitor_status': 1, + 'monitor_type': 'port', + 'monitor_url': None, + }), + '3': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 3, + 'monitor_name': 'Monitor 3', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 0, + 'monitor_type': 'json-query', + 'monitor_url': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/uptime_kuma/test_diagnostics.py b/tests/components/uptime_kuma/test_diagnostics.py new file mode 100644 index 00000000000..92d98d49b75 --- /dev/null +++ b/tests/components/uptime_kuma/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests Uptime Kuma diagnostics platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From f5b785acd50269268fa7502afdcf2c7b46299004 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:44:32 +0200 Subject: [PATCH 36/36] Update youtubeaio to 2.0.0 (#148814) --- homeassistant/components/youtube/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json index a1a71f6712e..56b0f0fdd3a 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["youtubeaio==1.1.5"] + "requirements": ["youtubeaio==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b09aff3f4bc..79d34968d39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3172,7 +3172,7 @@ yolink-api==0.5.7 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.06.09 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 081a7d6ed5e..05c9ff6adf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2619,7 +2619,7 @@ yolink-api==0.5.7 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.06.09