From aac19d03c52a3dfdb9fda1c82206879fea2188b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 08:40:46 +0000 Subject: [PATCH 01/76] Thermostat preset fixture --- .../fixtures/nodes/thermostat_presets.json | 494 ++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/thermostat_presets.json diff --git a/tests/components/matter/fixtures/nodes/thermostat_presets.json b/tests/components/matter/fixtures/nodes/thermostat_presets.json new file mode 100644 index 00000000000000..03892a4c68a5c7 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/thermostat_presets.json @@ -0,0 +1,494 @@ +{ + "node_id": 131, + "date_commissioned": "2025-09-27T07:17:07.885040", + "last_interview": "2025-09-27T07:17:07.885071", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "ZW5zMzM=", + "0/49/7": null, + "0/49/65533": 2, + "0/49/65532": 4, + "0/49/65531": [0, 1, 4, 5, 6, 7, 65533, 65532, 65531, 65529, 65528], + "0/49/65529": [], + "0/49/65528": [], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65533": 1, + "0/63/65532": 0, + "0/63/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], + "0/63/65529": [0, 1, 3, 4], + "0/63/65528": [5, 2], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65532": 0, + "0/60/65531": [0, 1, 2, 65533, 65532, 65531, 65529, 65528], + "0/60/65529": [0, 2], + "0/60/65528": [], + "0/55/2": 880, + "0/55/3": 552, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/1": true, + "0/55/0": 2, + "0/55/8": 27, + "0/55/65533": 1, + "0/55/65532": 3, + "0/55/65531": [ + 2, 3, 4, 5, 6, 7, 1, 0, 8, 65533, 65532, 65531, 65529, 65528 + ], + "0/55/65529": [0], + "0/55/65528": [], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/12": 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/65533": 1, + "0/54/65532": 3, + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 12, 6, 7, 8, 9, 10, 11, 65533, 65532, 65531, 65529, + 65528 + ], + "0/54/65529": [0], + "0/54/65528": [], + "0/52/0": [ + { + "0": 6457, + "1": "6457" + }, + { + "0": 6456, + "1": "6456" + }, + { + "0": 6455, + "1": "6455" + }, + { + "0": 6454, + "1": "6454" + }, + { + "0": 6453, + "1": "6453" + } + ], + "0/52/1": 567936, + "0/52/2": 624000, + "0/52/3": 624000, + "0/52/65533": 1, + "0/52/65532": 1, + "0/52/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], + "0/52/65529": [0], + "0/52/65528": [], + "0/51/0": [ + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwpwJ13", + "5": ["wKgBjg=="], + "6": [ + "KgEOCgKzOZAj+/X13bO7Ug==", + "KgEOCgKzOZACDCn//sCddw==", + "/oAAAAAAAAACDCn//sCddw==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/3": 0, + "0/51/4": 0, + "0/51/2": 27, + "0/51/65533": 2, + "0/51/65532": 0, + "0/51/65531": [0, 1, 8, 3, 4, 2, 65533, 65532, 65531, 65529, 65528], + "0/51/65529": [0, 1], + "0/51/65528": [2], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "TEST_PRODUCT", + "0/40/4": 32769, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/18": "B8816828C2854949", + "0/40/65533": 5, + "0/40/65532": 0, + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 24, 11, 12, 13, 14, 15, 16, + 18, 65533, 65532, 65531, 65529, 65528 + ], + "0/40/65529": [], + "0/40/65528": [], + "0/30/0": [], + "0/30/65533": 1, + "0/30/65532": 0, + "0/30/65531": [0, 65533, 65532, 65531, 65529, 65528], + "0/30/65529": [], + "0/30/65528": [], + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [ + 49, 63, 60, 55, 54, 52, 51, 40, 30, 29, 31, 42, 43, 45, 48, 50, 53, 62, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 3, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/43/0": "en-US", + "0/43/1": ["en-US"], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "0/45/0": 1, + "0/45/65532": 1, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65532, 65533, 65528, 65529, 65531], + "0/53/0": null, + "0/53/1": null, + "0/53/2": null, + "0/53/3": null, + "0/53/4": null, + "0/53/5": null, + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": null, + "0/53/10": null, + "0/53/11": null, + "0/53/12": null, + "0/53/13": null, + "0/53/14": 0, + "0/53/15": 0, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 0, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 0, + "0/53/23": 0, + "0/53/24": 0, + "0/53/25": 0, + "0/53/26": 0, + "0/53/27": 0, + "0/53/28": 0, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 0, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 0, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 0, + "0/53/40": 0, + "0/53/41": 0, + "0/53/42": 0, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 0, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": null, + "0/53/57": null, + "0/53/58": null, + "0/53/59": null, + "0/53/60": null, + "0/53/61": null, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 3, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65532, 65533, 65528, 65529, 65531 + ], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRgxgkBwEkCAEwCUEEHdpyQAr0R0cO3BfrovHCvZNk8r6IOcBOwpAmzC/qBAyxoQW5PIlsMbbFj0FclS7LRBBTiQ756brkIRMV0yR5fjcKNQEoARgkAgE2AwQCBAEYMAQUbOnFWbN3ecrHrsgoh4u4YeaUOCgwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0Bi3zJw9hAYJklIi1MpGzpdgj2WgWEJ2gFtiTYCe7F5ltYdxkBeNiBIAopa6okml8rNuo8dkVM09xpmA4m0qANKGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", + "2": 4939, + "3": 2, + "4": 131, + "5": "ha-freebox", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "0/62/65528": [1, 3, 5, 8, 14], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "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, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 769, + "1": 4 + } + ], + "1/29/1": [3, 4, 29, 513, 516], + "1/29/2": [3], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 3, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/513/0": 0, + "1/513/3": 700, + "1/513/4": 3000, + "1/513/5": 1600, + "1/513/6": 3200, + "1/513/16": 0, + "1/513/17": 2600, + "1/513/18": 2000, + "1/513/21": 700, + "1/513/22": 3000, + "1/513/23": 1600, + "1/513/24": 3200, + "1/513/25": 25, + "1/513/27": 4, + "1/513/28": 1, + "1/513/72": [ + { + "0": 1, + "1": 1, + "2": 1 + }, + { + "0": 2, + "1": 1, + "2": 1 + }, + { + "0": 3, + "1": 1, + "2": 2 + }, + { + "0": 4, + "1": 1, + "2": 2 + }, + { + "0": 5, + "1": 1, + "2": 2 + }, + { + "0": 254, + "1": 1, + "2": 2 + } + ], + "1/513/73": [ + { + "0": 4, + "1": 1, + "2": 2 + }, + { + "0": 3, + "1": 1, + "2": 2 + } + ], + "1/513/74": 5, + "1/513/78": null, + "1/513/80": [ + { + "0": "AQ==", + "1": 1, + "3": 2500, + "4": 2100, + "5": true + }, + { + "0": "Ag==", + "1": 2, + "3": 2600, + "4": 2000, + "5": true + } + ], + "1/513/82": 0, + "1/513/83": 5, + "1/513/84": [], + "1/513/85": null, + "1/513/86": null, + "1/513/65532": 291, + "1/513/65533": 8, + "1/513/65528": [2, 253], + "1/513/65529": [0, 6, 7, 8, 254], + "1/513/65531": [ + 0, 3, 4, 5, 6, 16, 17, 18, 21, 22, 23, 24, 25, 27, 28, 72, 73, 74, 78, 80, + 82, 83, 84, 85, 86, 65532, 65533, 65528, 65529, 65531 + ], + "1/516/0": 0, + "1/516/1": 0, + "1/516/65532": 0, + "1/516/65533": 2, + "1/516/65528": [], + "1/516/65529": [], + "1/516/65531": [0, 1, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} From 5e5db6ebbd6df514fd30c6cef23821f90f7e9810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 08:55:25 +0000 Subject: [PATCH 02/76] Add presets attributes --- homeassistant/components/matter/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index c15dd42d62b672..87ab1677158a1c 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -419,9 +419,12 @@ def _get_temperature_in_degrees( 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.PresetTypes, + clusters.Thermostat.Attributes.Presets, clusters.Thermostat.Attributes.SystemMode, clusters.Thermostat.Attributes.ThermostatRunningMode, clusters.Thermostat.Attributes.ThermostatRunningState, From 5704d3995ee0e827445c1f6a2bb91952fb5007fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 09:30:19 +0000 Subject: [PATCH 03/76] Presets --- homeassistant/components/matter/climate.py | 9 +++++++++ homeassistant/components/matter/const.py | 5 +++++ homeassistant/components/matter/services.yaml | 12 ++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 homeassistant/components/matter/services.yaml diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 87ab1677158a1c..6eca9ad142784b 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -5,6 +5,7 @@ from enum import IntEnum from typing import Any +# from .const import ATTR_PRESETS, SERVICE_SET_PRESETS 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 @@ -186,10 +187,18 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF + _attr_presets: float | None = None _feature_map: int | None = None _platform_translation_key = "thermostat" + async def async_get_presets(self) -> None: + """Get presets.""" + if presets_value := self.get_matter_attribute_value( + clusters.Thermostat.Attributes.Presets + ): + self._attr_presets = presets_value + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index 8018d5e09edf7e..10c4d153a3f5f9 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -1,6 +1,7 @@ """Constants for the Matter integration.""" import logging +from typing import Final ADDON_SLUG = "core_matter_server" @@ -15,3 +16,7 @@ ID_TYPE_SERIAL = "serial" FEATUREMAP_ATTRIBUTE_ID = 65532 + +ATTR_PRESETS: Final = "presets" + +SERVICE_SET_PRESETS = "service_set_presets" diff --git a/homeassistant/components/matter/services.yaml b/homeassistant/components/matter/services.yaml new file mode 100644 index 00000000000000..b4ec69e4a3d736 --- /dev/null +++ b/homeassistant/components/matter/services.yaml @@ -0,0 +1,12 @@ +set_presets: + target: + entity: + domain: climate + supported_features: + - climate.ClimateEntityFeature.PRESET_MODE + fields: + preset: + required: true + example: "away" + selector: + text: From bef40de97e3169cfc88584fee5cff41891ff28f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 09:50:42 +0000 Subject: [PATCH 04/76] Add snapshots --- tests/components/matter/conftest.py | 1 + .../matter/snapshots/test_climate.ambr | 70 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index dca29cd7abd632..a1a7d37ffb9491 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -123,6 +123,7 @@ async def integration_fixture( "switch_unit", "temperature_sensor", "thermostat", + "thermostat_presets", "vacuum_cleaner", "valve", "window_covering_full", diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 07a5a69d801aa6..9428e60074e30c 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -269,3 +269,73 @@ 'state': 'cool', }) # --- +# name: test_climates[thermostat_presets][climate.test_product-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_product', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000083-MatterNodeDevice-1-MatterThermostat-513-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climates[thermostat_presets][climate.test_product-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'TEST_PRODUCT', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': 26.0, + 'target_temp_low': 20.0, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.test_product', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- From 2bd70ee37b7e710f2f3996e234b91e6ee841519d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 09:55:27 +0000 Subject: [PATCH 05/76] Replace product name --- .../matter/fixtures/nodes/thermostat_presets.json | 4 ++-- tests/components/matter/snapshots/test_climate.ambr | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/components/matter/fixtures/nodes/thermostat_presets.json b/tests/components/matter/fixtures/nodes/thermostat_presets.json index 03892a4c68a5c7..f42cfda24432ea 100644 --- a/tests/components/matter/fixtures/nodes/thermostat_presets.json +++ b/tests/components/matter/fixtures/nodes/thermostat_presets.json @@ -143,9 +143,9 @@ "0/51/65529": [0, 1], "0/51/65528": [2], "0/40/0": 19, - "0/40/1": "TEST_VENDOR", + "0/40/1": "Mock vendor", "0/40/2": 65521, - "0/40/3": "TEST_PRODUCT", + "0/40/3": "Mock thermostat", "0/40/4": 32769, "0/40/5": "", "0/40/6": "**REDACTED**", diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 9428e60074e30c..34add9ee51ffda 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -269,7 +269,7 @@ 'state': 'cool', }) # --- -# name: test_climates[thermostat_presets][climate.test_product-entry] +# name: test_climates[thermostat_presets][climate.mock_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -291,7 +291,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.test_product', + 'entity_id': 'climate.mock_thermostat', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -313,11 +313,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_climates[thermostat_presets][climate.test_product-state] +# name: test_climates[thermostat_presets][climate.mock_thermostat-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': None, - 'friendly_name': 'TEST_PRODUCT', + 'friendly_name': 'Mock thermostat', 'hvac_modes': list([ , , @@ -332,7 +332,7 @@ 'temperature': None, }), 'context': , - 'entity_id': 'climate.test_product', + 'entity_id': 'climate.mock_thermostat', 'last_changed': , 'last_reported': , 'last_updated': , From 3f7330d2d6e1d6b4e1ebf9e38c4a1077c0a811bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 12:45:34 +0000 Subject: [PATCH 06/76] Enable feature --- tests/components/matter/fixtures/nodes/thermostat_presets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/matter/fixtures/nodes/thermostat_presets.json b/tests/components/matter/fixtures/nodes/thermostat_presets.json index f42cfda24432ea..909ae771dc19ff 100644 --- a/tests/components/matter/fixtures/nodes/thermostat_presets.json +++ b/tests/components/matter/fixtures/nodes/thermostat_presets.json @@ -474,7 +474,7 @@ "1/513/84": [], "1/513/85": null, "1/513/86": null, - "1/513/65532": 291, + "1/513/65532": 419, "1/513/65533": 8, "1/513/65528": [2, 253], "1/513/65529": [0, 6, 7, 8, 254], From 6d9f1bbac825eec82e393b674a9727bc8e46206a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 13:26:55 +0000 Subject: [PATCH 07/76] Test _attr_preset_modes --- homeassistant/components/matter/climate.py | 50 +++++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 6eca9ad142784b..8a920ed0d0da3a 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -12,6 +12,8 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, + # ATTR_PRESET_MODE, + # ATTR_PRESET_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, @@ -187,17 +189,16 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF - _attr_presets: float | None = None + matter_presets: list[clusters.Thermostat.Structs.PresetStruct] | None = None + _attr_preset_mode: str | None = None + _attr_preset_modes: list[str] | None = None _feature_map: int | None = None _platform_translation_key = "thermostat" async def async_get_presets(self) -> None: """Get presets.""" - if presets_value := self.get_matter_attribute_value( - clusters.Thermostat.Attributes.Presets - ): - self._attr_presets = presets_value + # return self._attr_preset_modes async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -267,6 +268,43 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() + # Decode presets + # Value type: Optional[List[Thermostat.Structs.PresetStruct]] + # [{"0":"AQ==","1":1,"3":2500,"4":2100,"5":true},{"0":"Ag==","1":2,"3":2600,"4":2000,"5":true}] + presets_value = [ + clusters.Thermostat.Structs.PresetStruct( + presetHandle=b"\x01", + presetScenario=(clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied), + name=None, + coolingSetpoint=2500, + heatingSetpoint=2100, + builtIn=True, + ), + clusters.Thermostat.Structs.PresetStruct( + presetHandle=b"\x02", + presetScenario=( + clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied + ), + name=None, + coolingSetpoint=2600, + heatingSetpoint=2000, + builtIn=True, + ), + ] + presets = [] + # Decode presets + i = 1 + if presets_value: + for preset in presets_value: + name = preset.name + if not name: + # fallback to scenario name if no name is set + name = "Preset" + str(i) + presets.append(name) + i += 1 + self.matter_presets = presets_value + self._attr_preset_modes = presets + self._attr_current_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.LocalTemperature ) @@ -432,7 +470,7 @@ def _get_temperature_in_degrees( clusters.Thermostat.Attributes.Occupancy, clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, - clusters.Thermostat.Attributes.PresetTypes, + # clusters.Thermostat.Attributes.PresetTypes, clusters.Thermostat.Attributes.Presets, clusters.Thermostat.Attributes.SystemMode, clusters.Thermostat.Attributes.ThermostatRunningMode, From 72ec2dcb2b66ad9378b0e864e975d7e00bc443ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 14:22:18 +0000 Subject: [PATCH 08/76] PresetTypeList --- homeassistant/components/matter/climate.py | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 8a920ed0d0da3a..a79334bfb54f03 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -305,6 +305,40 @@ def _update_from_device(self) -> None: self.matter_presets = presets_value self._attr_preset_modes = presets + PresetTypeList = [ + clusters.Thermostat.Structs.PresetTypeStruct( + presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied, + numberOfPresets=1, + presetTypeFeatures=1, + ), + clusters.Thermostat.Structs.PresetTypeStruct( + presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied, + numberOfPresets=1, + presetTypeFeatures=1, + ), + clusters.Thermostat.Structs.PresetTypeStruct( + presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kSleep, + numberOfPresets=1, + presetTypeFeatures=2, + ), + clusters.Thermostat.Structs.PresetTypeStruct( + presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kWake, + numberOfPresets=1, + presetTypeFeatures=2, + ), + clusters.Thermostat.Structs.PresetTypeStruct( + presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kVacation, + numberOfPresets=1, + presetTypeFeatures=2, + ), + clusters.Thermostat.Structs.PresetTypeStruct( + presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kUserDefined, + numberOfPresets=1, + presetTypeFeatures=2, + ), + ] + self.matter_presets_types = PresetTypeList + self._attr_current_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.LocalTemperature ) From bf0c7446314660580619fd986ce8c439e759dcc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 18:44:15 +0000 Subject: [PATCH 09/76] async_set_preset_mode --- homeassistant/components/matter/climate.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index a79334bfb54f03..d533ed7cc17240 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -8,12 +8,11 @@ # from .const import ATTR_PRESETS, SERVICE_SET_PRESETS from chip.clusters import Objects as clusters from matter_server.client.models import device_types +from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.climate import ( ATTR_HVAC_MODE, - # ATTR_PRESET_MODE, - # ATTR_PRESET_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, @@ -244,6 +243,20 @@ async def async_set_temperature(self, **kwargs: Any) -> None: matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + mode = preset_mode + preset_handle = b"\x01" # default to first preset + try: + await self.send_device_command( + clusters.Thermostat.Commands.SetActivePresetRequest( + preset=preset_handle + ) + ) + self._update_from_device() + except MatterError as ex: + raise ValueError(f"Error setting preset mode {mode}: {ex}") from ex + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" From e039b587d1ce31d8a7b4d378118db853c83bb094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 18:53:44 +0000 Subject: [PATCH 10/76] Add test --- tests/components/matter/test_climate.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index a887ce1b5df6ec..7885807da48189 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -366,3 +366,18 @@ async def test_room_airconditioner( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.room_airconditioner") assert state.attributes["supported_features"] & ClimateEntityFeature.TURN_ON + + +@pytest.mark.parametrize("node_fixture", ["thermostat_presets"]) +async def test_thermostat_presets( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test thermostat presets attributes and state updates.""" + # test entity attributes + state = hass.states.get("climate.mock_thermostat") + assert state + assert state.attributes["min_temp"] == 7 + assert state.attributes["max_temp"] == 32.0 + assert state.attributes["temperature"] is None From 0cedbea483abe67fcce4358c48c25ad32c10d747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 19:05:28 +0000 Subject: [PATCH 11/76] Add tests --- homeassistant/components/matter/climate.py | 3 +++ .../components/matter/snapshots/test_climate.ambr | 13 +++++++++++-- tests/components/matter/test_climate.py | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index d533ed7cc17240..a338b502496b07 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -471,6 +471,9 @@ def _calculate_features( self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF ) + if feature_map & ThermostatFeature.kPresets: + self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + if feature_map & ThermostatFeature.kHeating: self._attr_hvac_modes.append(HVACMode.HEAT) if feature_map & ThermostatFeature.kCooling: diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 34add9ee51ffda..01ddac39f03023 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -283,6 +283,10 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, + 'preset_modes': list([ + 'Preset1', + 'Preset2', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -307,7 +311,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000083-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, @@ -326,7 +330,12 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, - 'supported_features': , + 'preset_mode': None, + 'preset_modes': list([ + 'Preset1', + 'Preset2', + ]), + 'supported_features': , 'target_temp_high': 26.0, 'target_temp_low': 20.0, 'temperature': None, diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 7885807da48189..09daf4d640a3ed 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -381,3 +381,18 @@ async def test_thermostat_presets( assert state.attributes["min_temp"] == 7 assert state.attributes["max_temp"] == 32.0 assert state.attributes["temperature"] is None + + # test supported features correctly parsed + mask = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE + ) + assert state.attributes["supported_features"] & mask == mask + + # Test preset modes parsed correctly + assert state.attributes["preset_modes"] == [ + "Preset1", + "Preset2", + ] + assert state.attributes["preset_mode"] is None From 31e4114b1942d1d6b16034821fee11e6a31df003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 19:17:31 +0000 Subject: [PATCH 12/76] Test command --- homeassistant/components/matter/climate.py | 14 ++++++++++--- tests/components/matter/test_climate.py | 23 +++++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index a338b502496b07..79ad0e3e6cbb59 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -246,11 +246,19 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" mode = preset_mode - preset_handle = b"\x01" # default to first preset + if self.matter_presets: + for preset in self.matter_presets: + name = preset.name + if not name: + # fallback to scenario name if no name is set + name = "Preset" + str(preset.presetHandle[0]) + if name == mode: + preset_handle = preset.presetHandle + break try: await self.send_device_command( clusters.Thermostat.Commands.SetActivePresetRequest( - preset=preset_handle + presetHandle=preset_handle ) ) self._update_from_device() @@ -473,7 +481,7 @@ def _calculate_features( ) 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: diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 09daf4d640a3ed..79f7d19933e33b 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -376,7 +376,8 @@ async def test_thermostat_presets( ) -> None: """Test thermostat presets attributes and state updates.""" # test entity attributes - state = hass.states.get("climate.mock_thermostat") + entity_id = "climate.mock_thermostat" + state = hass.states.get(entity_id) assert state assert state.attributes["min_temp"] == 7 assert state.attributes["max_temp"] == 32.0 @@ -396,3 +397,23 @@ async def test_thermostat_presets( "Preset2", ] assert state.attributes["preset_mode"] is None + + # test return_to_base action + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Preset2", + }, + blocking=True, + ) + preset_handle = b"\x02" + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_handle + ), + ) From 0a34d6967de91bad6a47fecd66967c34811d425f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 19:19:11 +0000 Subject: [PATCH 13/76] Add comment --- homeassistant/components/matter/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 79ad0e3e6cbb59..57bdd5d09b7ba7 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -248,6 +248,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: mode = preset_mode if self.matter_presets: for preset in self.matter_presets: + # find preset handle for the given preset name name = preset.name if not name: # fallback to scenario name if no name is set From 2ce71daa087d53b6691ae771ccc27255ff8937ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 31 Oct 2025 18:57:54 +0000 Subject: [PATCH 14/76] Update integration fixture to include matterbridge_thermostat and add its JSON fixture --- tests/components/matter/conftest.py | 2 +- .../nodes/matterbridge_thermostat.json | 330 ++++++++++++++++++ 2 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 tests/components/matter/fixtures/nodes/matterbridge_thermostat.json diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index a1a7d37ffb9491..119fc6022ccb32 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -100,7 +100,7 @@ async def integration_fixture( "laundry_dryer", "leak_sensor", "light_sensor", - "microwave_oven", + "matterbridge_thermostat", "mounted_dimmable_load_control_fixture", "multi_endpoint_light", "occupancy_sensor", diff --git a/tests/components/matter/fixtures/nodes/matterbridge_thermostat.json b/tests/components/matter/fixtures/nodes/matterbridge_thermostat.json new file mode 100644 index 00000000000000..beea022f9a621a --- /dev/null +++ b/tests/components/matter/fixtures/nodes/matterbridge_thermostat.json @@ -0,0 +1,330 @@ +{ + "node_id": 138, + "date_commissioned": "2025-10-05T19:46:49.106792", + "last_interview": "2025-10-31T17:51:47.221269", + "interview_version": 6, + "available": true, + "is_bridge": true, + "attributes": { + "0/40/18": "UI6a5da14670586424", + "0/40/65533": 5, + "0/40/0": 19, + "0/40/1": "Matterbridge", + "0/40/2": 65521, + "0/40/3": "Matterbridge aggregator", + "0/40/4": 32768, + "0/40/5": "Matterbridge aggregator", + "0/40/6": "**REDACTED**", + "0/40/7": 61400, + "0/40/8": "6.14.0-33-generic", + "0/40/9": 30305, + "0/40/10": "3.3.5", + "0/40/14": "Matterbridge aggregator", + "0/40/15": "SN6a5da14670586424", + "0/40/17": true, + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039872, + "0/40/22": 10, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 15, 17, 18, 19, 21, 22, 24, 65533, + 65532, 65531, 65529, 65528 + ], + "0/40/65529": [], + "0/40/65528": [], + "0/31/65533": 2, + "0/31/65532": 1, + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + }, + { + "254": 3 + }, + { + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65531": [0, 2, 3, 4, 65533, 65532, 65531, 65529, 65528, 1], + "0/31/65529": [], + "0/31/65528": [], + "0/63/65533": 2, + "0/63/65532": 0, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 21, + "0/63/3": 20, + "0/63/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], + "0/63/65529": [0, 1, 3, 4], + "0/63/65528": [2, 5], + "0/48/65533": 2, + "0/48/65532": 0, + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 2, + "0/48/3": 2, + "0/48/4": true, + "0/48/65531": [0, 1, 2, 3, 4, 65533, 65532, 65531, 65529, 65528], + "0/48/65529": [0, 2, 4], + "0/48/65528": [1, 3, 5], + "0/60/65533": 1, + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65531": [0, 1, 2, 65533, 65532, 65531, 65529, 65528], + "0/60/65529": [0, 2], + "0/60/65528": [], + "0/62/65533": 2, + "0/62/1": [ + { + "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", + "2": 4939, + "3": 2, + "4": 138, + "5": "Home", + "254": 1 + }, + { + "1": "BP4HJ1jDfxwK1KhSFOkV/rIjt/WK4PDEkxQdl1diQ7qLhiFHMFJz+1B44nicBrZTYCpPzJ+LlyY5vQIPcP2hWWE=", + "2": 65521, + "3": 1, + "4": 10, + "5": "", + "254": 3 + }, + { + "1": "BHL9n2iHhODhrxADVqf4ddhVWttMOy2Yd0M/1iEXRv4ToDZscYSSx6kMNglvj9WlwYzwc63CMT+620TbhGFdc6I=", + "2": 65521, + "3": 1, + "4": 100, + "5": "", + "254": 4 + } + ], + "0/62/2": 254, + "0/62/3": 3, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE/gcnWMN/HArUqFIU6RX+siO39Yrg8MSTFB2XV2JDuouGIUcwUnP7UHjieJwGtlNgKk/Mn4uXJjm9Ag9w/aFZYTcKNQEpARgkAmAwBBQXoZ755LIkaawThtTN/yZZCkiFYjAFFBehnvnksiRprBOG1M3/JlkKSIViGDALQG4Fz8CHZCsJoY/MBeSHabstw4Ztq59yicNOKWeXP3r5AImozXTTL+cOUf+4ZfKd9ExM0yW938ogNdEp7doO5iQY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEcv2faIeE4OGvEANWp/h12FVa20w7LZh3Qz/WIRdG/hOgNmxxhJLHqQw2CW+P1aXBjPBzrcIxP7rbRNuEYV1zojcKNQEpARgkAmAwBBQbaJWE2YtTpcziy79ZW84owoCbHjAFFBtolYTZi1OlzOLLv1lbzijCgJseGDALQLa0sr1P7MVjrXWf8vbiJzpBVmVSPz9qygBEMdQG2oV2uNYsDgU2hN94/rAt15oQpLtrgVhtjHuWS4OemTK7nroY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65531": [0, 1, 2, 3, 4, 5, 65533, 65532, 65531, 65529, 65528], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65528": [1, 3, 5, 8], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRihgkBwEkCAEwCUEEsd5p9ELakQkUnsi6SU6LyAqeMwaJariWVROdsdK5LwSw2RAH5joNDrhOVc/2iJx2jiF1AScb68u0Vgd9ust8ljcKNQEoARgkAgE2AwQCBAEYMAQUcqS1pIFvmcB8XpFw4C9JKx1hfSUwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0Df8R66CEUs458bVGvTn/YtHzvEZp+vHcYNIARFEunaDXiPNbFQ4F5jSoF4OSJjtvaAvbpaHX+gCP+zJ46d/NDvGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", + "254": 1 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRChgkBwEkCAEwCUEENlqj7PP6kFqIJM79ZAr7ksZ5l2vQ97uSsUDlDAjii1/dwhYMb9GyGEK9Cl5TSQbyRuxdFaqj23g5FWIlM4WlpzcKNQEoARgkAgE2AwQCBAEYMAQUD6uXKudNTE/dISX3dbX5t3mLsyIwBRR0AQBElDtCjSNnAxs1QMCbvpyuPBgwC0ChqTAqBeCt3wTKNEfNwvfO3U/vXCWrngRySfE4uBh05zAcgh/CJY+uWmX7DMpE3jYT8FGvyR0VSDnupPVQ5TfTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEELz0coeGPoosv9dfXeGWnyCFsb+WJfU0k9a3pNNT+PcDgtwIEodmmhROWkXad/KdJmK02BhEUpJUm0clfsn9HbzcKNQEpARgkAmAwBBR0AQBElDtCjSNnAxs1QMCbvpyuPDAFFBehnvnksiRprBOG1M3/JlkKSIViGDALQGDMGQxXqr7/ww7Zq2SpUHvsYdBgeg2Vno/83qHkO2H/wwpJEGUNOP22bm1KrtnGKjkzHthz1J0Sy8cDXSNKrWwY", + "254": 3 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRZBgkBwEkCAEwCUEEk/t53mz3uQu9zN9pJgsqMC5mwkHVnHRKVgQisHZ9pqAD2SJ5pKaAcXCIY5UNpU5kq8IzeC3YizKcTPlj03ba/DcKNQEoARgkAgE2AwQCBAEYMAQULH+2N64yxpX8z6/fm4/8rhS/mZQwBRTK8k81lgqZXOGBDS1mIqIfrUS4qxgwC0A3WKonVqp4VDRMsmaWFBJ/WPM00DilwbqKsTw3aizIdoB3QMS9HtMFWg/T5NIqMQWtJWTcEYwVw4D7do9ySacbGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE72YZDEnMbrT9Rw8cJvHLvbves4yNMnWag7UXIz5/Dl4Dq3kzT0Hw5dOOfKyW987xAijvvZJlaN3CwE0RlywIwjcKNQEpARgkAmAwBBTK8k81lgqZXOGBDS1mIqIfrUS4qzAFFBtolYTZi1OlzOLLv1lbzijCgJseGDALQD77tmJuj88O9At5KoTaEfQjeG1CaMl9QT/lHdLG2jZFTJhlh103Hx8M8lLvhry6WRmEL14LvxQA61eNItwbd8IY", + "254": 4 + } + ], + "0/51/65533": 2, + "0/51/65532": 1, + "0/51/0": [ + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwpwJ13", + "5": ["wKgBjg=="], + "6": ["AgAAAA==", "AgAAAA==", "AAAAAA=="], + "7": 2 + } + ], + "0/51/1": 70, + "0/51/2": 896, + "0/51/3": 66, + "0/51/8": false, + "0/51/65531": [0, 1, 2, 3, 8, 65533, 65532, 65531, 65529, 65528], + "0/51/65529": [0, 1, 3], + "0/51/65528": [2, 4], + "0/29/65533": 3, + "0/29/65532": 0, + "0/29/0": [ + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [40, 31, 63, 48, 60, 62, 51, 29], + "0/29/2": [], + "0/29/3": [1, 93], + "0/29/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], + "0/29/65529": [], + "0/29/65528": [], + "1/29/65533": 3, + "1/29/65532": 0, + "1/29/0": [ + { + "0": 14, + "1": 2 + } + ], + "1/29/1": [29], + "1/29/2": [], + "1/29/3": [93], + "1/29/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], + "1/29/65529": [], + "1/29/65528": [], + "93/3/65533": 6, + "93/3/0": 0, + "93/3/1": 0, + "93/3/65532": 0, + "93/3/65531": [0, 1, 65533, 65532, 65531, 65529, 65528], + "93/3/65529": [0, 64], + "93/3/65528": [], + "93/4/65533": 4, + "93/4/65532": 1, + "93/4/0": 128, + "93/4/65531": [0, 65533, 65532, 65531, 65529, 65528], + "93/4/65529": [0, 1, 2, 3, 4, 5], + "93/4/65528": [0, 1, 2, 3], + "93/47/65533": 3, + "93/47/65532": 1, + "93/47/0": 1, + "93/47/1": 0, + "93/47/2": "AC Power", + "93/47/5": 0, + "93/47/31": [93], + "93/47/65531": [0, 1, 2, 31, 65533, 65532, 65531, 65529, 65528, 5], + "93/47/65529": [], + "93/47/65528": [], + "93/513/65533": 9, + "93/513/65532": 295, + "93/513/0": null, + "93/513/1": 2050, + "93/513/2": 1, + "93/513/3": 0, + "93/513/4": 3500, + "93/513/5": 1500, + "93/513/6": 5000, + "93/513/17": 2600, + "93/513/18": 1800, + "93/513/19": 3000, + "93/513/20": 1000, + "93/513/21": 0, + "93/513/22": 3500, + "93/513/23": 1500, + "93/513/24": 5000, + "93/513/25": 10, + "93/513/27": 4, + "93/513/28": 1, + "93/513/30": 0, + "93/513/72": [ + { + "0": 254, + "1": 1, + "2": 3 + }, + { + "0": 1, + "1": 1, + "2": 3 + }, + { + "0": 2, + "1": 1, + "2": 3 + } + ], + "93/513/74": 2, + "93/513/78": "AQ==", + "93/513/80": [ + { + "0": "AA==", + "1": 1, + "2": "Occ", + "3": 2200, + "4": 2000, + "5": true + }, + { + "0": "AQ==", + "1": 2, + "2": "Away", + "3": 2600, + "4": 1800, + "5": true + } + ], + "93/513/65531": [ + 0, 1, 27, 28, 65533, 65532, 65531, 65529, 65528, 2, 3, 4, 18, 21, 22, 5, + 6, 17, 23, 24, 19, 20, 25, 30, 72, 74, 78, 80 + ], + "93/513/65529": [0, 254, 6], + "93/513/65528": [253], + "93/29/65533": 3, + "93/29/65532": 0, + "93/29/0": [ + { + "0": 769, + "1": 4 + }, + { + "0": 19, + "1": 3 + }, + { + "0": 17, + "1": 1 + } + ], + "93/29/1": [29, 3, 4, 57, 513, 47], + "93/29/2": [], + "93/29/3": [], + "93/29/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], + "93/29/65529": [], + "93/29/65528": [], + "93/57/18": "10792dc5d9c52ae0ee392fca99a836f7", + "93/57/65533": 5, + "93/57/65532": 0, + "93/57/1": "Matterbridge", + "93/57/2": 65521, + "93/57/3": "Matterbridge Thermostat", + "93/57/5": "Thermostat (AutoPresets)", + "93/57/7": 335, + "93/57/8": "3.3.5", + "93/57/9": 200, + "93/57/10": "2.0.0", + "93/57/13": "https://www.npmjs.com/package/matterbridge", + "93/57/14": "Thermostat (AutoPresets)", + "93/57/15": "TAP00058", + "93/57/17": true, + "93/57/65531": [ + 1, 2, 3, 5, 7, 8, 9, 10, 13, 14, 15, 17, 18, 65533, 65532, 65531, 65529, + 65528 + ], + "93/57/65529": [], + "93/57/65528": [] + }, + "attribute_subscriptions": [] +} From a3a83749bb8dc0f509429d3ca1a1f2e928e0d686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 31 Oct 2025 21:01:13 +0000 Subject: [PATCH 15/76] Refactor MatterClimate to utilize dynamic preset types and simplify preset handling --- homeassistant/components/matter/climate.py | 75 +++++----------------- 1 file changed, 17 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 57bdd5d09b7ba7..795e8efa35050f 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -290,76 +290,35 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() + + # self.matter_presets_types = PresetTypeList + self.matter_presets_types = self.get_matter_attribute_value( + clusters.Thermostat.Attributes.PresetTypes + ) + self.matter_presets = self.get_matter_attribute_value( + clusters.Thermostat.Attributes.Presets + ) + # Decode presets # Value type: Optional[List[Thermostat.Structs.PresetStruct]] - # [{"0":"AQ==","1":1,"3":2500,"4":2100,"5":true},{"0":"Ag==","1":2,"3":2600,"4":2000,"5":true}] - presets_value = [ - clusters.Thermostat.Structs.PresetStruct( - presetHandle=b"\x01", - presetScenario=(clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied), - name=None, - coolingSetpoint=2500, - heatingSetpoint=2100, - builtIn=True, - ), - clusters.Thermostat.Structs.PresetStruct( - presetHandle=b"\x02", - presetScenario=( - clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied - ), - name=None, - coolingSetpoint=2600, - heatingSetpoint=2000, - builtIn=True, - ), - ] + presets = [] # Decode presets i = 1 - if presets_value: - for preset in presets_value: + if self.matter_presets: + for preset in self.matter_presets: name = preset.name if not name: # fallback to scenario name if no name is set name = "Preset" + str(i) presets.append(name) i += 1 - self.matter_presets = presets_value + # self.matter_presets = presets_value self._attr_preset_modes = presets - PresetTypeList = [ - clusters.Thermostat.Structs.PresetTypeStruct( - presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied, - numberOfPresets=1, - presetTypeFeatures=1, - ), - clusters.Thermostat.Structs.PresetTypeStruct( - presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied, - numberOfPresets=1, - presetTypeFeatures=1, - ), - clusters.Thermostat.Structs.PresetTypeStruct( - presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kSleep, - numberOfPresets=1, - presetTypeFeatures=2, - ), - clusters.Thermostat.Structs.PresetTypeStruct( - presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kWake, - numberOfPresets=1, - presetTypeFeatures=2, - ), - clusters.Thermostat.Structs.PresetTypeStruct( - presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kVacation, - numberOfPresets=1, - presetTypeFeatures=2, - ), - clusters.Thermostat.Structs.PresetTypeStruct( - presetScenario=clusters.Thermostat.Enums.PresetScenarioEnum.kUserDefined, - numberOfPresets=1, - presetTypeFeatures=2, - ), - ] - self.matter_presets_types = PresetTypeList + self._attr_current_temperature = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.LocalTemperature + ) self._attr_current_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.LocalTemperature @@ -529,8 +488,8 @@ def _get_temperature_in_degrees( clusters.Thermostat.Attributes.Occupancy, clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, - # clusters.Thermostat.Attributes.PresetTypes, clusters.Thermostat.Attributes.Presets, + clusters.Thermostat.Attributes.PresetTypes, clusters.Thermostat.Attributes.SystemMode, clusters.Thermostat.Attributes.ThermostatRunningMode, clusters.Thermostat.Attributes.ThermostatRunningState, From cc1a17b7e44213c21d31aa43e4eeff33f895ec87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 31 Oct 2025 21:06:42 +0000 Subject: [PATCH 16/76] Clean --- homeassistant/components/matter/climate.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 795e8efa35050f..62b988b4db7970 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -291,17 +291,12 @@ def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() - # self.matter_presets_types = PresetTypeList self.matter_presets_types = self.get_matter_attribute_value( clusters.Thermostat.Attributes.PresetTypes ) self.matter_presets = self.get_matter_attribute_value( clusters.Thermostat.Attributes.Presets ) - - # Decode presets - # Value type: Optional[List[Thermostat.Structs.PresetStruct]] - presets = [] # Decode presets i = 1 @@ -313,7 +308,6 @@ def _update_from_device(self) -> None: name = "Preset" + str(i) presets.append(name) i += 1 - # self.matter_presets = presets_value self._attr_preset_modes = presets self._attr_current_temperature = self._get_temperature_in_degrees( From f2b5671a58be4bb3bee142860723f596a3515377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 21:09:22 +0000 Subject: [PATCH 17/76] Add support for eve_thermo_v5 in integration fixture and snapshots --- tests/components/matter/conftest.py | 1 + .../matter/fixtures/nodes/eve_thermo_v5.json | 593 ++++++++++++++++++ .../matter/snapshots/test_climate.ambr | 83 +++ 3 files changed, 677 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/eve_thermo_v5.json diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 119fc6022ccb32..60f1894fcf289c 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -89,6 +89,7 @@ async def integration_fixture( "eve_energy_plug", "eve_energy_plug_patched", "eve_thermo", + "eve_thermo_v5", "eve_weather_sensor", "extended_color_light", "extractor_hood", diff --git a/tests/components/matter/fixtures/nodes/eve_thermo_v5.json b/tests/components/matter/fixtures/nodes/eve_thermo_v5.json new file mode 100644 index 00000000000000..1d3c4f018fec79 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve_thermo_v5.json @@ -0,0 +1,593 @@ +{ + "node_id": 12, + "date_commissioned": "2026-01-12T17:05:18.823583", + "last_interview": "2026-01-12T17:12:42.428644", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 17, + "1": 1 + }, + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 47, 48, 49, 50, 51, 52, 53, 56, 60, 62, 63, 70, 323615744 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 1 + }, + { + "254": 2 + }, + { + "254": 3 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 10, + "0/31/3": 3, + "0/31/4": 5, + "0/31/65532": 1, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Thermo 20ECD1701", + "0/40/4": 125, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "1.1", + "0/40/9": 10287, + "0/40/10": "3.6.5", + "0/40/15": "FX46O1M01234", + "0/40/18": "DF3D0B4137A71234", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039616, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/42/0": [ + { + "1": 556220604, + "2": 0, + "254": 1 + } + ], + "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/47/0": 1, + "0/47/1": 0, + "0/47/2": "Battery", + "0/47/11": null, + "0/47/12": 200, + "0/47/14": 0, + "0/47/15": false, + "0/47/16": 2, + "0/47/18": [], + "0/47/19": "", + "0/47/20": 2, + "0/47/25": 2, + "0/47/31": [], + "0/47/65532": 10, + "0/47/65533": 3, + "0/47/65528": [], + "0/47/65529": [], + "0/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 18, 19, 20, 25, 31, 65528, 65529, 65531, + 65532, 65533 + ], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "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": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/9": 4, + "0/49/10": 5, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 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": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "3jP5Rq6mlcw=", + "5": [], + "6": [ + "/akBUIsgAAB2ykzh+Z7oEA==", + "/QANuACgAAAAAAD//gAsAw==", + "/QANuACgAADNhSwQ0KnuNg==", + "/oAAAAAAAADcM/lGrqaVzA==" + ], + "7": 4 + } + ], + "0/51/1": 9, + "0/51/2": 11, + "0/51/3": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/52/1": 10172, + "0/52/2": 1948, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [1, 2, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 2, + "0/53/2": "MyHome", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "QP0ADbgAoAAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 12864791528929066571, + "1": 12, + "2": 11264, + "3": 1787623, + "4": 77786, + "5": 3, + "6": -50, + "7": -51, + "8": 28, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 12864791528929066571, + "1": 11264, + "2": 11, + "3": 0, + "4": 0, + "5": 3, + "6": 0, + "7": 12, + "8": true, + "9": true + } + ], + "0/53/9": 1775826714, + "0/53/10": 64, + "0/53/11": 96, + "0/53/12": 247, + "0/53/13": 57, + "0/53/14": 1, + "0/53/15": 1, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 795, + "0/53/23": 795, + "0/53/24": 0, + "0/53/25": 796, + "0/53/26": 797, + "0/53/27": 0, + "0/53/28": 687, + "0/53/29": 161, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 466, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 0, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 251, + "0/53/40": 143, + "0/53/41": 0, + "0/53/42": 142, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 98, + "0/53/49": 1, + "0/53/50": 2, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 7, + "0/53/55": 1, + "0/53/59": { + "0": 672, + "1": 143 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 3, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/56/0": null, + "0/56/1": 0, + "0/56/2": 0, + "0/56/3": null, + "0/56/5": [ + { + "0": 3600, + "1": 0, + "2": "Europe/Paris" + } + ], + "0/56/6": [ + { + "0": 0, + "1": 0, + "2": 828061200000000 + }, + { + "0": 3600, + "1": 828061200000000, + "2": 846205200000000 + } + ], + "0/56/7": null, + "0/56/8": 2, + "0/56/10": 2, + "0/56/11": 2, + "0/56/65532": 9, + "0/56/65533": 2, + "0/56/65528": [3], + "0/56/65529": [0, 1, 2, 4], + "0/56/65531": [ + 0, 1, 2, 3, 5, 6, 7, 8, 10, 11, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "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": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "254": 3 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRDBgkBwEkCAEwCUEEjpEdjgSj9HS+HEJVD/GpyTr4aD+5fAti/w8n4eIrPgWZGhqCV0qnqaWVnQ15JLw/y001clUJvTA0F6aotXHi6zcKNQEoARgkAgE2AwQCBAEYMAQU93OvKOKKLhOjzDp+3jm7VZEuC/MwBRRa34d1hFPuca7UFWclq9cFnlPhShgwC0DDZdbO0KEk7s3FtbyASnf25X/Rwj9BNpBNviVDFPpR2hnkqttW8rmplsec7DeAiYNDGqxt5shN8rNfJHpr9+Q2GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEAV5qZprx2HWOKSP2iCzsI7A0CHgZVtbwsQ/y4ssETfB9z00733STIN0AfD552Vi1h6fJSeEg0/pA82bJL/y0azcKNQEpARgkAmAwBBRa34d1hFPuca7UFWclq9cFnlPhSjAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQDD8OnB1NcHRxx387f9wZeFDYf32VZ3ZENQrlWBTQZqEKP+K6XjWmjTWttDEeW1kiNtB1T5ZBIaJUxVdqMuNQx8Y", + "254": 4 + } + ], + "0/62/1": [ + { + "1": "BIGMMa0wfrcohBn60cI5V0xt+DIkLSV24OUKndKIXUVuzH8GGO72Yl/9IfYSPDKlK2pRWlT3J4IQD9DEiZtWK6k=", + "2": 4937, + "3": 3003885711, + "4": 3179312192, + "5": "Home", + "254": 1 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBMfk9zAkBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQCm96olCh4FdOmdpai/048NktfVtRdSntFc2qDrwkfljr0v13vTxADZ8mUF2TxEmi0EpXiYLp6rcLm7SNOdQlSgY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEbUU9qPxT8hkwnSWRhFacvs82vrsjsaZsqqvM48qn3YZZmQwtvEeyRKl6EDEzFbqd6lAdav4Sr0sunvDLgIHtrjcKNQEpARgkAmAwBBRvaChVdZwDuXcf/oyr6vKPKCnGWzAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQLa3jnnqN0/o6VG8wM4V9FDzrgDfKPd5cn3BBz77K80Jzo/aNotaTNOa6zX//yIvOkBZfGyq1Dh1vXZ4g2NKcXoY" + ], + "0/62/5": 4, + "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": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "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/70/0": 120, + "0/70/1": 300, + "0/70/2": 2000, + "0/70/65532": 0, + "0/70/65533": 3, + "0/70/65528": [], + "0/70/65529": [], + "0/70/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/323615744/1": true, + "0/323615744/65532": 0, + "0/323615744/65533": 1, + "0/323615744/65528": [], + "0/323615744/65529": [], + "0/323615744/65531": [1, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 4, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 769, + "1": 4 + } + ], + "1/29/1": [3, 29, 30, 513, 516, 319486977], + "1/29/2": [1026], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "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/513/0": 1620, + "1/513/3": 1000, + "1/513/4": 3000, + "1/513/16": -15, + "1/513/18": 1750, + "1/513/21": 1000, + "1/513/22": 3000, + "1/513/26": 0, + "1/513/27": 2, + "1/513/28": 4, + "1/513/35": 0, + "1/513/36": 0, + "1/513/41": 0, + "1/513/48": 0, + "1/513/49": 0, + "1/513/50": 0, + "1/513/72": [ + { + "0": 1, + "1": 1, + "2": 2 + }, + { + "0": 2, + "1": 1, + "2": 2 + }, + { + "0": 3, + "1": 1, + "2": 2 + }, + { + "0": 4, + "1": 1, + "2": 2 + }, + { + "0": 5, + "1": 1, + "2": 2 + }, + { + "0": 6, + "1": 1, + "2": 2 + }, + { + "0": 254, + "1": 1, + "2": 2 + } + ], + "1/513/74": 8, + "1/513/78": null, + "1/513/80": [ + { + "0": "AQ==", + "1": 1, + "2": "Home", + "4": 2200, + "5": true + }, + { + "0": "Ag==", + "1": 2, + "2": "Away", + "4": 1800, + "5": true + }, + { + "0": "Aw==", + "1": 3, + "2": "Sleep", + "4": 2000, + "5": false + }, + { + "0": "BA==", + "1": 4, + "2": "Wake", + "4": 2300, + "5": false + }, + { + "0": "BQ==", + "1": 5, + "2": "Vacation", + "4": 1600, + "5": false + }, + { + "0": "Bg==", + "1": 6, + "2": "GoingToSleep", + "4": 2100, + "5": false + }, + { + "0": "/g==", + "1": 254, + "2": "Eco", + "4": 1600, + "5": false + } + ], + "1/513/82": 0, + "1/513/65532": 257, + "1/513/65533": 8, + "1/513/65528": [253], + "1/513/65529": [0, 6, 254], + "1/513/65531": [ + 0, 3, 4, 16, 18, 21, 22, 26, 27, 28, 35, 36, 41, 48, 49, 50, 72, 74, 78, + 80, 82, 65528, 65529, 65531, 65532, 65533 + ], + "1/516/0": 0, + "1/516/1": 0, + "1/516/2": 0, + "1/516/65532": 0, + "1/516/65533": 2, + "1/516/65528": [], + "1/516/65529": [], + "1/516/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAJ9AAsCAAADAjYoBAxGWDQ2TzFNMDM2NDecAQD/BAECAyz5AQEdAQj/BCUCVQY7DTEuMC4xNS8yLjIuMAA8AQA3AQA/AQAmAQAnAQBPBgAAICAoKP8DIwHx/wYmBCXZAiD/BDQCmAhFBQUAAAAARgkFAAAADgAAQgZJBgUMCBCAAUQRBQAABQMAAAAAAAAAAAAAAABHEQUAAAAAAAAAAAAAAAAAAAAASAYFAAAAAABKBgUAAAAAAP8LIgkQAAAAAAAAAAA=", + "1/319486977/319422466": "bwIAAAAAAAAAAAAABgECEQIQARIBHQE0Ag0AABAAAAAAAgAAAAEBAA==", + "1/319486977/319422467": "FQQAAAACAAAAgAAAAAAAAAAAAAAA", + "1/319486977/319422476": 0, + "1/319486977/319422482": 11267, + "1/319486977/319422487": false, + "1/319486977/319422488": 0, + "1/319486977/319422489": 30240, + "1/319486977/319422490": 262144, + "1/319486977/65532": 0, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [319422464], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422476, 319422482, 319422487, 319422488, + 319422489, 319422490, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 01ddac39f03023..4d5b994682db03 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -127,6 +127,89 @@ 'state': 'heat', }) # --- +# name: test_climates[eve_thermo_v5][climate.eve_thermo_20ecd1701-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 10.0, + 'preset_modes': list([ + 'Home', + 'Away', + 'Sleep', + 'Wake', + 'Vacation', + 'GoingToSleep', + 'Eco', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.eve_thermo_20ecd1701', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climates[eve_thermo_v5][climate.eve_thermo_20ecd1701-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 16.2, + 'friendly_name': 'Eve Thermo 20ECD1701', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 10.0, + 'preset_mode': None, + 'preset_modes': list([ + 'Home', + 'Away', + 'Sleep', + 'Wake', + 'Vacation', + 'GoingToSleep', + 'Eco', + ]), + 'supported_features': , + 'temperature': 17.5, + }), + 'context': , + 'entity_id': 'climate.eve_thermo_20ecd1701', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_climates[room_airconditioner][climate.room_airconditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 6d790ea7a4c84fa61d95b9794edf8e4841c26cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 27 Sep 2025 18:44:15 +0000 Subject: [PATCH 18/76] Matter: Support dynamic preset types in MatterClimate - Refactor MatterClimate to utilize dynamic preset types - Add support for eve_thermo_v5 in integration fixture and snapshots - Simplify preset handling with async_set_preset_mode - Update test fixtures and add comprehensive tests --- tests/components/matter/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 60f1894fcf289c..76daf21f6ced64 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -101,7 +101,6 @@ async def integration_fixture( "laundry_dryer", "leak_sensor", "light_sensor", - "matterbridge_thermostat", "mounted_dimmable_load_control_fixture", "multi_endpoint_light", "occupancy_sensor", From 4cd86dacd9f30c3764425ac7f1dd2419e6edb24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 21:43:01 +0000 Subject: [PATCH 19/76] Remove unused thermostat preset from integration fixture and delete obsolete Matterbridge thermostat JSON file --- tests/components/matter/conftest.py | 1 - .../nodes/matterbridge_thermostat.json | 330 ------------------ 2 files changed, 331 deletions(-) delete mode 100644 tests/components/matter/fixtures/nodes/matterbridge_thermostat.json diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 1130781f2b195b..d0d7a6d5fcfc60 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -144,7 +144,6 @@ async def integration_fixture( "switch_unit", "tado_smart_radiator_thermostat_x", "temperature_sensor", - "thermostat", "thermostat_presets", "longan_link_thermostat", "vacuum_cleaner", diff --git a/tests/components/matter/fixtures/nodes/matterbridge_thermostat.json b/tests/components/matter/fixtures/nodes/matterbridge_thermostat.json deleted file mode 100644 index beea022f9a621a..00000000000000 --- a/tests/components/matter/fixtures/nodes/matterbridge_thermostat.json +++ /dev/null @@ -1,330 +0,0 @@ -{ - "node_id": 138, - "date_commissioned": "2025-10-05T19:46:49.106792", - "last_interview": "2025-10-31T17:51:47.221269", - "interview_version": 6, - "available": true, - "is_bridge": true, - "attributes": { - "0/40/18": "UI6a5da14670586424", - "0/40/65533": 5, - "0/40/0": 19, - "0/40/1": "Matterbridge", - "0/40/2": 65521, - "0/40/3": "Matterbridge aggregator", - "0/40/4": 32768, - "0/40/5": "Matterbridge aggregator", - "0/40/6": "**REDACTED**", - "0/40/7": 61400, - "0/40/8": "6.14.0-33-generic", - "0/40/9": 30305, - "0/40/10": "3.3.5", - "0/40/14": "Matterbridge aggregator", - "0/40/15": "SN6a5da14670586424", - "0/40/17": true, - "0/40/19": { - "0": 3, - "1": 3 - }, - "0/40/21": 17039872, - "0/40/22": 10, - "0/40/24": 1, - "0/40/65532": 0, - "0/40/65531": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 15, 17, 18, 19, 21, 22, 24, 65533, - 65532, 65531, 65529, 65528 - ], - "0/40/65529": [], - "0/40/65528": [], - "0/31/65533": 2, - "0/31/65532": 1, - "0/31/0": [ - { - "1": 5, - "2": 2, - "3": [112233], - "4": null, - "254": 1 - }, - { - "254": 3 - }, - { - "254": 4 - } - ], - "0/31/1": [], - "0/31/2": 4, - "0/31/3": 3, - "0/31/4": 4, - "0/31/65531": [0, 2, 3, 4, 65533, 65532, 65531, 65529, 65528, 1], - "0/31/65529": [], - "0/31/65528": [], - "0/63/65533": 2, - "0/63/65532": 0, - "0/63/0": [], - "0/63/1": [], - "0/63/2": 21, - "0/63/3": 20, - "0/63/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], - "0/63/65529": [0, 1, 3, 4], - "0/63/65528": [2, 5], - "0/48/65533": 2, - "0/48/65532": 0, - "0/48/0": 0, - "0/48/1": { - "0": 60, - "1": 900 - }, - "0/48/2": 2, - "0/48/3": 2, - "0/48/4": true, - "0/48/65531": [0, 1, 2, 3, 4, 65533, 65532, 65531, 65529, 65528], - "0/48/65529": [0, 2, 4], - "0/48/65528": [1, 3, 5], - "0/60/65533": 1, - "0/60/65532": 0, - "0/60/0": 0, - "0/60/1": null, - "0/60/2": null, - "0/60/65531": [0, 1, 2, 65533, 65532, 65531, 65529, 65528], - "0/60/65529": [0, 2], - "0/60/65528": [], - "0/62/65533": 2, - "0/62/1": [ - { - "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", - "2": 4939, - "3": 2, - "4": 138, - "5": "Home", - "254": 1 - }, - { - "1": "BP4HJ1jDfxwK1KhSFOkV/rIjt/WK4PDEkxQdl1diQ7qLhiFHMFJz+1B44nicBrZTYCpPzJ+LlyY5vQIPcP2hWWE=", - "2": 65521, - "3": 1, - "4": 10, - "5": "", - "254": 3 - }, - { - "1": "BHL9n2iHhODhrxADVqf4ddhVWttMOy2Yd0M/1iEXRv4ToDZscYSSx6kMNglvj9WlwYzwc63CMT+620TbhGFdc6I=", - "2": 65521, - "3": 1, - "4": 100, - "5": "", - "254": 4 - } - ], - "0/62/2": 254, - "0/62/3": 3, - "0/62/4": [ - "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y", - "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE/gcnWMN/HArUqFIU6RX+siO39Yrg8MSTFB2XV2JDuouGIUcwUnP7UHjieJwGtlNgKk/Mn4uXJjm9Ag9w/aFZYTcKNQEpARgkAmAwBBQXoZ755LIkaawThtTN/yZZCkiFYjAFFBehnvnksiRprBOG1M3/JlkKSIViGDALQG4Fz8CHZCsJoY/MBeSHabstw4Ztq59yicNOKWeXP3r5AImozXTTL+cOUf+4ZfKd9ExM0yW938ogNdEp7doO5iQY", - "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEcv2faIeE4OGvEANWp/h12FVa20w7LZh3Qz/WIRdG/hOgNmxxhJLHqQw2CW+P1aXBjPBzrcIxP7rbRNuEYV1zojcKNQEpARgkAmAwBBQbaJWE2YtTpcziy79ZW84owoCbHjAFFBtolYTZi1OlzOLLv1lbzijCgJseGDALQLa0sr1P7MVjrXWf8vbiJzpBVmVSPz9qygBEMdQG2oV2uNYsDgU2hN94/rAt15oQpLtrgVhtjHuWS4OemTK7nroY" - ], - "0/62/5": 1, - "0/62/65532": 0, - "0/62/65531": [0, 1, 2, 3, 4, 5, 65533, 65532, 65531, 65529, 65528], - "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], - "0/62/65528": [1, 3, 5, 8], - "0/62/0": [ - { - "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRihgkBwEkCAEwCUEEsd5p9ELakQkUnsi6SU6LyAqeMwaJariWVROdsdK5LwSw2RAH5joNDrhOVc/2iJx2jiF1AScb68u0Vgd9ust8ljcKNQEoARgkAgE2AwQCBAEYMAQUcqS1pIFvmcB8XpFw4C9JKx1hfSUwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0Df8R66CEUs458bVGvTn/YtHzvEZp+vHcYNIARFEunaDXiPNbFQ4F5jSoF4OSJjtvaAvbpaHX+gCP+zJ46d/NDvGA==", - "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", - "254": 1 - }, - { - "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRChgkBwEkCAEwCUEENlqj7PP6kFqIJM79ZAr7ksZ5l2vQ97uSsUDlDAjii1/dwhYMb9GyGEK9Cl5TSQbyRuxdFaqj23g5FWIlM4WlpzcKNQEoARgkAgE2AwQCBAEYMAQUD6uXKudNTE/dISX3dbX5t3mLsyIwBRR0AQBElDtCjSNnAxs1QMCbvpyuPBgwC0ChqTAqBeCt3wTKNEfNwvfO3U/vXCWrngRySfE4uBh05zAcgh/CJY+uWmX7DMpE3jYT8FGvyR0VSDnupPVQ5TfTGA==", - "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEELz0coeGPoosv9dfXeGWnyCFsb+WJfU0k9a3pNNT+PcDgtwIEodmmhROWkXad/KdJmK02BhEUpJUm0clfsn9HbzcKNQEpARgkAmAwBBR0AQBElDtCjSNnAxs1QMCbvpyuPDAFFBehnvnksiRprBOG1M3/JlkKSIViGDALQGDMGQxXqr7/ww7Zq2SpUHvsYdBgeg2Vno/83qHkO2H/wwpJEGUNOP22bm1KrtnGKjkzHthz1J0Sy8cDXSNKrWwY", - "254": 3 - }, - { - "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRZBgkBwEkCAEwCUEEk/t53mz3uQu9zN9pJgsqMC5mwkHVnHRKVgQisHZ9pqAD2SJ5pKaAcXCIY5UNpU5kq8IzeC3YizKcTPlj03ba/DcKNQEoARgkAgE2AwQCBAEYMAQULH+2N64yxpX8z6/fm4/8rhS/mZQwBRTK8k81lgqZXOGBDS1mIqIfrUS4qxgwC0A3WKonVqp4VDRMsmaWFBJ/WPM00DilwbqKsTw3aizIdoB3QMS9HtMFWg/T5NIqMQWtJWTcEYwVw4D7do9ySacbGA==", - "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE72YZDEnMbrT9Rw8cJvHLvbves4yNMnWag7UXIz5/Dl4Dq3kzT0Hw5dOOfKyW987xAijvvZJlaN3CwE0RlywIwjcKNQEpARgkAmAwBBTK8k81lgqZXOGBDS1mIqIfrUS4qzAFFBtolYTZi1OlzOLLv1lbzijCgJseGDALQD77tmJuj88O9At5KoTaEfQjeG1CaMl9QT/lHdLG2jZFTJhlh103Hx8M8lLvhry6WRmEL14LvxQA61eNItwbd8IY", - "254": 4 - } - ], - "0/51/65533": 2, - "0/51/65532": 1, - "0/51/0": [ - { - "0": "ens33", - "1": true, - "2": null, - "3": null, - "4": "AAwpwJ13", - "5": ["wKgBjg=="], - "6": ["AgAAAA==", "AgAAAA==", "AAAAAA=="], - "7": 2 - } - ], - "0/51/1": 70, - "0/51/2": 896, - "0/51/3": 66, - "0/51/8": false, - "0/51/65531": [0, 1, 2, 3, 8, 65533, 65532, 65531, 65529, 65528], - "0/51/65529": [0, 1, 3], - "0/51/65528": [2, 4], - "0/29/65533": 3, - "0/29/65532": 0, - "0/29/0": [ - { - "0": 22, - "1": 3 - } - ], - "0/29/1": [40, 31, 63, 48, 60, 62, 51, 29], - "0/29/2": [], - "0/29/3": [1, 93], - "0/29/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], - "0/29/65529": [], - "0/29/65528": [], - "1/29/65533": 3, - "1/29/65532": 0, - "1/29/0": [ - { - "0": 14, - "1": 2 - } - ], - "1/29/1": [29], - "1/29/2": [], - "1/29/3": [93], - "1/29/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], - "1/29/65529": [], - "1/29/65528": [], - "93/3/65533": 6, - "93/3/0": 0, - "93/3/1": 0, - "93/3/65532": 0, - "93/3/65531": [0, 1, 65533, 65532, 65531, 65529, 65528], - "93/3/65529": [0, 64], - "93/3/65528": [], - "93/4/65533": 4, - "93/4/65532": 1, - "93/4/0": 128, - "93/4/65531": [0, 65533, 65532, 65531, 65529, 65528], - "93/4/65529": [0, 1, 2, 3, 4, 5], - "93/4/65528": [0, 1, 2, 3], - "93/47/65533": 3, - "93/47/65532": 1, - "93/47/0": 1, - "93/47/1": 0, - "93/47/2": "AC Power", - "93/47/5": 0, - "93/47/31": [93], - "93/47/65531": [0, 1, 2, 31, 65533, 65532, 65531, 65529, 65528, 5], - "93/47/65529": [], - "93/47/65528": [], - "93/513/65533": 9, - "93/513/65532": 295, - "93/513/0": null, - "93/513/1": 2050, - "93/513/2": 1, - "93/513/3": 0, - "93/513/4": 3500, - "93/513/5": 1500, - "93/513/6": 5000, - "93/513/17": 2600, - "93/513/18": 1800, - "93/513/19": 3000, - "93/513/20": 1000, - "93/513/21": 0, - "93/513/22": 3500, - "93/513/23": 1500, - "93/513/24": 5000, - "93/513/25": 10, - "93/513/27": 4, - "93/513/28": 1, - "93/513/30": 0, - "93/513/72": [ - { - "0": 254, - "1": 1, - "2": 3 - }, - { - "0": 1, - "1": 1, - "2": 3 - }, - { - "0": 2, - "1": 1, - "2": 3 - } - ], - "93/513/74": 2, - "93/513/78": "AQ==", - "93/513/80": [ - { - "0": "AA==", - "1": 1, - "2": "Occ", - "3": 2200, - "4": 2000, - "5": true - }, - { - "0": "AQ==", - "1": 2, - "2": "Away", - "3": 2600, - "4": 1800, - "5": true - } - ], - "93/513/65531": [ - 0, 1, 27, 28, 65533, 65532, 65531, 65529, 65528, 2, 3, 4, 18, 21, 22, 5, - 6, 17, 23, 24, 19, 20, 25, 30, 72, 74, 78, 80 - ], - "93/513/65529": [0, 254, 6], - "93/513/65528": [253], - "93/29/65533": 3, - "93/29/65532": 0, - "93/29/0": [ - { - "0": 769, - "1": 4 - }, - { - "0": 19, - "1": 3 - }, - { - "0": 17, - "1": 1 - } - ], - "93/29/1": [29, 3, 4, 57, 513, 47], - "93/29/2": [], - "93/29/3": [], - "93/29/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], - "93/29/65529": [], - "93/29/65528": [], - "93/57/18": "10792dc5d9c52ae0ee392fca99a836f7", - "93/57/65533": 5, - "93/57/65532": 0, - "93/57/1": "Matterbridge", - "93/57/2": 65521, - "93/57/3": "Matterbridge Thermostat", - "93/57/5": "Thermostat (AutoPresets)", - "93/57/7": 335, - "93/57/8": "3.3.5", - "93/57/9": 200, - "93/57/10": "2.0.0", - "93/57/13": "https://www.npmjs.com/package/matterbridge", - "93/57/14": "Thermostat (AutoPresets)", - "93/57/15": "TAP00058", - "93/57/17": true, - "93/57/65531": [ - 1, 2, 3, 5, 7, 8, 9, 10, 13, 14, 15, 17, 18, 65533, 65532, 65531, 65529, - 65528 - ], - "93/57/65529": [], - "93/57/65528": [] - }, - "attribute_subscriptions": [] -} From 1be5173f4deb47f0a072249f3616b318d08e3901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 21:43:41 +0000 Subject: [PATCH 20/76] Add preset modes support for Matter thermostats in test snapshots --- .../matter/snapshots/test_climate.ambr | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 25b3f354dca1e9..b15956d8cc6162 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -267,6 +267,15 @@ ]), 'max_temp': 30.0, 'min_temp': 10.0, + 'preset_modes': list([ + 'Home', + 'Away', + 'Sleep', + 'Wake', + 'Vacation', + 'GoingToSleep', + 'Eco', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -291,7 +300,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, @@ -308,7 +317,17 @@ ]), 'max_temp': 30.0, 'min_temp': 10.0, - 'supported_features': , + 'preset_mode': None, + 'preset_modes': list([ + 'Home', + 'Away', + 'Sleep', + 'Wake', + 'Vacation', + 'GoingToSleep', + 'Eco', + ]), + 'supported_features': , 'temperature': 17.5, }), 'context': , @@ -403,6 +422,10 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, + 'preset_modes': list([ + 'Preset1', + 'Preset2', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -427,7 +450,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, @@ -447,7 +470,12 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, - 'supported_features': , + 'preset_mode': None, + 'preset_modes': list([ + 'Preset1', + 'Preset2', + ]), + 'supported_features': , 'target_temp_high': 26.0, 'target_temp_low': 20.0, 'temperature': None, From 984f76bdaebc94a8895220ff460295a1a72d7a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 22:02:45 +0000 Subject: [PATCH 21/76] Enhance preset mode handling in MatterClimate by adding a mapping for preset handles and improving error handling for preset mode setting. --- homeassistant/components/matter/climate.py | 35 ++++++++++------------ 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index ab997b58740e21..0c9bb9e17757bf 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -197,6 +197,7 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF matter_presets: list[clusters.Thermostat.Structs.PresetStruct] | None = None + _preset_handle_by_name: dict[str, bytes] = {} _attr_preset_mode: str | None = None _attr_preset_modes: list[str] | None = None _feature_map: int | None = None @@ -253,17 +254,14 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - mode = preset_mode - if self.matter_presets: - for preset in self.matter_presets: - # find preset handle for the given preset name - name = preset.name - if not name: - # fallback to scenario name if no name is set - name = "Preset" + str(preset.presetHandle[0]) - if name == mode: - preset_handle = preset.presetHandle - break + preset_handle = self._preset_handle_by_name.get(preset_mode) + + if preset_handle is None: + raise ValueError( + f"Preset mode '{preset_mode}' not found. " + f"Available presets: {self.preset_modes}" + ) + try: await self.send_device_command( clusters.Thermostat.Commands.SetActivePresetRequest( @@ -272,7 +270,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: ) self._update_from_device() except MatterError as ex: - raise ValueError(f"Error setting preset mode {mode}: {ex}") from ex + raise ValueError(f"Error setting preset mode {preset_mode}: {ex}") from ex async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -305,17 +303,14 @@ def _update_from_device(self) -> None: self.matter_presets = self.get_matter_attribute_value( clusters.Thermostat.Attributes.Presets ) + # Build preset mapping and list + self._preset_handle_by_name.clear() presets = [] - # Decode presets - i = 1 if self.matter_presets: - for preset in self.matter_presets: - name = preset.name - if not name: - # fallback to scenario name if no name is set - name = "Preset" + str(i) + for i, preset in enumerate(self.matter_presets, start=1): + name = preset.name or f"Preset{i}" presets.append(name) - i += 1 + self._preset_handle_by_name[name] = preset.presetHandle self._attr_preset_modes = presets self._attr_current_temperature = self._get_temperature_in_degrees( From bbd6a237fc41cfabc33136a2ddd2041b256def3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 22:05:58 +0000 Subject: [PATCH 22/76] Add tests for set_preset_mode functionality in thermostat presets --- tests/components/matter/test_climate.py | 142 +++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 67f180a6e66cc1..b48eef09f750d4 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ClimateEntityFeature, HVACAction, HVACMode from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from .common import ( @@ -451,7 +452,7 @@ async def test_thermostat_presets( ] assert state.attributes["preset_mode"] is None - # test return_to_base action + # test set_preset_mode action await hass.services.async_call( "climate", "set_preset_mode", @@ -470,3 +471,142 @@ async def test_thermostat_presets( presetHandle=preset_handle ), ) + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with invalid preset mode + # The climate platform validates preset modes before calling our method + + with pytest.raises( + ServiceValidationError, match="Preset mode InvalidPreset is not valid" + ): + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "InvalidPreset", + }, + blocking=True, + ) + + # Ensure no command was sent for invalid preset + assert matter_client.send_device_command.call_count == 0 + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) +async def test_eve_thermo_v5_presets( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Eve Thermo v5 thermostat presets attributes and state updates.""" + # test entity attributes + entity_id = "climate.eve_thermo_20ecd1701" + state = hass.states.get(entity_id) + assert state + + # test supported features correctly parsed + mask = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE + ) + assert state.attributes["supported_features"] & mask == mask + + # Test preset modes parsed correctly from Eve Thermo v5 + assert state.attributes["preset_modes"] == [ + "Home", + "Away", + "Sleep", + "Wake", + "Vacation", + "GoingToSleep", + "Eco", + ] + assert state.attributes["preset_mode"] is None + + # Get presets from the node for dynamic testing + presets_attribute = matter_node.endpoints[1].get_attribute_value( + clusters.Thermostat.Attributes.Presets.cluster_id, + clusters.Thermostat.Attributes.Presets.attribute_id, + ) + preset_by_name = {preset.name: preset.presetHandle for preset in presets_attribute} + + # test set_preset_mode with "Home" preset + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Home", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_by_name["Home"] + ), + ) + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with "Away" preset + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Away", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_by_name["Away"] + ), + ) + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with "Eco" preset + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Eco", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_by_name["Eco"] + ), + ) + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with invalid preset mode + # The climate platform validates preset modes before calling our method + + with pytest.raises( + ServiceValidationError, match="Preset mode InvalidPreset is not valid" + ): + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "InvalidPreset", + }, + blocking=True, + ) + + # Ensure no command was sent for invalid preset + assert matter_client.send_device_command.call_count == 0 From c7ca12954ba2b89845c2e06733d82c2461d9e285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 22:09:54 +0000 Subject: [PATCH 23/76] Add service and strings for setting presets in Matter thermostats --- homeassistant/components/matter/icons.json | 3 +++ homeassistant/components/matter/strings.json | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index ec96875c06b446..4bc1a6a021f802 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -174,6 +174,9 @@ } }, "services": { + "set_presets": { + "service": "mdi:home-thermometer" + }, "water_heater_boost": { "service": "mdi:water-boiler" } diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 1b7910fbd47e5f..9584e521fd254e 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -625,6 +625,16 @@ }, "name": "Open commissioning window" }, + "set_presets": { + "description": "Set the preset for a Matter thermostat.", + "fields": { + "preset": { + "description": "The preset mode to set.", + "name": "Preset" + } + }, + "name": "Set thermostat preset" + }, "water_heater_boost": { "description": "Enables water heater boost for a specific duration.", "fields": { From 7b826fe02148e3469decf992f48f03907df6b7e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 22:22:17 +0000 Subject: [PATCH 24/76] Add mock thermostat presets for buttons, selects, and sensors in test snapshots --- .../matter/snapshots/test_button.ambr | 49 ++++++++++++++++ .../matter/snapshots/test_select.ambr | 57 +++++++++++++++++++ .../matter/snapshots/test_sensor.ambr | 56 ++++++++++++++++++ 3 files changed, 162 insertions(+) diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 4e04affa69b27f..813a0b09acb3af 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -3898,6 +3898,55 @@ 'state': 'unknown', }) # --- +# name: test_buttons[thermostat_presets][button.mock_thermostat_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_thermostat_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000083-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[thermostat_presets][button.mock_thermostat_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Mock thermostat Identify', + }), + 'context': , + 'entity_id': 'button.mock_thermostat_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[window_covering_pa_lift][button.longan_link_wncv_da01_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 443a34450e46cf..b79c4c4531f738 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -4414,6 +4414,63 @@ 'state': 'Quick', }) # --- +# name: test_selects[thermostat_presets][select.mock_thermostat_temperature_display_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mock_thermostat_temperature_display_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature display mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_mode', + 'unique_id': '00000000000004D2-0000000000000083-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[thermostat_presets][select.mock_thermostat_temperature_display_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock thermostat Temperature display mode', + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.mock_thermostat_temperature_display_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Celsius', + }) +# --- # name: test_selects[vacuum_cleaner][select.mock_vacuum_clean_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index e686e6ed89cd0b..5189ff5ee1b9a1 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -12157,6 +12157,62 @@ 'state': '21.0', }) # --- +# name: test_sensors[thermostat_presets][sensor.mock_thermostat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_thermostat_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000083-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[thermostat_presets][sensor.mock_thermostat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock thermostat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_thermostat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_estimated_end_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 6d0e8eea5182f38e314d4f9338e564958fdebe18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 22:28:07 +0000 Subject: [PATCH 25/76] Remove thermostat presets related tests and snapshots --- tests/components/matter/conftest.py | 1 - .../matter/snapshots/test_button.ambr | 49 ------------ .../matter/snapshots/test_climate.ambr | 79 ------------------- .../matter/snapshots/test_select.ambr | 55 ------------- .../matter/snapshots/test_sensor.ambr | 56 ------------- tests/components/matter/test_climate.py | 71 ----------------- 6 files changed, 311 deletions(-) diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index d0d7a6d5fcfc60..68c62d700a2d84 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -144,7 +144,6 @@ async def integration_fixture( "switch_unit", "tado_smart_radiator_thermostat_x", "temperature_sensor", - "thermostat_presets", "longan_link_thermostat", "vacuum_cleaner", "valve", diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 813a0b09acb3af..4e04affa69b27f 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -3898,55 +3898,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[thermostat_presets][button.mock_thermostat_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_thermostat_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000083-MatterNodeDevice-1-IdentifyButton-3-1', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[thermostat_presets][button.mock_thermostat_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock thermostat Identify', - }), - 'context': , - 'entity_id': 'button.mock_thermostat_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[window_covering_pa_lift][button.longan_link_wncv_da01_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index b15956d8cc6162..8b49797476da2d 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -625,82 +625,3 @@ 'state': 'off', }) # --- -# name: test_climates[thermostat_presets][climate.mock_thermostat-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'hvac_modes': list([ - , - , - , - , - ]), - 'max_temp': 32.0, - 'min_temp': 7.0, - 'preset_modes': list([ - 'Preset1', - 'Preset2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'climate', - 'entity_category': None, - 'entity_id': 'climate.mock_thermostat', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000083-MatterNodeDevice-1-MatterThermostat-513-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_climates[thermostat_presets][climate.mock_thermostat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': None, - 'friendly_name': 'Mock thermostat', - 'hvac_modes': list([ - , - , - , - , - ]), - 'max_temp': 32.0, - 'min_temp': 7.0, - 'preset_mode': None, - 'preset_modes': list([ - 'Preset1', - 'Preset2', - ]), - 'supported_features': , - 'target_temp_high': 26.0, - 'target_temp_low': 20.0, - 'temperature': None, - }), - 'context': , - 'entity_id': 'climate.mock_thermostat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'heat_cool', - }) -# --- diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index b79c4c4531f738..e2376273d2f554 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -4413,61 +4413,6 @@ 'last_updated': , 'state': 'Quick', }) -# --- -# name: test_selects[thermostat_presets][select.mock_thermostat_temperature_display_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'Celsius', - 'Fahrenheit', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.mock_thermostat_temperature_display_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Temperature display mode', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'temperature_display_mode', - 'unique_id': '00000000000004D2-0000000000000083-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[thermostat_presets][select.mock_thermostat_temperature_display_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock thermostat Temperature display mode', - 'options': list([ - 'Celsius', - 'Fahrenheit', - ]), - }), - 'context': , - 'entity_id': 'select.mock_thermostat_temperature_display_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , 'state': 'Celsius', }) # --- diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 5189ff5ee1b9a1..e686e6ed89cd0b 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -12157,62 +12157,6 @@ 'state': '21.0', }) # --- -# name: test_sensors[thermostat_presets][sensor.mock_thermostat_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.mock_thermostat_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000083-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[thermostat_presets][sensor.mock_thermostat_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Mock thermostat Temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.mock_thermostat_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_estimated_end_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index b48eef09f750d4..68ad82437cb79c 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -422,77 +422,6 @@ async def test_room_airconditioner( assert state.attributes["supported_features"] & ClimateEntityFeature.TURN_ON -@pytest.mark.parametrize("node_fixture", ["thermostat_presets"]) -async def test_thermostat_presets( - hass: HomeAssistant, - matter_client: MagicMock, - matter_node: MatterNode, -) -> None: - """Test thermostat presets attributes and state updates.""" - # test entity attributes - entity_id = "climate.mock_thermostat" - state = hass.states.get(entity_id) - assert state - assert state.attributes["min_temp"] == 7 - assert state.attributes["max_temp"] == 32.0 - assert state.attributes["temperature"] is None - - # test supported features correctly parsed - mask = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.PRESET_MODE - ) - assert state.attributes["supported_features"] & mask == mask - - # Test preset modes parsed correctly - assert state.attributes["preset_modes"] == [ - "Preset1", - "Preset2", - ] - assert state.attributes["preset_mode"] is None - - # test set_preset_mode action - await hass.services.async_call( - "climate", - "set_preset_mode", - { - "entity_id": entity_id, - "preset_mode": "Preset2", - }, - blocking=True, - ) - preset_handle = b"\x02" - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( - node_id=matter_node.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetActivePresetRequest( - presetHandle=preset_handle - ), - ) - matter_client.send_device_command.reset_mock() - - # test set_preset_mode with invalid preset mode - # The climate platform validates preset modes before calling our method - - with pytest.raises( - ServiceValidationError, match="Preset mode InvalidPreset is not valid" - ): - await hass.services.async_call( - "climate", - "set_preset_mode", - { - "entity_id": entity_id, - "preset_mode": "InvalidPreset", - }, - blocking=True, - ) - - # Ensure no command was sent for invalid preset - assert matter_client.send_device_command.call_count == 0 - - @pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) async def test_eve_thermo_v5_presets( hass: HomeAssistant, From 489e5fc6d28d6652d57ba15b419bcf35a7551d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 22:33:48 +0000 Subject: [PATCH 26/76] Remove unused import for preset attributes in climate.py --- homeassistant/components/matter/climate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 0c9bb9e17757bf..64960926a70328 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -6,7 +6,6 @@ from enum import IntEnum from typing import Any -# from .const import ATTR_PRESETS, SERVICE_SET_PRESETS from chip.clusters import Objects as clusters from matter_server.client.models import device_types from matter_server.common.errors import MatterError From 113dbf175f0ed83715ed4409586b87faffe56e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 22:38:31 +0000 Subject: [PATCH 27/76] Remove thermostat presets fixture file --- .../fixtures/nodes/thermostat_presets.json | 494 ------------------ 1 file changed, 494 deletions(-) delete mode 100644 tests/components/matter/fixtures/nodes/thermostat_presets.json diff --git a/tests/components/matter/fixtures/nodes/thermostat_presets.json b/tests/components/matter/fixtures/nodes/thermostat_presets.json deleted file mode 100644 index 909ae771dc19ff..00000000000000 --- a/tests/components/matter/fixtures/nodes/thermostat_presets.json +++ /dev/null @@ -1,494 +0,0 @@ -{ - "node_id": 131, - "date_commissioned": "2025-09-27T07:17:07.885040", - "last_interview": "2025-09-27T07:17:07.885071", - "interview_version": 6, - "available": true, - "is_bridge": false, - "attributes": { - "0/49/0": 1, - "0/49/1": [ - { - "0": "ZW5zMzM=", - "1": true - } - ], - "0/49/4": true, - "0/49/5": 0, - "0/49/6": "ZW5zMzM=", - "0/49/7": null, - "0/49/65533": 2, - "0/49/65532": 4, - "0/49/65531": [0, 1, 4, 5, 6, 7, 65533, 65532, 65531, 65529, 65528], - "0/49/65529": [], - "0/49/65528": [], - "0/63/0": [], - "0/63/1": [], - "0/63/2": 4, - "0/63/3": 3, - "0/63/65533": 1, - "0/63/65532": 0, - "0/63/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], - "0/63/65529": [0, 1, 3, 4], - "0/63/65528": [5, 2], - "0/60/0": 0, - "0/60/1": null, - "0/60/2": null, - "0/60/65533": 1, - "0/60/65532": 0, - "0/60/65531": [0, 1, 2, 65533, 65532, 65531, 65529, 65528], - "0/60/65529": [0, 2], - "0/60/65528": [], - "0/55/2": 880, - "0/55/3": 552, - "0/55/4": 0, - "0/55/5": 0, - "0/55/6": 0, - "0/55/7": null, - "0/55/1": true, - "0/55/0": 2, - "0/55/8": 27, - "0/55/65533": 1, - "0/55/65532": 3, - "0/55/65531": [ - 2, 3, 4, 5, 6, 7, 1, 0, 8, 65533, 65532, 65531, 65529, 65528 - ], - "0/55/65529": [0], - "0/55/65528": [], - "0/54/0": null, - "0/54/1": null, - "0/54/2": 3, - "0/54/3": null, - "0/54/4": null, - "0/54/5": null, - "0/54/12": 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/65533": 1, - "0/54/65532": 3, - "0/54/65531": [ - 0, 1, 2, 3, 4, 5, 12, 6, 7, 8, 9, 10, 11, 65533, 65532, 65531, 65529, - 65528 - ], - "0/54/65529": [0], - "0/54/65528": [], - "0/52/0": [ - { - "0": 6457, - "1": "6457" - }, - { - "0": 6456, - "1": "6456" - }, - { - "0": 6455, - "1": "6455" - }, - { - "0": 6454, - "1": "6454" - }, - { - "0": 6453, - "1": "6453" - } - ], - "0/52/1": 567936, - "0/52/2": 624000, - "0/52/3": 624000, - "0/52/65533": 1, - "0/52/65532": 1, - "0/52/65531": [0, 1, 2, 3, 65533, 65532, 65531, 65529, 65528], - "0/52/65529": [0], - "0/52/65528": [], - "0/51/0": [ - { - "0": "ens33", - "1": true, - "2": null, - "3": null, - "4": "AAwpwJ13", - "5": ["wKgBjg=="], - "6": [ - "KgEOCgKzOZAj+/X13bO7Ug==", - "KgEOCgKzOZACDCn//sCddw==", - "/oAAAAAAAAACDCn//sCddw==" - ], - "7": 2 - }, - { - "0": "lo", - "1": true, - "2": null, - "3": null, - "4": "AAAAAAAA", - "5": ["fwAAAQ=="], - "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "7": 0 - } - ], - "0/51/1": 1, - "0/51/8": false, - "0/51/3": 0, - "0/51/4": 0, - "0/51/2": 27, - "0/51/65533": 2, - "0/51/65532": 0, - "0/51/65531": [0, 1, 8, 3, 4, 2, 65533, 65532, 65531, 65529, 65528], - "0/51/65529": [0, 1], - "0/51/65528": [2], - "0/40/0": 19, - "0/40/1": "Mock vendor", - "0/40/2": 65521, - "0/40/3": "Mock thermostat", - "0/40/4": 32769, - "0/40/5": "", - "0/40/6": "**REDACTED**", - "0/40/7": 0, - "0/40/8": "TEST_VERSION", - "0/40/9": 1, - "0/40/10": "1.0", - "0/40/19": { - "0": 3, - "1": 65535 - }, - "0/40/21": 17104896, - "0/40/22": 1, - "0/40/24": 1, - "0/40/11": "20200101", - "0/40/12": "", - "0/40/13": "", - "0/40/14": "", - "0/40/15": "TEST_SN", - "0/40/16": false, - "0/40/18": "B8816828C2854949", - "0/40/65533": 5, - "0/40/65532": 0, - "0/40/65531": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 24, 11, 12, 13, 14, 15, 16, - 18, 65533, 65532, 65531, 65529, 65528 - ], - "0/40/65529": [], - "0/40/65528": [], - "0/30/0": [], - "0/30/65533": 1, - "0/30/65532": 0, - "0/30/65531": [0, 65533, 65532, 65531, 65529, 65528], - "0/30/65529": [], - "0/30/65528": [], - "0/29/0": [ - { - "0": 18, - "1": 1 - }, - { - "0": 22, - "1": 3 - } - ], - "0/29/1": [ - 49, 63, 60, 55, 54, 52, 51, 40, 30, 29, 31, 42, 43, 45, 48, 50, 53, 62, 65 - ], - "0/29/2": [41], - "0/29/3": [1], - "0/29/65532": 0, - "0/29/65533": 3, - "0/29/65528": [], - "0/29/65529": [], - "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], - "0/31/0": [ - { - "1": 5, - "2": 2, - "3": [112233], - "4": null, - "254": 1 - } - ], - "0/31/2": 4, - "0/31/3": 3, - "0/31/4": 4, - "0/31/65532": 0, - "0/31/65533": 2, - "0/31/65528": [], - "0/31/65529": [], - "0/31/65531": [0, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], - "0/42/0": [], - "0/42/1": true, - "0/42/2": 0, - "0/42/3": 0, - "0/42/65532": 0, - "0/42/65533": 1, - "0/42/65528": [], - "0/42/65529": [0], - "0/42/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], - "0/43/0": "en-US", - "0/43/1": ["en-US"], - "0/43/65532": 0, - "0/43/65533": 1, - "0/43/65528": [], - "0/43/65529": [], - "0/43/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], - "0/45/0": 1, - "0/45/65532": 1, - "0/45/65533": 2, - "0/45/65528": [], - "0/45/65529": [], - "0/45/65531": [0, 65532, 65533, 65528, 65529, 65531], - "0/48/0": 0, - "0/48/1": { - "0": 60, - "1": 900 - }, - "0/48/2": 0, - "0/48/3": 2, - "0/48/4": true, - "0/48/65532": 0, - "0/48/65533": 2, - "0/48/65528": [1, 3, 5], - "0/48/65529": [0, 2, 4], - "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], - "0/50/65532": 0, - "0/50/65533": 1, - "0/50/65528": [1], - "0/50/65529": [0], - "0/50/65531": [65532, 65533, 65528, 65529, 65531], - "0/53/0": null, - "0/53/1": null, - "0/53/2": null, - "0/53/3": null, - "0/53/4": null, - "0/53/5": null, - "0/53/6": 0, - "0/53/7": [], - "0/53/8": [], - "0/53/9": null, - "0/53/10": null, - "0/53/11": null, - "0/53/12": null, - "0/53/13": null, - "0/53/14": 0, - "0/53/15": 0, - "0/53/16": 0, - "0/53/17": 0, - "0/53/18": 0, - "0/53/19": 0, - "0/53/20": 0, - "0/53/21": 0, - "0/53/22": 0, - "0/53/23": 0, - "0/53/24": 0, - "0/53/25": 0, - "0/53/26": 0, - "0/53/27": 0, - "0/53/28": 0, - "0/53/29": 0, - "0/53/30": 0, - "0/53/31": 0, - "0/53/32": 0, - "0/53/33": 0, - "0/53/34": 0, - "0/53/35": 0, - "0/53/36": 0, - "0/53/37": 0, - "0/53/38": 0, - "0/53/39": 0, - "0/53/40": 0, - "0/53/41": 0, - "0/53/42": 0, - "0/53/43": 0, - "0/53/44": 0, - "0/53/45": 0, - "0/53/46": 0, - "0/53/47": 0, - "0/53/48": 0, - "0/53/49": 0, - "0/53/50": 0, - "0/53/51": 0, - "0/53/52": 0, - "0/53/53": 0, - "0/53/54": 0, - "0/53/55": 0, - "0/53/56": null, - "0/53/57": null, - "0/53/58": null, - "0/53/59": null, - "0/53/60": null, - "0/53/61": null, - "0/53/62": [], - "0/53/65532": 15, - "0/53/65533": 3, - "0/53/65528": [], - "0/53/65529": [0], - "0/53/65531": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, - 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, - 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, - 57, 58, 59, 60, 61, 62, 65532, 65533, 65528, 65529, 65531 - ], - "0/62/0": [ - { - "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRgxgkBwEkCAEwCUEEHdpyQAr0R0cO3BfrovHCvZNk8r6IOcBOwpAmzC/qBAyxoQW5PIlsMbbFj0FclS7LRBBTiQ756brkIRMV0yR5fjcKNQEoARgkAgE2AwQCBAEYMAQUbOnFWbN3ecrHrsgoh4u4YeaUOCgwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0Bi3zJw9hAYJklIi1MpGzpdgj2WgWEJ2gFtiTYCe7F5ltYdxkBeNiBIAopa6okml8rNuo8dkVM09xpmA4m0qANKGA==", - "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", - "254": 1 - } - ], - "0/62/1": [ - { - "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", - "2": 4939, - "3": 2, - "4": 131, - "5": "ha-freebox", - "254": 1 - } - ], - "0/62/2": 16, - "0/62/3": 1, - "0/62/4": [ - "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y" - ], - "0/62/5": 1, - "0/62/65532": 0, - "0/62/65533": 2, - "0/62/65528": [1, 3, 5, 8, 14], - "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], - "0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], - "0/65/0": [], - "0/65/65532": 0, - "0/65/65533": 1, - "0/65/65528": [], - "0/65/65529": [], - "0/65/65531": [0, 65532, 65533, 65528, 65529, 65531], - "1/3/0": 0, - "1/3/1": 2, - "1/3/65532": 0, - "1/3/65533": 5, - "1/3/65528": [], - "1/3/65529": [0, 64], - "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], - "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, 65532, 65533, 65528, 65529, 65531], - "1/29/0": [ - { - "0": 769, - "1": 4 - } - ], - "1/29/1": [3, 4, 29, 513, 516], - "1/29/2": [3], - "1/29/3": [], - "1/29/65532": 0, - "1/29/65533": 3, - "1/29/65528": [], - "1/29/65529": [], - "1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], - "1/513/0": 0, - "1/513/3": 700, - "1/513/4": 3000, - "1/513/5": 1600, - "1/513/6": 3200, - "1/513/16": 0, - "1/513/17": 2600, - "1/513/18": 2000, - "1/513/21": 700, - "1/513/22": 3000, - "1/513/23": 1600, - "1/513/24": 3200, - "1/513/25": 25, - "1/513/27": 4, - "1/513/28": 1, - "1/513/72": [ - { - "0": 1, - "1": 1, - "2": 1 - }, - { - "0": 2, - "1": 1, - "2": 1 - }, - { - "0": 3, - "1": 1, - "2": 2 - }, - { - "0": 4, - "1": 1, - "2": 2 - }, - { - "0": 5, - "1": 1, - "2": 2 - }, - { - "0": 254, - "1": 1, - "2": 2 - } - ], - "1/513/73": [ - { - "0": 4, - "1": 1, - "2": 2 - }, - { - "0": 3, - "1": 1, - "2": 2 - } - ], - "1/513/74": 5, - "1/513/78": null, - "1/513/80": [ - { - "0": "AQ==", - "1": 1, - "3": 2500, - "4": 2100, - "5": true - }, - { - "0": "Ag==", - "1": 2, - "3": 2600, - "4": 2000, - "5": true - } - ], - "1/513/82": 0, - "1/513/83": 5, - "1/513/84": [], - "1/513/85": null, - "1/513/86": null, - "1/513/65532": 419, - "1/513/65533": 8, - "1/513/65528": [2, 253], - "1/513/65529": [0, 6, 7, 8, 254], - "1/513/65531": [ - 0, 3, 4, 5, 6, 16, 17, 18, 21, 22, 23, 24, 25, 27, 28, 72, 73, 74, 78, 80, - 82, 83, 84, 85, 86, 65532, 65533, 65528, 65529, 65531 - ], - "1/516/0": 0, - "1/516/1": 0, - "1/516/65532": 0, - "1/516/65533": 2, - "1/516/65528": [], - "1/516/65529": [], - "1/516/65531": [0, 1, 65532, 65533, 65528, 65529, 65531] - }, - "attribute_subscriptions": [] -} From d439121e091ef868e8e3a83c585eaf45470b0d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 22:42:42 +0000 Subject: [PATCH 28/76] Fix example value case for preset in services.yaml --- homeassistant/components/matter/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/matter/services.yaml b/homeassistant/components/matter/services.yaml index a052e505213e32..f584ddbb9bc3e1 100644 --- a/homeassistant/components/matter/services.yaml +++ b/homeassistant/components/matter/services.yaml @@ -7,7 +7,7 @@ set_presets: fields: preset: required: true - example: "away" + example: "Away" selector: text: water_heater_boost: From 20c6021c41b83b953362f64b93928bd080ee1e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Jan 2026 23:27:12 +0000 Subject: [PATCH 29/76] Remove unused state value from vacuum cleaner mock snapshot --- tests/components/matter/snapshots/test_select.ambr | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index e2376273d2f554..443a34450e46cf 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -4413,8 +4413,6 @@ 'last_updated': , 'state': 'Quick', }) - 'state': 'Celsius', - }) # --- # name: test_selects[vacuum_cleaner][select.mock_vacuum_clean_mode-entry] EntityRegistryEntrySnapshot({ From 8533130c50369e37ea72f2a6ee0bc53ebc4e126b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 22:02:47 +0100 Subject: [PATCH 30/76] Update homeassistant/components/matter/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/matter/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 9584e521fd254e..9750a0e8feddd9 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -626,7 +626,7 @@ "name": "Open commissioning window" }, "set_presets": { - "description": "Set the preset for a Matter thermostat.", + "description": "Sets the preset for a Matter thermostat.", "fields": { "preset": { "description": "The preset mode to set.", From 011d03e5c2664f899035c98d906b958f05633760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 22:14:54 +0000 Subject: [PATCH 31/76] Update active preset mode handling in MatterClimate class --- homeassistant/components/matter/climate.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 64960926a70328..6205118c81efc7 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -312,6 +312,17 @@ def _update_from_device(self) -> None: self._preset_handle_by_name[name] = preset.presetHandle self._attr_preset_modes = presets + # Update active preset mode + if active_preset_handle := self.get_matter_attribute_value( + clusters.Thermostat.Attributes.ActivePresetHandle + ): + for preset_name, handle in self._preset_handle_by_name.items(): + if handle == active_preset_handle: + self._attr_preset_mode = preset_name + break + else: + self._attr_preset_mode = None + self._attr_current_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.LocalTemperature ) From 3d29a236e854f7a492d04d10b55f764d627929f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 22:24:03 +0000 Subject: [PATCH 32/76] Refactor MatterClimate to update current temperature and humidity attributes correctly --- homeassistant/components/matter/climate.py | 33 +++++++++------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 6205118c81efc7..5ce7148bcfc982 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -296,6 +296,20 @@ 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.matter_presets_types = self.get_matter_attribute_value( clusters.Thermostat.Attributes.PresetTypes ) @@ -323,25 +337,6 @@ def _update_from_device(self) -> None: else: self._attr_preset_mode = None - self._attr_current_temperature = self._get_temperature_in_degrees( - clusters.Thermostat.Attributes.LocalTemperature - ) - - 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 - ) - 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 From 4f9aa455a217bcef3a21835715f2a6b9b060804e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 22:31:13 +0000 Subject: [PATCH 33/76] Add logging for preset mode lookup failures and improve preset name handling --- homeassistant/components/matter/climate.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 5ce7148bcfc982..a3b94bb8401880 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from enum import IntEnum +import logging from typing import Any from chip.clusters import Objects as clusters @@ -32,6 +33,8 @@ from .helpers import get_matter from .models import MatterDiscoverySchema +_LOGGER = logging.getLogger(__name__) + HUMIDITY_SCALING_FACTOR = 100 TEMPERATURE_SCALING_FACTOR = 100 HVAC_SYSTEM_MODE_MAP = { @@ -256,6 +259,12 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: preset_handle = self._preset_handle_by_name.get(preset_mode) if preset_handle is None: + _LOGGER.debug( + "Preset lookup failed. Requested: '%s', Available: %s, Dictionary keys: %s", + preset_mode, + self.preset_modes, + list(self._preset_handle_by_name.keys()), + ) raise ValueError( f"Preset mode '{preset_mode}' not found. " f"Available presets: {self.preset_modes}" @@ -321,7 +330,7 @@ def _update_from_device(self) -> None: presets = [] if self.matter_presets: for i, preset in enumerate(self.matter_presets, start=1): - name = preset.name or f"Preset{i}" + name = (preset.name.strip() if preset.name else None) or f"Preset{i}" presets.append(name) self._preset_handle_by_name[name] = preset.presetHandle self._attr_preset_modes = presets From 7bd496d15fe1c8943495e9ce5f5f606878755cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 22:44:07 +0000 Subject: [PATCH 34/76] Add debug logging for active preset handle and update preset mode tests --- homeassistant/components/matter/climate.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index a3b94bb8401880..6d68917d3ead00 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -339,6 +339,21 @@ def _update_from_device(self) -> None: if active_preset_handle := self.get_matter_attribute_value( clusters.Thermostat.Attributes.ActivePresetHandle ): + # Debug: log the type and value of active_preset_handle + _LOGGER.debug( + "Active preset handle - type: %s, repr: %s, value: %s", + type(active_preset_handle).__name__, + repr(active_preset_handle), + active_preset_handle, + ) + # Debug: log all stored preset handles + _LOGGER.debug( + "Stored preset handles: %s", + { + name: (type(h).__name__, repr(h)) + for name, h in self._preset_handle_by_name.items() + }, + ) for preset_name, handle in self._preset_handle_by_name.items(): if handle == active_preset_handle: self._attr_preset_mode = preset_name From 44688d13abede003866370eea5fe9cf98cb4aec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 22:51:01 +0000 Subject: [PATCH 35/76] Add debug logging for ActivePresetHandle updates and enhance preset mode tests --- homeassistant/components/matter/climate.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 6d68917d3ead00..eeaee65a83cca5 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -303,6 +303,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @callback def _update_from_device(self) -> None: """Update from device.""" + _LOGGER.debug("_update_from_device called") self._calculate_features() self._attr_current_temperature = self._get_temperature_in_degrees( @@ -325,6 +326,7 @@ def _update_from_device(self) -> None: self.matter_presets = self.get_matter_attribute_value( clusters.Thermostat.Attributes.Presets ) + _LOGGER.debug("Matter presets: %s", self.matter_presets) # Build preset mapping and list self._preset_handle_by_name.clear() presets = [] @@ -336,9 +338,17 @@ def _update_from_device(self) -> None: self._attr_preset_modes = presets # Update active preset mode - if active_preset_handle := self.get_matter_attribute_value( + active_preset_handle = self.get_matter_attribute_value( clusters.Thermostat.Attributes.ActivePresetHandle - ): + ) + _LOGGER.debug( + "ActivePresetHandle raw value - type: %s, repr: %s, value: %s, bool: %s", + type(active_preset_handle).__name__, + repr(active_preset_handle), + active_preset_handle, + bool(active_preset_handle), + ) + if active_preset_handle: # Debug: log the type and value of active_preset_handle _LOGGER.debug( "Active preset handle - type: %s, repr: %s, value: %s", From efdd279913af3221e7add721541d91e10f61a9dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 22:58:02 +0000 Subject: [PATCH 36/76] Update preset mode handling in MatterClimate and remove debug logs --- homeassistant/components/matter/climate.py | 33 ++++------------------ 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index eeaee65a83cca5..c6272ba37da98e 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -276,6 +276,9 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: presetHandle=preset_handle ) ) + # Optimistically update the preset mode since we just set it + self._attr_preset_mode = preset_mode + self.async_write_ha_state() self._update_from_device() except MatterError as ex: raise ValueError(f"Error setting preset mode {preset_mode}: {ex}") from ex @@ -303,7 +306,6 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @callback def _update_from_device(self) -> None: """Update from device.""" - _LOGGER.debug("_update_from_device called") self._calculate_features() self._attr_current_temperature = self._get_temperature_in_degrees( @@ -326,7 +328,6 @@ def _update_from_device(self) -> None: self.matter_presets = self.get_matter_attribute_value( clusters.Thermostat.Attributes.Presets ) - _LOGGER.debug("Matter presets: %s", self.matter_presets) # Build preset mapping and list self._preset_handle_by_name.clear() presets = [] @@ -338,32 +339,9 @@ def _update_from_device(self) -> None: self._attr_preset_modes = presets # Update active preset mode - active_preset_handle = self.get_matter_attribute_value( + if active_preset_handle := self.get_matter_attribute_value( clusters.Thermostat.Attributes.ActivePresetHandle - ) - _LOGGER.debug( - "ActivePresetHandle raw value - type: %s, repr: %s, value: %s, bool: %s", - type(active_preset_handle).__name__, - repr(active_preset_handle), - active_preset_handle, - bool(active_preset_handle), - ) - if active_preset_handle: - # Debug: log the type and value of active_preset_handle - _LOGGER.debug( - "Active preset handle - type: %s, repr: %s, value: %s", - type(active_preset_handle).__name__, - repr(active_preset_handle), - active_preset_handle, - ) - # Debug: log all stored preset handles - _LOGGER.debug( - "Stored preset handles: %s", - { - name: (type(h).__name__, repr(h)) - for name, h in self._preset_handle_by_name.items() - }, - ) + ): for preset_name, handle in self._preset_handle_by_name.items(): if handle == active_preset_handle: self._attr_preset_mode = preset_name @@ -538,6 +516,7 @@ def _get_temperature_in_degrees( 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, From 208b4bc8d9637cb9b7197e21ac19f9da536fc37c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 23:00:58 +0000 Subject: [PATCH 37/76] Add tests to verify optimistic updates of preset_mode in Eve Thermo v5 --- tests/components/matter/test_climate.py | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 68ad82437cb79c..7a9f4809246ade 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -479,6 +479,10 @@ async def test_eve_thermo_v5_presets( presetHandle=preset_by_name["Home"] ), ) + # Verify preset_mode is optimistically updated + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "Home" matter_client.send_device_command.reset_mock() # test set_preset_mode with "Away" preset @@ -499,6 +503,10 @@ async def test_eve_thermo_v5_presets( presetHandle=preset_by_name["Away"] ), ) + # Verify preset_mode is optimistically updated + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "Away" matter_client.send_device_command.reset_mock() # test set_preset_mode with "Eco" preset @@ -539,3 +547,41 @@ async def test_eve_thermo_v5_presets( # Ensure no command was sent for invalid preset assert matter_client.send_device_command.call_count == 0 + # Test that preset_mode is updated when ActivePresetHandle is set from device + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + preset_by_name["Home"], + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "Home" + + # Test that preset_mode is updated when ActivePresetHandle changes to different preset + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + preset_by_name["Away"], + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "Away" + + # Test that preset_mode is None when ActivePresetHandle is cleared + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + None, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] is None From 38b7ba2f2e81ffdc936817cc555241eed793a77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 23:14:12 +0000 Subject: [PATCH 38/76] Update Eve Thermo v5 JSON fixture to set value for 1/513/78 --- tests/components/matter/fixtures/nodes/eve_thermo_v5.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/matter/fixtures/nodes/eve_thermo_v5.json b/tests/components/matter/fixtures/nodes/eve_thermo_v5.json index 1d3c4f018fec79..01923529ed9598 100644 --- a/tests/components/matter/fixtures/nodes/eve_thermo_v5.json +++ b/tests/components/matter/fixtures/nodes/eve_thermo_v5.json @@ -501,7 +501,7 @@ } ], "1/513/74": 8, - "1/513/78": null, + "1/513/78": "AQ==", "1/513/80": [ { "0": "AQ==", From 8c5ac947ad5f95e63dd4483d780644405da9c0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 23:14:27 +0000 Subject: [PATCH 39/76] Update preset_mode assertion in test_eve_thermo_v5_presets to check for 'Home' --- tests/components/matter/test_climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 7a9f4809246ade..f0e5f71389a531 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -452,7 +452,7 @@ async def test_eve_thermo_v5_presets( "GoingToSleep", "Eco", ] - assert state.attributes["preset_mode"] is None + assert state.attributes["preset_mode"] == "Home" # Get presets from the node for dynamic testing presets_attribute = matter_node.endpoints[1].get_attribute_value( From 9d68e3078667e5561930155b45c0cdf954a1feac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 14 Jan 2026 23:35:09 +0000 Subject: [PATCH 40/76] Initialize preset handle mapping in MatterClimate constructor --- homeassistant/components/matter/climate.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index c6272ba37da98e..da0a89ca813fc5 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -199,13 +199,19 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF matter_presets: list[clusters.Thermostat.Structs.PresetStruct] | None = None - _preset_handle_by_name: dict[str, bytes] = {} _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._preset_handle_by_name: dict[str, bytes] = {} + super().__init__(*args, **kwargs) + async def async_get_presets(self) -> None: """Get presets.""" # return self._attr_preset_modes @@ -259,12 +265,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: preset_handle = self._preset_handle_by_name.get(preset_mode) if preset_handle is None: - _LOGGER.debug( - "Preset lookup failed. Requested: '%s', Available: %s, Dictionary keys: %s", - preset_mode, - self.preset_modes, - list(self._preset_handle_by_name.keys()), - ) raise ValueError( f"Preset mode '{preset_mode}' not found. " f"Available presets: {self.preset_modes}" @@ -336,6 +336,7 @@ def _update_from_device(self) -> None: name = (preset.name.strip() if preset.name else None) or f"Preset{i}" presets.append(name) self._preset_handle_by_name[name] = preset.presetHandle + self._attr_preset_modes = presets # Update active preset mode From 83ca1fca4724d348803e3b1b3d0182f9f9905b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Thu, 15 Jan 2026 09:07:23 +0000 Subject: [PATCH 41/76] Update preset_mode in test_climate snapshot to 'Home' --- tests/components/matter/snapshots/test_climate.ambr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 8b49797476da2d..86ac07e54d3981 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -317,7 +317,7 @@ ]), 'max_temp': 30.0, 'min_temp': 10.0, - 'preset_mode': None, + 'preset_mode': 'Home', 'preset_modes': list([ 'Home', 'Away', From 2d72311e35cbef6054f245fc2cddfd2dc6f03ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Thu, 15 Jan 2026 11:02:37 +0000 Subject: [PATCH 42/76] Enhance Matter climate presets mapping and handling for improved compatibility --- homeassistant/components/matter/climate.py | 44 +++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index da0a89ca813fc5..3d2c8f9d99e9d6 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -18,6 +18,10 @@ ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + PRESET_AWAY, + PRESET_ECO, + PRESET_HOME, + PRESET_SLEEP, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -46,6 +50,33 @@ HVACMode.FAN_ONLY: 7, } +# Map of known Matter preset names that have HA standard preset equivalents +# Maps both Matter spec standard names and custom device-provided names to HA presets +KNOWN_PRESETS: dict[str, str] = { + # PresetScenarioEnum standard names (from Matter spec) + "occupied": PRESET_HOME, + "unoccupied": PRESET_AWAY, + "sleep": PRESET_SLEEP, + # Custom preset names (device-provided, may vary by manufacturer) + "Home": PRESET_HOME, + "Away": PRESET_AWAY, + "Eco": PRESET_ECO, + "Wake": "wake", + "Vacation": "vacation", + "GoingToSleep": "going_to_sleep", +} + +# Map of PresetScenarioEnum values to preset names from Matter spec +PRESET_SCENARIO_MAP: dict[int, str] = { + clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied: "occupied", + clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied: "unoccupied", + clusters.Thermostat.Enums.PresetScenarioEnum.kSleep: "sleep", + clusters.Thermostat.Enums.PresetScenarioEnum.kWake: "wake", + clusters.Thermostat.Enums.PresetScenarioEnum.kVacation: "vacation", + clusters.Thermostat.Enums.PresetScenarioEnum.kGoingToSleep: "going_to_sleep", + clusters.Thermostat.Enums.PresetScenarioEnum.kUserDefined: "user_defined", +} + 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. @@ -333,7 +364,18 @@ def _update_from_device(self) -> None: presets = [] if self.matter_presets: for i, preset in enumerate(self.matter_presets, start=1): - name = (preset.name.strip() if preset.name else None) or f"Preset{i}" + # Use preset name if available, otherwise try to map from scenarioType + name = None + if preset.name and preset.name.strip(): + name = preset.name.strip() + elif hasattr(preset, "scenarioType") and preset.scenarioType: + # Try to get the standard name from PresetScenarioEnum + name = PRESET_SCENARIO_MAP.get(preset.scenarioType) + + # Fall back to generic name if no name was determined + if not name: + name = f"Preset{i}" + presets.append(name) self._preset_handle_by_name[name] = preset.presetHandle From 67aab37350dd3a0f02ae6b5277a4c9a4570bb81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Thu, 15 Jan 2026 11:04:32 +0000 Subject: [PATCH 43/76] Remove set_presets service and related strings from Matter integration --- homeassistant/components/matter/icons.json | 3 --- homeassistant/components/matter/services.yaml | 12 ------------ homeassistant/components/matter/strings.json | 10 ---------- 3 files changed, 25 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 4bc1a6a021f802..ec96875c06b446 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -174,9 +174,6 @@ } }, "services": { - "set_presets": { - "service": "mdi:home-thermometer" - }, "water_heater_boost": { "service": "mdi:water-boiler" } diff --git a/homeassistant/components/matter/services.yaml b/homeassistant/components/matter/services.yaml index f584ddbb9bc3e1..a0f8b9d7862acd 100644 --- a/homeassistant/components/matter/services.yaml +++ b/homeassistant/components/matter/services.yaml @@ -1,15 +1,3 @@ -set_presets: - target: - entity: - domain: climate - supported_features: - - climate.ClimateEntityFeature.PRESET_MODE - fields: - preset: - required: true - example: "Away" - selector: - text: water_heater_boost: target: entity: diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 9750a0e8feddd9..1b7910fbd47e5f 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -625,16 +625,6 @@ }, "name": "Open commissioning window" }, - "set_presets": { - "description": "Sets the preset for a Matter thermostat.", - "fields": { - "preset": { - "description": "The preset mode to set.", - "name": "Preset" - } - }, - "name": "Set thermostat preset" - }, "water_heater_boost": { "description": "Enables water heater boost for a specific duration.", "fields": { From 3b3e1437dc9774f38b796ea4c73a8727e82df6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Thu, 15 Jan 2026 11:58:35 +0000 Subject: [PATCH 44/76] Add methods to update presets and HVAC mode in MatterClimate --- homeassistant/components/matter/climate.py | 34 +++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 3d2c8f9d99e9d6..84ed54c31c4277 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -229,6 +229,9 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF + matter_presets_types: list[clusters.Thermostat.Structs.PresetTypeStruct] | None = ( + None + ) matter_presets: list[clusters.Thermostat.Structs.PresetStruct] | None = None _attr_preset_mode: str | None = None _attr_preset_modes: list[str] | None = None @@ -353,6 +356,26 @@ def _update_from_device(self) -> 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 + feature_map = int( + self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap) + ) + if not (feature_map & ThermostatFeature.kPresets): + # Device doesn't support presets, skip preset update + self._preset_handle_by_name.clear() + self._attr_preset_modes = [] + self._attr_preset_mode = None + return + self.matter_presets_types = self.get_matter_attribute_value( clusters.Thermostat.Attributes.PresetTypes ) @@ -392,6 +415,9 @@ def _update_from_device(self) -> None: else: self._attr_preset_mode = 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 @@ -443,7 +469,10 @@ def _update_from_device(self) -> None: self._attr_hvac_action = HVACAction.FAN else: self._attr_hvac_action = HVACAction.OFF - # update target temperature high/low + + @callback + def _update_target_temperatures(self) -> None: + """Update target temperature or temperature range.""" supports_range = ( self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -469,6 +498,9 @@ def _update_from_device(self) -> None: 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 From 87072eedb0b77585d0024f6a82963220528b8223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 17 Jan 2026 00:29:56 +0100 Subject: [PATCH 45/76] Update homeassistant/components/matter/const.py Co-authored-by: TheJulianJES --- homeassistant/components/matter/const.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index 10c4d153a3f5f9..ab4cf723563602 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -16,7 +16,3 @@ ID_TYPE_SERIAL = "serial" FEATUREMAP_ATTRIBUTE_ID = 65532 - -ATTR_PRESETS: Final = "presets" - -SERVICE_SET_PRESETS = "service_set_presets" From 4cd5940c58e0fb7dd0767b66ae3aaffea407c40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 23 Jan 2026 11:19:08 +0100 Subject: [PATCH 46/76] Remove unused import from const.py --- homeassistant/components/matter/const.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index ab4cf723563602..8018d5e09edf7e 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -1,7 +1,6 @@ """Constants for the Matter integration.""" import logging -from typing import Final ADDON_SLUG = "core_matter_server" From 0e1efd9411e0b12dd3ece963acb01c8fd38d1615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 18:48:33 +0100 Subject: [PATCH 47/76] Refactor Matter climate presets to use slugified names and update related tests --- homeassistant/components/matter/climate.py | 40 +++++++------------ homeassistant/components/matter/strings.json | 15 ++++++- .../matter/snapshots/test_climate.ambr | 38 +++++++++--------- tests/components/matter/test_climate.py | 37 ++++++++--------- 4 files changed, 66 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 84ed54c31c4277..7a58abbee4c5f8 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -60,23 +60,13 @@ # Custom preset names (device-provided, may vary by manufacturer) "Home": PRESET_HOME, "Away": PRESET_AWAY, + "Sleep": PRESET_SLEEP, "Eco": PRESET_ECO, "Wake": "wake", "Vacation": "vacation", "GoingToSleep": "going_to_sleep", } -# Map of PresetScenarioEnum values to preset names from Matter spec -PRESET_SCENARIO_MAP: dict[int, str] = { - clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied: "occupied", - clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied: "unoccupied", - clusters.Thermostat.Enums.PresetScenarioEnum.kSleep: "sleep", - clusters.Thermostat.Enums.PresetScenarioEnum.kWake: "wake", - clusters.Thermostat.Enums.PresetScenarioEnum.kVacation: "vacation", - clusters.Thermostat.Enums.PresetScenarioEnum.kGoingToSleep: "going_to_sleep", - clusters.Thermostat.Enums.PresetScenarioEnum.kUserDefined: "user_defined", -} - 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. @@ -246,10 +236,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._preset_handle_by_name: dict[str, bytes] = {} super().__init__(*args, **kwargs) - async def async_get_presets(self) -> None: - """Get presets.""" - # return self._attr_preset_modes - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) @@ -387,20 +373,22 @@ def _update_presets(self) -> None: presets = [] if self.matter_presets: for i, preset in enumerate(self.matter_presets, start=1): - # Use preset name if available, otherwise try to map from scenarioType - name = None - if preset.name and preset.name.strip(): - name = preset.name.strip() - elif hasattr(preset, "scenarioType") and preset.scenarioType: - # Try to get the standard name from PresetScenarioEnum - name = PRESET_SCENARIO_MAP.get(preset.scenarioType) + # Get device preset name from preset.name if available + device_preset_name = None + if preset.name and (name := preset.name.strip()): + device_preset_name = name # Fall back to generic name if no name was determined - if not name: - name = f"Preset{i}" + if not device_preset_name: + device_preset_name = f"Preset{i}" + + # Map to HA translation key (slugified) version if known, otherwise slugify + ha_preset_name = KNOWN_PRESETS.get( + device_preset_name, device_preset_name.lower().replace(" ", "_") + ) - presets.append(name) - self._preset_handle_by_name[name] = preset.presetHandle + presets.append(ha_preset_name) + self._preset_handle_by_name[ha_preset_name] = preset.presetHandle self._attr_preset_modes = presets diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 1b7910fbd47e5f..2da9d37fa889ac 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -139,7 +139,20 @@ }, "climate": { "thermostat": { - "name": "Thermostat" + "name": "Thermostat", + "state_attributes": { + "preset_mode": { + "state": { + "away": "[%key:common::state::not_home%]", + "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", + "going_to_sleep": "Going to sleep", + "home": "[%key:common::state::home%]", + "sleep": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", + "vacation": "Vacation", + "wake": "Wake" + } + } + } } }, "cover": { diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 86ac07e54d3981..66f05f76c99bed 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -268,13 +268,13 @@ 'max_temp': 30.0, 'min_temp': 10.0, 'preset_modes': list([ - 'Home', - 'Away', - 'Sleep', - 'Wake', - 'Vacation', - 'GoingToSleep', - 'Eco', + 'home', + 'away', + 'sleep', + 'wake', + 'vacation', + 'going_to_sleep', + 'eco', ]), }), 'config_entry_id': , @@ -317,15 +317,15 @@ ]), 'max_temp': 30.0, 'min_temp': 10.0, - 'preset_mode': 'Home', + 'preset_mode': 'home', 'preset_modes': list([ - 'Home', - 'Away', - 'Sleep', - 'Wake', - 'Vacation', - 'GoingToSleep', - 'Eco', + 'home', + 'away', + 'sleep', + 'wake', + 'vacation', + 'going_to_sleep', + 'eco', ]), 'supported_features': , 'temperature': 17.5, @@ -423,8 +423,8 @@ 'max_temp': 32.0, 'min_temp': 7.0, 'preset_modes': list([ - 'Preset1', - 'Preset2', + 'preset1', + 'preset2', ]), }), 'config_entry_id': , @@ -472,8 +472,8 @@ 'min_temp': 7.0, 'preset_mode': None, 'preset_modes': list([ - 'Preset1', - 'Preset2', + 'preset1', + 'preset2', ]), 'supported_features': , 'target_temp_high': 26.0, diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index f0e5f71389a531..8d210c683631b6 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -443,16 +443,17 @@ async def test_eve_thermo_v5_presets( assert state.attributes["supported_features"] & mask == mask # Test preset modes parsed correctly from Eve Thermo v5 + # Should be slugified/translation key versions assert state.attributes["preset_modes"] == [ - "Home", - "Away", - "Sleep", - "Wake", - "Vacation", - "GoingToSleep", - "Eco", + "home", + "away", + "sleep", + "wake", + "vacation", + "going_to_sleep", + "eco", ] - assert state.attributes["preset_mode"] == "Home" + assert state.attributes["preset_mode"] == "home" # Get presets from the node for dynamic testing presets_attribute = matter_node.endpoints[1].get_attribute_value( @@ -461,13 +462,13 @@ async def test_eve_thermo_v5_presets( ) preset_by_name = {preset.name: preset.presetHandle for preset in presets_attribute} - # test set_preset_mode with "Home" preset + # test set_preset_mode with "home" preset (slugified) await hass.services.async_call( "climate", "set_preset_mode", { "entity_id": entity_id, - "preset_mode": "Home", + "preset_mode": "home", }, blocking=True, ) @@ -482,16 +483,16 @@ async def test_eve_thermo_v5_presets( # Verify preset_mode is optimistically updated state = hass.states.get(entity_id) assert state - assert state.attributes["preset_mode"] == "Home" + assert state.attributes["preset_mode"] == "home" matter_client.send_device_command.reset_mock() - # test set_preset_mode with "Away" preset + # test set_preset_mode with "away" preset (slugified) await hass.services.async_call( "climate", "set_preset_mode", { "entity_id": entity_id, - "preset_mode": "Away", + "preset_mode": "away", }, blocking=True, ) @@ -506,16 +507,16 @@ async def test_eve_thermo_v5_presets( # Verify preset_mode is optimistically updated state = hass.states.get(entity_id) assert state - assert state.attributes["preset_mode"] == "Away" + assert state.attributes["preset_mode"] == "away" matter_client.send_device_command.reset_mock() - # test set_preset_mode with "Eco" preset + # test set_preset_mode with "eco" preset (slugified) await hass.services.async_call( "climate", "set_preset_mode", { "entity_id": entity_id, - "preset_mode": "Eco", + "preset_mode": "eco", }, blocking=True, ) @@ -558,7 +559,7 @@ async def test_eve_thermo_v5_presets( await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state - assert state.attributes["preset_mode"] == "Home" + assert state.attributes["preset_mode"] == "home" # Test that preset_mode is updated when ActivePresetHandle changes to different preset set_node_attribute( @@ -571,7 +572,7 @@ async def test_eve_thermo_v5_presets( await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state - assert state.attributes["preset_mode"] == "Away" + assert state.attributes["preset_mode"] == "away" # Test that preset_mode is None when ActivePresetHandle is cleared set_node_attribute( From e8273acd07f942b5af9946f5cedb0ad40a4044fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 18:57:22 +0100 Subject: [PATCH 48/76] Remove unused matter_presets_types attribute from MatterClimate class --- homeassistant/components/matter/climate.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 7a58abbee4c5f8..05d01b77e8e785 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -219,9 +219,6 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF - matter_presets_types: list[clusters.Thermostat.Structs.PresetTypeStruct] | None = ( - None - ) matter_presets: list[clusters.Thermostat.Structs.PresetStruct] | None = None _attr_preset_mode: str | None = None _attr_preset_modes: list[str] | None = None @@ -362,9 +359,6 @@ def _update_presets(self) -> None: self._attr_preset_mode = None return - self.matter_presets_types = self.get_matter_attribute_value( - clusters.Thermostat.Attributes.PresetTypes - ) self.matter_presets = self.get_matter_attribute_value( clusters.Thermostat.Attributes.Presets ) From 79c085475afc65547e5c095b21a6c51de255745e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 18:58:02 +0100 Subject: [PATCH 49/76] Remove unused logging import and related logger initialization in climate.py --- homeassistant/components/matter/climate.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 05d01b77e8e785..1422f957e89956 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from enum import IntEnum -import logging from typing import Any from chip.clusters import Objects as clusters @@ -37,8 +36,6 @@ from .helpers import get_matter from .models import MatterDiscoverySchema -_LOGGER = logging.getLogger(__name__) - HUMIDITY_SCALING_FACTOR = 100 TEMPERATURE_SCALING_FACTOR = 100 HVAC_SYSTEM_MODE_MAP = { @@ -183,7 +180,6 @@ } SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum -ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum ThermostatFeature = clusters.Thermostat.Bitmaps.Feature From d98cb4953296ccf4721f74bcbe245b950a31b936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 19:18:40 +0100 Subject: [PATCH 50/76] Refactor MatterClimate to remove error handling for preset mode setting and streamline command execution --- homeassistant/components/matter/climate.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 1422f957e89956..d586aaec4874db 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -8,7 +8,6 @@ from chip.clusters import Objects as clusters from matter_server.client.models import device_types -from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.climate import ( @@ -283,18 +282,15 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: f"Available presets: {self.preset_modes}" ) - try: - await self.send_device_command( - clusters.Thermostat.Commands.SetActivePresetRequest( - presetHandle=preset_handle - ) + await self.send_device_command( + clusters.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_handle ) - # Optimistically update the preset mode since we just set it - self._attr_preset_mode = preset_mode - self.async_write_ha_state() - self._update_from_device() - except MatterError as ex: - raise ValueError(f"Error setting preset mode {preset_mode}: {ex}") from ex + ) + # Optimistically update the preset mode since we just set it + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + self._update_from_device() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" From 8b5d45a3be6fddb1167b1d928fd5669764d824bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 19:19:57 +0100 Subject: [PATCH 51/76] Replace ValueError with HomeAssistantError for missing preset mode in MatterClimate --- homeassistant/components/matter/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index d586aaec4874db..77d01db6dc326c 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -29,6 +29,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription @@ -277,7 +278,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: preset_handle = self._preset_handle_by_name.get(preset_mode) if preset_handle is None: - raise ValueError( + raise HomeAssistantError( f"Preset mode '{preset_mode}' not found. " f"Available presets: {self.preset_modes}" ) From df67d7bc2ffa57d6a92e2369cdef5fd97480b54a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 19:22:09 +0100 Subject: [PATCH 52/76] Reset preset mode to None before updating in MatterClimate --- homeassistant/components/matter/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 77d01db6dc326c..39d90db66e971d 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -383,6 +383,7 @@ def _update_presets(self) -> None: if active_preset_handle := self.get_matter_attribute_value( clusters.Thermostat.Attributes.ActivePresetHandle ): + self._attr_preset_mode = None for preset_name, handle in self._preset_handle_by_name.items(): if handle == active_preset_handle: self._attr_preset_mode = preset_name From 37c0edd14c8e93fc64b6f1681cd58847a56cdcbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 19:23:21 +0100 Subject: [PATCH 53/76] Use slugify for device preset name mapping in MatterClimate --- homeassistant/components/matter/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 39d90db66e971d..8f66a5f3c2ae6e 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -31,6 +31,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import slugify from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -371,7 +372,7 @@ def _update_presets(self) -> None: # Map to HA translation key (slugified) version if known, otherwise slugify ha_preset_name = KNOWN_PRESETS.get( - device_preset_name, device_preset_name.lower().replace(" ", "_") + device_preset_name, slugify(device_preset_name) ) presets.append(ha_preset_name) From d37fdb169ed43f0b249b0c590f75f7305d86da1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 19:27:24 +0100 Subject: [PATCH 54/76] Update ActivePresetHandle attribute in MatterClimate to prevent stale state --- homeassistant/components/matter/climate.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 8f66a5f3c2ae6e..3f8f81dcbc4375 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -289,9 +289,13 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: presetHandle=preset_handle ) ) - # Optimistically update the preset mode since we just set it - self._attr_preset_mode = preset_mode - self.async_write_ha_state() + # Optimistically update the endpoint's ActivePresetHandle attribute + # to prevent _update_from_device() from reverting to stale device state + 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) self._update_from_device() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: From 56185468992225600e06c792f44664c237344268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 19:38:54 +0100 Subject: [PATCH 55/76] Update MatterClimate to ensure state is written after setting active preset and adjust test for preset attribute retrieval --- homeassistant/components/matter/climate.py | 5 ++--- tests/components/matter/test_climate.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 3f8f81dcbc4375..ceb3a77fcfe551 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -297,6 +297,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: ) self._endpoint.set_attribute_value(active_preset_path, preset_handle) self._update_from_device() + self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -385,16 +386,14 @@ def _update_presets(self) -> None: self._attr_preset_modes = presets # Update active preset mode + self._attr_preset_mode = None if active_preset_handle := self.get_matter_attribute_value( clusters.Thermostat.Attributes.ActivePresetHandle ): - self._attr_preset_mode = None for preset_name, handle in self._preset_handle_by_name.items(): if handle == active_preset_handle: self._attr_preset_mode = preset_name break - else: - self._attr_preset_mode = None @callback def _update_hvac_mode_and_action(self) -> None: diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 8d210c683631b6..0fe1fb84715f6e 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -457,7 +457,7 @@ async def test_eve_thermo_v5_presets( # Get presets from the node for dynamic testing presets_attribute = matter_node.endpoints[1].get_attribute_value( - clusters.Thermostat.Attributes.Presets.cluster_id, + 513, clusters.Thermostat.Attributes.Presets.attribute_id, ) preset_by_name = {preset.name: preset.presetHandle for preset in presets_attribute} From 6f2f6e2e679231ff6a30204307ca5b490ea45ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 21:45:04 +0100 Subject: [PATCH 56/76] Refactor MatterClimate preset handling to use standard names and set PRESET_NONE as default --- homeassistant/components/matter/climate.py | 17 +++++++------ .../matter/snapshots/test_climate.ambr | 22 ++++++++-------- tests/components/matter/test_climate.py | 25 +++++++++++-------- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index ceb3a77fcfe551..32ac7a0b66c384 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -19,6 +19,7 @@ PRESET_AWAY, PRESET_ECO, PRESET_HOME, + PRESET_NONE, PRESET_SLEEP, ClimateEntity, ClimateEntityDescription, @@ -31,7 +32,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import slugify from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -60,9 +60,6 @@ "Away": PRESET_AWAY, "Sleep": PRESET_SLEEP, "Eco": PRESET_ECO, - "Wake": "wake", - "Vacation": "vacation", - "GoingToSleep": "going_to_sleep", } SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = { @@ -355,7 +352,7 @@ def _update_presets(self) -> None: # Device doesn't support presets, skip preset update self._preset_handle_by_name.clear() self._attr_preset_modes = [] - self._attr_preset_mode = None + self._attr_preset_mode = PRESET_NONE return self.matter_presets = self.get_matter_attribute_value( @@ -375,9 +372,13 @@ def _update_presets(self) -> None: if not device_preset_name: device_preset_name = f"Preset{i}" - # Map to HA translation key (slugified) version if known, otherwise slugify + # Map to HA standard presets if known (home, away, sleep, eco). + # For custom device presets (Wake, Vacation, etc.), keep the original + # device name instead of slugifying it. This preserves proper capitalization + # and makes preset names more readable in the UI. + # Example: "Wake" stays as "Wake" instead of becoming "wake" ha_preset_name = KNOWN_PRESETS.get( - device_preset_name, slugify(device_preset_name) + device_preset_name, device_preset_name ) presets.append(ha_preset_name) @@ -386,7 +387,7 @@ def _update_presets(self) -> None: self._attr_preset_modes = presets # Update active preset mode - self._attr_preset_mode = None + self._attr_preset_mode = PRESET_NONE if active_preset_handle := self.get_matter_attribute_value( clusters.Thermostat.Attributes.ActivePresetHandle ): diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 3c31da0faf447b..6cdaab8e98c185 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -275,9 +275,9 @@ 'home', 'away', 'sleep', - 'wake', - 'vacation', - 'going_to_sleep', + 'Wake', + 'Vacation', + 'GoingToSleep', 'eco', ]), }), @@ -327,9 +327,9 @@ 'home', 'away', 'sleep', - 'wake', - 'vacation', - 'going_to_sleep', + 'Wake', + 'Vacation', + 'GoingToSleep', 'eco', ]), 'supported_features': , @@ -429,8 +429,8 @@ 'max_temp': 32.0, 'min_temp': 7.0, 'preset_modes': list([ - 'preset1', - 'preset2', + 'Preset1', + 'Preset2', ]), }), 'config_entry_id': , @@ -477,10 +477,10 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, - 'preset_mode': None, + 'preset_mode': 'none', 'preset_modes': list([ - 'preset1', - 'preset2', + 'Preset1', + 'Preset2', ]), 'supported_features': , 'target_temp_high': 26.0, diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 0fe1fb84715f6e..1d8c68d3e496ca 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -8,7 +8,12 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.climate import ClimateEntityFeature, HVACAction, HVACMode +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -443,14 +448,14 @@ async def test_eve_thermo_v5_presets( assert state.attributes["supported_features"] & mask == mask # Test preset modes parsed correctly from Eve Thermo v5 - # Should be slugified/translation key versions + # Should use HA standard presets for known ones, original names for others assert state.attributes["preset_modes"] == [ "home", "away", "sleep", - "wake", - "vacation", - "going_to_sleep", + "Wake", + "Vacation", + "GoingToSleep", "eco", ] assert state.attributes["preset_mode"] == "home" @@ -462,7 +467,7 @@ async def test_eve_thermo_v5_presets( ) preset_by_name = {preset.name: preset.presetHandle for preset in presets_attribute} - # test set_preset_mode with "home" preset (slugified) + # test set_preset_mode with "home" preset (HA standard) await hass.services.async_call( "climate", "set_preset_mode", @@ -486,7 +491,7 @@ async def test_eve_thermo_v5_presets( assert state.attributes["preset_mode"] == "home" matter_client.send_device_command.reset_mock() - # test set_preset_mode with "away" preset (slugified) + # test set_preset_mode with "away" preset (HA standard) await hass.services.async_call( "climate", "set_preset_mode", @@ -510,7 +515,7 @@ async def test_eve_thermo_v5_presets( assert state.attributes["preset_mode"] == "away" matter_client.send_device_command.reset_mock() - # test set_preset_mode with "eco" preset (slugified) + # test set_preset_mode with "eco" preset (HA standard) await hass.services.async_call( "climate", "set_preset_mode", @@ -574,7 +579,7 @@ async def test_eve_thermo_v5_presets( assert state assert state.attributes["preset_mode"] == "away" - # Test that preset_mode is None when ActivePresetHandle is cleared + # Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared set_node_attribute( matter_node, 1, @@ -585,4 +590,4 @@ async def test_eve_thermo_v5_presets( await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state - assert state.attributes["preset_mode"] is None + assert state.attributes["preset_mode"] == PRESET_NONE From a9173c614bfdf72e4e6564fefbf55838c74262ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 22:23:18 +0100 Subject: [PATCH 57/76] Refactor Matter preset handling to standardize names and improve translation mapping --- homeassistant/components/matter/climate.py | 49 +++++++------------ .../matter/snapshots/test_climate.ambr | 24 ++++----- tests/components/matter/test_climate.py | 12 ++--- 3 files changed, 37 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 32ac7a0b66c384..fe41beca239775 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -17,7 +17,6 @@ DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, PRESET_AWAY, - PRESET_ECO, PRESET_HOME, PRESET_NONE, PRESET_SLEEP, @@ -48,18 +47,16 @@ HVACMode.FAN_ONLY: 7, } -# Map of known Matter preset names that have HA standard preset equivalents -# Maps both Matter spec standard names and custom device-provided names to HA presets -KNOWN_PRESETS: dict[str, str] = { - # PresetScenarioEnum standard names (from Matter spec) - "occupied": PRESET_HOME, - "unoccupied": PRESET_AWAY, - "sleep": PRESET_SLEEP, - # Custom preset names (device-provided, may vary by manufacturer) - "Home": PRESET_HOME, - "Away": PRESET_AWAY, - "Sleep": PRESET_SLEEP, - "Eco": PRESET_ECO, +# 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]] = { @@ -363,23 +360,15 @@ def _update_presets(self) -> None: presets = [] if self.matter_presets: for i, preset in enumerate(self.matter_presets, start=1): - # Get device preset name from preset.name if available - device_preset_name = None - if preset.name and (name := preset.name.strip()): - device_preset_name = name - - # Fall back to generic name if no name was determined - if not device_preset_name: - device_preset_name = f"Preset{i}" - - # Map to HA standard presets if known (home, away, sleep, eco). - # For custom device presets (Wake, Vacation, etc.), keep the original - # device name instead of slugifying it. This preserves proper capitalization - # and makes preset names more readable in the UI. - # Example: "Wake" stays as "Wake" instead of becoming "wake" - ha_preset_name = KNOWN_PRESETS.get( - device_preset_name, device_preset_name - ) + # Map Matter PresetScenarioEnum to HA standard presets for translations + if preset.presetScenario in PRESET_SCENARIO_TO_HA_PRESET: + # Use the mapped preset name from the dictionary + ha_preset_name = PRESET_SCENARIO_TO_HA_PRESET[preset.presetScenario] + # For unmapped scenarios, use device-provided name + elif preset.name and (name := preset.name.strip()): + ha_preset_name = name + else: + ha_preset_name = f"Preset{i}" presets.append(ha_preset_name) self._preset_handle_by_name[ha_preset_name] = preset.presetHandle diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 6cdaab8e98c185..c5740eb137f8d0 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -275,10 +275,10 @@ 'home', 'away', 'sleep', - 'Wake', - 'Vacation', - 'GoingToSleep', - 'eco', + 'wake', + 'vacation', + 'going_to_sleep', + 'Eco', ]), }), 'config_entry_id': , @@ -327,10 +327,10 @@ 'home', 'away', 'sleep', - 'Wake', - 'Vacation', - 'GoingToSleep', - 'eco', + 'wake', + 'vacation', + 'going_to_sleep', + 'Eco', ]), 'supported_features': , 'temperature': 17.5, @@ -429,8 +429,8 @@ 'max_temp': 32.0, 'min_temp': 7.0, 'preset_modes': list([ - 'Preset1', - 'Preset2', + 'home', + 'away', ]), }), 'config_entry_id': , @@ -479,8 +479,8 @@ 'min_temp': 7.0, 'preset_mode': 'none', 'preset_modes': list([ - 'Preset1', - 'Preset2', + 'home', + 'away', ]), 'supported_features': , 'target_temp_high': 26.0, diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 1d8c68d3e496ca..d31f0ee1acdac7 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -453,10 +453,10 @@ async def test_eve_thermo_v5_presets( "home", "away", "sleep", - "Wake", - "Vacation", - "GoingToSleep", - "eco", + "wake", + "vacation", + "going_to_sleep", + "Eco", ] assert state.attributes["preset_mode"] == "home" @@ -515,13 +515,13 @@ async def test_eve_thermo_v5_presets( assert state.attributes["preset_mode"] == "away" matter_client.send_device_command.reset_mock() - # test set_preset_mode with "eco" preset (HA standard) + # test set_preset_mode with "eco" preset (custom, device-provided name) await hass.services.async_call( "climate", "set_preset_mode", { "entity_id": entity_id, - "preset_mode": "eco", + "preset_mode": "Eco", }, blocking=True, ) From 8a9a51e42df066abdfef61f448052ffcaf2c0cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 24 Jan 2026 22:41:23 +0100 Subject: [PATCH 58/76] Enhance error handling in test_eve_thermo_v5_presets to assert translation key and placeholders for invalid preset mode --- tests/components/matter/test_climate.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index d31f0ee1acdac7..936fa98350ffa7 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -538,9 +538,7 @@ async def test_eve_thermo_v5_presets( # test set_preset_mode with invalid preset mode # The climate platform validates preset modes before calling our method - with pytest.raises( - ServiceValidationError, match="Preset mode InvalidPreset is not valid" - ): + with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( "climate", "set_preset_mode", @@ -551,6 +549,12 @@ async def test_eve_thermo_v5_presets( blocking=True, ) + assert err.value.translation_key == "not_valid_preset_mode" + assert err.value.translation_placeholders == { + "mode": "InvalidPreset", + "modes": "home, away, sleep, wake, vacation, going_to_sleep, Eco", + } + # Ensure no command was sent for invalid preset assert matter_client.send_device_command.call_count == 0 # Test that preset_mode is updated when ActivePresetHandle is set from device From d913986908984c8e36e3381f7f40c67feb15d5b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 26 Jan 2026 14:44:45 +0000 Subject: [PATCH 59/76] Remove unused preset modes from climate state attributes in strings.json --- homeassistant/components/matter/strings.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 000013b3a1df39..4d230bb302b5ab 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -146,11 +146,7 @@ "state_attributes": { "preset_mode": { "state": { - "away": "[%key:common::state::not_home%]", - "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", "going_to_sleep": "Going to sleep", - "home": "[%key:common::state::home%]", - "sleep": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "vacation": "Vacation", "wake": "Wake" } From 6b3c535780c618aa04118b12ae9842d53da1f370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 26 Jan 2026 14:57:39 +0000 Subject: [PATCH 60/76] Fix active preset mode handling in MatterClimate to prevent stale values --- homeassistant/components/matter/climate.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index fe41beca239775..8ef0372e3154ae 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -376,14 +376,22 @@ def _update_presets(self) -> None: self._attr_preset_modes = presets # Update active preset mode - self._attr_preset_mode = PRESET_NONE - if active_preset_handle := self.get_matter_attribute_value( + active_preset_handle = self.get_matter_attribute_value( clusters.Thermostat.Attributes.ActivePresetHandle - ): + ) + if active_preset_handle is None: + # Explicitly no active preset selected on device + self._attr_preset_mode = PRESET_NONE + else: + self._attr_preset_mode = None for preset_name, handle in self._preset_handle_by_name.items(): if handle == active_preset_handle: self._attr_preset_mode = preset_name break + else: + # Device reports an active preset handle that we don't know + # Avoid reporting PRESET_NONE in that case to prevent stale values + self._attr_preset_mode = None @callback def _update_hvac_mode_and_action(self) -> None: From 23f72cda5f437871ae3a73bd08a23158a874ded0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 16 Feb 2026 13:26:43 +0000 Subject: [PATCH 61/76] Update supported_features and add preset_modes for Matter thermostats --- .../components/matter/snapshots/test_climate.ambr | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 16182e76126c2b..2cc1a6a137d087 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -240,7 +240,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, @@ -501,6 +501,10 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, + 'preset_modes': list([ + 'home', + 'away', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -526,7 +530,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, @@ -546,7 +550,12 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, - 'supported_features': , + 'preset_mode': 'none', + 'preset_modes': list([ + 'home', + 'away', + ]), + 'supported_features': , 'target_temp_high': 26.0, 'target_temp_low': 20.0, 'temperature': None, From 3f7ac5cb38195ab638bc578288b8a3c84213cdb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 16 Feb 2026 14:09:56 +0000 Subject: [PATCH 62/76] Add tests for HVAC and preset mode error handling in Matter climate entities --- tests/components/matter/test_climate.py | 82 ++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index e9e0c41cce7b8f..29c02cf356ed80 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -16,7 +16,7 @@ ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from .common import ( @@ -322,6 +322,35 @@ async def test_thermostat_service_calls( ) matter_client.write_attribute.reset_mock() + # test changing only target_temp_high when target_temp_low stays the same + set_node_attribute(matter_node, 1, 513, 18, 1000) + set_node_attribute(matter_node, 1, 513, 17, 2500) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["target_temp_high"] == 25 + assert state.attributes["target_temp_low"] == 10 + + await hass.services.async_call( + "climate", + "set_temperature", + { + "entity_id": "climate.longan_link_hvac", + "target_temp_low": 10, # Same as current + "target_temp_high": 28, # Different from current + }, + blocking=True, + ) + + # Only target_temp_high should be written since target_temp_low hasn't changed + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path="1/513/17", + value=2800, + ) + matter_client.write_attribute.reset_mock() + # test change HAVC mode to heat await hass.services.async_call( "climate", @@ -595,3 +624,54 @@ async def test_eve_thermo_v5_presets( state = hass.states.get(entity_id) assert state assert state.attributes["preset_mode"] == PRESET_NONE + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) +async def test_preset_mode_error_on_invalid_preset( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test preset mode error when calling entity method directly with invalid preset.""" + entity_id = "climate.eve_thermo_20ecd1701" + + # Get the entity object directly via component + component = hass.data.get("entity_components", {}).get("climate") + assert component is not None + + entity = component.get_entity(entity_id) + assert entity is not None + + # Test calling async_set_preset_mode directly with invalid preset + # This tests the HomeAssistantError path in our code (lines 275-279) + with pytest.raises(HomeAssistantError, match="not found"): + await entity.async_set_preset_mode("NonExistentPreset") + + # Ensure no command was sent + assert matter_client.send_device_command.call_count == 0 + + +@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"]) +async def test_hvac_mode_error_on_unsupported_mode( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test HVAC mode error when calling entity method directly with unsupported mode.""" + entity_id = "climate.longan_link_hvac" + + # Get the entity object directly via component + component = hass.data.get("entity_components", {}).get("climate") + assert component is not None + + entity = component.get_entity(entity_id) + assert entity is not None + + # Test calling async_set_hvac_mode directly with an unsupported HVAC mode string + # This tests the ValueError path in our code (lines 300-301) + # We pass a string that's not in HVAC_SYSTEM_MODE_MAP + with pytest.raises(ValueError, match="Unsupported hvac mode"): + await entity.async_set_hvac_mode("unsupported_mode") + + # Ensure no command was sent + assert matter_client.write_attribute.call_count == 0 From 45755a782c5c22b07e88c6e4fbe8d724336ee16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 16 Feb 2026 15:48:24 +0000 Subject: [PATCH 63/76] Enhance preset mode handling in Matter climate entities to support clearing presets and update tests accordingly --- homeassistant/components/matter/climate.py | 41 +++++++++++++------ .../matter/snapshots/test_climate.ambr | 4 ++ tests/components/matter/test_climate.py | 41 ++++++++++++++++++- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 8ef0372e3154ae..e23e7beaf0c2cd 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -221,7 +221,7 @@ 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._preset_handle_by_name: dict[str, bytes] = {} + self._preset_handle_by_name: dict[str, bytes | None] = {} super().__init__(*args, **kwargs) async def async_set_temperature(self, **kwargs: Any) -> None: @@ -270,26 +270,37 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - preset_handle = self._preset_handle_by_name.get(preset_mode) - - if preset_handle is None: + if preset_mode not in self._preset_handle_by_name: raise HomeAssistantError( f"Preset mode '{preset_mode}' not found. " f"Available presets: {self.preset_modes}" ) - await self.send_device_command( - clusters.Thermostat.Commands.SetActivePresetRequest( - presetHandle=preset_handle + preset_handle = self._preset_handle_by_name[preset_mode] + + # Handle clearing the preset (PRESET_NONE maps to None) + if preset_handle is None: + # Clear the active preset by setting ActivePresetHandle to None + # Use an empty bytes value or None to represent "no preset" + await self.send_device_command( + clusters.Thermostat.Commands.SetActivePresetRequest(presetHandle=b"") ) - ) + active_preset_handle = None + else: + await self.send_device_command( + clusters.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_handle + ) + ) + active_preset_handle = preset_handle + # Optimistically update the endpoint's ActivePresetHandle attribute # to prevent _update_from_device() from reverting to stale device state 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) + self._endpoint.set_attribute_value(active_preset_path, active_preset_handle) self._update_from_device() self.async_write_ha_state() @@ -349,7 +360,7 @@ def _update_presets(self) -> None: # Device doesn't support presets, skip preset update self._preset_handle_by_name.clear() self._attr_preset_modes = [] - self._attr_preset_mode = PRESET_NONE + self._attr_preset_mode = None return self.matter_presets = self.get_matter_attribute_value( @@ -373,6 +384,10 @@ def _update_presets(self) -> None: presets.append(ha_preset_name) self._preset_handle_by_name[ha_preset_name] = preset.presetHandle + # Always include PRESET_NONE to allow users to clear the preset + presets.append(PRESET_NONE) + self._preset_handle_by_name[PRESET_NONE] = None + self._attr_preset_modes = presets # Update active preset mode @@ -385,13 +400,13 @@ def _update_presets(self) -> None: else: self._attr_preset_mode = None for preset_name, handle in self._preset_handle_by_name.items(): - if handle == active_preset_handle: + if preset_name != PRESET_NONE and handle == active_preset_handle: self._attr_preset_mode = preset_name break else: # Device reports an active preset handle that we don't know - # Avoid reporting PRESET_NONE in that case to prevent stale values - self._attr_preset_mode = None + # Avoid reporting an invalid mode in that case, fall back to PRESET_NONE + self._attr_preset_mode = PRESET_NONE @callback def _update_hvac_mode_and_action(self) -> None: diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 2cc1a6a137d087..5481d7b8c37f67 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -214,6 +214,7 @@ 'vacation', 'going_to_sleep', 'Eco', + 'none', ]), }), 'config_entry_id': , @@ -266,6 +267,7 @@ 'vacation', 'going_to_sleep', 'Eco', + 'none', ]), 'supported_features': , 'temperature': 17.5, @@ -504,6 +506,7 @@ 'preset_modes': list([ 'home', 'away', + 'none', ]), }), 'config_entry_id': , @@ -554,6 +557,7 @@ 'preset_modes': list([ 'home', 'away', + 'none', ]), 'supported_features': , 'target_temp_high': 26.0, diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 29c02cf356ed80..b3f1045874f588 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -478,6 +478,7 @@ async def test_eve_thermo_v5_presets( # Test preset modes parsed correctly from Eve Thermo v5 # Should use HA standard presets for known ones, original names for others + # PRESET_NONE is always included to allow users to clear the preset assert state.attributes["preset_modes"] == [ "home", "away", @@ -486,6 +487,7 @@ async def test_eve_thermo_v5_presets( "vacation", "going_to_sleep", "Eco", + PRESET_NONE, ] assert state.attributes["preset_mode"] == "home" @@ -581,7 +583,7 @@ async def test_eve_thermo_v5_presets( assert err.value.translation_key == "not_valid_preset_mode" assert err.value.translation_placeholders == { "mode": "InvalidPreset", - "modes": "home, away, sleep, wake, vacation, going_to_sleep, Eco", + "modes": f"home, away, sleep, wake, vacation, going_to_sleep, Eco, {PRESET_NONE}", } # Ensure no command was sent for invalid preset @@ -625,6 +627,43 @@ async def test_eve_thermo_v5_presets( assert state assert state.attributes["preset_mode"] == PRESET_NONE + # Test that users can set preset_mode to PRESET_NONE to clear the active preset + matter_client.send_device_command.reset_mock() + # First set a preset so we have something to clear + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "home", + }, + blocking=True, + ) + matter_client.send_device_command.reset_mock() + + # Now call set_preset_mode with PRESET_NONE to clear it + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": PRESET_NONE, + }, + blocking=True, + ) + + # Verify the command was sent with empty bytes to clear the preset + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.Thermostat.Commands.SetActivePresetRequest(presetHandle=b""), + ) + # Verify preset_mode is optimistically updated to PRESET_NONE + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == PRESET_NONE + @pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) async def test_preset_mode_error_on_invalid_preset( From 2138f51422bbe76dc1a3ad8ca5e54130bdc6f966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 16 Feb 2026 17:52:42 +0000 Subject: [PATCH 64/76] Refactor preset mode update logic to use computed supported features for Matter climate entities --- homeassistant/components/matter/climate.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index e23e7beaf0c2cd..3571c11adad7b0 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -352,12 +352,13 @@ def _update_from_device(self) -> None: @callback def _update_presets(self) -> None: """Update preset modes and active preset.""" - # Check if the device supports presets feature before attempting to load - feature_map = int( - self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap) - ) - if not (feature_map & ThermostatFeature.kPresets): - # Device doesn't support presets, skip preset update + # 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._attr_preset_modes = [] self._attr_preset_mode = None From 4f3605f3878ee47c010258ddb5c78e0c4a722380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Feb 2026 11:04:48 +0000 Subject: [PATCH 65/76] Add test for preset mode with unnamed preset in climate component --- tests/components/matter/test_climate.py | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index b3f1045874f588..3e7106ab2adb68 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -714,3 +714,50 @@ async def test_hvac_mode_error_on_unsupported_mode( # Ensure no command was sent assert matter_client.write_attribute.call_count == 0 + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) +async def test_preset_mode_with_unnamed_preset( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test preset mode when a preset has no name or empty name. + + This tests the fallback preset naming case where a preset does not have + a mapped presetScenario and also has no device-provided name, requiring + the fallback Preset{i} naming pattern. + """ + entity_id = "climate.eve_thermo_20ecd1701" + + # Get current presets from the node + presets_attribute = matter_node.endpoints[1].get_attribute_value( + 513, + clusters.Thermostat.Attributes.Presets.attribute_id, + ) + + # Add a new preset with unmapped scenario (e.g., 255) and no name + if presets_attribute: + new_preset = clusters.Thermostat.Structs.PresetStruct( + presetHandle=b"\xff", + presetScenario=255, # Unmapped scenario + name="", # Empty name + ) + presets_attribute.append(new_preset) + + # Update the node with the new preset list + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.Presets.attribute_id, + presets_attribute, + ) + + # Trigger subscription callback to update entity + await trigger_subscription_callback(hass, matter_client) + + # Verify the preset was added with the fallback name "Preset8" + state = hass.states.get(entity_id) + assert state + assert "Preset8" in state.attributes["preset_modes"] From 86476cbdb867678e6901f4db3a9683373c1f5f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Feb 2026 11:08:05 +0000 Subject: [PATCH 66/76] Add tests for unnamed preset mode in climate component --- tests/components/matter/test_climate.py | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 3e7106ab2adb68..46d70de316aa7d 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -761,3 +761,31 @@ async def test_preset_mode_with_unnamed_preset( state = hass.states.get(entity_id) assert state assert "Preset8" in state.attributes["preset_modes"] + + # Test that the unnamed preset can be set as active + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Preset8", + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "Preset8" + + # Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared + # after adding an unnamed preset + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + None, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == PRESET_NONE From c299f7b2fd1a5cf678809b8f6376b3e423af6d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Feb 2026 11:14:48 +0000 Subject: [PATCH 67/76] Refactor climate component tests to use DATA_INSTANCES helper for entity retrieval --- tests/components/matter/test_climate.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 46d70de316aa7d..cbf3aa0f836961 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import DATA_INSTANCES from .common import ( set_node_attribute, @@ -674,8 +675,8 @@ async def test_preset_mode_error_on_invalid_preset( """Test preset mode error when calling entity method directly with invalid preset.""" entity_id = "climate.eve_thermo_20ecd1701" - # Get the entity object directly via component - component = hass.data.get("entity_components", {}).get("climate") + # Get the entity object directly via component using DATA_INSTANCES helper + component = hass.data.get(DATA_INSTANCES, {}).get(Platform.CLIMATE) assert component is not None entity = component.get_entity(entity_id) @@ -699,8 +700,8 @@ async def test_hvac_mode_error_on_unsupported_mode( """Test HVAC mode error when calling entity method directly with unsupported mode.""" entity_id = "climate.longan_link_hvac" - # Get the entity object directly via component - component = hass.data.get("entity_components", {}).get("climate") + # Get the entity object directly via component using DATA_INSTANCES helper + component = hass.data.get(DATA_INSTANCES, {}).get(Platform.CLIMATE) assert component is not None entity = component.get_entity(entity_id) @@ -777,7 +778,6 @@ async def test_preset_mode_with_unnamed_preset( assert state.attributes["preset_mode"] == "Preset8" # Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared - # after adding an unnamed preset set_node_attribute( matter_node, 1, From 57fcd5db3baa089c27739c1294b60f164807c85c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Feb 2026 12:23:35 +0000 Subject: [PATCH 68/76] Remove preset mode validation in async_set_preset_mode method --- homeassistant/components/matter/climate.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 3571c11adad7b0..f7936d96b5731c 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -29,7 +29,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription @@ -270,12 +269,6 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if preset_mode not in self._preset_handle_by_name: - raise HomeAssistantError( - f"Preset mode '{preset_mode}' not found. " - f"Available presets: {self.preset_modes}" - ) - preset_handle = self._preset_handle_by_name[preset_mode] # Handle clearing the preset (PRESET_NONE maps to None) From 28d9ccc17b6f19da280d570202c2b7fe812b9c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Feb 2026 12:26:29 +0000 Subject: [PATCH 69/76] Remove test for preset mode error on invalid preset in climate component --- tests/components/matter/test_climate.py | 27 +------------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index cbf3aa0f836961..5ea03c3044e5e7 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -16,7 +16,7 @@ ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import DATA_INSTANCES @@ -666,31 +666,6 @@ async def test_eve_thermo_v5_presets( assert state.attributes["preset_mode"] == PRESET_NONE -@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) -async def test_preset_mode_error_on_invalid_preset( - hass: HomeAssistant, - matter_client: MagicMock, - matter_node: MatterNode, -) -> None: - """Test preset mode error when calling entity method directly with invalid preset.""" - entity_id = "climate.eve_thermo_20ecd1701" - - # Get the entity object directly via component using DATA_INSTANCES helper - component = hass.data.get(DATA_INSTANCES, {}).get(Platform.CLIMATE) - assert component is not None - - entity = component.get_entity(entity_id) - assert entity is not None - - # Test calling async_set_preset_mode directly with invalid preset - # This tests the HomeAssistantError path in our code (lines 275-279) - with pytest.raises(HomeAssistantError, match="not found"): - await entity.async_set_preset_mode("NonExistentPreset") - - # Ensure no command was sent - assert matter_client.send_device_command.call_count == 0 - - @pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"]) async def test_hvac_mode_error_on_unsupported_mode( hass: HomeAssistant, From 3d3a735f5ae10e4d6d8ca5cf9545ce9454a2083f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Feb 2026 12:28:36 +0000 Subject: [PATCH 70/76] Update preset mode validation in test_eve_thermo_v5_presets to derive expected modes from current state --- tests/components/matter/test_climate.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 5ea03c3044e5e7..c1cce6616027b2 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -570,6 +570,11 @@ async def test_eve_thermo_v5_presets( # test set_preset_mode with invalid preset mode # The climate platform validates preset modes before calling our method + # Get current state to derive expected modes + state = hass.states.get(entity_id) + assert state + expected_modes = ", ".join(state.attributes["preset_modes"]) + with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( "climate", @@ -584,7 +589,7 @@ async def test_eve_thermo_v5_presets( assert err.value.translation_key == "not_valid_preset_mode" assert err.value.translation_placeholders == { "mode": "InvalidPreset", - "modes": f"home, away, sleep, wake, vacation, going_to_sleep, Eco, {PRESET_NONE}", + "modes": expected_modes, } # Ensure no command was sent for invalid preset @@ -683,7 +688,6 @@ async def test_hvac_mode_error_on_unsupported_mode( assert entity is not None # Test calling async_set_hvac_mode directly with an unsupported HVAC mode string - # This tests the ValueError path in our code (lines 300-301) # We pass a string that's not in HVAC_SYSTEM_MODE_MAP with pytest.raises(ValueError, match="Unsupported hvac mode"): await entity.async_set_hvac_mode("unsupported_mode") From f2d6c48f33de6113b36f260b921ef58ad155ad81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 11 Mar 2026 18:16:56 +0000 Subject: [PATCH 71/76] Refactor MatterClimate preset handling to use private attribute --- homeassistant/components/matter/climate.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 511a841ad99fc8..e380b8dfff53f8 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -210,7 +210,7 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF - matter_presets: list[clusters.Thermostat.Structs.PresetStruct] | None = None + _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 @@ -221,6 +221,7 @@ 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] = {} super().__init__(*args, **kwargs) @@ -358,14 +359,15 @@ def _update_presets(self) -> None: self._attr_preset_mode = None return - self.matter_presets = self.get_matter_attribute_value( - clusters.Thermostat.Attributes.Presets + self._matter_presets = ( + self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets) + or [] ) # Build preset mapping and list self._preset_handle_by_name.clear() presets = [] - if self.matter_presets: - for i, preset in enumerate(self.matter_presets, start=1): + if self._matter_presets: + for i, preset in enumerate(self._matter_presets, start=1): # Map Matter PresetScenarioEnum to HA standard presets for translations if preset.presetScenario in PRESET_SCENARIO_TO_HA_PRESET: # Use the mapped preset name from the dictionary From 9222c943ba2b64da7965e23c9e1eb7231ce37bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 11 Mar 2026 18:20:32 +0000 Subject: [PATCH 72/76] Refactor preset handling in MatterClimate to simplify mapping logic --- homeassistant/components/matter/climate.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index e380b8dfff53f8..1f4217dfd0f7a9 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -363,9 +363,8 @@ def _update_presets(self) -> None: self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets) or [] ) - # Build preset mapping and list + # Build preset mapping self._preset_handle_by_name.clear() - presets = [] if self._matter_presets: for i, preset in enumerate(self._matter_presets, start=1): # Map Matter PresetScenarioEnum to HA standard presets for translations @@ -378,14 +377,12 @@ def _update_presets(self) -> None: else: ha_preset_name = f"Preset{i}" - presets.append(ha_preset_name) self._preset_handle_by_name[ha_preset_name] = preset.presetHandle # Always include PRESET_NONE to allow users to clear the preset - presets.append(PRESET_NONE) self._preset_handle_by_name[PRESET_NONE] = None - self._attr_preset_modes = presets + self._attr_preset_modes = list(self._preset_handle_by_name) # Update active preset mode active_preset_handle = self.get_matter_attribute_value( From 95f73b0ea92bc98beb72746c8d93a7ec7ec96a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 11 Mar 2026 18:23:33 +0000 Subject: [PATCH 73/76] Remove conditions in tests --- tests/components/matter/test_climate.py | 97 +++++++++++++------------ 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index c1cce6616027b2..443d55d7d3db40 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -716,55 +716,56 @@ async def test_preset_mode_with_unnamed_preset( clusters.Thermostat.Attributes.Presets.attribute_id, ) + assert presets_attribute is not None + # Add a new preset with unmapped scenario (e.g., 255) and no name - if presets_attribute: - new_preset = clusters.Thermostat.Structs.PresetStruct( - presetHandle=b"\xff", - presetScenario=255, # Unmapped scenario - name="", # Empty name - ) - presets_attribute.append(new_preset) - - # Update the node with the new preset list - set_node_attribute( - matter_node, - 1, - 513, - clusters.Thermostat.Attributes.Presets.attribute_id, - presets_attribute, - ) + new_preset = clusters.Thermostat.Structs.PresetStruct( + presetHandle=b"\xff", + presetScenario=255, # Unmapped scenario + name="", # Empty name + ) + presets_attribute.append(new_preset) - # Trigger subscription callback to update entity - await trigger_subscription_callback(hass, matter_client) + # Update the node with the new preset list + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.Presets.attribute_id, + presets_attribute, + ) - # Verify the preset was added with the fallback name "Preset8" - state = hass.states.get(entity_id) - assert state - assert "Preset8" in state.attributes["preset_modes"] + # Trigger subscription callback to update entity + await trigger_subscription_callback(hass, matter_client) - # Test that the unnamed preset can be set as active - await hass.services.async_call( - "climate", - "set_preset_mode", - { - "entity_id": entity_id, - "preset_mode": "Preset8", - }, - blocking=True, - ) - state = hass.states.get(entity_id) - assert state - assert state.attributes["preset_mode"] == "Preset8" - - # Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared - set_node_attribute( - matter_node, - 1, - 513, - clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, - None, - ) - await trigger_subscription_callback(hass, matter_client) - state = hass.states.get(entity_id) - assert state - assert state.attributes["preset_mode"] == PRESET_NONE + # Verify the preset was added with the fallback name "Preset8" + state = hass.states.get(entity_id) + assert state + assert "Preset8" in state.attributes["preset_modes"] + + # Test that the unnamed preset can be set as active + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Preset8", + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "Preset8" + + # Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + None, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == PRESET_NONE From 8d6d56ef97da4e8e328915ea8dc1321907f8d2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 13 Mar 2026 22:30:52 +0100 Subject: [PATCH 74/76] Handle clearing the preset when preset_mode is PRESET_NONE --- homeassistant/components/matter/climate.py | 90 +++++++++++----------- 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 1f4217dfd0f7a9..1b4e3dd9d30b68 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -223,6 +223,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # 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: @@ -273,31 +274,24 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" preset_handle = self._preset_handle_by_name[preset_mode] - # Handle clearing the preset (PRESET_NONE maps to None) - if preset_handle is None: - # Clear the active preset by setting ActivePresetHandle to None - # Use an empty bytes value or None to represent "no preset" - await self.send_device_command( - clusters.Thermostat.Commands.SetActivePresetRequest(presetHandle=b"") - ) - active_preset_handle = None - else: - await self.send_device_command( - clusters.Thermostat.Commands.SetActivePresetRequest( - presetHandle=preset_handle - ) - ) - active_preset_handle = preset_handle + 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. + # Without this, HA can briefly keep showing the previous preset right + # after the service call returns, which causes UI flicker/inconsistency. + self._attr_preset_mode = preset_mode + self.async_write_ha_state() - # Optimistically update the endpoint's ActivePresetHandle attribute - # to prevent _update_from_device() from reverting to stale device 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, active_preset_handle) - self._update_from_device() - self.async_write_ha_state() + 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.""" @@ -355,6 +349,7 @@ def _update_presets(self) -> None: 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 @@ -363,24 +358,41 @@ def _update_presets(self) -> None: self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets) or [] ) - # Build preset mapping + # 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): - # Map Matter PresetScenarioEnum to HA standard presets for translations - if preset.presetScenario in PRESET_SCENARIO_TO_HA_PRESET: - # Use the mapped preset name from the dictionary - ha_preset_name = PRESET_SCENARIO_TO_HA_PRESET[preset.presetScenario] - # For unmapped scenarios, use device-provided name - elif preset.name and (name := preset.name.strip()): - ha_preset_name = name + preset_translation = PRESET_SCENARIO_TO_HA_PRESET.get( + preset.presetScenario + ) + if preset_translation: + preset_name = preset_translation.lower() else: - ha_preset_name = f"Preset{i}" - - self._preset_handle_by_name[ha_preset_name] = preset.presetHandle + 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) @@ -388,19 +400,9 @@ def _update_presets(self) -> None: active_preset_handle = self.get_matter_attribute_value( clusters.Thermostat.Attributes.ActivePresetHandle ) - if active_preset_handle is None: - # Explicitly no active preset selected on device - self._attr_preset_mode = PRESET_NONE - else: - self._attr_preset_mode = None - for preset_name, handle in self._preset_handle_by_name.items(): - if preset_name != PRESET_NONE and handle == active_preset_handle: - self._attr_preset_mode = preset_name - break - else: - # Device reports an active preset handle that we don't know - # Avoid reporting an invalid mode in that case, fall back to PRESET_NONE - self._attr_preset_mode = PRESET_NONE + self._attr_preset_mode = self._preset_name_by_handle.get( + active_preset_handle, PRESET_NONE + ) @callback def _update_hvac_mode_and_action(self) -> None: From 9b7efb723daeec015aac96556b9325be9c09ade7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 13 Mar 2026 22:31:47 +0100 Subject: [PATCH 75/76] Update test to use null value for clearing preset in Eve Thermo V5 --- tests/components/matter/test_climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 443d55d7d3db40..2c859bfee7a8be 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -658,12 +658,12 @@ async def test_eve_thermo_v5_presets( blocking=True, ) - # Verify the command was sent with empty bytes to clear the preset + # Verify the command was sent with null value to clear the preset assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( node_id=matter_node.node_id, endpoint_id=1, - command=clusters.Thermostat.Commands.SetActivePresetRequest(presetHandle=b""), + command=clusters.Thermostat.Commands.SetActivePresetRequest(presetHandle=None), ) # Verify preset_mode is optimistically updated to PRESET_NONE state = hass.states.get(entity_id) From 292e21082912ae5d74329aaa9c14f57e17847cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 13 Mar 2026 23:28:38 +0100 Subject: [PATCH 76/76] Clarify comments on optimistic update for preset changes in MatterClimate to address SDK bug and reporting delays --- homeassistant/components/matter/climate.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 1b4e3dd9d30b68..a67d5dcc8ebc66 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -281,8 +281,13 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: # Optimistic update is required because Matter devices usually confirm # preset changes asynchronously via a later attribute subscription. - # Without this, HA can briefly keep showing the previous preset right - # after the service call returns, which causes UI flicker/inconsistency. + # 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()