-
-
Notifications
You must be signed in to change notification settings - Fork 37.6k
Add Matter Climate support #95434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
MartinHjelmare
merged 44 commits into
home-assistant:dev
from
hidaris:draft_climate_support
Jul 3, 2023
Merged
Add Matter Climate support #95434
Changes from all commits
Commits
Show all changes
44 commits
Select commit
Hold shift + click to select a range
a5c192b
Add Matter Climate support
hidaris 664dfd5
update set target temp and update callback
hidaris d9a4fb9
remove print
hidaris 3a196f6
remove optional property
hidaris c5ead46
Adjust the code to improve readability.
hidaris 22bad2f
add thermostat test
hidaris 6678f86
Remove irrelevant cases in setting the target temperature.
hidaris be999ee
add temp range support
hidaris be97a5b
update hvac action
hidaris 8ef77fd
support adjust low high setpoint..
hidaris aa7b355
support set hvac mode
hidaris ba604d0
address some review feedback
marcelveldt 62533d2
move some methods around
marcelveldt 85ea3a5
dont discover climate in switch platform
marcelveldt 9859164
set some default values
marcelveldt 5ae774b
fix some of the tests
marcelveldt d9bd12b
fix some typos
marcelveldt bd940de
Update thermostat.json
marcelveldt 21c4b72
Update homeassistant/components/matter/climate.py
hidaris a1a09b1
Update homeassistant/components/matter/climate.py
hidaris 8f2f936
support heat_cool in hvac_modes
hidaris eea4df4
address some review feedback
hidaris 7768173
handle hvac mode param in set temp service
hidaris aa8ca8b
check hvac modes by featuremap
hidaris f0bd18e
add comment to thermostat feature class
hidaris 314f504
make ruff happy..
hidaris 4e3f022
use enum to enhance readability.
hidaris 6c6e556
use builtin feature bitmap
hidaris 784bde4
fix target temp range and address some feedback
hidaris 2520ffe
use instance attribute instead of class attr
hidaris d65d75e
make ruff happy...
hidaris 261557b
address feedback about single case
hidaris b4d2c5f
add init docstring
hidaris 74707b7
more test
hidaris d1d659b
fix typo in tests
hidaris 93269dc
make ruff happy
hidaris 157e108
fix hvac modes test
hidaris 3c71390
test case for update callback
hidaris 02330ab
remove optional check
hidaris d0c1662
more tests
hidaris 95579a2
more tests
hidaris bb427c2
update all attributes in the update callback
marcelveldt 875d176
Update climate.py
marcelveldt 7f022c7
fix missing test
marcelveldt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,313 @@ | ||
| """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") | ||
|
hidaris marked this conversation as resolved.
|
||
| 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( | ||
|
hidaris marked this conversation as resolved.
|
||
| 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,), | ||
| ), | ||
| ] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.