"""Matter climate platform.""" from __future__ import annotations from enum import IntEnum from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters from matter_server.client.models import device_types from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import MatterEntity from .helpers import get_matter from .models import MatterDiscoverySchema if TYPE_CHECKING: from matter_server.client import MatterClient from matter_server.client.models.node import MatterEndpoint from .discovery import MatterEntityInfo TEMPERATURE_SCALING_FACTOR = 100 HVAC_SYSTEM_MODE_MAP = { HVACMode.OFF: 0, HVACMode.HEAT_COOL: 1, HVACMode.COOL: 3, HVACMode.HEAT: 4, } SystemModeEnum = clusters.Thermostat.Enums.ThermostatSystemMode ControlSequenceEnum = clusters.Thermostat.Enums.ThermostatControlSequence ThermostatFeature = clusters.Thermostat.Bitmaps.ThermostatFeature class ThermostatRunningState(IntEnum): """Thermostat Running State, Matter spec Thermostat 7.33.""" Heat = 1 # 1 << 0 = 1 Cool = 2 # 1 << 1 = 2 Fan = 4 # 1 << 2 = 4 HeatStage2 = 8 # 1 << 3 = 8 CoolStage2 = 16 # 1 << 4 = 16 FanStage2 = 32 # 1 << 5 = 32 FanStage3 = 64 # 1 << 6 = 64 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Matter climate platform from Config Entry.""" matter = get_matter(hass) matter.register_platform_handler(Platform.CLIMATE, async_add_entities) class MatterClimate(MatterEntity, ClimateEntity): """Representation of a Matter climate entity.""" _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_supported_features: ClimateEntityFeature = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) _attr_hvac_mode: HVACMode = HVACMode.OFF def __init__( self, matter_client: MatterClient, endpoint: MatterEndpoint, entity_info: MatterEntityInfo, ) -> None: """Initialize the Matter climate entity.""" super().__init__(matter_client, endpoint, entity_info) # set hvac_modes based on feature map self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF] feature_map = int( self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap) ) if feature_map & ThermostatFeature.kHeating: self._attr_hvac_modes.append(HVACMode.HEAT) if feature_map & ThermostatFeature.kCooling: self._attr_hvac_modes.append(HVACMode.COOL) if feature_map & ThermostatFeature.kAutoMode: self._attr_hvac_modes.append(HVACMode.HEAT_COOL) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) if target_hvac_mode is not None: await self.async_set_hvac_mode(target_hvac_mode) current_mode = target_hvac_mode or self.hvac_mode command = None if current_mode in (HVACMode.HEAT, HVACMode.COOL): # when current mode is either heat or cool, the temperature arg must be provided. temperature: float | None = kwargs.get(ATTR_TEMPERATURE) if temperature is None: raise ValueError("Temperature must be provided") if self.target_temperature is None: raise ValueError("Current target_temperature should not be None") command = self._create_optional_setpoint_command( clusters.Thermostat.Enums.SetpointAdjustMode.kCool if current_mode == HVACMode.COOL else clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, temperature, self.target_temperature, ) elif current_mode == HVACMode.HEAT_COOL: temperature_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW) temperature_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH) if temperature_low is None or temperature_high is None: raise ValueError( "temperature_low and temperature_high must be provided" ) if ( self.target_temperature_low is None or self.target_temperature_high is None ): raise ValueError( "current target_temperature_low and target_temperature_high should not be None" ) # due to ha send both high and low temperature, we need to check which one is changed command = self._create_optional_setpoint_command( clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, temperature_low, self.target_temperature_low, ) if command is None: command = self._create_optional_setpoint_command( clusters.Thermostat.Enums.SetpointAdjustMode.kCool, temperature_high, self.target_temperature_high, ) if command: await self.matter_client.send_device_command( node_id=self._endpoint.node.node_id, endpoint_id=self._endpoint.endpoint_id, command=command, ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" system_mode_path = create_attribute_path_from_attribute( endpoint_id=self._endpoint.endpoint_id, attribute=clusters.Thermostat.Attributes.SystemMode, ) system_mode_value = HVAC_SYSTEM_MODE_MAP.get(hvac_mode) if system_mode_value is None: raise ValueError(f"Unsupported hvac mode {hvac_mode} in Matter") await self.matter_client.write_attribute( node_id=self._endpoint.node.node_id, attribute_path=system_mode_path, value=system_mode_value, ) # we need to optimistically update the attribute's value here # to prevent a race condition when adjusting the mode and temperature # in the same call self._endpoint.set_attribute_value(system_mode_path, system_mode_value) self._update_from_device() @callback def _update_from_device(self) -> None: """Update from device.""" self._attr_current_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.LocalTemperature ) # update hvac_mode from SystemMode system_mode_value = int( self.get_matter_attribute_value(clusters.Thermostat.Attributes.SystemMode) ) match system_mode_value: case SystemModeEnum.kAuto: self._attr_hvac_mode = HVACMode.HEAT_COOL case SystemModeEnum.kDry: self._attr_hvac_mode = HVACMode.DRY case SystemModeEnum.kFanOnly: self._attr_hvac_mode = HVACMode.FAN_ONLY case SystemModeEnum.kCool | SystemModeEnum.kPrecooling: self._attr_hvac_mode = HVACMode.COOL case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat: self._attr_hvac_mode = HVACMode.HEAT case _: self._attr_hvac_mode = HVACMode.OFF # running state is an optional attribute # which we map to hvac_action if it exists (its value is not None) self._attr_hvac_action = None if running_state_value := self.get_matter_attribute_value( clusters.Thermostat.Attributes.ThermostatRunningState ): match running_state_value: case ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2: self._attr_hvac_action = HVACAction.HEATING case ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2: self._attr_hvac_action = HVACAction.COOLING case ( ThermostatRunningState.Fan | ThermostatRunningState.FanStage2 | ThermostatRunningState.FanStage3 ): self._attr_hvac_action = HVACAction.FAN case _: self._attr_hvac_action = HVACAction.OFF # update target_temperature if self._attr_hvac_mode == HVACMode.HEAT_COOL: self._attr_target_temperature = None elif self._attr_hvac_mode == HVACMode.COOL: self._attr_target_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.OccupiedCoolingSetpoint ) else: self._attr_target_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) # update target temperature high/low if self._attr_hvac_mode == HVACMode.HEAT_COOL: self._attr_target_temperature_high = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.OccupiedCoolingSetpoint ) self._attr_target_temperature_low = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) else: self._attr_target_temperature_high = None self._attr_target_temperature_low = None # update min_temp if self._attr_hvac_mode == HVACMode.COOL: attribute = clusters.Thermostat.Attributes.AbsMinCoolSetpointLimit else: attribute = clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit if (value := self._get_temperature_in_degrees(attribute)) is not None: self._attr_min_temp = value else: self._attr_min_temp = DEFAULT_MIN_TEMP # update max_temp if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT_COOL): attribute = clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit else: attribute = clusters.Thermostat.Attributes.AbsMaxCoolSetpointLimit if (value := self._get_temperature_in_degrees(attribute)) is not None: self._attr_max_temp = value else: self._attr_max_temp = DEFAULT_MAX_TEMP def _get_temperature_in_degrees( self, attribute: type[clusters.ClusterAttributeDescriptor] ) -> float | None: """Return the scaled temperature value for the given attribute.""" if value := self.get_matter_attribute_value(attribute): return float(value) / TEMPERATURE_SCALING_FACTOR return None @staticmethod def _create_optional_setpoint_command( mode: clusters.Thermostat.Enums.SetpointAdjustMode, target_temp: float, current_target_temp: float, ) -> clusters.Thermostat.Commands.SetpointRaiseLower | None: """Create a setpoint command if the target temperature is different from the current one.""" temp_diff = int((target_temp - current_target_temp) * 10) if temp_diff == 0: return None return clusters.Thermostat.Commands.SetpointRaiseLower( mode, temp_diff, ) # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.CLIMATE, entity_description=ClimateEntityDescription( key="MatterThermostat", name=None, ), entity_class=MatterClimate, required_attributes=(clusters.Thermostat.Attributes.LocalTemperature,), optional_attributes=( clusters.Thermostat.Attributes.FeatureMap, clusters.Thermostat.Attributes.ControlSequenceOfOperation, clusters.Thermostat.Attributes.Occupancy, clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, clusters.Thermostat.Attributes.SystemMode, clusters.Thermostat.Attributes.ThermostatRunningMode, clusters.Thermostat.Attributes.ThermostatRunningState, clusters.Thermostat.Attributes.TemperatureSetpointHold, clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint, clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint, ), device_type=(device_types.Thermostat,), ), ]