diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index c15dd42d62b..4b28fe7625b 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -30,6 +30,7 @@ from .entity import MatterEntity from .helpers import get_matter from .models import MatterDiscoverySchema +HUMIDITY_SCALING_FACTOR = 100 TEMPERATURE_SCALING_FACTOR = 100 HVAC_SYSTEM_MODE_MAP = { HVACMode.OFF: 0, @@ -261,6 +262,18 @@ class MatterClimate(MatterEntity, ClimateEntity): 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 @@ -428,6 +441,7 @@ DISCOVERY_SCHEMAS = [ clusters.Thermostat.Attributes.TemperatureSetpointHold, clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint, clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint, + clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, clusters.OnOff.Attributes.OnOff, ), device_type=(device_types.Thermostat, device_types.RoomAirConditioner), diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index b8249e9efa3..0c95cda9474 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -351,6 +351,7 @@ DISCOVERY_SCHEMAS = [ required_attributes=( clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, ), + allow_multi=True, # also used for climate entity ), MatterDiscoverySchema( platform=Platform.SENSOR, diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index dca29cd7abd..9b82f2ac305 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -121,6 +121,7 @@ async def integration_fixture( "smoke_detector", "solar_power", "switch_unit", + "tado_smart_radiator_thermostat_x", "temperature_sensor", "thermostat", "vacuum_cleaner", diff --git a/tests/components/matter/fixtures/nodes/tado_smart_radiator_thermostat_x.json b/tests/components/matter/fixtures/nodes/tado_smart_radiator_thermostat_x.json new file mode 100644 index 00000000000..9111ffd03fe --- /dev/null +++ b/tests/components/matter/fixtures/nodes/tado_smart_radiator_thermostat_x.json @@ -0,0 +1,198 @@ +{ + "node_id": 12, + "date_commissioned": "2024-11-30T14:42:32.255793", + "last_interview": "2025-09-02T11:11:02.931246", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "tado\u00b0 GmbH", + "0/40/2": 4942, + "0/40/3": "Smart Radiator Thermostat X", + "0/40/4": 1, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "VA04", + "0/40/9": 64, + "0/40/10": "1.0", + "0/40/18": "86A085E50D5A98E9", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 18, 19, 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": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "DghqP9mExis=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "DghqP9mExis=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "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, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "JgVorK4gwNo=", + "5": [], + "6": [ + "/cSCg76PeeDU8k9/8VDoCg==", + "/oAAAAAAAAAkBWisriDA2g==", + "/YyzDI0GAAEI590S93bZ+g==" + ], + "7": 4 + } + ], + "0/51/1": 23, + "0/51/2": 110, + "0/51/3": 6840, + "0/51/4": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 4, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [], + "0/62/1": [], + "0/62/2": 5, + "0/62/3": 5, + "0/62/4": [ + "FTABAQAkAgE3AycU3mS65o4n65AmFdZw72wYJgQxwoAuJAUANwYnFN5kuuaOJ+uQJhXWcO9sGCQHASQIATAJQQQdNLSJLh6Ew+9dc42ZSEaQD2i1mavRjPh7ERTyLn8CmfJWgG9s4LZKLdh1Qu5gz5wiKQtzQwLmvjEVyMbO7YwDNwo1ASkBGCQCYDAEFG7exdou0CWA9KDmSWy1OVdhMBKHMAUUbt7F2i7QJYD0oOZJbLU5V2EwEocYMAtAF3IcZnJT290miGeEgwDYwxCO383N3BO+F5ESozS503RetTDlxunlA1cPDTKdyPRksfD14zu5erZ51aPKHxa2Qhg=", + "FTABAQAkAgE3AycUi1H2tJ00+fUkFQEYJgRfkd0uJAUANwYnFItR9rSdNPn1JBUBGCQHASQIATAJQQS9bdXZ/ocAnGmFJBkbm6+buMcdLgy3kQnyiIJ0gPArOweblS5eFfXnRSBWP7QcV7Nd7yiAUNncF+0kMrbpjEX+Nwo1ASkBGCQCYDAEFON8FiGqis2G9n3okV7J/BquBFbUMAUU43wWIaqKzYb2feiRXsn8Gq4EVtQYMAtAVYvBt/DVrSHJdjHZ7Spdtn3amDLOsTNzjsQcBOyESjCH43ZsgKQXmgqSXh+DS4qBNJm0eVo+Vn2gbhOlqubYMBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEUVnmOqdwGAsJNKvBP6t8dNPIV8vb+7vMEdmLTlDtli9YsaJCIhfOAGWRQROt8++O953j/fnjmO6BiAKctAnrxTcKNQEpARgkAmAwBBQrF7Zs6XmGG6lbxviD1v3sViKTrDAFFCsXtmzpeYYbqVvG+IPW/exWIpOsGDALQOe8gq02WhNZYr3kUdGqSKmcl1yFgBY80ebOduJb4lzLWgCq527c8xUZjxx4fFsP9A/K8GqHwQ3mZ2+5/riGunsY", + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABAQAkAgE3AyYU4K5SDiYVI+Px/RgmBFfPHS8kBQA3BiYU4K5SDiYVI+Px/RgkBwEkCAEwCUEE/TWWQD6IXIqrlp/p0JaU1cWtFS88ERh82o2TP6qME9opV5HUntiUCAhRLHnIWtYZ4pubaOWUFoIp61NEP7tuUDcKNQEpARgkAmAwBBQ6xz8FGl9kRhSgC0R+nqgacfJGiDAFFDrHPwUaX2RGFKALRH6eqBpx8kaIGDALQLo8R2G//5ZeXJcE5MQ3YbJ0AJl0Ik97fKD6i/Kx2aGK2oumz3pyAsWd4gVWQxShlFdhoBhv27/HxvP3C9U++k0Y" + ], + "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": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 4, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 769, + "1": 2 + } + ], + "1/29/1": [3, 29, 513, 1029], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/513/0": 2090, + "1/513/3": 500, + "1/513/4": 3000, + "1/513/18": 1800, + "1/513/27": 2, + "1/513/28": 0, + "1/513/65532": 1, + "1/513/65533": 5, + "1/513/65528": [], + "1/513/65529": [0], + "1/513/65531": [0, 3, 4, 18, 27, 28, 65528, 65529, 65531, 65532, 65533], + "1/1029/0": 7492, + "1/1029/1": 0, + "1/1029/2": 10000, + "1/1029/65532": 0, + "1/1029/65533": 3, + "1/1029/65528": [], + "1/1029/65529": [], + "1/1029/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 39c8f66dfd9..c16f66a5e88 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -2282,6 +2282,55 @@ 'state': 'unknown', }) # --- +# name: test_buttons[tado_smart_radiator_thermostat_x][button.smart_radiator_thermostat_x_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.smart_radiator_thermostat_x_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-000000000000000C-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[tado_smart_radiator_thermostat_x][button.smart_radiator_thermostat_x_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Smart Radiator Thermostat X Identify', + }), + 'context': , + 'entity_id': 'button.smart_radiator_thermostat_x_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[temperature_sensor][button.mock_temperature_sensor_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 07a5a69d801..f0745bfe50c 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -199,6 +199,71 @@ 'state': 'off', }) # --- +# name: test_climates[tado_smart_radiator_thermostat_x][climate.smart_radiator_thermostat_x-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.smart_radiator_thermostat_x', + '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[tado_smart_radiator_thermostat_x][climate.smart_radiator_thermostat_x-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 74.92, + 'current_temperature': 20.9, + 'friendly_name': 'Smart Radiator Thermostat X', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'supported_features': , + 'temperature': 18.0, + }), + 'context': , + 'entity_id': 'climate.smart_radiator_thermostat_x', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_climates[thermostat][climate.longan_link_hvac-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 911ea004995..1f3fc5b0a35 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -6791,6 +6791,115 @@ 'state': '234.899', }) # --- +# name: test_sensors[tado_smart_radiator_thermostat_x][sensor.smart_radiator_thermostat_x_humidity-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.smart_radiator_thermostat_x_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-HumiditySensor-1029-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[tado_smart_radiator_thermostat_x][sensor.smart_radiator_thermostat_x_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Smart Radiator Thermostat X Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smart_radiator_thermostat_x_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.92', + }) +# --- +# name: test_sensors[tado_smart_radiator_thermostat_x][sensor.smart_radiator_thermostat_x_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.smart_radiator_thermostat_x_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-000000000000000C-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[tado_smart_radiator_thermostat_x][sensor.smart_radiator_thermostat_x_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Smart Radiator Thermostat X Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_radiator_thermostat_x_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.9', + }) +# --- # name: test_sensors[temperature_sensor][sensor.mock_temperature_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index a887ce1b5df..4e9afb4e696 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -162,6 +162,59 @@ async def test_thermostat_base( assert state.attributes["temperature"] == 20 +@pytest.mark.parametrize("node_fixture", ["thermostat"]) +async def test_thermostat_humidity( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test thermostat humidity attribute and state updates.""" + # test entity attributes + state = hass.states.get("climate.longan_link_hvac") + assert state + + measured_value = clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue + + # test current humidity update from device + set_node_attribute( + matter_node, + 1, + measured_value.cluster_id, + measured_value.attribute_id, + 1234, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["current_humidity"] == 12.34 + + # test current humidity update from device with zero value + set_node_attribute( + matter_node, + 1, + measured_value.cluster_id, + measured_value.attribute_id, + 0, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["current_humidity"] == 0.0 + + # test current humidity update from device with None value + set_node_attribute( + matter_node, + 1, + measured_value.cluster_id, + measured_value.attribute_id, + None, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert "current_humidity" not in state.attributes + + @pytest.mark.parametrize("node_fixture", ["thermostat"]) async def test_thermostat_service_calls( hass: HomeAssistant,