Fix Matter Door Lock Operating Mode select entity (#158468)

This commit is contained in:
Ludovic BOUÉ
2025-12-12 17:19:00 +00:00
committed by Franck Nijhof
parent a6cdacc8fe
commit e3a47bfc51
3 changed files with 64 additions and 40 deletions
+49 -4
View File
@@ -183,6 +183,48 @@ class MatterModeSelectEntity(MatterAttributeSelectEntity):
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 @@ DISCOVERY_SCHEMAS = [
),
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,
),
),
]
@@ -241,10 +241,7 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
@@ -282,10 +279,7 @@
'friendly_name': 'Aqara Smart Lock U200 Operating mode',
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'context': <ANY>,
@@ -684,10 +678,7 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
@@ -725,10 +716,7 @@
'friendly_name': 'Mock Door Lock Operating mode',
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'context': <ANY>,
@@ -869,10 +857,7 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
@@ -910,10 +895,7 @@
'friendly_name': 'Mock Door Lock with unbolt Operating mode',
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'context': <ANY>,
@@ -2454,10 +2436,7 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
@@ -2495,10 +2474,7 @@
'friendly_name': 'Mock Lock Operating mode',
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'context': <ANY>,
@@ -3657,10 +3633,8 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
@@ -3698,10 +3672,8 @@
'friendly_name': 'Secuyou Smart Lock Operating mode',
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'context': <ANY>,
+15 -8
View File
@@ -8,7 +8,6 @@ from matter_server.common.helpers.util import create_attribute_path_from_attribu
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,
)