diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index a88c297d31a..8e5ef617304 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters from matter_server.common.helpers.util import create_attribute_path_from_attribute @@ -57,6 +57,7 @@ class MatterFan(MatterEntity, FanEntity): """Representation of a Matter fan.""" _last_known_preset_mode: str | None = None + _last_known_percentage: int = 0 _enable_turn_on_off_backwards_compatibility = False async def async_turn_on( @@ -66,14 +67,27 @@ class MatterFan(MatterEntity, FanEntity): **kwargs: Any, ) -> None: """Turn on the fan.""" + if percentage is None and preset_mode is None: + # turn_on without explicit percentage or preset_mode given + # try to handle this with the last known value + if self._last_known_percentage != 0: + percentage = self._last_known_percentage + elif self._last_known_preset_mode is not None: + preset_mode = self._last_known_preset_mode + elif self._attr_preset_modes: + # fallback: default to first supported preset + preset_mode = self._attr_preset_modes[0] + else: + # this really should not be possible but handle it anyways + percentage = 50 + + # prefer setting fan speed by percentage if percentage is not None: - # handle setting fan speed by percentage await self.async_set_percentage(percentage) return # handle setting fan mode by preset - if preset_mode is None: - # no preset given, try to handle this with the last known value - preset_mode = self._last_known_preset_mode or PRESET_AUTO + if TYPE_CHECKING: + assert preset_mode is not None await self.async_set_preset_mode(preset_mode) async def async_turn_off(self, **kwargs: Any) -> None: @@ -236,6 +250,8 @@ class MatterFan(MatterEntity, FanEntity): # keep track of the last known mode for turn_on commands without preset if self._attr_preset_mode is not None: self._last_known_preset_mode = self._attr_preset_mode + if current_percent: + self._last_known_percentage = current_percent @callback def _calculate_features( @@ -276,8 +292,10 @@ class MatterFan(MatterEntity, FanEntity): preset_modes = [PRESET_LOW, PRESET_MEDIUM, PRESET_HIGH] elif fan_mode_seq == FanModeSequenceEnum.kOffLowMedHighAuto: preset_modes = [PRESET_LOW, PRESET_MEDIUM, PRESET_HIGH, PRESET_AUTO] - elif fan_mode_seq == FanModeSequenceEnum.kOffOnAuto: - preset_modes = [PRESET_AUTO] + elif fan_mode_seq == FanModeSequenceEnum.kOffHighAuto: + preset_modes = [PRESET_HIGH, PRESET_AUTO] + elif fan_mode_seq == FanModeSequenceEnum.kOffHigh: + preset_modes = [PRESET_HIGH] # treat Matter Wind feature as additional preset(s) if feature_map & FanControlFeature.kWind: wind_support = int( diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 2fb325b8808..953897fdaa6 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -124,6 +124,7 @@ DISCOVERY_SCHEMAS = [ device_types.Cooktop, device_types.Dishwasher, device_types.ExtractorHood, + device_types.Fan, device_types.HeatingCoolingUnit, device_types.LaundryDryer, device_types.LaundryWasher, diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index 7878ac564fd..541f7383f1d 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -31,9 +31,12 @@ async def setup_integration_with_node_fixture( hass: HomeAssistant, node_fixture: str, client: MagicMock, + override_attributes: dict[str, Any] | None = None, ) -> MatterNode: """Set up Matter integration with fixture as node.""" node_data = load_and_parse_node_fixture(node_fixture) + if override_attributes: + node_data["attributes"].update(override_attributes) node = MatterNode( dataclass_from_dict( MatterNodeData, diff --git a/tests/components/matter/fixtures/nodes/fan.json b/tests/components/matter/fixtures/nodes/fan.json new file mode 100644 index 00000000000..e33c29ce66d --- /dev/null +++ b/tests/components/matter/fixtures/nodes/fan.json @@ -0,0 +1,340 @@ +{ + "node_id": 29, + "date_commissioned": "2024-07-25T08:34:23.014310", + "last_interview": "2024-07-25T08:34:23.014315", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63, 64], + "0/29/2": [41], + "0/29/3": [1, 2, 3, 4, 5, 6], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65530": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 5 + } + ], + "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/65530": [0, 1], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Mock", + "0/40/2": 4961, + "0/40/3": "Fan", + "0/40/4": 2, + "0/40/5": "Mocked Fan Switch", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "1.0", + "0/40/9": 4, + "0/40/10": "0.0.1", + "0/40/11": "", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 2, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65530": [0], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65530": [0, 1, 2], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65530, 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/65530": [], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "J/YquJb4Ao4=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "J/YquJb4Ao4=", + "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/65530": [], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/51/0": [], + "0/51/1": 15, + "0/51/2": 5688, + "0/51/3": 1, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65530": [3], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "ha-thread", + "0/53/3": 12768, + "0/53/4": 5924944741529093989, + "0/53/5": "", + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": 933034070, + "0/53/10": 68, + "0/53/11": 16, + "0/53/12": 151, + "0/53/13": 31, + "0/53/14": 1, + "0/53/15": 0, + "0/53/16": 1, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 3533, + "0/53/23": 3105, + "0/53/24": 428, + "0/53/25": 1889, + "0/53/26": 1879, + "0/53/27": 1644, + "0/53/28": 2317, + "0/53/29": 0, + "0/53/30": 1216, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 534, + "0/53/34": 10, + "0/53/35": 0, + "0/53/36": 42, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 18130, + "0/53/40": 12178, + "0/53/41": 5863, + "0/53/42": 5103, + "0/53/43": 0, + "0/53/44": 11639, + "0/53/45": 1216, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 14, + "0/53/50": 0, + "0/53/51": 89, + "0/53/52": 0, + "0/53/53": 69, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 131072, + "0/53/57": 0, + "0/53/58": 0, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "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, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65530": [], + "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, 65528, 65529, 65530, 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/65530": [], + "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/62/0": [], + "0/62/1": [], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [], + "0/62/5": 5, + "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/65530": [], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65530, 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/65530": [], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/64/0": [ + { + "0": "Vendor", + "1": "Mocked" + }, + { + "0": "Product", + "1": "Fan" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65530": [], + "0/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65530": [], + "1/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65530": [], + "1/4/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 43, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29, 64, 80, 514, 305134641], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65530": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "1/64/0": [ + { + "0": "DeviceType", + "1": "Fan" + } + ], + "1/64/65532": 0, + "1/64/65533": 1, + "1/64/65528": [], + "1/64/65529": [], + "1/64/65530": [], + "1/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + + "1/514/0": 8, + "1/514/1": 2, + "1/514/2": 0, + "1/514/3": 0, + "1/514/4": 3, + "1/514/5": 0, + "1/514/6": 0, + "1/514/9": 3, + "1/514/10": 0, + "1/514/65532": 25, + "1/514/65533": 4, + "1/514/65528": [], + "1/514/65529": [0], + "1/514/65530": [], + "1/514/65531": [ + 0, 1, 2, 3, 4, 5, 6, 9, 10, 65528, 65529, 65530, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py index 7e964d672ca..18c2c2ed255 100644 --- a/tests/components/matter/test_fan.py +++ b/tests/components/matter/test_fan.py @@ -1,5 +1,6 @@ """Test Matter Fan platform.""" +from typing import Any from unittest.mock import MagicMock, call from matter_server.client.models.node import MatterNode @@ -27,6 +28,14 @@ from .common import ( ) +@pytest.fixture(name="fan_node") +async def simple_fan_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Fan node.""" + return await setup_integration_with_node_fixture(hass, "fan", matter_client) + + @pytest.fixture(name="air_purifier") async def air_purifier_fixture( hass: HomeAssistant, matter_client: MagicMock @@ -100,6 +109,7 @@ async def test_fan_base( assert state.attributes["percentage"] == 0 +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_fan_turn_on_with_percentage( hass: HomeAssistant, matter_client: MagicMock, @@ -119,15 +129,31 @@ async def test_fan_turn_on_with_percentage( attribute_path="1/514/2", value=50, ) + # test again where preset_mode is omitted in the service call + # which should select the last active percentage + matter_client.write_attribute.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/2", + value=255, + ) +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_fan_turn_on_with_preset_mode( hass: HomeAssistant, matter_client: MagicMock, - air_purifier: MatterNode, + fan_node: MatterNode, ) -> None: """Test turning on the fan with a specific preset mode.""" - entity_id = "fan.air_purifier_fan" + entity_id = "fan.mocked_fan_switch_fan" await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, @@ -136,7 +162,7 @@ async def test_fan_turn_on_with_preset_mode( ) assert matter_client.write_attribute.call_count == 1 assert matter_client.write_attribute.call_args == call( - node_id=air_purifier.node_id, + node_id=fan_node.node_id, attribute_path="1/514/0", value=2, ) @@ -151,28 +177,13 @@ async def test_fan_turn_on_with_preset_mode( ) assert matter_client.write_attribute.call_count == 1 assert matter_client.write_attribute.call_args == call( - node_id=air_purifier.node_id, + node_id=fan_node.node_id, attribute_path="1/514/10", value=value, ) - # test again where preset_mode is omitted in the service call - # which should select a default preset mode - matter_client.write_attribute.reset_mock() - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert matter_client.write_attribute.call_count == 1 - assert matter_client.write_attribute.call_args == call( - node_id=air_purifier.node_id, - attribute_path="1/514/0", - value=5, - ) # test again if wind mode is explicitly turned off when we set a new preset mode matter_client.write_attribute.reset_mock() - set_node_attribute(air_purifier, 1, 514, 10, 2) + set_node_attribute(fan_node, 1, 514, 10, 2) await trigger_subscription_callback(hass, matter_client) await hass.services.async_call( FAN_DOMAIN, @@ -182,15 +193,33 @@ async def test_fan_turn_on_with_preset_mode( ) assert matter_client.write_attribute.call_count == 2 assert matter_client.write_attribute.call_args_list[0] == call( - node_id=air_purifier.node_id, + node_id=fan_node.node_id, attribute_path="1/514/10", value=0, ) assert matter_client.write_attribute.call_args == call( - node_id=air_purifier.node_id, + node_id=fan_node.node_id, attribute_path="1/514/0", value=2, ) + # test again where preset_mode is omitted in the service call + # which should select the last active preset + matter_client.write_attribute.reset_mock() + set_node_attribute(fan_node, 1, 514, 0, 1) + set_node_attribute(fan_node, 1, 514, 10, 0) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=fan_node.node_id, + attribute_path="1/514/0", + value=1, + ) async def test_fan_turn_off( @@ -279,3 +308,133 @@ async def test_fan_set_direction( value=value, ) matter_client.write_attribute.reset_mock() + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id", "attributes", "features"), + [ + ( + "fan", + "fan.mocked_fan_switch_fan", + { + "1/514/65532": 0, + }, + (FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF), + ), + ( + "fan", + "fan.mocked_fan_switch_fan", + { + "1/514/65532": 1, + }, + ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ), + ), + ( + "fan", + "fan.mocked_fan_switch_fan", + { + "1/514/65532": 4, + }, + ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.OSCILLATE + ), + ), + ( + "fan", + "fan.mocked_fan_switch_fan", + { + "1/514/65532": 36, + }, + ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.OSCILLATE + | FanEntityFeature.DIRECTION + ), + ), + ], +) +async def test_fan_supported_features( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, + attributes: dict[str, Any], + features: int, +) -> None: + """Test if the correct features get discovered from featuremap.""" + await setup_integration_with_node_fixture(hass, fixture, matter_client, attributes) + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] & features == features + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id", "attributes", "preset_modes"), + [ + ( + "fan", + "fan.mocked_fan_switch_fan", + {"1/514/1": 0, "1/514/65532": 0}, + [ + "low", + "medium", + "high", + ], + ), + ( + "fan", + "fan.mocked_fan_switch_fan", + {"1/514/1": 1, "1/514/65532": 0}, + [ + "low", + "high", + ], + ), + ( + "fan", + "fan.mocked_fan_switch_fan", + {"1/514/1": 2, "1/514/65532": 0}, + ["low", "medium", "high", "auto"], + ), + ( + "fan", + "fan.mocked_fan_switch_fan", + {"1/514/1": 4, "1/514/65532": 0}, + ["high", "auto"], + ), + ( + "fan", + "fan.mocked_fan_switch_fan", + {"1/514/1": 5, "1/514/65532": 0}, + ["high"], + ), + ( + "fan", + "fan.mocked_fan_switch_fan", + {"1/514/1": 5, "1/514/65532": 8, "1/514/9": 3}, + ["high", "natural_wind", "sleep_wind"], + ), + ], +) +async def test_fan_features( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, + attributes: dict[str, Any], + preset_modes: list[str], +) -> None: + """Test if the correct presets get discovered from fanmodesequence.""" + await setup_integration_with_node_fixture(hass, fixture, matter_client, attributes) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_modes"] == preset_modes