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 returnpull/98807/head
parent
c025244ac1
commit
17050a3286
|
@ -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")
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue