From 6e1a13f878e4e93fb69b1f546f9a671419d31d55 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 9 Jan 2025 15:28:36 -0500 Subject: [PATCH] Add support for Shelly BLU TRV (#128439) * feat: add support for Shelly BLU TRV * chore: apply some fixes * make BLUTRV a separate device * apply review comment * review comments and small optimization * add HVACMode.OFF * a couple of fixes * 2 more fixes * better approach * cleanup * small optimization * remove cooling as not supported by firmware * tweaks * humidity and entity name * fix naming * allign async_set_hvac_mode * align settings * restore temp * fix * remove OFF * cleanup * hvac_mode * add tests * typo * more tests * bump aioshelly --- homeassistant/components/shelly/climate.py | 89 ++++++++++++++++- homeassistant/components/shelly/const.py | 7 ++ .../components/shelly/coordinator.py | 1 + homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/__init__.py | 7 ++ tests/components/shelly/conftest.py | 97 ++++++++++++++++++ tests/components/shelly/test_climate.py | 98 ++++++++++++++++++- 9 files changed, 293 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 842abc5ecc4..940343fc069 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,7 +7,7 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -22,7 +22,11 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity @@ -31,6 +35,7 @@ from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( + BLU_TRV_TEMPERATURE_SETTINGS, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, @@ -124,6 +129,7 @@ def async_setup_rpc_entry( coordinator = config_entry.runtime_data.rpc assert coordinator climate_key_ids = get_rpc_key_ids(coordinator.device.status, "thermostat") + blutrv_key_ids = get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER) climate_ids = [] for id_ in climate_key_ids: @@ -139,10 +145,11 @@ def async_setup_rpc_entry( unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) - if not climate_ids: - return + if climate_ids: + async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) - async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) + if blutrv_key_ids: + async_add_entities(RpcBluTrvClimate(coordinator, id_) for id_ in blutrv_key_ids) @dataclass @@ -526,3 +533,75 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): await self.call_rpc( "Thermostat.SetConfig", {"config": {"id": self._id, "enable": mode}} ) + + +class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): + """Entity that controls a thermostat on RPC based Shelly devices.""" + + _attr_max_temp = BLU_TRV_TEMPERATURE_SETTINGS["max"] + _attr_min_temp = BLU_TRV_TEMPERATURE_SETTINGS["min"] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_target_temperature_step = BLU_TRV_TEMPERATURE_SETTINGS["step"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + """Initialize.""" + + super().__init__(coordinator, f"{BLU_TRV_IDENTIFIER}:{id_}") + self._id = id_ + self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] + ble_addr: str = self._config["addr"] + self._attr_unique_id = f"{ble_addr}-{self.key}" + name = self._config["name"] or f"shellyblutrv-{ble_addr.replace(":", "")}" + model_id = self._config.get("local_name") + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)}, + identifiers={(DOMAIN, ble_addr)}, + via_device=(DOMAIN, self.coordinator.mac), + manufacturer="Shelly", + model=BLU_TRV_MODEL_NAME.get(model_id), + model_id=model_id, + name=name, + ) + # Added intentionally to the constructor to avoid double name from base class + self._attr_name = None + + @property + def target_temperature(self) -> float | None: + """Set target temperature.""" + if not self._config["enable"]: + return None + + 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_action(self) -> HVACAction: + """HVAC current action.""" + if not self.status["pos"]: + return HVACAction.IDLE + + return 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( + "BluTRV.Call", + { + "id": self._id, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": target_temp}, + }, + ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 88d8c1f5f17..1adaad8f975 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -187,6 +187,13 @@ RPC_THERMOSTAT_SETTINGS: Final = { "step": 0.5, } +BLU_TRV_TEMPERATURE_SETTINGS: Final = { + "min": 4, + "max": 30, + "step": 0.1, + "default": 20.0, +} + # Kelvin value for colorTemp KELVIN_MAX_VALUE: Final = 6500 KELVIN_MIN_VALUE_WHITE: Final = 2700 diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 8273c7626eb..f58e42a78d8 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -154,6 +154,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( config_entry_id=self.entry.entry_id, name=self.name, connections={(CONNECTION_NETWORK_MAC, self.mac)}, + identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", model=MODEL_NAMES.get(self.model), model_id=self.model, diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 29c8fd4c369..2db45c3fb03 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.2.0"], + "requirements": ["aioshelly==12.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 50ce62e9cb1..1c7cb80f566 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.2.0 +aioshelly==12.3.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0303dfdf83c..bbe7448d891 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.2.0 +aioshelly==12.3.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 7de45eeee98..7a20560e25f 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -148,6 +148,13 @@ def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: return entity.state +def get_entity_attribute(hass: HomeAssistant, entity_id: str, attribute: str) -> str: + """Return entity attribute.""" + entity = hass.states.get(entity_id) + assert entity + return entity.attributes[attribute] + + def register_device( device_registry: DeviceRegistry, config_entry: ConfigEntry ) -> DeviceEntry: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index b2550c2b9d4..7bcc1c04c6a 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -202,6 +202,64 @@ MOCK_CONFIG = { "voltmeter:100": {"xvoltage": {"unit": "ppm"}}, } + +MOCK_BLU_TRV_REMOTE_CONFIG = { + "components": [ + { + "key": "blutrv:200", + "status": { + "id": 200, + "target_C": 17.1, + "current_C": 17.1, + "pos": 0, + "rssi": -60, + "battery": 100, + "packet_id": 58, + "last_updated_ts": 1734967725, + "paired": True, + "rpc": True, + "rsv": 61, + }, + "config": { + "id": 200, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "key": None, + "trv": "bthomedevice:200", + "temp_sensors": [], + "dw_sensors": [], + "override_delay": 30, + "meta": {}, + }, + }, + ], + "blutrv:200": { + "id": 0, + "enable": True, + "min_valve_position": 0, + "default_boost_duration": 1800, + "default_override_duration": 2147483647, + "default_override_target_C": 8, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "local_name": "SBTR-001AEU", + }, +} + + +MOCK_BLU_TRV_REMOTE_STATUS = { + "blutrv:200": { + "id": 0, + "pos": 0, + "steps": 0, + "current_C": 15.2, + "target_C": 17.1, + "schedule_rev": 0, + "errors": [], + }, +} + + MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, @@ -373,6 +431,24 @@ def _mock_rpc_device(version: str | None = None): return device +def _mock_blu_rtv_device(version: str | None = None): + """Mock rpc (Gen2, Websocket) device.""" + device = Mock( + spec=RpcDevice, + config=MOCK_CONFIG | MOCK_BLU_TRV_REMOTE_CONFIG, + event={}, + shelly=MOCK_SHELLY_RPC, + version=version or "1.0.0", + hostname="test-host", + status=MOCK_STATUS_RPC | MOCK_BLU_TRV_REMOTE_STATUS, + firmware_version="some fw string", + initialized=True, + connected=True, + ) + type(device).name = PropertyMock(return_value="Test name") + return device + + @pytest.fixture async def mock_rpc_device(): """Mock rpc (Gen2, Websocket) device with BLE support.""" @@ -420,3 +496,24 @@ async def mock_rpc_device(): @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture(autouse=True) +async def mock_blu_trv(): + """Mock BLU TRV.""" + + with ( + patch("aioshelly.rpc_device.RpcDevice.create") as blu_trv_device_mock, + patch("homeassistant.components.shelly.bluetooth.async_start_scanner"), + ): + + def update(): + blu_trv_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.STATUS + ) + + device = _mock_blu_rtv_device() + blu_trv_device_mock.return_value = device + blu_trv_device_mock.return_value.mock_update = Mock(side_effect=update) + + yield blu_trv_device_mock.return_value diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index aeeeca30edd..352bdcb0a7d 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -3,7 +3,12 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock, PropertyMock -from aioshelly.const import MODEL_VALVE, MODEL_WALL_DISPLAY +from aioshelly.const import ( + BLU_TRV_IDENTIFIER, + MODEL_BLU_GATEWAY_GEN3, + MODEL_VALVE, + MODEL_WALL_DISPLAY, +) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest @@ -37,7 +42,13 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import MOCK_MAC, init_integration, register_device, register_entity +from . import ( + MOCK_MAC, + get_entity_attribute, + init_integration, + register_device, + register_entity, +) from .conftest import MOCK_STATUS_COAP from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @@ -294,13 +305,13 @@ async def test_block_restored_climate( assert hass.states.get(entity_id).attributes.get("temperature") == 22.0 -async def test_block_restored_climate_us_customery( +async def test_block_restored_climate_us_customary( hass: HomeAssistant, mock_block_device: Mock, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test block restored climate with US CUSTOMATY unit system.""" + """Test block restored climate with US CUSTOMARY unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") @@ -759,3 +770,82 @@ async def test_wall_display_thermostat_mode_external_actuator( entry = entity_registry.async_get(climate_entity_id) assert entry assert entry.unique_id == "123456789ABC-thermostat:0" + + +async def test_blu_trv_climate_set_temperature( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV set target temperature.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "target_C", 28 + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 28}, + blocking=True, + ) + mock_blu_trv.mock_update() + + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": 28.0}, + }, + ) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 + + +async def test_blu_trv_climate_disabled( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV disabled.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.config[f"{BLU_TRV_IDENTIFIER}:200"], "enable", False + ) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) is None + + +async def test_blu_trv_climate_hvac_action( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV is heating.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE + + monkeypatch.setitem(mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "pos", 10) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.HEATING