1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-17 15:44:52 +01:00
Files
core/homeassistant/components/matter/climate.py
2026-03-30 04:41:11 +02:00

603 lines
24 KiB
Python

"""Matter climate platform."""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
from typing import 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,
PRESET_AWAY,
PRESET_HOME,
PRESET_NONE,
PRESET_SLEEP,
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 AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
from .models import MatterDiscoverySchema
HUMIDITY_SCALING_FACTOR = 100
TEMPERATURE_SCALING_FACTOR = 100
HVAC_SYSTEM_MODE_MAP = {
HVACMode.OFF: 0,
HVACMode.HEAT_COOL: 1,
HVACMode.COOL: 3,
HVACMode.HEAT: 4,
HVACMode.DRY: 8,
HVACMode.FAN_ONLY: 7,
}
# Map of Matter PresetScenarioEnum to HA standard preset constants or custom names
# This ensures presets are translated correctly using HA's translation system.
# kUserDefined scenarios always use device-provided names.
PRESET_SCENARIO_TO_HA_PRESET: dict[int, str] = {
clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied: PRESET_HOME,
clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied: PRESET_AWAY,
clusters.Thermostat.Enums.PresetScenarioEnum.kSleep: PRESET_SLEEP,
clusters.Thermostat.Enums.PresetScenarioEnum.kWake: "wake",
clusters.Thermostat.Enums.PresetScenarioEnum.kVacation: "vacation",
clusters.Thermostat.Enums.PresetScenarioEnum.kGoingToSleep: "going_to_sleep",
}
SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = {
# Some devices only have a single setpoint while the matter spec
# assumes that you need separate setpoints for heating and cooling.
# We were told this is just some legacy inheritance from zigbee specs.
# In the list below specify tuples of (vendorid, productid) of devices for
# which we just need a single setpoint to control both heating and cooling.
(0x1209, 0x8000),
(0x1209, 0x8001),
(0x1209, 0x8002),
(0x1209, 0x8003),
(0x1209, 0x8004),
(0x1209, 0x8005),
(0x1209, 0x8006),
(0x1209, 0x8007),
(0x1209, 0x8008),
(0x1209, 0x8009),
(0x1209, 0x800A),
(0x1209, 0x800B),
(0x1209, 0x800C),
(0x1209, 0x800D),
(0x1209, 0x800E),
(0x1209, 0x8010),
(0x1209, 0x8011),
(0x1209, 0x8012),
(0x1209, 0x8013),
(0x1209, 0x8014),
(0x1209, 0x8020),
(0x1209, 0x8021),
(0x1209, 0x8022),
(0x1209, 0x8023),
(0x1209, 0x8024),
(0x1209, 0x8025),
(0x1209, 0x8026),
(0x1209, 0x8027),
(0x1209, 0x8028),
(0x1209, 0x8029),
}
SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = {
# The Matter spec is missing a feature flag if the device supports a dry mode.
# In the list below specify tuples of (vendorid, productid) of devices that
# support dry mode.
(0x0001, 0x0108),
(0x0001, 0x010A),
(0x1209, 0x8000),
(0x1209, 0x8001),
(0x1209, 0x8002),
(0x1209, 0x8003),
(0x1209, 0x8004),
(0x1209, 0x8005),
(0x1209, 0x8006),
(0x1209, 0x8007),
(0x1209, 0x8008),
(0x1209, 0x8009),
(0x1209, 0x800A),
(0x1209, 0x800B),
(0x1209, 0x800C),
(0x1209, 0x800D),
(0x1209, 0x800E),
(0x1209, 0x8010),
(0x1209, 0x8011),
(0x1209, 0x8012),
(0x1209, 0x8013),
(0x1209, 0x8014),
(0x1209, 0x8020),
(0x1209, 0x8021),
(0x1209, 0x8022),
(0x1209, 0x8023),
(0x1209, 0x8024),
(0x1209, 0x8025),
(0x1209, 0x8026),
(0x1209, 0x8027),
(0x1209, 0x8028),
(0x1209, 0x8029),
}
SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
# The Matter spec is missing a feature flag if the device supports a fan-only mode.
# In the list below specify tuples of (vendorid, productid) of devices that
# support fan-only mode.
(0x0001, 0x0108),
(0x0001, 0x010A),
(0x118C, 0x2022),
(0x1209, 0x8000),
(0x1209, 0x8001),
(0x1209, 0x8002),
(0x1209, 0x8003),
(0x1209, 0x8004),
(0x1209, 0x8005),
(0x1209, 0x8006),
(0x1209, 0x8007),
(0x1209, 0x8008),
(0x1209, 0x8009),
(0x1209, 0x800A),
(0x1209, 0x800B),
(0x1209, 0x800C),
(0x1209, 0x800D),
(0x1209, 0x800E),
(0x1209, 0x8010),
(0x1209, 0x8011),
(0x1209, 0x8012),
(0x1209, 0x8013),
(0x1209, 0x8014),
(0x1209, 0x8020),
(0x1209, 0x8021),
(0x1209, 0x8022),
(0x1209, 0x8023),
(0x1209, 0x8024),
(0x1209, 0x8025),
(0x1209, 0x8026),
(0x1209, 0x8027),
(0x1209, 0x8028),
(0x1209, 0x8029),
(0x131A, 0x1000),
}
SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum
ThermostatFeature = clusters.Thermostat.Bitmaps.Feature
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: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter climate platform from Config Entry."""
matter = get_matter(hass)
matter.register_platform_handler(Platform.CLIMATE, async_add_entities)
@dataclass(frozen=True, kw_only=True)
class MatterClimateEntityDescription(ClimateEntityDescription, MatterEntityDescription):
"""Describe Matter Climate entities."""
class MatterClimate(MatterEntity, ClimateEntity):
"""Representation of a Matter climate entity."""
_attr_temperature_unit: str = UnitOfTemperature.CELSIUS
_attr_hvac_mode: HVACMode = HVACMode.OFF
_matter_presets: list[clusters.Thermostat.Structs.PresetStruct]
_attr_preset_mode: str | None = None
_attr_preset_modes: list[str] | None = None
_feature_map: int | None = None
_platform_translation_key = "thermostat"
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the climate entity."""
# Initialize preset handle mapping as instance attribute before calling super().__init__()
# because MatterEntity.__init__() calls _update_from_device() which needs this attribute
self._matter_presets = []
self._preset_handle_by_name: dict[str, bytes | None] = {}
self._preset_name_by_handle: dict[bytes | None, str] = {}
super().__init__(*args, **kwargs)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE)
target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE)
target_temperature_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW)
target_temperature_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH)
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
if target_temperature is not None:
# single setpoint control
if self.target_temperature != target_temperature:
if current_mode == HVACMode.COOL:
matter_attribute = (
clusters.Thermostat.Attributes.OccupiedCoolingSetpoint
)
else:
matter_attribute = (
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
)
await self.write_attribute(
value=int(target_temperature * TEMPERATURE_SCALING_FACTOR),
matter_attribute=matter_attribute,
)
return
if target_temperature_low is not None:
# multi setpoint control - low setpoint (heat)
if self.target_temperature_low != target_temperature_low:
await self.write_attribute(
value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR),
matter_attribute=clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
)
if target_temperature_high is not None:
# multi setpoint control - high setpoint (cool)
if self.target_temperature_high != target_temperature_high:
await self.write_attribute(
value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR),
matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
preset_handle = self._preset_handle_by_name[preset_mode]
command = clusters.Thermostat.Commands.SetActivePresetRequest(
presetHandle=preset_handle
)
await self.send_device_command(command)
# Optimistic update is required because Matter devices usually confirm
# preset changes asynchronously via a later attribute subscription.
# Additionally, some devices based on connectedhomeip do not send a
# subscription report for ActivePresetHandle after SetActivePresetRequest
# because thermostat-server-presets.cpp/SetActivePreset() updates the
# value without notifying the reporting engine. Keep this optimistic
# update as a workaround for that SDK bug and for normal report delays.
# Reference: project-chip/connectedhomeip,
# src/app/clusters/thermostat-server/thermostat-server-presets.cpp.
self._attr_preset_mode = preset_mode
self.async_write_ha_state()
# Keep the local ActivePresetHandle in sync until subscription update.
active_preset_path = create_attribute_path_from_attribute(
endpoint_id=self._endpoint.endpoint_id,
attribute=clusters.Thermostat.Attributes.ActivePresetHandle,
)
self._endpoint.set_attribute_value(active_preset_path, preset_handle)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
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.write_attribute(
value=system_mode_value,
matter_attribute=clusters.Thermostat.Attributes.SystemMode,
)
# 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
system_mode_path = create_attribute_path_from_attribute(
endpoint_id=self._endpoint.endpoint_id,
attribute=clusters.Thermostat.Attributes.SystemMode,
)
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._calculate_features()
self._attr_current_temperature = self._get_temperature_in_degrees(
clusters.Thermostat.Attributes.LocalTemperature
)
self._attr_current_humidity = (
int(raw_measured_humidity) / HUMIDITY_SCALING_FACTOR
if (
raw_measured_humidity := self.get_matter_attribute_value(
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue
)
)
is not None
else None
)
self._update_presets()
self._update_hvac_mode_and_action()
self._update_target_temperatures()
self._update_temperature_limits()
@callback
def _update_presets(self) -> None:
"""Update preset modes and active preset."""
# Check if the device supports presets feature before attempting to load.
# Use the already computed supported features instead of re-reading
# the FeatureMap attribute to keep a single source of truth and avoid
# casting None when the attribute is temporarily unavailable.
supported_features = self._attr_supported_features or 0
if not (supported_features & ClimateEntityFeature.PRESET_MODE):
# Device does not support presets, skip preset update
self._preset_handle_by_name.clear()
self._preset_name_by_handle.clear()
self._attr_preset_modes = []
self._attr_preset_mode = None
return
self._matter_presets = (
self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets)
or []
)
# Build preset mapping: use device-provided name if available, else generate unique name
self._preset_handle_by_name.clear()
self._preset_name_by_handle.clear()
if self._matter_presets:
used_names = set()
for i, preset in enumerate(self._matter_presets, start=1):
preset_translation = PRESET_SCENARIO_TO_HA_PRESET.get(
preset.presetScenario
)
if preset_translation:
preset_name = preset_translation.lower()
else:
name = str(preset.name) if preset.name is not None else ""
name = name.strip()
if name:
preset_name = name
else:
# Ensure fallback name is unique
j = i
preset_name = f"Preset{j}"
while preset_name in used_names:
j += 1
preset_name = f"Preset{j}"
used_names.add(preset_name)
preset_handle = (
preset.presetHandle
if isinstance(preset.presetHandle, (bytes, type(None)))
else None
)
self._preset_handle_by_name[preset_name] = preset_handle
self._preset_name_by_handle[preset_handle] = preset_name
# Always include PRESET_NONE to allow users to clear the preset
self._preset_handle_by_name[PRESET_NONE] = None
self._preset_name_by_handle[None] = PRESET_NONE
self._attr_preset_modes = list(self._preset_handle_by_name)
# Update active preset mode
active_preset_handle = self.get_matter_attribute_value(
clusters.Thermostat.Attributes.ActivePresetHandle
)
self._attr_preset_mode = self._preset_name_by_handle.get(
active_preset_handle, PRESET_NONE
)
@callback
def _update_hvac_mode_and_action(self) -> None:
"""Update HVAC mode and action from device."""
if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False:
# special case: the appliance has a dedicated Power switch on the OnOff cluster
# if the mains power is off - treat it as if the HVAC mode is off
self._attr_hvac_mode = HVACMode.OFF
self._attr_hvac_action = None
else:
# 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 SystemModeEnum.kFanOnly:
self._attr_hvac_mode = HVACMode.FAN_ONLY
case SystemModeEnum.kDry:
self._attr_hvac_mode = HVACMode.DRY
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
):
if running_state_value & (
ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2
):
self._attr_hvac_action = HVACAction.HEATING
elif running_state_value & (
ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2
):
self._attr_hvac_action = HVACAction.COOLING
elif running_state_value & (
ThermostatRunningState.Fan
| ThermostatRunningState.FanStage2
| ThermostatRunningState.FanStage3
):
self._attr_hvac_action = HVACAction.FAN
else:
self._attr_hvac_action = HVACAction.OFF
@callback
def _update_target_temperatures(self) -> None:
"""Update target temperature or temperature range."""
supports_range = (
self._attr_supported_features
& ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
if supports_range and self._attr_hvac_mode == HVACMode.HEAT_COOL:
self._attr_target_temperature = None
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 target_temperature
if 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
)
@callback
def _update_temperature_limits(self) -> None:
"""Update min and max temperature limits."""
# 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.AbsMaxCoolSetpointLimit
else:
attribute = clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit
if (value := self._get_temperature_in_degrees(attribute)) is not None:
self._attr_max_temp = value
else:
self._attr_max_temp = DEFAULT_MAX_TEMP
@callback
def _calculate_features(
self,
) -> None:
"""Calculate features for HA Thermostat platform from Matter FeatureMap."""
feature_map = int(
self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap)
)
# NOTE: the featuremap can dynamically change, so we need to update the
# supported features if the featuremap changes.
# work out supported features and presets from matter featuremap
if self._feature_map == feature_map:
return
self._feature_map = feature_map
product_id = self._endpoint.node.device_info.productID
vendor_id = self._endpoint.node.device_info.vendorID
self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF]
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF
)
if feature_map & ThermostatFeature.kPresets:
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
# determine supported hvac modes
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 (vendor_id, product_id) in SUPPORT_DRY_MODE_DEVICES:
self._attr_hvac_modes.append(HVACMode.DRY)
if (vendor_id, product_id) in SUPPORT_FAN_MODE_DEVICES:
self._attr_hvac_modes.append(HVACMode.FAN_ONLY)
if feature_map & ThermostatFeature.kAutoMode:
self._attr_hvac_modes.append(HVACMode.HEAT_COOL)
# only enable temperature_range feature if the device actually supports that
if (vendor_id, product_id) not in SINGLE_SETPOINT_DEVICES:
self._attr_supported_features |= (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
if any(mode for mode in self.hvac_modes if mode != HVACMode.OFF):
self._attr_supported_features |= ClimateEntityFeature.TURN_ON
@callback
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
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
platform=Platform.CLIMATE,
entity_description=MatterClimateEntityDescription(
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.NumberOfPresets,
clusters.Thermostat.Attributes.Occupancy,
clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
clusters.Thermostat.Attributes.Presets,
clusters.Thermostat.Attributes.PresetTypes,
clusters.Thermostat.Attributes.ActivePresetHandle,
clusters.Thermostat.Attributes.SystemMode,
clusters.Thermostat.Attributes.ThermostatRunningMode,
clusters.Thermostat.Attributes.ThermostatRunningState,
clusters.Thermostat.Attributes.TemperatureSetpointHold,
clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint,
clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint,
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
clusters.OnOff.Attributes.OnOff,
),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
allow_multi=True, # also used for sensor entity
allow_none_value=True,
),
]