|
| 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 | + ) |
0 commit comments