diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index a441160b02a4b8..ee6b537e9b3052 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -183,6 +183,48 @@ def _update_from_device(self) -> None: self._attr_name = desc +class MatterDoorLockOperatingModeSelectEntity(MatterAttributeSelectEntity): + """Representation of a Door Lock Operating Mode select entity. + + This entity dynamically filters available operating modes based on the device's + `SupportedOperatingModes` bitmap attribute. In this bitmap, bit=0 indicates a + supported mode and bit=1 indicates unsupported (inverted from typical bitmap conventions). + If the bitmap is unavailable, only mandatory modes are included. The mapping from + bitmap bits to operating mode values is defined by the Matter specification. + """ + + entity_description: MatterMapSelectEntityDescription + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + # Get the bitmap of supported operating modes + supported_modes_bitmap = self.get_matter_attribute_value( + self.entity_description.list_attribute + ) + + # Convert bitmap to list of supported mode values + # NOTE: The Matter spec inverts the usual meaning: bit=0 means supported, + # bit=1 means not supported, undefined bits must be 1. Mandatory modes are + # bits 0 (Normal) and 3 (NoRemoteLockUnlock). + num_mode_bits = supported_modes_bitmap.bit_length() + supported_mode_values = [ + bit_position + for bit_position in range(num_mode_bits) + if not supported_modes_bitmap & (1 << bit_position) + ] + + # Map supported mode values to their string representations + self._attr_options = [ + mapped_value + for mode_value in supported_mode_values + if (mapped_value := self.entity_description.device_to_ha(mode_value)) + ] + + # Use base implementation to set the current option + super()._update_from_device() + + class MatterListSelectEntity(MatterEntity, SelectEntity): """Representation of a select entity from Matter list and selected item Cluster attribute(s).""" @@ -594,15 +636,18 @@ def _update_from_device(self) -> None: ), MatterDiscoverySchema( platform=Platform.SELECT, - entity_description=MatterSelectEntityDescription( + entity_description=MatterMapSelectEntityDescription( key="DoorLockOperatingMode", entity_category=EntityCategory.CONFIG, translation_key="door_lock_operating_mode", - options=list(DOOR_LOCK_OPERATING_MODE_MAP.values()), + list_attribute=clusters.DoorLock.Attributes.SupportedOperatingModes, device_to_ha=DOOR_LOCK_OPERATING_MODE_MAP.get, ha_to_device=DOOR_LOCK_OPERATING_MODE_MAP_REVERSE.get, ), - entity_class=MatterAttributeSelectEntity, - required_attributes=(clusters.DoorLock.Attributes.OperatingMode,), + entity_class=MatterDoorLockOperatingModeSelectEntity, + required_attributes=( + clusters.DoorLock.Attributes.OperatingMode, + clusters.DoorLock.Attributes.SupportedOperatingModes, + ), ), ] diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index d0bc46f2268533..00f51698a37e33 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -241,10 +241,7 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -282,10 +279,7 @@ 'friendly_name': 'Aqara Smart Lock U200 Operating mode', 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , @@ -684,10 +678,7 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -725,10 +716,7 @@ 'friendly_name': 'Mock Door Lock Operating mode', 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , @@ -869,10 +857,7 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -910,10 +895,7 @@ 'friendly_name': 'Mock Door Lock with unbolt Operating mode', 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , @@ -2454,10 +2436,7 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -2495,10 +2474,7 @@ 'friendly_name': 'Mock Lock Operating mode', 'options': list([ 'normal', - 'vacation', - 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , @@ -3657,10 +3633,8 @@ 'capabilities': dict({ 'options': list([ 'normal', - 'vacation', 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'config_entry_id': , @@ -3698,10 +3672,8 @@ 'friendly_name': 'Secuyou Smart Lock Operating mode', 'options': list([ 'normal', - 'vacation', 'privacy', 'no_remote_lock_unlock', - 'passage', ]), }), 'context': , diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 64fd5a98816a98..f2280633b4e42c 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -8,7 +8,6 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.matter.select import DOOR_LOCK_OPERATING_MODE_MAP from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -314,22 +313,30 @@ async def test_door_lock_operating_mode_select( """Test Door Lock Operating Mode select entity discovery and interaction. Verifies: - - Options match mapping in DOOR_LOCK_OPERATING_MODE_MAP + - Options are filtered based on SupportedOperatingModes bitmap - Attribute updates reflect current option - Selecting an option writes correct enum value """ entity_id = "select.secuyou_smart_lock_operating_mode" state = hass.states.get(entity_id) assert state, "Missing operating mode select entity" - assert state.attributes["options"] == list(DOOR_LOCK_OPERATING_MODE_MAP.values()) - # Initial state should be one of the allowed options + # According to the spec, bit=0 means supported and bit=1 means not supported. + # The fixture bitmap clears bits 0, 2, and 3, so the supported modes are + # Normal, Privacy, and NoRemoteLockUnlock; the other bits are set (not + # supported). + assert set(state.attributes["options"]) == { + "normal", + "privacy", + "no_remote_lock_unlock", + } + # Verify that the initial state is part of the allowed options assert state.state in state.attributes["options"] # Dynamically obtain ids instead of hardcoding door_lock_cluster_id = clusters.DoorLock.Attributes.OperatingMode.cluster_id operating_mode_attr_id = clusters.DoorLock.Attributes.OperatingMode.attribute_id - # Change OperatingMode attribute on the node to 'privacy' + # Change OperatingMode attribute on the node to a supported mode ('privacy') set_node_attribute( matter_node, 1, @@ -341,12 +348,12 @@ async def test_door_lock_operating_mode_select( state = hass.states.get(entity_id) assert state.state == "privacy" - # Select another option (vacation) via service to validate mapping + # Select another supported option (NoRemoteLockUnlock) via service to validate mapping matter_client.write_attribute.reset_mock() await hass.services.async_call( "select", "select_option", - {"entity_id": entity_id, "option": "vacation"}, + {"entity_id": entity_id, "option": "no_remote_lock_unlock"}, blocking=True, ) assert matter_client.write_attribute.call_count == 1 @@ -356,5 +363,5 @@ async def test_door_lock_operating_mode_select( endpoint_id=1, attribute=clusters.DoorLock.Attributes.OperatingMode, ), - value=clusters.DoorLock.Enums.OperatingModeEnum.kVacation, + value=clusters.DoorLock.Enums.OperatingModeEnum.kNoRemoteLockUnlock, )