Add support for Shelly Wall Display in thermostat mode (#103937)

pull/103888/head
Maciej Bieniek 2023-11-24 12:55:41 +01:00 committed by GitHub
parent 130822fcc6
commit adc56b6b67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 262 additions and 5 deletions

View File

@ -73,6 +73,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [
RPC_PLATFORMS: Final = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
Platform.EVENT,
Platform.LIGHT,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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