Skip to content

Commit 841b659

Browse files
Implement Climate group
This would enable grouping different climate entities into a single entity to be controlled together. This implementation is a fork of the existing Pull request that is right now being unmaintained: home-assistant#77737
1 parent 135c40c commit 841b659

File tree

7 files changed

+758
-0
lines changed

7 files changed

+758
-0
lines changed

homeassistant/components/group/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464

6565
PLATFORMS = [
6666
Platform.BINARY_SENSOR,
67+
Platform.CLIMATE,
6768
Platform.COVER,
6869
Platform.FAN,
6970
Platform.LIGHT,
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
"""Platform for allowing several climate devices to be grouped into one climate device."""
2+
from __future__ import annotations
3+
4+
import logging
5+
from statistics import mean
6+
from typing import Any
7+
8+
import voluptuous as vol
9+
10+
from homeassistant.components.climate import (
11+
ATTR_CURRENT_TEMPERATURE,
12+
ATTR_FAN_MODE,
13+
ATTR_FAN_MODES,
14+
ATTR_HVAC_ACTION,
15+
ATTR_HVAC_MODE,
16+
ATTR_HVAC_MODES,
17+
ATTR_MAX_TEMP,
18+
ATTR_MIN_TEMP,
19+
ATTR_PRESET_MODE,
20+
ATTR_PRESET_MODES,
21+
ATTR_SWING_MODE,
22+
ATTR_SWING_MODES,
23+
ATTR_TARGET_TEMP_HIGH,
24+
ATTR_TARGET_TEMP_LOW,
25+
ATTR_TARGET_TEMP_STEP,
26+
DOMAIN,
27+
PLATFORM_SCHEMA,
28+
SERVICE_SET_FAN_MODE,
29+
SERVICE_SET_HVAC_MODE,
30+
SERVICE_SET_PRESET_MODE,
31+
SERVICE_SET_SWING_MODE,
32+
SERVICE_SET_TEMPERATURE,
33+
ClimateEntity,
34+
ClimateEntityFeature,
35+
HVACAction,
36+
HVACMode,
37+
)
38+
from homeassistant.config_entries import ConfigEntry
39+
from homeassistant.const import (
40+
ATTR_ENTITY_ID,
41+
ATTR_SUPPORTED_FEATURES,
42+
ATTR_TEMPERATURE,
43+
CONF_ENTITIES,
44+
CONF_NAME,
45+
CONF_TEMPERATURE_UNIT,
46+
CONF_UNIQUE_ID,
47+
STATE_UNAVAILABLE,
48+
STATE_UNKNOWN,
49+
)
50+
from homeassistant.core import HomeAssistant, callback
51+
from homeassistant.helpers import config_validation as cv, entity_registry as er
52+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
53+
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
54+
55+
from .entity import GroupEntity
56+
from .util import (
57+
find_state_attributes,
58+
most_frequent_attribute,
59+
reduce_attribute,
60+
states_equal,
61+
)
62+
63+
_LOGGER = logging.getLogger(__name__)
64+
65+
DEFAULT_NAME = "Climate Group"
66+
67+
# No limit on parallel updates to enable a group calling another group
68+
PARALLEL_UPDATES = 0
69+
70+
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
71+
{
72+
vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN),
73+
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
74+
vol.Optional(CONF_UNIQUE_ID): cv.string,
75+
vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit,
76+
}
77+
)
78+
# edit the supported_flags
79+
SUPPORT_FLAGS = (
80+
ClimateEntityFeature.TARGET_TEMPERATURE
81+
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
82+
| ClimateEntityFeature.PRESET_MODE
83+
| ClimateEntityFeature.SWING_MODE
84+
| ClimateEntityFeature.FAN_MODE
85+
)
86+
87+
88+
async def async_setup_platform(
89+
hass: HomeAssistant,
90+
config: ConfigType,
91+
async_add_entities: AddEntitiesCallback,
92+
discovery_info: DiscoveryInfoType | None = None,
93+
) -> None:
94+
"""Initialize climate.group platform."""
95+
async_add_entities(
96+
[
97+
ClimateGroup(
98+
config.get(CONF_UNIQUE_ID),
99+
config[CONF_NAME],
100+
config[CONF_ENTITIES],
101+
config.get(CONF_TEMPERATURE_UNIT, hass.config.units.temperature_unit),
102+
)
103+
]
104+
)
105+
106+
107+
async def async_setup_entry(
108+
hass: HomeAssistant,
109+
config_entry: ConfigEntry,
110+
async_add_entities: AddEntitiesCallback,
111+
) -> None:
112+
"""Initialize Climate Group config entry."""
113+
registry = er.async_get(hass)
114+
entities = er.async_validate_entity_ids(
115+
registry, config_entry.options[CONF_ENTITIES]
116+
)
117+
118+
async_add_entities(
119+
[
120+
ClimateGroup(
121+
config_entry.entry_id,
122+
config_entry.title,
123+
entities,
124+
config_entry.options.get(
125+
CONF_TEMPERATURE_UNIT, hass.config.units.temperature_unit
126+
),
127+
)
128+
]
129+
)
130+
131+
132+
class ClimateGroup(GroupEntity, ClimateEntity):
133+
"""Representation of a climate group."""
134+
135+
_attr_available: bool = False
136+
_attr_assumed_state: bool = True
137+
138+
def __init__(
139+
self,
140+
unique_id: str | None,
141+
name: str,
142+
entity_ids: list[str],
143+
temperature_unit: str,
144+
) -> None:
145+
"""Initialize a climate group."""
146+
self._entity_ids = entity_ids
147+
148+
self._attr_name = name
149+
self._attr_unique_id = unique_id
150+
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
151+
152+
self._attr_temperature_unit = temperature_unit
153+
154+
# Set some defaults (will be overwritten on update)
155+
self._attr_supported_features = ClimateEntityFeature(0)
156+
self._attr_hvac_modes = [HVACMode.OFF]
157+
self._attr_hvac_mode = None
158+
self._attr_hvac_action = None
159+
160+
self._attr_swing_modes = None
161+
self._attr_swing_mode = None
162+
163+
self._attr_fan_modes = None
164+
self._attr_fan_mode = None
165+
166+
self._attr_preset_modes = None
167+
self._attr_preset_mode = None
168+
169+
@callback
170+
def async_update_group_state(self) -> None:
171+
"""Query all members and determine the climate group state."""
172+
self._attr_assumed_state = False
173+
174+
states = [
175+
state
176+
for entity_id in self._entity_ids
177+
if (state := self.hass.states.get(entity_id)) is not None
178+
]
179+
self._attr_assumed_state |= not states_equal(states)
180+
181+
# Set group as unavailable if all members are unavailable or missing
182+
self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states)
183+
184+
# Temperature settings
185+
self._attr_target_temperature = reduce_attribute(
186+
states, ATTR_TEMPERATURE, reduce=lambda *data: mean(data)
187+
)
188+
189+
self._attr_target_temperature_step = reduce_attribute(
190+
states, ATTR_TARGET_TEMP_STEP, reduce=max
191+
)
192+
193+
self._attr_target_temperature_low = reduce_attribute(
194+
states, ATTR_TARGET_TEMP_LOW, reduce=lambda *data: mean(data)
195+
)
196+
self._attr_target_temperature_high = reduce_attribute(
197+
states, ATTR_TARGET_TEMP_HIGH, reduce=lambda *data: mean(data)
198+
)
199+
200+
self._attr_current_temperature = reduce_attribute(
201+
states, ATTR_CURRENT_TEMPERATURE, reduce=lambda *data: mean(data)
202+
)
203+
204+
self._attr_min_temp = reduce_attribute(states, ATTR_MIN_TEMP, reduce=max)
205+
self._attr_max_temp = reduce_attribute(states, ATTR_MAX_TEMP, reduce=min)
206+
# End temperature settings
207+
208+
# available HVAC modes
209+
all_hvac_modes = list(find_state_attributes(states, ATTR_HVAC_MODES))
210+
if all_hvac_modes:
211+
# Merge all effects from all effect_lists with a union merge.
212+
self._attr_hvac_modes = list(set().union(*all_hvac_modes))
213+
214+
current_hvac_modes = [
215+
x.state
216+
for x in states
217+
if x.state not in [HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN]
218+
]
219+
# return the most common hvac mode (what the thermostat is set to do) except OFF, UNKNOWN and UNAVAILABE
220+
if current_hvac_modes:
221+
self._attr_hvac_mode = HVACMode(
222+
max(sorted(set(current_hvac_modes)), key=current_hvac_modes.count)
223+
)
224+
# return off if any is off
225+
elif any(x.state == HVACMode.OFF for x in states):
226+
self._attr_hvac_mode = HVACMode.OFF
227+
# else it's none
228+
else:
229+
self._attr_hvac_mode = None
230+
# return the most common action if it is not off
231+
hvac_actions = list(find_state_attributes(states, ATTR_HVAC_ACTION))
232+
current_hvac_actions = [a for a in hvac_actions if a != HVACAction.OFF]
233+
# return the most common action if it is not off
234+
if current_hvac_actions:
235+
self._attr_hvac_action = max(
236+
sorted(set(current_hvac_actions)), key=current_hvac_actions.count
237+
)
238+
# return action off if all are off
239+
elif all(a == HVACAction.OFF for a in hvac_actions):
240+
self._attr_hvac_action = HVACAction.OFF
241+
# else it's none
242+
else:
243+
self._attr_hvac_action = None
244+
245+
# available swing modes
246+
all_swing_modes = list(find_state_attributes(states, ATTR_SWING_MODES))
247+
if all_swing_modes:
248+
self._attr_swing_modes = list(set().union(*all_swing_modes))
249+
250+
# Report the most common swing_mode.
251+
self._attr_swing_mode = most_frequent_attribute(states, ATTR_SWING_MODE)
252+
253+
# available fan modes
254+
all_fan_modes = list(find_state_attributes(states, ATTR_FAN_MODES))
255+
if all_fan_modes:
256+
# Merge all effects from all effect_lists with a union merge.
257+
self._attr_fan_modes = list(set().union(*all_fan_modes))
258+
259+
# Report the most common fan_mode.
260+
self._attr_fan_mode = most_frequent_attribute(states, ATTR_FAN_MODE)
261+
262+
# available preset modes
263+
all_preset_modes = list(find_state_attributes(states, ATTR_PRESET_MODES))
264+
if all_preset_modes:
265+
# Merge all effects from all effect_lists with a union merge.
266+
self._attr_preset_modes = list(set().union(*all_preset_modes))
267+
268+
# Report the most common fan_mode.
269+
self._attr_preset_mode = most_frequent_attribute(states, ATTR_PRESET_MODE)
270+
271+
# Supported flags
272+
for support in find_state_attributes(states, ATTR_SUPPORTED_FEATURES):
273+
# Merge supported features by emulating support for every feature
274+
# we find.
275+
self._attr_supported_features |= support
276+
277+
# Bitwise-and the supported features with the Grouped climate's features
278+
# so that we don't break in the future when a new feature is added.
279+
self._attr_supported_features &= SUPPORT_FLAGS
280+
281+
_LOGGER.debug("State update complete")
282+
283+
async def async_set_temperature(self, **kwargs: Any) -> None:
284+
"""Forward the turn_on command to all climate in the climate group."""
285+
data = {ATTR_ENTITY_ID: self._entity_ids}
286+
287+
if ATTR_HVAC_MODE in kwargs:
288+
_LOGGER.debug("Set temperature with HVAC MODE")
289+
await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE])
290+
291+
if ATTR_TEMPERATURE in kwargs:
292+
data[ATTR_TEMPERATURE] = kwargs[ATTR_TEMPERATURE]
293+
if ATTR_TARGET_TEMP_LOW in kwargs:
294+
data[ATTR_TARGET_TEMP_LOW] = kwargs[ATTR_TARGET_TEMP_LOW]
295+
if ATTR_TARGET_TEMP_HIGH in kwargs:
296+
data[ATTR_TARGET_TEMP_HIGH] = kwargs[ATTR_TARGET_TEMP_HIGH]
297+
298+
_LOGGER.debug("Setting temperature: %s", data)
299+
300+
await self.hass.services.async_call(
301+
DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True, context=self._context
302+
)
303+
304+
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
305+
"""Forward the turn_on command to all climate in the climate group."""
306+
data = {ATTR_ENTITY_ID: self._entity_ids, ATTR_HVAC_MODE: hvac_mode}
307+
_LOGGER.debug("Setting hvac mode: %s", data)
308+
await self.hass.services.async_call(
309+
DOMAIN, SERVICE_SET_HVAC_MODE, data, blocking=True, context=self._context
310+
)
311+
312+
async def async_set_fan_mode(self, fan_mode: str) -> None:
313+
"""Forward the fan_mode to all climate in the climate group."""
314+
data = {ATTR_ENTITY_ID: self._entity_ids, ATTR_FAN_MODE: fan_mode}
315+
_LOGGER.debug("Setting fan mode: %s", data)
316+
await self.hass.services.async_call(
317+
DOMAIN, SERVICE_SET_FAN_MODE, data, blocking=True, context=self._context
318+
)
319+
320+
async def async_set_swing_mode(self, swing_mode: str) -> None:
321+
"""Forward the swing_mode to all climate in the climate group."""
322+
data = {ATTR_ENTITY_ID: self._entity_ids, ATTR_SWING_MODE: swing_mode}
323+
_LOGGER.debug("Setting swing mode: %s", data)
324+
await self.hass.services.async_call(
325+
DOMAIN,
326+
SERVICE_SET_SWING_MODE,
327+
data,
328+
blocking=True,
329+
context=self._context,
330+
)
331+
332+
async def async_set_preset_mode(self, preset_mode: str) -> None:
333+
"""Forward the preset_mode to all climate in the climate group."""
334+
data = {ATTR_ENTITY_ID: self._entity_ids, ATTR_PRESET_MODE: preset_mode}
335+
_LOGGER.debug("Setting preset mode: %s", data)
336+
await self.hass.services.async_call(
337+
DOMAIN,
338+
SERVICE_SET_PRESET_MODE,
339+
data,
340+
blocking=True,
341+
context=self._context,
342+
)

homeassistant/components/group/config_flow.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ async def light_switch_options_schema(
144144

145145
GROUP_TYPES = [
146146
"binary_sensor",
147+
"climate",
147148
"cover",
148149
"event",
149150
"fan",
@@ -183,6 +184,11 @@ async def _set_group_type(
183184
preview="group",
184185
validate_user_input=set_group_type("binary_sensor"),
185186
),
187+
"climate": SchemaFlowFormStep(
188+
basic_group_config_schema("climate"),
189+
preview="climate",
190+
validate_user_input=set_group_type("climate"),
191+
),
186192
"cover": SchemaFlowFormStep(
187193
basic_group_config_schema("cover"),
188194
preview="group",
@@ -232,6 +238,10 @@ async def _set_group_type(
232238
binary_sensor_options_schema,
233239
preview="group",
234240
),
241+
"climate": SchemaFlowFormStep(
242+
partial(basic_group_options_schema, "climate"),
243+
preview="climate",
244+
),
235245
"cover": SchemaFlowFormStep(
236246
partial(basic_group_options_schema, "cover"),
237247
preview="group",

homeassistant/components/group/strings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
"name": "[%key:common::config_flow::data::name%]"
2828
}
2929
},
30+
"climate": {
31+
"title": "[%key:component::group::config::step::user::title%]",
32+
"data": {
33+
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
34+
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]",
35+
"name": "[%key:common::config_flow::data::name%]"
36+
}
37+
},
3038
"cover": {
3139
"title": "[%key:component::group::config::step::user::title%]",
3240
"data": {

0 commit comments

Comments
 (0)