Fix sending commands to Matter vacuum (#147567)

This commit is contained in:
Marcel van der Veldt 2025-06-26 10:47:24 +02:00 committed by Franck Nijhof
parent f28d6582c6
commit d523f85404
No known key found for this signature in database
GPG Key ID: AB33ADACE7101952
3 changed files with 75 additions and 51 deletions

View File

@ -62,14 +62,25 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
_last_accepted_commands: list[int] | None = None _last_accepted_commands: list[int] | None = None
_supported_run_modes: ( _supported_run_modes: (
dict[int, clusters.RvcCleanMode.Structs.ModeOptionStruct] | None dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None
) = None ) = None
entity_description: StateVacuumEntityDescription entity_description: StateVacuumEntityDescription
_platform_translation_key = "vacuum" _platform_translation_key = "vacuum"
async def async_stop(self, **kwargs: Any) -> None: async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner.""" """Stop the vacuum cleaner."""
await self.send_device_command(clusters.OperationalState.Commands.Stop()) # We simply set the RvcRunMode to the first runmode
# that has the idle tag to stop the vacuum cleaner.
# this is compatible with both Matter 1.2 and 1.3+ devices.
supported_run_modes = self._supported_run_modes or {}
for mode in supported_run_modes.values():
for tag in mode.modeTags:
if tag.value == ModeTag.IDLE:
# stop the vacuum by changing the run mode to idle
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
return
async def async_return_to_base(self, **kwargs: Any) -> None: async def async_return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock.""" """Set the vacuum cleaner to return to the dock."""
@ -83,15 +94,30 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
"""Start or resume the cleaning task.""" """Start or resume the cleaning task."""
if TYPE_CHECKING: if TYPE_CHECKING:
assert self._last_accepted_commands is not None assert self._last_accepted_commands is not None
accepted_operational_commands = self._last_accepted_commands
if ( if (
clusters.RvcOperationalState.Commands.Resume.command_id clusters.RvcOperationalState.Commands.Resume.command_id
in self._last_accepted_commands in accepted_operational_commands
and self.state == VacuumActivity.PAUSED
): ):
# vacuum is paused and supports resume command
await self.send_device_command( await self.send_device_command(
clusters.RvcOperationalState.Commands.Resume() clusters.RvcOperationalState.Commands.Resume()
) )
else: return
await self.send_device_command(clusters.OperationalState.Commands.Start())
# We simply set the RvcRunMode to the first runmode
# that has the cleaning tag to start the vacuum cleaner.
# this is compatible with both Matter 1.2 and 1.3+ devices.
supported_run_modes = self._supported_run_modes or {}
for mode in supported_run_modes.values():
for tag in mode.modeTags:
if tag.value == ModeTag.CLEANING:
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
return
async def async_pause(self) -> None: async def async_pause(self) -> None:
"""Pause the cleaning task.""" """Pause the cleaning task."""
@ -130,6 +156,8 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
state = VacuumActivity.CLEANING state = VacuumActivity.CLEANING
elif ModeTag.IDLE in tags: elif ModeTag.IDLE in tags:
state = VacuumActivity.IDLE state = VacuumActivity.IDLE
elif ModeTag.MAPPING in tags:
state = VacuumActivity.CLEANING
self._attr_activity = state self._attr_activity = state
@callback @callback
@ -143,7 +171,10 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
return return
self._last_accepted_commands = accepted_operational_commands self._last_accepted_commands = accepted_operational_commands
supported_features: VacuumEntityFeature = VacuumEntityFeature(0) supported_features: VacuumEntityFeature = VacuumEntityFeature(0)
supported_features |= VacuumEntityFeature.START
supported_features |= VacuumEntityFeature.STATE supported_features |= VacuumEntityFeature.STATE
supported_features |= VacuumEntityFeature.STOP
# optional battery attribute = battery feature # optional battery attribute = battery feature
if self.get_matter_attribute_value( if self.get_matter_attribute_value(
clusters.PowerSource.Attributes.BatPercentRemaining clusters.PowerSource.Attributes.BatPercentRemaining
@ -153,7 +184,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType): if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType):
supported_features |= VacuumEntityFeature.LOCATE supported_features |= VacuumEntityFeature.LOCATE
# create a map of supported run modes # create a map of supported run modes
run_modes: list[clusters.RvcCleanMode.Structs.ModeOptionStruct] = ( run_modes: list[clusters.RvcRunMode.Structs.ModeOptionStruct] = (
self.get_matter_attribute_value( self.get_matter_attribute_value(
clusters.RvcRunMode.Attributes.SupportedModes clusters.RvcRunMode.Attributes.SupportedModes
) )
@ -165,22 +196,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
in accepted_operational_commands in accepted_operational_commands
): ):
supported_features |= VacuumEntityFeature.PAUSE supported_features |= VacuumEntityFeature.PAUSE
if (
clusters.OperationalState.Commands.Stop.command_id
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.STOP
if (
clusters.OperationalState.Commands.Start.command_id
in accepted_operational_commands
):
# note that start has been replaced by resume in rev2 of the spec
supported_features |= VacuumEntityFeature.START
if (
clusters.RvcOperationalState.Commands.Resume.command_id
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.START
if ( if (
clusters.RvcOperationalState.Commands.GoHome.command_id clusters.RvcOperationalState.Commands.GoHome.command_id
in accepted_operational_commands in accepted_operational_commands
@ -202,10 +217,7 @@ DISCOVERY_SCHEMAS = [
clusters.RvcRunMode.Attributes.CurrentMode, clusters.RvcRunMode.Attributes.CurrentMode,
clusters.RvcOperationalState.Attributes.OperationalState, clusters.RvcOperationalState.Attributes.OperationalState,
), ),
optional_attributes=( optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,),
clusters.RvcCleanMode.Attributes.CurrentMode,
clusters.PowerSource.Attributes.BatPercentRemaining,
),
device_type=(device_types.RoboticVacuumCleaner,), device_type=(device_types.RoboticVacuumCleaner,),
allow_none_value=True, allow_none_value=True,
), ),

View File

@ -28,7 +28,7 @@
'platform': 'matter', 'platform': 'matter',
'previous_unique_id': None, 'previous_unique_id': None,
'suggested_object_id': None, 'suggested_object_id': None,
'supported_features': <VacuumEntityFeature: 12308>, 'supported_features': <VacuumEntityFeature: 12316>,
'translation_key': None, 'translation_key': None,
'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1',
'unit_of_measurement': None, 'unit_of_measurement': None,
@ -38,7 +38,7 @@
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'friendly_name': 'Mock Vacuum', 'friendly_name': 'Mock Vacuum',
'supported_features': <VacuumEntityFeature: 12308>, 'supported_features': <VacuumEntityFeature: 12316>,
}), }),
'context': <ANY>, 'context': <ANY>,
'entity_id': 'vacuum.mock_vacuum', 'entity_id': 'vacuum.mock_vacuum',

View File

@ -9,7 +9,6 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceNotSupported
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -61,7 +60,29 @@ async def test_vacuum_actions(
) )
matter_client.send_device_command.reset_mock() matter_client.send_device_command.reset_mock()
# test start/resume action # test start action (from idle state)
await hass.services.async_call(
"vacuum",
"start",
{
"entity_id": entity_id,
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=1),
)
matter_client.send_device_command.reset_mock()
# test resume action (from paused state)
# first set the operational state to paused
set_node_attribute(matter_node, 1, 97, 4, 0x02)
await trigger_subscription_callback(hass, matter_client)
await hass.services.async_call( await hass.services.async_call(
"vacuum", "vacuum",
"start", "start",
@ -98,25 +119,6 @@ async def test_vacuum_actions(
matter_client.send_device_command.reset_mock() matter_client.send_device_command.reset_mock()
# test stop action # test stop action
# stop command is not supported by the vacuum fixture
with pytest.raises(
ServiceNotSupported,
match="Entity vacuum.mock_vacuum does not support action vacuum.stop",
):
await hass.services.async_call(
"vacuum",
"stop",
{
"entity_id": entity_id,
},
blocking=True,
)
# update accepted command list to add support for stop command
set_node_attribute(
matter_node, 1, 97, 65529, [clusters.OperationalState.Commands.Stop.command_id]
)
await trigger_subscription_callback(hass, matter_client)
await hass.services.async_call( await hass.services.async_call(
"vacuum", "vacuum",
"stop", "stop",
@ -129,7 +131,7 @@ async def test_vacuum_actions(
assert matter_client.send_device_command.call_args == call( assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id, node_id=matter_node.node_id,
endpoint_id=1, endpoint_id=1,
command=clusters.OperationalState.Commands.Stop(), command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=0),
) )
matter_client.send_device_command.reset_mock() matter_client.send_device_command.reset_mock()
@ -209,11 +211,21 @@ async def test_vacuum_updates(
assert state assert state
assert state.state == "idle" assert state.state == "idle"
# confirm state is 'cleaning' by setting;
# - the operational state to 0x00
# - the run mode is set to a mode which has mapping tag
set_node_attribute(matter_node, 1, 97, 4, 0)
set_node_attribute(matter_node, 1, 84, 1, 2)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "cleaning"
# confirm state is 'unknown' by setting; # confirm state is 'unknown' by setting;
# - the operational state to 0x00 # - the operational state to 0x00
# - the run mode is set to a mode which has neither cleaning or idle tag # - the run mode is set to a mode which has neither cleaning or idle tag
set_node_attribute(matter_node, 1, 97, 4, 0) set_node_attribute(matter_node, 1, 97, 4, 0)
set_node_attribute(matter_node, 1, 84, 1, 2) set_node_attribute(matter_node, 1, 84, 1, 5)
await trigger_subscription_callback(hass, matter_client) await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state assert state