Add support for Shelly Wall Display in thermostat mode (#103937)
parent
130822fcc6
commit
adc56b6b67
|
@ -73,6 +73,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [
|
|||
RPC_PLATFORMS: Final = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.EVENT,
|
||||
Platform.LIGHT,
|
||||
|
|
|
@ -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}}
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue