From adc56b6b67ba786a3f7f62d9a2851cfd60986113 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 24 Nov 2023 12:55:41 +0100 Subject: [PATCH] Add support for Shelly Wall Display in thermostat mode (#103937) --- homeassistant/components/shelly/__init__.py | 1 + homeassistant/components/shelly/climate.py | 102 +++++++++++++++++++- homeassistant/components/shelly/const.py | 5 + homeassistant/components/shelly/switch.py | 10 +- tests/components/shelly/conftest.py | 9 ++ tests/components/shelly/test_climate.py | 101 ++++++++++++++++++- tests/components/shelly/test_switch.py | 39 +++++++- 7 files changed, 262 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 5efc5c849d7..b29fdcc6d19 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -73,6 +73,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [ RPC_PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.EVENT, Platform.LIGHT, diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 35c18511860..dbc4960af58 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -37,9 +37,12 @@ from .const import ( DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, + RPC_THERMOSTAT_SETTINGS, SHTRV_01_TEMPERATURE_SETTINGS, ) -from .coordinator import ShellyBlockCoordinator, get_entry_data +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data +from .entity import ShellyRpcEntity +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( @@ -48,6 +51,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up climate device.""" + if get_device_entry_gen(config_entry) == 2: + return async_setup_rpc_entry(hass, config_entry, async_add_entities) + coordinator = get_entry_data(hass)[config_entry.entry_id].block assert coordinator if coordinator.device.initialized: @@ -105,6 +111,29 @@ def async_restore_climate_entities( break +@callback +def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + climate_key_ids = get_rpc_key_ids(coordinator.device.status, "thermostat") + + climate_ids = [] + for id_ in climate_key_ids: + climate_ids.append(id_) + unique_id = f"{coordinator.mac}-switch:{id_}" + async_remove_shelly_entity(hass, "switch", unique_id) + + if not climate_ids: + return + + async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) + + @dataclass class ShellyClimateExtraStoredData(ExtraStoredData): """Object to hold extra stored data.""" @@ -381,3 +410,74 @@ class BlockSleepingClimate( self.coordinator.entry.async_start_reauth(self.hass) else: self.async_write_ha_state() + + +class RpcClimate(ShellyRpcEntity, ClimateEntity): + """Entity that controls a thermostat on RPC based Shelly devices.""" + + _attr_hvac_modes = [HVACMode.OFF] + _attr_icon = "mdi:thermostat" + _attr_max_temp = RPC_THERMOSTAT_SETTINGS["max"] + _attr_min_temp = RPC_THERMOSTAT_SETTINGS["min"] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = RPC_THERMOSTAT_SETTINGS["step"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + """Initialize.""" + super().__init__(coordinator, f"thermostat:{id_}") + self._id = id_ + self._thermostat_type = coordinator.device.config[f"thermostat:{id_}"].get( + "type", "heating" + ) + if self._thermostat_type == "cooling": + self._attr_hvac_modes.append(HVACMode.COOL) + else: + self._attr_hvac_modes.append(HVACMode.HEAT) + + @property + def target_temperature(self) -> float | None: + """Set target temperature.""" + return cast(float, self.status["target_C"]) + + @property + def current_temperature(self) -> float | None: + """Return current temperature.""" + return cast(float, self.status["current_C"]) + + @property + def hvac_mode(self) -> HVACMode: + """HVAC current mode.""" + if not self.status["enable"]: + return HVACMode.OFF + + return HVACMode.COOL if self._thermostat_type == "cooling" else HVACMode.HEAT + + @property + def hvac_action(self) -> HVACAction: + """HVAC current action.""" + if not self.status["output"]: + return HVACAction.IDLE + + return ( + HVACAction.COOLING + if self._thermostat_type == "cooling" + else HVACAction.HEATING + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + await self.call_rpc( + "Thermostat.SetConfig", + {"config": {"id": self._id, "target_C": target_temp}}, + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + mode = hvac_mode in (HVACMode.COOL, HVACMode.HEAT) + await self.call_rpc( + "Thermostat.SetConfig", {"config": {"id": self._id, "enable": mode}} + ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 95ffa2de91e..db7623f684e 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -149,6 +149,11 @@ SHTRV_01_TEMPERATURE_SETTINGS: Final = { "step": 0.5, "default": 20.0, } +RPC_THERMOSTAT_SETTINGS: Final = { + "min": 5, + "max": 35, + "step": 0.5, +} # Kelvin value for colorTemp KELVIN_MAX_VALUE: Final = 6500 diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 395b386993a..5610956e790 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import GAS_VALVE_OPEN_STATES +from .const import GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( BlockEntityDescription, @@ -116,6 +116,14 @@ def async_setup_rpc_entry( if is_rpc_channel_type_light(coordinator.device.config, id_): continue + if coordinator.model == MODEL_WALL_DISPLAY: + if coordinator.device.shelly["relay_operational"]: + # Wall Display in relay mode, we need to remove a climate entity + unique_id = f"{coordinator.mac}-thermostat:{id_}" + async_remove_shelly_entity(hass, "climate", unique_id) + else: + continue + switch_ids.append(id_) unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "light", unique_id) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8b4ca0824c4..12d84200720 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -148,6 +148,7 @@ MOCK_CONFIG = { "light:0": {"name": "test light_0"}, "switch:0": {"name": "test switch_0"}, "cover:0": {"name": "test cover_0"}, + "thermostat:0": {"id": 0, "enable": True, "type": "heating"}, "sys": { "ui_data": {}, "device": {"name": "Test name"}, @@ -174,6 +175,7 @@ MOCK_SHELLY_RPC = { "auth_en": False, "auth_domain": None, "profile": "cover", + "relay_operational": False, } MOCK_STATUS_COAP = { @@ -207,6 +209,13 @@ MOCK_STATUS_RPC = { "em1:1": {"act_power": 123.3}, "em1data:0": {"total_act_energy": 123456.4}, "em1data:1": {"total_act_energy": 987654.3}, + "thermostat:0": { + "id": 0, + "enable": True, + "target_C": 23, + "current_C": 12.3, + "output": True, + }, "sys": { "available_updates": { "beta": {"version": "some_beta_version"}, diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 08ec548d3f0..d1e37f77574 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -1,10 +1,13 @@ """Tests for Shelly climate platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, PropertyMock from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_TARGET_TEMP_HIGH, @@ -14,13 +17,15 @@ from homeassistant.components.climate import ( SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -534,3 +539,97 @@ async def test_device_not_calibrated( assert not issue_registry.async_get_issue( domain=DOMAIN, issue_id=f"not_calibrated_{MOCK_MAC}" ) + + +async def test_rpc_climate_hvac_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rpc_device, + monkeypatch, +) -> None: + """Test climate hvac mode service.""" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 23 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + + entry = entity_registry.async_get(ENTITY_ID) + assert entry + assert entry.unique_id == "123456789ABC-thermostat:0" + + monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "output", False) + mock_rpc_device.mock_update() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + + monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "enable", False) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "Thermostat.SetConfig", {"config": {"id": 0, "enable": False}} + ) + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.OFF + + +async def test_rpc_climate_set_temperature( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test climate set target temperature.""" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TEMPERATURE] == 23 + + # test set temperature without target temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_LOW: 20, + ATTR_TARGET_TEMP_HIGH: 30, + }, + blocking=True, + ) + mock_rpc_device.call_rpc.assert_not_called() + + monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "target_C", 28) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 28}, + blocking=True, + ) + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "Thermostat.SetConfig", {"config": {"id": 0, "target_C": 28}} + ) + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TEMPERATURE] == 28 + + +async def test_rpc_climate_hvac_mode_cool( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test climate with hvac mode cooling.""" + new_config = deepcopy(mock_rpc_device.config) + new_config["thermostat:0"]["type"] = "cooling" + monkeypatch.setattr(mock_rpc_device, "config", new_config) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 115ad5edabb..9bc065ed166 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,10 +1,12 @@ """Tests for Shelly switch platform.""" +from copy import deepcopy from unittest.mock import AsyncMock from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -19,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import init_integration, register_entity RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 @@ -277,3 +279,36 @@ async def test_block_device_gas_valve( assert state assert state.state == STATE_ON # valve is open assert state.attributes.get(ATTR_ICON) == "mdi:valve-open" + + +async def test_wall_display_thermostat_mode( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test Wall Display in thermostat mode.""" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + # the switch entity should not be created, only the climate entity + assert hass.states.get("switch.test_name") is None + assert hass.states.get("climate.test_name") + + +async def test_wall_display_relay_mode( + hass: HomeAssistant, entity_registry, mock_rpc_device, monkeypatch +) -> None: + """Test Wall Display in thermostat mode.""" + entity_id = register_entity( + hass, + CLIMATE_DOMAIN, + "test_name", + "thermostat:0", + ) + + new_shelly = deepcopy(mock_rpc_device.shelly) + new_shelly["relay_operational"] = True + + monkeypatch.setattr(mock_rpc_device, "shelly", new_shelly) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + # the climate entity should be removed + assert hass.states.get(entity_id) is None