Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions homeassistant/components/matter/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand Down Expand Up @@ -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,
),
),
]
28 changes: 0 additions & 28 deletions tests/components/matter/snapshots/test_select.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,7 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
Expand Down Expand Up @@ -282,10 +279,7 @@
'friendly_name': 'Aqara Smart Lock U200 Operating mode',
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'context': <ANY>,
Expand Down Expand Up @@ -684,10 +678,7 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
Expand Down Expand Up @@ -725,10 +716,7 @@
'friendly_name': 'Mock Door Lock Operating mode',
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'context': <ANY>,
Expand Down Expand Up @@ -869,10 +857,7 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -2454,10 +2436,7 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
Expand Down Expand Up @@ -2495,10 +2474,7 @@
'friendly_name': 'Mock Lock Operating mode',
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'context': <ANY>,
Expand Down Expand Up @@ -3657,10 +3633,8 @@
'capabilities': dict({
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'config_entry_id': <ANY>,
Expand Down Expand Up @@ -3698,10 +3672,8 @@
'friendly_name': 'Secuyou Smart Lock Operating mode',
'options': list([
'normal',
'vacation',
'privacy',
'no_remote_lock_unlock',
'passage',
]),
}),
'context': <ANY>,
Expand Down
23 changes: 14 additions & 9 deletions tests/components/matter/test_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
)
Loading