Fix default turn_on without explicit preset or percentage in Matter Fan platform (#122591)

This commit is contained in:
Marcel van der Veldt 2024-07-29 12:03:40 +02:00 committed by GitHub
parent 745eea9a29
commit 85aca4f095
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 550 additions and 29 deletions

View File

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

View File

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

View File

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

View File

@ -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": []
}

View File

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