Files
core/homeassistant/components/group/valve.py
Shay Levy 204ff5d45f Add valve group support (#154749)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-10-19 21:01:15 +02:00

263 lines
8.4 KiB
Python

"""Platform allowing several valves to be grouped into one valve."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.valve import (
ATTR_CURRENT_POSITION,
ATTR_POSITION,
DOMAIN as VALVE_DOMAIN,
PLATFORM_SCHEMA as VALVE_PLATFORM_SCHEMA,
ValveEntity,
ValveEntityFeature,
ValveState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
CONF_ENTITIES,
CONF_NAME,
CONF_UNIQUE_ID,
SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION,
SERVICE_STOP_VALVE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
from .util import reduce_attribute
KEY_OPEN_CLOSE = "open_close"
KEY_STOP = "stop"
KEY_SET_POSITION = "set_position"
DEFAULT_NAME = "Valve Group"
# No limit on parallel updates to enable a group calling another group
PARALLEL_UPDATES = 0
PLATFORM_SCHEMA = VALVE_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ENTITIES): cv.entities_domain(VALVE_DOMAIN),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Valve Group platform."""
async_add_entities(
[
ValveGroup(
config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES]
)
]
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Valve Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
async_add_entities(
[ValveGroup(config_entry.entry_id, config_entry.title, entities)]
)
@callback
def async_create_preview_valve(
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
) -> ValveGroup:
"""Create a preview valve."""
return ValveGroup(
None,
name,
validated_config[CONF_ENTITIES],
)
class ValveGroup(GroupEntity, ValveEntity):
"""Representation of a ValveGroup."""
_attr_available: bool = False
_attr_current_valve_position: int | None = None
_attr_is_closed: bool | None = None
_attr_is_closing: bool | None = False
_attr_is_opening: bool | None = False
_attr_reports_position: bool = False
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
"""Initialize a ValveGroup entity."""
self._entity_ids = entities
self._valves: dict[str, set[str]] = {
KEY_OPEN_CLOSE: set(),
KEY_STOP: set(),
KEY_SET_POSITION: set(),
}
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities}
self._attr_unique_id = unique_id
@callback
def async_update_supported_features(
self,
entity_id: str,
new_state: State | None,
) -> None:
"""Update dictionaries with supported features."""
if not new_state:
for values in self._valves.values():
values.discard(entity_id)
return
features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if features & (ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE):
self._valves[KEY_OPEN_CLOSE].add(entity_id)
else:
self._valves[KEY_OPEN_CLOSE].discard(entity_id)
if features & (ValveEntityFeature.STOP):
self._valves[KEY_STOP].add(entity_id)
else:
self._valves[KEY_STOP].discard(entity_id)
if features & (ValveEntityFeature.SET_POSITION):
self._valves[KEY_SET_POSITION].add(entity_id)
else:
self._valves[KEY_SET_POSITION].discard(entity_id)
async def async_open_valve(self) -> None:
"""Open the valves."""
data = {ATTR_ENTITY_ID: self._valves[KEY_OPEN_CLOSE]}
await self.hass.services.async_call(
VALVE_DOMAIN, SERVICE_OPEN_VALVE, data, blocking=True, context=self._context
)
async def async_handle_open_valve(self) -> None: # type: ignore[misc]
"""Open the valves.
Override the base class to avoid calling the set position service
for all valves. Transfer the service call to the base class and let
it decide if the valve uses set position or open service.
"""
await self.async_open_valve()
async def async_close_valve(self) -> None:
"""Close valves."""
data = {ATTR_ENTITY_ID: self._valves[KEY_OPEN_CLOSE]}
await self.hass.services.async_call(
VALVE_DOMAIN,
SERVICE_CLOSE_VALVE,
data,
blocking=True,
context=self._context,
)
async def async_handle_close_valve(self) -> None: # type: ignore[misc]
"""Close the valves.
Override the base class to avoid calling the set position service
for all valves. Transfer the service call to the base class and let
it decide if the valve uses set position or close service.
"""
await self.async_close_valve()
async def async_set_valve_position(self, position: int) -> None:
"""Move the valves to a specific position."""
data = {
ATTR_ENTITY_ID: self._valves[KEY_SET_POSITION],
ATTR_POSITION: position,
}
await self.hass.services.async_call(
VALVE_DOMAIN,
SERVICE_SET_VALVE_POSITION,
data,
blocking=True,
context=self._context,
)
async def async_stop_valve(self) -> None:
"""Stop the valves."""
data = {ATTR_ENTITY_ID: self._valves[KEY_STOP]}
await self.hass.services.async_call(
VALVE_DOMAIN, SERVICE_STOP_VALVE, data, blocking=True, context=self._context
)
@callback
def async_update_group_state(self) -> None:
"""Update state and attributes."""
states = [
state
for entity_id in self._entity_ids
if (state := self.hass.states.get(entity_id)) is not None
]
# Set group as unavailable if all members are unavailable or missing
self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states)
self._attr_is_closed = True
self._attr_is_closing = False
self._attr_is_opening = False
self._attr_reports_position = False
self._update_assumed_state_from_members()
for state in states:
if state.attributes.get(ATTR_CURRENT_POSITION) is not None:
self._attr_reports_position = True
if state.state == ValveState.OPEN:
self._attr_is_closed = False
continue
if state.state == ValveState.CLOSED:
continue
if state.state == ValveState.CLOSING:
self._attr_is_closing = True
continue
if state.state == ValveState.OPENING:
self._attr_is_opening = True
continue
valid_state = any(
state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states
)
if not valid_state:
# Set as unknown if all members are unknown or unavailable
self._attr_is_closed = None
self._attr_current_valve_position = reduce_attribute(
states, ATTR_CURRENT_POSITION
)
supported_features = ValveEntityFeature(0)
if self._valves[KEY_OPEN_CLOSE]:
supported_features |= ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
if self._valves[KEY_STOP]:
supported_features |= ValveEntityFeature.STOP
if self._valves[KEY_SET_POSITION]:
supported_features |= ValveEntityFeature.SET_POSITION
self._attr_supported_features = supported_features