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
This commit is contained in:
Simone Chemelli 2025-01-09 15:28:36 -05:00 committed by GitHub
parent ee865d2f0f
commit 6e1a13f878
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 293 additions and 12 deletions

View File

@ -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},
},
)

View File

@ -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

View File

@ -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,

View File

@ -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.",

2
requirements_all.txt generated
View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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