-
-
Notifications
You must be signed in to change notification settings - Fork 37k
Add Matter Thermostat presets feature #160885
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 77 commits
aac19d0
5e5db6e
5704d39
bef40de
2bd70ee
3f7330d
6d9f1bb
72ec2dc
bf0c744
e039b58
0cedbea
31e4114
0a34d69
2ce71da
a3a8374
cc1a17b
f2b5671
6d790ea
eba87f2
4cd86da
1be5173
984f76b
bbd6a23
c7ca129
7b826fe
6d0e8ee
489e5fc
113dbf1
d439121
20c6021
8533130
011d03e
3d29a23
4f9aa45
7bd496d
44688d1
efdd279
208b4bc
38b7ba2
8c5ac94
9d68e30
83ca1fc
2d72311
67aab37
3b3e143
87072ee
4cd5940
0e1efd9
e8273ac
79c0854
5c093b1
d98cb49
8b5d45a
df67d7b
37c0edd
d37fdb1
5618546
6f2f6e2
a9173c6
8a9a51e
d913986
6b3c535
8b2093d
b3d5c1b
23f72cd
3f7ac5c
45755a7
2138f51
62cc3a2
c7f4757
4f3605f
86476cb
c299f7b
57fcd5d
28d9ccc
3d3a735
6991134
161a836
78ed127
f2d6c48
9222c94
95f73b0
8d6d56e
9b7efb7
292e210
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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", | ||
lboue marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| 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. | ||
|
|
@@ -158,7 +174,6 @@ | |
| } | ||
|
|
||
| SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum | ||
| ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum | ||
| ThermostatFeature = clusters.Thermostat.Bitmaps.Feature | ||
|
|
||
|
|
||
|
|
@@ -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 | ||
lboue marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| _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) | ||
|
|
@@ -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() | ||
|
||
|
|
||
| async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: | ||
| """Set new target hvac mode.""" | ||
|
|
||
|
|
@@ -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 ( | ||
|
|
@@ -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( | ||
lboue marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| clusters.Thermostat.Attributes.Presets | ||
| ) | ||
| # Build preset mapping and list | ||
| self._preset_handle_by_name.clear() | ||
| presets = [] | ||
| if self.matter_presets: | ||
lboue marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| for i, preset in enumerate(self.matter_presets, start=1): | ||
lboue marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| # 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) | ||
lboue marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| self._preset_handle_by_name[ha_preset_name] = preset.presetHandle | ||
lboue marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| # 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 | ||
|
|
||
lboue marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| # 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: | ||
lboue marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 | ||
lboue marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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: | ||
|
|
@@ -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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -501,7 +501,7 @@ | |
| } | ||
| ], | ||
| "1/513/74": 8, | ||
| "1/513/78": null, | ||
| "1/513/78": "AQ==", | ||
| "1/513/80": [ | ||
| { | ||
| "0": "AQ==", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.