mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +00:00
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:
parent
ee865d2f0f
commit
6e1a13f878
@ -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},
|
||||
},
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
2
requirements_all.txt
generated
@ -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
|
||||
|
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user