Add support for Shelly Gas Valve addon (#98705)

* Support Gas Valve

* Treat opening and closing as open

* Use set_state()

* Change entity icon and name

* Add valve state sensor

* Closing == closed

* Add translations for valve state entity

* Valve state -> Valve status

* Add tests; use control_result

* Fix mypy error

* Add missing "valve" to the Mock

* Improve docstrings

* Fix climate platform tests

* Increase test coverage

* Add mising return
pull/98807/head
Maciej Bieniek 2023-08-22 08:53:52 +00:00 committed by GitHub
parent c025244ac1
commit 17050a3286
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 179 additions and 2 deletions

View File

@ -179,3 +179,5 @@ MAX_PUSH_UPDATE_FAILURES = 5
PUSH_UPDATE_ISSUE_ID = "push_update_{unique}"
NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}"
GAS_VALVE_OPEN_STATES = ("opening", "opened")

View File

@ -312,6 +312,24 @@ SENSORS: Final = {
value=lambda value: value,
extra_state_attributes=lambda block: {"self_test": block.selfTest},
),
("valve", "valve"): BlockSensorDescription(
key="valve|valve",
name="Valve status",
translation_key="valve_status",
icon="mdi:valve",
device_class=SensorDeviceClass.ENUM,
options=[
"checking",
"closed",
"closing",
"failure",
"opened",
"opening",
"unknown",
],
entity_category=EntityCategory.DIAGNOSTIC,
removal_condition=lambda _, block: block.valve == "not_connected",
),
}
REST_SENSORS: Final = {

View File

@ -116,6 +116,17 @@
}
}
}
},
"valve_status": {
"state": {
"checking": "Checking",
"closed": "Closed",
"closing": "Closing",
"failure": "Failure",
"opened": "Opened",
"opening": "Opening",
"unknown": "[%key:component::shelly::entity::sensor::operation::state::unknown%]"
}
}
}
},

View File

@ -1,17 +1,25 @@
"""Switch for Shelly."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, cast
from aioshelly.block_device import Block
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
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 .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data
from .entity import ShellyBlockEntity, ShellyRpcEntity
from .entity import (
BlockEntityDescription,
ShellyBlockAttributeEntity,
ShellyBlockEntity,
ShellyRpcEntity,
async_setup_block_attribute_entities,
)
from .utils import (
async_remove_shelly_entity,
get_device_entry_gen,
@ -21,6 +29,19 @@ from .utils import (
)
@dataclass
class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription):
"""Class to describe a BLOCK switch."""
GAS_VALVE_SWITCH = BlockSwitchDescription(
key="valve|valve",
name="Valve",
available=lambda block: block.valve not in ("failure", "checking"),
removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -43,6 +64,17 @@ def async_setup_block_entry(
coordinator = get_entry_data(hass)[config_entry.entry_id].block
assert coordinator
# Add Shelly Gas Valve as a switch
if coordinator.model == "SHGS-1":
async_setup_block_attribute_entities(
hass,
async_add_entities,
coordinator,
{("valve", "valve"): GAS_VALVE_SWITCH},
BlockValveSwitch,
)
return
# In roller mode the relay blocks exist but do not contain required info
if (
coordinator.model in ["SHSW-21", "SHSW-25"]
@ -94,6 +126,53 @@ def async_setup_rpc_entry(
async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids)
class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity):
"""Entity that controls a Gas Valve on Block based Shelly devices."""
entity_description: BlockSwitchDescription
def __init__(
self,
coordinator: ShellyBlockCoordinator,
block: Block,
attribute: str,
description: BlockSwitchDescription,
) -> None:
"""Initialize valve."""
super().__init__(coordinator, block, attribute, description)
self.control_result: dict[str, Any] | None = None
@property
def is_on(self) -> bool:
"""If valve is open."""
if self.control_result:
return self.control_result["state"] in GAS_VALVE_OPEN_STATES
return self.attribute_value in GAS_VALVE_OPEN_STATES
@property
def icon(self) -> str:
"""Return the icon."""
return "mdi:valve-open" if self.is_on else "mdi:valve-closed"
async def async_turn_on(self, **kwargs: Any) -> None:
"""Open valve."""
self.control_result = await self.set_state(go="open")
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Close valve."""
self.control_result = await self.set_state(go="close")
self.async_write_ha_state()
@callback
def _update_callback(self) -> None:
"""When device updates, clear control result that overrides state."""
self.control_result = None
super()._update_callback()
class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity):
"""Entity that controls a relay on Block based Shelly devices."""

View File

@ -131,6 +131,16 @@ MOCK_BLOCKS = [
description="emeter_0",
type="emeter",
),
Mock(
sensor_ids={"valve": "closed"},
valve="closed",
channel="0",
description="valve_0",
type="valve",
set_state=AsyncMock(
side_effect=lambda go: {"state": "opening" if go == "open" else "closing"}
),
),
]
MOCK_CONFIG = {

View File

@ -32,6 +32,7 @@ from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data
SENSOR_BLOCK_ID = 3
DEVICE_BLOCK_ID = 4
EMETER_BLOCK_ID = 5
GAS_VALVE_BLOCK_ID = 6
ENTITY_ID = f"{CLIMATE_DOMAIN}.test_name"
@ -47,6 +48,7 @@ async def test_climate_hvac_mode(
)
monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0)
monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp")
monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp")
await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01")
# Make device online
@ -103,6 +105,7 @@ async def test_climate_set_temperature(
"""Test climate set temperature service."""
monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp")
monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0)
monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp")
await init_integration(hass, 1, sleep_period=1000)
# Make device online
@ -144,6 +147,7 @@ async def test_climate_set_preset_mode(
) -> None:
"""Test climate set preset mode service."""
monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp")
monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp")
monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0)
monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", None)
await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01")
@ -198,6 +202,7 @@ async def test_block_restored_climate(
) -> None:
"""Test block restored climate."""
monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp")
monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp")
monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0)
monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp")
entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True)
@ -261,6 +266,7 @@ async def test_block_restored_climate_us_customery(
"""Test block restored climate with US CUSTOMATY 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")
monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0)
monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp")
entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True)

View File

@ -9,6 +9,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ICON,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
@ -16,10 +17,12 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import init_integration
RELAY_BLOCK_ID = 0
GAS_VALVE_BLOCK_ID = 6
async def test_block_device_services(hass: HomeAssistant, mock_block_device) -> None:
@ -226,3 +229,51 @@ async def test_rpc_auth_error(
assert "context" in flow
assert flow["context"].get("source") == SOURCE_REAUTH
assert flow["context"].get("entry_id") == entry.entry_id
async def test_block_device_gas_valve(
hass: HomeAssistant, mock_block_device, monkeypatch
) -> None:
"""Test block device Shelly Gas with Valve addon."""
registry = er.async_get(hass)
await init_integration(hass, 1, "SHGS-1")
entity_id = "switch.test_name_valve"
entry = registry.async_get(entity_id)
assert entry
assert entry.unique_id == "123456789ABC-valve_0-valve"
assert hass.states.get(entity_id).state == STATE_OFF # valve is closed
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON # valve is open
assert state.attributes.get(ATTR_ICON) == "mdi:valve-open"
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF # valve is closed
assert state.attributes.get(ATTR_ICON) == "mdi:valve-closed"
monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened")
mock_block_device.mock_update()
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON # valve is open
assert state.attributes.get(ATTR_ICON) == "mdi:valve-open"