Compare commits

...

6 Commits

Author SHA1 Message Date
Ludovic BOUÉ
4195be15bb Removed the fallback logic because attribute is mandatory 2025-12-09 17:18:07 +00:00
Ludovic BOUÉ
b9f2ff39fb Update select.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-09 17:14:06 +01:00
Ludovic BOUÉ
26a0eaaf36 Refactor Door Lock Operating Mode logic to align with Matter spec and update tests for supported modes 2025-12-09 15:59:00 +00:00
Ludovic BOUÉ
ae6e79af0d Update snapshots 2025-12-09 15:40:22 +00:00
Ludovic BOUÉ
415fc33f67 Update Door Lock Operating Mode test to filter options based on SupportedOperatingModes bitmap 2025-12-09 15:39:41 +00:00
Ludovic BOUÉ
cdf07665e8 Fix Door Lock Operating Mode select entity 2025-12-09 15:39:08 +00:00
3 changed files with 62 additions and 41 deletions

View File

@@ -183,6 +183,47 @@ 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).
supported_modes = [
bit_position
for bit_position in range(5) # Operating modes are bits 0-4
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_modes
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 +635,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,
),
),
]

View File

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

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,28 @@ 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
assert state.state in state.attributes["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",
}
# 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 +346,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 +361,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,
)