diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index c289757a2f8..b35cb8af9a9 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -81,12 +81,28 @@ AVAILABLE_SWING_MODES = { "horizontal", "both", } +AVAILABLE_HORIZONTAL_SWING_MODES = { + "stopped", + "fixedleft", + "fixedcenterleft", + "fixedcenter", + "fixedcenterright", + "fixedright", + "fixedleftright", + "rangecenter", + "rangefull", + "rangeleft", + "rangeright", + "horizontal", + "both", +} PARALLEL_UPDATES = 0 FIELD_TO_FLAG = { "fanLevel": ClimateEntityFeature.FAN_MODE, "swing": ClimateEntityFeature.SWING_MODE, + "horizontalSwing": ClimateEntityFeature.SWING_HORIZONTAL_MODE, "targetTemperature": ClimateEntityFeature.TARGET_TEMPERATURE, } @@ -107,6 +123,7 @@ AC_STATE_TO_DATA = { "on": "device_on", "mode": "hvac_mode", "swing": "swing_mode", + "horizontalSwing": "horizontal_swing_mode", } @@ -292,6 +309,16 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Return the list of available swing modes.""" return self.device_data.swing_modes + @property + def swing_horizontal_mode(self) -> str | None: + """Return the horizontal swing setting.""" + return self.device_data.horizontal_swing_mode + + @property + def swing_horizontal_modes(self) -> list[str] | None: + """Return the list of available horizontal swing modes.""" + return self.device_data.horizontal_swing_modes + @property def min_temp(self) -> float: """Return the minimum temperature.""" @@ -379,6 +406,26 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): transformation=transformation, ) + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + if swing_horizontal_mode not in AVAILABLE_HORIZONTAL_SWING_MODES: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="horizontal_swing_not_supported", + translation_placeholders={ + "horizontal_swing_mode": swing_horizontal_mode + }, + ) + + transformation = self.device_data.horizontal_swing_modes_translated + await self.async_send_api_call( + key=AC_STATE_TO_DATA["horizontalSwing"], + value=swing_horizontal_mode, + name="horizontalSwing", + assumed_state=False, + transformation=transformation, + ) + async def async_turn_on(self) -> None: """Turn Sensibo unit on.""" await self.async_send_api_call( diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 4cc1426743c..12e7364d6ee 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -8,10 +8,22 @@ from typing import TYPE_CHECKING, Any from pysensibo.model import SensiboDevice -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import SensiboConfigEntry from .const import DOMAIN @@ -31,15 +43,17 @@ class SensiboSelectEntityDescription(SelectEntityDescription): transformation: Callable[[SensiboDevice], dict | None] +HORIZONTAL_SWING_MODE_TYPE = SensiboSelectEntityDescription( + key="horizontalSwing", + data_key="horizontal_swing_mode", + value_fn=lambda data: data.horizontal_swing_mode, + options_fn=lambda data: data.horizontal_swing_modes, + translation_key="horizontalswing", + transformation=lambda data: data.horizontal_swing_modes_translated, + entity_registry_enabled_default=False, +) + DEVICE_SELECT_TYPES = ( - SensiboSelectEntityDescription( - key="horizontalSwing", - data_key="horizontal_swing_mode", - value_fn=lambda data: data.horizontal_swing_mode, - options_fn=lambda data: data.horizontal_swing_modes, - translation_key="horizontalswing", - transformation=lambda data: data.horizontal_swing_modes_translated, - ), SensiboSelectEntityDescription( key="light", data_key="light_mode", @@ -60,12 +74,51 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboSelect(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DEVICE_SELECT_TYPES - if description.key in device_data.full_features + entities: list[SensiboSelect] = [] + + entity_registry = er.async_get(hass) + for device_id, device_data in coordinator.data.parsed.items(): + if entity_id := entity_registry.async_get_entity_id( + SELECT_DOMAIN, DOMAIN, f"{device_id}-horizontalSwing" + ): + entity = entity_registry.async_get(entity_id) + if entity and entity.disabled: + entity_registry.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + "deprecated_entity_horizontalswing", + ) + elif entity and HORIZONTAL_SWING_MODE_TYPE.key in device_data.full_features: + entities.append( + SensiboSelect(coordinator, device_id, HORIZONTAL_SWING_MODE_TYPE) + ) + if automations_with_entity(hass, entity_id) or scripts_with_entity( + hass, entity_id + ): + async_create_issue( + hass, + DOMAIN, + "deprecated_entity_horizontalswing", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity_horizontalswing", + translation_placeholders={ + "name": str(entity.name or entity.original_name), + "entity": entity_id, + }, + ) + + entities.extend( + [ + SensiboSelect(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + for description in DEVICE_SELECT_TYPES + if description.key in device_data.full_features + ] ) + async_add_entities(entities) class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 0970ef32af5..a1f60c247a3 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -387,6 +387,21 @@ "horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]", "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]" } + }, + "swing_horizontal_mode": { + "state": { + "stopped": "[%key:common::state::off%]", + "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", + "rangeleft": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeleft%]", + "rangeright": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeright%]" + } } } } @@ -551,6 +566,9 @@ "swing_not_supported": { "message": "Climate swing mode {swing_mode} is not supported by the integration, please open an issue" }, + "horizontal_swing_not_supported": { + "message": "Climate horizontal swing mode {horizontal_swing_mode} is not supported by the integration, please open an issue" + }, "service_result_not_true": { "message": "Could not perform action for {name}" }, @@ -575,5 +593,11 @@ "mode_not_exist": { "message": "The entity does not support the chosen mode" } + }, + "issues": { + "deprecated_entity_horizontalswing": { + "title": "The Sensibo {name} entity is deprecated", + "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\n, Disable the `{entity}` and reload the config entry or restart Home Assistant to fix this issue." + } } } diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr index 71a21b6d5ab..5bcfae0917e 100644 --- a/tests/components/sensibo/snapshots/test_climate.ambr +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -81,6 +81,11 @@ ]), 'max_temp': 20, 'min_temp': 10, + 'swing_horizontal_modes': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), 'swing_modes': list([ 'stopped', 'fixedtop', @@ -109,7 +114,7 @@ 'original_name': None, 'platform': 'sensibo', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'climate_device', 'unique_id': 'ABC999111', 'unit_of_measurement': None, @@ -137,7 +142,13 @@ ]), 'max_temp': 20, 'min_temp': 10, - 'supported_features': , + 'supported_features': , + 'swing_horizontal_mode': 'stopped', + 'swing_horizontal_modes': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), 'swing_mode': 'stopped', 'swing_modes': list([ 'stopped', diff --git a/tests/components/sensibo/snapshots/test_select.ambr b/tests/components/sensibo/snapshots/test_select.ambr index bdafc8654ff..7438fb70140 100644 --- a/tests/components/sensibo/snapshots/test_select.ambr +++ b/tests/components/sensibo/snapshots/test_select.ambr @@ -1,61 +1,4 @@ # serializer version: 1 -# name: test_select[load_platforms0][select.hallway_horizontal_swing-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'stopped', - 'fixedleft', - 'fixedcenterleft', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.hallway_horizontal_swing', - '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': 'Horizontal swing', - 'platform': 'sensibo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'horizontalswing', - 'unique_id': 'ABC999111-horizontalSwing', - 'unit_of_measurement': None, - }) -# --- -# name: test_select[load_platforms0][select.hallway_horizontal_swing-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Hallway Horizontal swing', - 'options': list([ - 'stopped', - 'fixedleft', - 'fixedcenterleft', - ]), - }), - 'context': , - 'entity_id': 'select.hallway_horizontal_swing', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- # name: test_select[load_platforms0][select.hallway_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 607e120bd27..d6176003582 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -13,12 +13,14 @@ from voluptuous import MultipleInvalid from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, HVACMode, @@ -265,6 +267,95 @@ async def test_climate_swing( assert "swing_mode" not in state.attributes +async def test_climate_horizontal_swing( + hass: HomeAssistant, + load_int: ConfigEntry, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Sensibo climate horizontal swing service.""" + + state = hass.states.get("climate.hallway") + assert state.attributes["swing_horizontal_mode"] == "stopped" + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].horizontal_swing_modes = [ + "stopped", + "fixedleft", + "fixedcenter", + "fixedright", + "not_in_ha", + ] + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].swing_modes_translated = { + "stopped": "stopped", + "fixedleft": "fixedLeft", + "fixedcenter": "fixedCenter", + "fixedright": "fixedRight", + "not_in_ha": "not_in_ha", + } + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, + match="Climate horizontal swing mode not_in_ha is not supported by the integration", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_HORIZONTAL_MODE: "not_in_ha"}, + blocking=True, + ) + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_HORIZONTAL_MODE: "fixedleft"}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.attributes["swing_horizontal_mode"] == "fixedleft" + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].active_features = [ + "timestamp", + "on", + "mode", + "targetTemperature", + "swing", + "light", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match="service_not_supported"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_SWING_HORIZONTAL_MODE: "fixedcenter", + }, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert "swing_horizontal_mode" not in state.attributes + + async def test_climate_temperatures( hass: HomeAssistant, load_int: ConfigEntry, diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 5e1c3f68e41..c93eff92f3a 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -14,13 +14,16 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir -from tests.common import async_fire_time_changed, snapshot_platform +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.parametrize( @@ -40,15 +43,15 @@ async def test_select( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) mock_client.async_get_devices_data.return_value.parsed[ - "ABC999111" - ].horizontal_swing_mode = "fixedleft" + "AAZZAAZZ" + ].light_mode = "dim" freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "fixedleft" + state = hass.states.get("select.kitchen_light") + assert state.state == "dim" async def test_select_set_option( @@ -73,8 +76,8 @@ async def test_select_set_option( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "stopped" + state = hass.states.get("select.kitchen_light") + assert state.state == "on" mock_client.async_set_ac_state_property.return_value = { "result": {"status": "failed"} @@ -86,12 +89,12 @@ async def test_select_set_option( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "fixedleft"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "dim"}, blocking=True, ) - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "stopped" + state = hass.states.get("select.kitchen_light") + assert state.state == "on" mock_client.async_get_devices_data.return_value.parsed[ "ABC999111" @@ -118,12 +121,12 @@ async def test_select_set_option( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "fixedleft"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "dim"}, blocking=True, ) - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "stopped" + state = hass.states.get("select.kitchen_light") + assert state.state == "on" mock_client.async_set_ac_state_property.return_value = { "result": {"status": "Success"} @@ -132,9 +135,93 @@ async def test_select_set_option( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "fixedleft"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "dim"}, blocking=True, ) + state = hass.states.get("select.kitchen_light") + assert state.state == "dim" + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SELECT]], +) +async def test_deprecated_horizontal_swing_select( + hass: HomeAssistant, + load_platforms: list[Platform], + mock_client: MagicMock, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated horizontal swing select entity.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="firstnamelastname", + version=2, + ) + + config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + SELECT_DOMAIN, + DOMAIN, + "ABC999111-horizontalSwing", + config_entry=config_entry, + disabled_by=None, + has_entity_name=True, + suggested_object_id="hallway_horizontal_swing", + ) + + with patch("homeassistant.components.sensibo.PLATFORMS", load_platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "fixedleft" + assert state.state == "stopped" + + # No issue created without automation or script + assert issue_registry.issues == {} + + with ( + patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), + patch( + # Patch check for automation, that one exist + "homeassistant.components.sensibo.select.automations_with_entity", + return_value=["automation.test"], + ), + ): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done(True) + + # Issue is created when entity is enabled and automation/script exist + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") + assert issue + assert issue.translation_key == "deprecated_entity_horizontalswing" + assert hass.states.get("select.hallway_horizontal_swing") + assert entity_registry.async_is_registered("select.hallway_horizontal_swing") + + # Disabling the entity should remove the entity and remove the issue + # once the integration is reloaded + entity_registry.async_update_entity( + state.entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) + + with ( + patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), + patch( + "homeassistant.components.sensibo.select.automations_with_entity", + return_value=["automation.test"], + ), + ): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done(True) + + # Disabling the entity and reloading has removed the entity and issue + assert not hass.states.get("select.hallway_horizontal_swing") + assert not entity_registry.async_is_registered("select.hallway_horizontal_swing") + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") + assert not issue