Add Matter Climate support (#95434)

* Add Matter Climate support

* update set target temp and update callback

* remove print

* remove optional property

* Adjust the code to improve readability.

* add thermostat test

* Remove irrelevant cases in setting the target temperature.

* add temp range support

* update hvac action

* support adjust low high setpoint..

* support set hvac mode

* address some review feedback

* move some methods around

* dont discover climate in switch platform

* set some default values

* fix some of the tests

* fix some typos

* Update thermostat.json

* Update homeassistant/components/matter/climate.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/matter/climate.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* support heat_cool in hvac_modes

* address some review feedback

* handle hvac mode param in set temp service

* check hvac modes by featuremap

* add comment to thermostat feature class

* make ruff happy..

* use enum to enhance readability.

* use builtin feature bitmap

* fix target temp range and address some feedback

* use instance attribute instead of class attr

* make ruff happy...

* address feedback about single case

* add init docstring

* more test

* fix typo in tests

* make ruff happy

* fix hvac modes test

* test case for update callback

* remove optional check

* more tests

* more tests

* update all attributes in the update callback

* Update climate.py

* fix missing test

---------

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
hidaris 2023-07-03 18:33:50 +08:00 committed by GitHub
parent 367acd0433
commit 3bd8955e0e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 1085 additions and 0 deletions

View file

@ -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")
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,),
),
]

View file

@ -10,6 +10,7 @@ from homeassistant.const import Platform
from homeassistant.core import callback
from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS
from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS
from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS
from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS
from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS
@ -19,6 +20,7 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS
DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = {
Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS,
Platform.CLIMATE: CLIMATE_SENSOR_SCHEMAS,
Platform.COVER: COVER_SCHEMAS,
Platform.LIGHT: LIGHT_SCHEMAS,
Platform.LOCK: LOCK_SCHEMAS,

View file

@ -77,6 +77,7 @@ DISCOVERY_SCHEMAS = [
device_types.ColorDimmerSwitch,
device_types.DimmerSwitch,
device_types.OnOffLightSwitch,
device_types.Thermostat,
),
),
]

View file

@ -0,0 +1,370 @@
{
"node_id": 4,
"date_commissioned": "2023-06-28T16:26:35.525058",
"last_interview": "2023-06-28T16:26:35.525060",
"interview_version": 4,
"available": true,
"is_bridge": false,
"attributes": {
"0/29/0": [
{
"deviceType": 22,
"revision": 1
}
],
"0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 54, 60, 62, 63, 64],
"0/29/2": [41],
"0/29/3": [1],
"0/29/65532": 0,
"0/29/65533": 1,
"0/29/65528": [],
"0/29/65529": [],
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/31/0": [
{
"privilege": 0,
"authMode": 0,
"subjects": null,
"targets": null,
"fabricIndex": 1
},
{
"privilege": 5,
"authMode": 2,
"subjects": [112233],
"targets": null,
"fabricIndex": 2
}
],
"0/31/1": [],
"0/31/2": 4,
"0/31/3": 3,
"0/31/4": 3,
"0/31/65532": 0,
"0/31/65533": 1,
"0/31/65528": [],
"0/31/65529": [],
"0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
"0/40/0": 1,
"0/40/1": "LONGAN-LINK",
"0/40/2": 4895,
"0/40/3": "Longan link HVAC",
"0/40/4": 8192,
"0/40/5": "",
"0/40/6": "XX",
"0/40/7": 1,
"0/40/8": "1.0",
"0/40/9": 2,
"0/40/10": "v2.0",
"0/40/11": "20200101",
"0/40/12": "",
"0/40/14": "",
"0/40/15": "5a1fd2d040f23cf66e3a9d2a88e11f78",
"0/40/16": false,
"0/40/17": true,
"0/40/18": "3D06D025F9E026A0",
"0/40/19": {
"caseSessionsPerFabric": 3,
"subscriptionsPerFabric": 3
},
"0/40/65532": 0,
"0/40/65533": 1,
"0/40/65528": [],
"0/40/65529": [],
"0/40/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 17, 18, 19, 65528,
65529, 65531, 65532, 65533
],
"0/42/0": [],
"0/42/1": true,
"0/42/2": 1,
"0/42/3": null,
"0/42/65532": 0,
"0/42/65533": 1,
"0/42/65528": [],
"0/42/65529": [0],
"0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/48/0": 0,
"0/48/1": {
"failSafeExpiryLengthSeconds": 60,
"maxCumulativeFailsafeSeconds": 900
},
"0/48/2": 0,
"0/48/3": 0,
"0/48/4": true,
"0/48/65532": 0,
"0/48/65533": 1,
"0/48/65528": [1, 3, 5],
"0/48/65529": [0, 2, 4],
"0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
"0/49/0": 1,
"0/49/1": [
{
"networkID": "TE9OR0FOLUlPVA==",
"connected": true
}
],
"0/49/2": 10,
"0/49/3": 30,
"0/49/4": true,
"0/49/5": 0,
"0/49/6": "TE9OR0FOLUlPVA==",
"0/49/7": null,
"0/49/65532": 1,
"0/49/65533": 1,
"0/49/65528": [1, 5, 7],
"0/49/65529": [0, 2, 4, 6, 8],
"0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533],
"0/50/65532": 0,
"0/50/65533": 1,
"0/50/65528": [1],
"0/50/65529": [0],
"0/50/65531": [65528, 65529, 65531, 65532, 65533],
"0/51/0": [
{
"name": "WIFI_STA_DEF",
"isOperational": true,
"offPremiseServicesReachableIPv4": null,
"offPremiseServicesReachableIPv6": null,
"hardwareAddress": "3FR1X7qs",
"IPv4Addresses": ["wKgI7g=="],
"IPv6Addresses": [
"/oAAAAAAAADeVHX//l+6rA==",
"JA4DsgZ9jUDeVHX//l+6rA==",
"/UgvJAe/AADeVHX//l+6rA=="
],
"type": 1
}
],
"0/51/1": 4,
"0/51/2": 30,
"0/51/3": 0,
"0/51/4": 0,
"0/51/5": [],
"0/51/6": [],
"0/51/7": [],
"0/51/8": true,
"0/51/65532": 0,
"0/51/65533": 1,
"0/51/65528": [],
"0/51/65529": [0],
"0/51/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533
],
"0/54/0": "aHckDXAk",
"0/54/1": 0,
"0/54/2": 3,
"0/54/3": 1,
"0/54/4": -61,
"0/54/5": null,
"0/54/6": null,
"0/54/7": null,
"0/54/8": null,
"0/54/9": null,
"0/54/10": null,
"0/54/11": null,
"0/54/12": null,
"0/54/65532": 3,
"0/54/65533": 1,
"0/54/65528": [],
"0/54/65529": [0],
"0/54/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532,
65533
],
"0/60/0": 0,
"0/60/1": null,
"0/60/2": null,
"0/60/65532": 0,
"0/60/65533": 1,
"0/60/65528": [],
"0/60/65529": [0, 1, 2],
"0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
"0/62/0": [
{
"noc": "",
"icac": null,
"fabricIndex": 1
},
{
"noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==",
"icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY",
"fabricIndex": 2
}
],
"0/62/1": [
{
"rootPublicKey": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=",
"vendorID": 4996,
"fabricID": 1,
"nodeID": 1425709672,
"label": "",
"fabricIndex": 1
},
{
"rootPublicKey": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=",
"vendorID": 65521,
"fabricID": 1,
"nodeID": 4,
"label": "",
"fabricIndex": 2
}
],
"0/62/2": 5,
"0/62/3": 2,
"0/62/4": [
"FTABAQAkAgE3AycULlZRw4lgwKgkFQEYJgT1e7grJgV1r5ktNwYnFC5WUcOJYMCoJBUBGCQHASQIATAJQQQD/QSbeWkPTffApT03TcaTGdf1YfK/brMOgvGIIu/QCJrCauFIwGmGndFCPS4dDpkQPbFhNmkj2x43+NPYv/e9Nwo1ASkBGCQCYDAEFCqZHzimE2c+jPoEuJoM1rQaAPFRMAUUKpkfOKYTZz6M+gS4mgzWtBoA8VEYMAtANu49PfywV8aJmtxNYZa7SJXGlK1EciiF6vhZsoqdDCwx1VQX8FdyVunw0H3ljzbvucU6o8aY6HwBsPJKCQVHzhg=",
"FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEld/KKkyn4nHimShOe9igBg4OKzjEmS0p1Be7wxKkEsjAgjnwEPQqQgb02a4dynTFRArOqV/IH9uQBqd687vdkjcKNQEpARgkAmAwBBSUKOlBAVky7WVWBWcEQYJ/qrLaUzAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQCNU8W3im+pmCBR5A4e15ByjPq2msE05NI9eeFI6BO0p/whhaBSGtjI7Tb1onNNu9AH6AQoji8XDDa7Nj/1w9KoY"
],
"0/62/5": 2,
"0/62/65532": 0,
"0/62/65533": 1,
"0/62/65528": [1, 3, 5, 8],
"0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11],
"0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533],
"0/63/0": [],
"0/63/1": [],
"0/63/2": 3,
"0/63/3": 3,
"0/63/65532": 0,
"0/63/65533": 1,
"0/63/65528": [2, 5],
"0/63/65529": [0, 1, 3, 4],
"0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/64/0": [
{
"label": "room",
"value": "bedroom 2"
},
{
"label": "orientation",
"value": "North"
},
{
"label": "floor",
"value": "2"
},
{
"label": "direction",
"value": "up"
}
],
"0/64/65532": 0,
"0/64/65533": 1,
"0/64/65528": [],
"0/64/65529": [],
"0/64/65531": [0, 65528, 65529, 65531, 65532, 65533],
"1/3/0": 0,
"1/3/1": 4,
"1/3/65532": 0,
"1/3/65533": 4,
"1/3/65528": [],
"1/3/65529": [0],
"1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
"1/4/0": 128,
"1/4/65532": 1,
"1/4/65533": 4,
"1/4/65528": [0, 1, 2, 3],
"1/4/65529": [0, 1, 2, 3, 4, 5],
"1/4/65531": [0, 65528, 65529, 65531, 65532, 65533],
"1/6/0": true,
"1/6/65532": 0,
"1/6/65533": 4,
"1/6/65528": [],
"1/6/65529": [0, 1, 2],
"1/6/65531": [0, 65528, 65529, 65531, 65532, 65533],
"1/29/0": [
{
"deviceType": 769,
"revision": 1
}
],
"1/29/1": [3, 4, 6, 29, 30, 64, 513, 514, 516],
"1/29/2": [],
"1/29/3": [],
"1/29/65532": 0,
"1/29/65533": 1,
"1/29/65528": [],
"1/29/65529": [],
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"1/30/0": [],
"1/30/65532": 0,
"1/30/65533": 1,
"1/30/65528": [],
"1/30/65529": [],
"1/30/65531": [0, 65528, 65529, 65531, 65532, 65533],
"1/64/0": [
{
"label": "room",
"value": "bedroom 2"
},
{
"label": "orientation",
"value": "North"
},
{
"label": "floor",
"value": "2"
},
{
"label": "direction",
"value": "up"
}
],
"1/64/65532": 0,
"1/64/65533": 1,
"1/64/65528": [],
"1/64/65529": [],
"1/64/65531": [0, 65528, 65529, 65531, 65532, 65533],
"1/513/0": 2830,
"1/513/3": null,
"1/513/4": null,
"1/513/5": null,
"1/513/6": null,
"1/513/9": 0,
"1/513/17": null,
"1/513/18": null,
"1/513/21": 1600,
"1/513/22": 3000,
"1/513/23": 1600,
"1/513/24": 3000,
"1/513/25": 5,
"1/513/27": 4,
"1/513/28": 3,
"1/513/30": 0,
"1/513/65532": 35,
"1/513/65533": 5,
"1/513/65528": [],
"1/513/65529": [0],
"1/513/65531": [
0, 3, 4, 5, 6, 9, 17, 18, 21, 22, 23, 24, 25, 27, 28, 30, 65528, 65529,
65531, 65532, 65533
],
"1/514/0": 0,
"1/514/1": 2,
"1/514/2": 0,
"1/514/3": 0,
"1/514/65532": 0,
"1/514/65533": 1,
"1/514/65528": [],
"1/514/65529": [],
"1/514/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"1/516/0": 0,
"1/516/1": 0,
"1/516/65532": 0,
"1/516/65533": 1,
"1/516/65528": [],
"1/516/65529": [],
"1/516/65531": [0, 1, 65528, 65529, 65531, 65532, 65533]
},
"attribute_subscriptions": [
[1, 513, 17],
[1, 6, 0],
[1, 513, 0],
[1, 513, 28],
[1, 513, 65532],
[1, 513, 18],
[1, 513, 30],
[1, 513, 27]
]
}

View file

@ -0,0 +1,399 @@
"""Test Matter locks."""
from unittest.mock import MagicMock, call
from chip.clusters import Objects as clusters
from matter_server.client.models.node import MatterNode
from matter_server.common.helpers.util import create_attribute_path_from_attribute
import pytest
from homeassistant.components.climate import (
HVAC_MODE_COOL,
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVACAction,
HVACMode,
)
from homeassistant.components.climate.const import (
HVAC_MODE_DRY,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_OFF,
)
from homeassistant.core import HomeAssistant
from .common import (
set_node_attribute,
setup_integration_with_node_fixture,
trigger_subscription_callback,
)
@pytest.fixture(name="thermostat")
async def thermostat_fixture(
hass: HomeAssistant, matter_client: MagicMock
) -> MatterNode:
"""Fixture for a thermostat node."""
return await setup_integration_with_node_fixture(hass, "thermostat", matter_client)
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_thermostat(
hass: HomeAssistant,
matter_client: MagicMock,
thermostat: MatterNode,
) -> None:
"""Test thermostat."""
# test default temp range
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["min_temp"] == 7
assert state.attributes["max_temp"] == 35
# test set temperature when target temp is None
assert state.attributes["temperature"] is None
assert state.state == HVAC_MODE_COOL
with pytest.raises(
ValueError, match="Current target_temperature should not be None"
):
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"temperature": 22.5,
},
blocking=True,
)
with pytest.raises(ValueError, match="Temperature must be provided"):
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"target_temp_low": 18,
"target_temp_high": 26,
},
blocking=True,
)
# change system mode to heat_cool
set_node_attribute(thermostat, 1, 513, 28, 1)
await trigger_subscription_callback(hass, matter_client)
with pytest.raises(
ValueError,
match="current target_temperature_low and target_temperature_high should not be None",
):
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.state == HVAC_MODE_HEAT_COOL
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"target_temp_low": 18,
"target_temp_high": 26,
},
blocking=True,
)
# initial state
set_node_attribute(thermostat, 1, 513, 3, 1600)
set_node_attribute(thermostat, 1, 513, 4, 3000)
set_node_attribute(thermostat, 1, 513, 5, 1600)
set_node_attribute(thermostat, 1, 513, 6, 3000)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["min_temp"] == 16
assert state.attributes["max_temp"] == 30
assert state.attributes["hvac_modes"] == [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
]
# test system mode update from device
set_node_attribute(thermostat, 1, 513, 28, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.state == HVAC_MODE_OFF
set_node_attribute(thermostat, 1, 513, 28, 7)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.state == HVAC_MODE_FAN_ONLY
set_node_attribute(thermostat, 1, 513, 28, 8)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.state == HVAC_MODE_DRY
# test running state update from device
set_node_attribute(thermostat, 1, 513, 41, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["hvac_action"] == HVACAction.HEATING
set_node_attribute(thermostat, 1, 513, 41, 8)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["hvac_action"] == HVACAction.HEATING
set_node_attribute(thermostat, 1, 513, 41, 2)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["hvac_action"] == HVACAction.COOLING
set_node_attribute(thermostat, 1, 513, 41, 16)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["hvac_action"] == HVACAction.COOLING
set_node_attribute(thermostat, 1, 513, 41, 4)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["hvac_action"] == HVACAction.FAN
set_node_attribute(thermostat, 1, 513, 41, 32)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["hvac_action"] == HVACAction.FAN
set_node_attribute(thermostat, 1, 513, 41, 64)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["hvac_action"] == HVACAction.FAN
set_node_attribute(thermostat, 1, 513, 41, 66)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["hvac_action"] == HVACAction.OFF
# change system mode to heat
set_node_attribute(thermostat, 1, 513, 28, 4)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.state == HVAC_MODE_HEAT
# change occupied heating setpoint to 20
set_node_attribute(thermostat, 1, 513, 18, 2000)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["temperature"] == 20
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"temperature": 25,
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=thermostat.node_id,
endpoint_id=1,
command=clusters.Thermostat.Commands.SetpointRaiseLower(
clusters.Thermostat.Enums.SetpointAdjustMode.kHeat,
50,
),
)
matter_client.send_device_command.reset_mock()
# change system mode to cool
set_node_attribute(thermostat, 1, 513, 28, 3)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.state == HVAC_MODE_COOL
# change occupied cooling setpoint to 18
set_node_attribute(thermostat, 1, 513, 17, 1800)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["temperature"] == 18
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"temperature": 16,
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=thermostat.node_id,
endpoint_id=1,
command=clusters.Thermostat.Commands.SetpointRaiseLower(
clusters.Thermostat.Enums.SetpointAdjustMode.kCool, -20
),
)
matter_client.send_device_command.reset_mock()
# change system mode to heat_cool
set_node_attribute(thermostat, 1, 513, 28, 1)
await trigger_subscription_callback(hass, matter_client)
with pytest.raises(
ValueError, match="temperature_low and temperature_high must be provided"
):
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"temperature": 18,
},
blocking=True,
)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.state == HVAC_MODE_HEAT_COOL
# change occupied cooling setpoint to 18
set_node_attribute(thermostat, 1, 513, 17, 2500)
await trigger_subscription_callback(hass, matter_client)
# change occupied heating setpoint to 18
set_node_attribute(thermostat, 1, 513, 18, 1700)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["target_temp_low"] == 17
assert state.attributes["target_temp_high"] == 25
# change target_temp_low to 18
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"target_temp_low": 18,
"target_temp_high": 25,
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=thermostat.node_id,
endpoint_id=1,
command=clusters.Thermostat.Commands.SetpointRaiseLower(
clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, 10
),
)
matter_client.send_device_command.reset_mock()
set_node_attribute(thermostat, 1, 513, 18, 1800)
await trigger_subscription_callback(hass, matter_client)
# change target_temp_high to 26
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"target_temp_low": 18,
"target_temp_high": 26,
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=thermostat.node_id,
endpoint_id=1,
command=clusters.Thermostat.Commands.SetpointRaiseLower(
clusters.Thermostat.Enums.SetpointAdjustMode.kCool, 10
),
)
matter_client.send_device_command.reset_mock()
set_node_attribute(thermostat, 1, 513, 17, 2600)
await trigger_subscription_callback(hass, matter_client)
await hass.services.async_call(
"climate",
"set_hvac_mode",
{
"entity_id": "climate.longan_link_hvac",
"hvac_mode": HVAC_MODE_HEAT,
},
blocking=True,
)
assert matter_client.write_attribute.call_count == 1
assert matter_client.write_attribute.call_args == call(
node_id=thermostat.node_id,
attribute_path=create_attribute_path_from_attribute(
endpoint_id=1,
attribute=clusters.Thermostat.Attributes.SystemMode,
),
value=4,
)
matter_client.send_device_command.reset_mock()
with pytest.raises(ValueError, match="Unsupported hvac mode dry in Matter"):
await hass.services.async_call(
"climate",
"set_hvac_mode",
{
"entity_id": "climate.longan_link_hvac",
"hvac_mode": HVACMode.DRY,
},
blocking=True,
)
# change target_temp and hvac_mode in the same call
matter_client.send_device_command.reset_mock()
matter_client.write_attribute.reset_mock()
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"temperature": 22,
"hvac_mode": HVACMode.COOL,
},
blocking=True,
)
assert matter_client.write_attribute.call_count == 1
assert matter_client.write_attribute.call_args == call(
node_id=thermostat.node_id,
attribute_path=create_attribute_path_from_attribute(
endpoint_id=1,
attribute=clusters.Thermostat.Attributes.SystemMode,
),
value=3,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=thermostat.node_id,
endpoint_id=1,
command=clusters.Thermostat.Commands.SetpointRaiseLower(
clusters.Thermostat.Enums.SetpointAdjustMode.kCool, -40
),
)