Skip to content
Open
Show file tree
Hide file tree
Changes from 77 commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
aac19d0
Thermostat preset fixture
lboue Sep 27, 2025
5e5db6e
Add presets attributes
lboue Sep 27, 2025
5704d39
Presets
lboue Sep 27, 2025
bef40de
Add snapshots
lboue Sep 27, 2025
2bd70ee
Replace product name
lboue Sep 27, 2025
3f7330d
Enable feature
lboue Sep 27, 2025
6d9f1bb
Test _attr_preset_modes
lboue Sep 27, 2025
72ec2dc
PresetTypeList
lboue Sep 27, 2025
bf0c744
async_set_preset_mode
lboue Sep 27, 2025
e039b58
Add test
lboue Sep 27, 2025
0cedbea
Add tests
lboue Sep 27, 2025
31e4114
Test command
lboue Sep 27, 2025
0a34d69
Add comment
lboue Sep 27, 2025
2ce71da
Update integration fixture to include matterbridge_thermostat and add…
lboue Oct 31, 2025
a3a8374
Refactor MatterClimate to utilize dynamic preset types and simplify p…
lboue Oct 31, 2025
cc1a17b
Clean
lboue Oct 31, 2025
f2b5671
Add support for eve_thermo_v5 in integration fixture and snapshots
lboue Jan 13, 2026
6d790ea
Matter: Support dynamic preset types in MatterClimate
lboue Sep 27, 2025
eba87f2
Merge branch 'dev' into MA-TH-Presets
lboue Jan 13, 2026
4cd86da
Remove unused thermostat preset from integration fixture and delete o…
lboue Jan 13, 2026
1be5173
Add preset modes support for Matter thermostats in test snapshots
lboue Jan 13, 2026
984f76b
Enhance preset mode handling in MatterClimate by adding a mapping for…
lboue Jan 13, 2026
bbd6a23
Add tests for set_preset_mode functionality in thermostat presets
lboue Jan 13, 2026
c7ca129
Add service and strings for setting presets in Matter thermostats
lboue Jan 13, 2026
7b826fe
Add mock thermostat presets for buttons, selects, and sensors in test…
lboue Jan 13, 2026
6d0e8ee
Remove thermostat presets related tests and snapshots
lboue Jan 13, 2026
489e5fc
Remove unused import for preset attributes in climate.py
lboue Jan 13, 2026
113dbf1
Remove thermostat presets fixture file
lboue Jan 13, 2026
d439121
Fix example value case for preset in services.yaml
lboue Jan 13, 2026
20c6021
Remove unused state value from vacuum cleaner mock snapshot
lboue Jan 13, 2026
8533130
Update homeassistant/components/matter/strings.json
lboue Jan 14, 2026
011d03e
Update active preset mode handling in MatterClimate class
lboue Jan 14, 2026
3d29a23
Refactor MatterClimate to update current temperature and humidity att…
lboue Jan 14, 2026
4f9aa45
Add logging for preset mode lookup failures and improve preset name h…
lboue Jan 14, 2026
7bd496d
Add debug logging for active preset handle and update preset mode tests
lboue Jan 14, 2026
44688d1
Add debug logging for ActivePresetHandle updates and enhance preset m…
lboue Jan 14, 2026
efdd279
Update preset mode handling in MatterClimate and remove debug logs
lboue Jan 14, 2026
208b4bc
Add tests to verify optimistic updates of preset_mode in Eve Thermo v5
lboue Jan 14, 2026
38b7ba2
Update Eve Thermo v5 JSON fixture to set value for 1/513/78
lboue Jan 14, 2026
8c5ac94
Update preset_mode assertion in test_eve_thermo_v5_presets to check f…
lboue Jan 14, 2026
9d68e30
Initialize preset handle mapping in MatterClimate constructor
lboue Jan 14, 2026
83ca1fc
Update preset_mode in test_climate snapshot to 'Home'
lboue Jan 15, 2026
2d72311
Enhance Matter climate presets mapping and handling for improved comp…
lboue Jan 15, 2026
67aab37
Remove set_presets service and related strings from Matter integration
lboue Jan 15, 2026
3b3e143
Add methods to update presets and HVAC mode in MatterClimate
lboue Jan 15, 2026
87072ee
Update homeassistant/components/matter/const.py
lboue Jan 16, 2026
4cd5940
Remove unused import from const.py
lboue Jan 23, 2026
0e1efd9
Refactor Matter climate presets to use slugified names and update rel…
lboue Jan 24, 2026
e8273ac
Remove unused matter_presets_types attribute from MatterClimate class
lboue Jan 24, 2026
79c0854
Remove unused logging import and related logger initialization in cli…
lboue Jan 24, 2026
5c093b1
Merge branch 'dev' into MA-TH-Presets
lboue Jan 24, 2026
d98cb49
Refactor MatterClimate to remove error handling for preset mode setti…
lboue Jan 24, 2026
8b5d45a
Replace ValueError with HomeAssistantError for missing preset mode in…
lboue Jan 24, 2026
df67d7b
Reset preset mode to None before updating in MatterClimate
lboue Jan 24, 2026
37c0edd
Use slugify for device preset name mapping in MatterClimate
lboue Jan 24, 2026
d37fdb1
Update ActivePresetHandle attribute in MatterClimate to prevent stale…
lboue Jan 24, 2026
5618546
Update MatterClimate to ensure state is written after setting active …
lboue Jan 24, 2026
6f2f6e2
Refactor MatterClimate preset handling to use standard names and set …
lboue Jan 24, 2026
a9173c6
Refactor Matter preset handling to standardize names and improve tran…
lboue Jan 24, 2026
8a9a51e
Enhance error handling in test_eve_thermo_v5_presets to assert transl…
lboue Jan 24, 2026
d913986
Remove unused preset modes from climate state attributes in strings.json
lboue Jan 26, 2026
6b3c535
Fix active preset mode handling in MatterClimate to prevent stale values
lboue Jan 26, 2026
8b2093d
Merge branch 'dev' into MA-TH-Presets
lboue Jan 26, 2026
b3d5c1b
Merge branch 'dev' into MA-TH-Presets
lboue Feb 16, 2026
23f72cd
Update supported_features and add preset_modes for Matter thermostats
lboue Feb 16, 2026
3f7ac5c
Add tests for HVAC and preset mode error handling in Matter climate e…
lboue Feb 16, 2026
45755a7
Enhance preset mode handling in Matter climate entities to support cl…
lboue Feb 16, 2026
2138f51
Refactor preset mode update logic to use computed supported features …
lboue Feb 16, 2026
62cc3a2
Merge branch 'dev' into MA-TH-Presets
lboue Feb 16, 2026
c7f4757
Merge branch 'dev' into MA-TH-Presets
lboue Feb 23, 2026
4f3605f
Add test for preset mode with unnamed preset in climate component
lboue Feb 23, 2026
86476cb
Add tests for unnamed preset mode in climate component
lboue Feb 23, 2026
c299f7b
Refactor climate component tests to use DATA_INSTANCES helper for ent…
lboue Feb 23, 2026
57fcd5d
Remove preset mode validation in async_set_preset_mode method
lboue Feb 23, 2026
28d9ccc
Remove test for preset mode error on invalid preset in climate component
lboue Feb 23, 2026
3d3a735
Update preset mode validation in test_eve_thermo_v5_presets to derive…
lboue Feb 23, 2026
6991134
Merge branch 'dev' into MA-TH-Presets
lboue Feb 24, 2026
161a836
Merge branch 'dev' into MA-TH-Presets
lboue Mar 11, 2026
78ed127
Merge branch 'dev' into MA-TH-Presets
lboue Mar 11, 2026
f2d6c48
Refactor MatterClimate preset handling to use private attribute
lboue Mar 11, 2026
9222c94
Refactor preset handling in MatterClimate to simplify mapping logic
lboue Mar 11, 2026
95f73b0
Remove conditions in tests
lboue Mar 11, 2026
8d6d56e
Handle clearing the preset when preset_mode is PRESET_NONE
lboue Mar 13, 2026
9b7efb7
Update test to use null value for clearing preset in Eve Thermo V5
lboue Mar 13, 2026
292e210
Clarify comments on optimistic update for preset changes in MatterCli…
lboue Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 140 additions & 3 deletions homeassistant/components/matter/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
ATTR_TARGET_TEMP_LOW,
DEFAULT_MAX_TEMP,
DEFAULT_MIN_TEMP,
PRESET_AWAY,
PRESET_HOME,
PRESET_NONE,
PRESET_SLEEP,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
Expand All @@ -42,6 +46,18 @@
HVACMode.FAN_ONLY: 7,
}

# Map of Matter PresetScenarioEnum to HA standard preset constants or custom names
# This ensures presets are translated correctly using HA's translation system.
# kUserDefined scenarios always use device-provided names.
PRESET_SCENARIO_TO_HA_PRESET: dict[int, str] = {
clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied: PRESET_HOME,
clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied: PRESET_AWAY,
clusters.Thermostat.Enums.PresetScenarioEnum.kSleep: PRESET_SLEEP,
clusters.Thermostat.Enums.PresetScenarioEnum.kWake: "wake",
clusters.Thermostat.Enums.PresetScenarioEnum.kVacation: "vacation",
clusters.Thermostat.Enums.PresetScenarioEnum.kGoingToSleep: "going_to_sleep",
}

SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = {
# Some devices only have a single setpoint while the matter spec
# assumes that you need separate setpoints for heating and cooling.
Expand Down Expand Up @@ -158,7 +174,6 @@
}

SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum
ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum
ThermostatFeature = clusters.Thermostat.Bitmaps.Feature


Expand Down Expand Up @@ -194,10 +209,20 @@ 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
_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 | None] = {}
super().__init__(*args, **kwargs)

async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE)
Expand Down Expand Up @@ -242,6 +267,36 @@ 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."""
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, active_preset_handle)
self._update_from_device()
self.async_write_ha_state()
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The async_set_preset_mode method calls async_write_ha_state() after optimistic updates, but async_set_hvac_mode (lines 307-325) does not. This inconsistency suggests that either async_set_preset_mode has an unnecessary explicit state write (since device attribute updates will trigger _on_matter_event which already calls async_write_ha_state()), or async_set_hvac_mode is missing one. Review the existing pattern: if write_attribute and send_device_command automatically trigger state updates through subscriptions, the explicit async_write_ha_state() call may be redundant. If it's needed for immediate UI feedback before device confirmation, consider documenting why and applying it consistently to both methods.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address this comment. Why do we need to write state optimistically?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When SetActivePresetRequest is sent to the device, the ActivePresetHandle attribute is updated on the device side immediately. However, Home Assistant only learns about attribute changes via Matter subscriptions, which are subject to a negotiated minIntervalFloor set by the device. This means the subscription report carrying the updated ActivePresetHandle value may arrive several seconds later, even though the device has already applied the change.

Without the optimistic update, HA would keep displaying the previous preset mode in the UI until the subscription report arrives, creating a confusing inconsistency right after the user triggers the action.

To avoid this, async_set_preset_mode() immediately:

  1. Updates _attr_preset_mode to the new value and calls async_write_ha_state() so the UI reflects the change instantly.
  2. Calls set_attribute_value() on the local endpoint cache for ActivePresetHandle, so that any intermediate call to _update_from_device() (e.g. triggered by another subscription report on a different attribute) does not revert the preset back to the old value before the device's own subscription report arrives.

This is consistent with how async_set_hvac_mode() handles the SystemMode attribute in the same file.


async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""

Expand All @@ -266,10 +321,10 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
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 (
Expand All @@ -281,6 +336,75 @@ 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.
# 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
return

self.matter_presets = self.get_matter_attribute_value(
clusters.Thermostat.Attributes.Presets
)
# 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):
# 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}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can manufacturers just fill in anything here? How diverse is this list?

Copy link
Contributor Author

@lboue lboue Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manufacturers can only choose from the fixed PresetScenarioEnum values (Occupied, Unoccupied, Sleep, Wake, Vacation, GoingToSleep, and UserDefined). The name field, however, is vendor-supplied and can vary widely. Devices may ship localized names or even empty strings. In practice the diversity is mostly in those names, while the scenario enum itself is tightly bounded.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between the name and the presets?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is only used when the preset does not have an explicitly specified name.


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
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

@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
Expand Down Expand Up @@ -332,7 +456,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
Expand All @@ -358,6 +485,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
Expand Down Expand Up @@ -397,6 +527,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
# determine supported hvac modes
if feature_map & ThermostatFeature.kHeating:
self._attr_hvac_modes.append(HVACMode.HEAT)
if feature_map & ThermostatFeature.kCooling:
Expand Down Expand Up @@ -439,9 +572,13 @@ 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.Presets,
clusters.Thermostat.Attributes.PresetTypes,
clusters.Thermostat.Attributes.ActivePresetHandle,
clusters.Thermostat.Attributes.SystemMode,
clusters.Thermostat.Attributes.ThermostatRunningMode,
clusters.Thermostat.Attributes.ThermostatRunningState,
Expand Down
11 changes: 10 additions & 1 deletion homeassistant/components/matter/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,16 @@
},
"climate": {
"thermostat": {
"name": "Thermostat"
"name": "Thermostat",
"state_attributes": {
"preset_mode": {
"state": {
"going_to_sleep": "Going to sleep",
"vacation": "Vacation",
"wake": "Wake"
}
}
}
}
},
"cover": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@
}
],
"1/513/74": 8,
"1/513/78": null,
"1/513/78": "AQ==",
"1/513/80": [
{
"0": "AQ==",
Expand Down
40 changes: 36 additions & 4 deletions tests/components/matter/snapshots/test_climate.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,16 @@
]),
'max_temp': 30.0,
'min_temp': 10.0,
'preset_modes': list([
'home',
'away',
'sleep',
'wake',
'vacation',
'going_to_sleep',
'Eco',
'none',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
Expand All @@ -231,7 +241,7 @@
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 385>,
'supported_features': <ClimateEntityFeature: 401>,
'translation_key': 'thermostat',
'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0',
'unit_of_measurement': None,
Expand All @@ -248,7 +258,18 @@
]),
'max_temp': 30.0,
'min_temp': 10.0,
'supported_features': <ClimateEntityFeature: 385>,
'preset_mode': 'home',
'preset_modes': list([
'home',
'away',
'sleep',
'wake',
'vacation',
'going_to_sleep',
'Eco',
'none',
]),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.5,
}),
'context': <ANY>,
Expand Down Expand Up @@ -482,6 +503,11 @@
]),
'max_temp': 32.0,
'min_temp': 7.0,
'preset_modes': list([
'home',
'away',
'none',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
Expand All @@ -507,7 +533,7 @@
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 387>,
'supported_features': <ClimateEntityFeature: 403>,
'translation_key': 'thermostat',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0',
'unit_of_measurement': None,
Expand All @@ -527,7 +553,13 @@
]),
'max_temp': 32.0,
'min_temp': 7.0,
'supported_features': <ClimateEntityFeature: 387>,
'preset_mode': 'none',
'preset_modes': list([
'home',
'away',
'none',
]),
'supported_features': <ClimateEntityFeature: 403>,
'target_temp_high': 26.0,
'target_temp_low': 20.0,
'temperature': None,
Expand Down
Loading
Loading