Files

2684 lines
85 KiB
Python

"""Test Matter locks."""
from typing import Any
from unittest.mock import AsyncMock, MagicMock, call
from chip.clusters import Objects as clusters
from chip.clusters.Objects import NullValue
from matter_server.client.models.node import MatterNode
from matter_server.common.errors import MatterError
from matter_server.common.models import EventType, MatterNodeEvent
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntityFeature, LockState
from homeassistant.components.matter.const import (
ATTR_CREDENTIAL_DATA,
ATTR_CREDENTIAL_INDEX,
ATTR_CREDENTIAL_RULE,
ATTR_CREDENTIAL_TYPE,
ATTR_USER_INDEX,
ATTR_USER_NAME,
ATTR_USER_STATUS,
ATTR_USER_TYPE,
CLEAR_ALL_INDEX,
DOMAIN,
)
from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from .common import (
set_node_attribute,
snapshot_matter_entities,
trigger_subscription_callback,
)
# Feature map bits
_FEATURE_PIN = 1 # kPinCredential (bit 0)
_FEATURE_RFID = 2 # kRfidCredential (bit 1)
_FEATURE_FINGER = 4 # kFingerCredentials (bit 2)
_FEATURE_USR = 256 # kUser (bit 8)
_FEATURE_USR_PIN = _FEATURE_USR | _FEATURE_PIN # 257
_FEATURE_USR_RFID = _FEATURE_USR | _FEATURE_RFID # 258
_FEATURE_USR_PIN_RFID = _FEATURE_USR | _FEATURE_PIN | _FEATURE_RFID # 259
_FEATURE_USR_FINGER = _FEATURE_USR | _FEATURE_FINGER # 260
@pytest.mark.usefixtures("matter_devices")
async def test_locks(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test locks."""
snapshot_matter_entities(hass, entity_registry, snapshot, Platform.LOCK)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_lock(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test door lock."""
await hass.services.async_call(
"lock",
"unlock",
{
"entity_id": "lock.mock_door_lock",
},
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.DoorLock.Commands.UnlockDoor(),
timed_request_timeout_ms=10000,
)
matter_client.send_device_command.reset_mock()
await hass.services.async_call(
"lock",
"lock",
{
"entity_id": "lock.mock_door_lock",
},
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.DoorLock.Commands.LockDoor(),
timed_request_timeout_ms=10000,
)
matter_client.send_device_command.reset_mock()
await hass.async_block_till_done()
state = hass.states.get("lock.mock_door_lock")
assert state
assert state.state == LockState.LOCKING
set_node_attribute(matter_node, 1, 257, 0, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("lock.mock_door_lock")
assert state
assert state.state == LockState.UNLOCKED
set_node_attribute(matter_node, 1, 257, 0, 2)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("lock.mock_door_lock")
assert state
assert state.state == LockState.UNLOCKED
set_node_attribute(matter_node, 1, 257, 0, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("lock.mock_door_lock")
assert state
assert state.state == LockState.LOCKED
set_node_attribute(matter_node, 1, 257, 0, None)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("lock.mock_door_lock")
assert state
assert state.state == STATE_UNKNOWN
# test featuremap update
set_node_attribute(matter_node, 1, 257, 65532, 4096)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("lock.mock_door_lock")
assert state.attributes["supported_features"] & LockEntityFeature.OPEN
# test handling of a node LockOperation event
await trigger_subscription_callback(
hass,
matter_client,
EventType.NODE_EVENT,
MatterNodeEvent(
node_id=matter_node.node_id,
endpoint_id=1,
cluster_id=257,
event_id=2,
event_number=0,
priority=1,
timestamp=0,
timestamp_type=0,
data={"operationSource": 3},
),
)
state = hass.states.get("lock.mock_door_lock")
assert state.attributes[ATTR_CHANGED_BY] == "Keypad"
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_lock_requires_pin(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
entity_registry: er.EntityRegistry,
) -> None:
"""Test door lock with PINCode."""
code = "1234567"
# set RequirePINforRemoteOperation
set_node_attribute(matter_node, 1, 257, 51, True)
# set door state to unlocked
set_node_attribute(matter_node, 1, 257, 0, 2)
await trigger_subscription_callback(hass, matter_client)
with pytest.raises(ServiceValidationError):
# Lock door using invalid code format
await hass.services.async_call(
"lock",
"lock",
{"entity_id": "lock.mock_door_lock", ATTR_CODE: "1234"},
blocking=True,
)
# Lock door using valid code
await trigger_subscription_callback(hass, matter_client)
await hass.services.async_call(
"lock",
"lock",
{"entity_id": "lock.mock_door_lock", ATTR_CODE: code},
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.DoorLock.Commands.LockDoor(code.encode()),
timed_request_timeout_ms=10000,
)
# Lock door using default code
default_code = "7654321"
entity_registry.async_update_entity_options(
"lock.mock_door_lock", "lock", {"default_code": default_code}
)
await trigger_subscription_callback(hass, matter_client)
await hass.services.async_call(
"lock",
"lock",
{"entity_id": "lock.mock_door_lock"},
blocking=True,
)
assert matter_client.send_device_command.call_count == 2
assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.LockDoor(default_code.encode()),
timed_request_timeout_ms=10000,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock_with_unbolt"])
async def test_lock_with_unbolt(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test door lock."""
state = hass.states.get("lock.mock_door_lock_with_unbolt")
assert state
assert state.state == LockState.LOCKED
assert state.attributes["supported_features"] & LockEntityFeature.OPEN
# test unlock/unbolt
await hass.services.async_call(
"lock",
"unlock",
{
"entity_id": "lock.mock_door_lock_with_unbolt",
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
# unlock should unbolt on a lock with unbolt feature
assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.UnboltDoor(),
timed_request_timeout_ms=10000,
)
matter_client.send_device_command.reset_mock()
# test open / unlatch
await hass.services.async_call(
"lock",
"open",
{
"entity_id": "lock.mock_door_lock_with_unbolt",
},
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.DoorLock.Commands.UnlockDoor(),
timed_request_timeout_ms=10000,
)
await hass.async_block_till_done()
state = hass.states.get("lock.mock_door_lock_with_unbolt")
assert state
assert state.state == LockState.OPENING
set_node_attribute(matter_node, 1, 257, 0, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("lock.mock_door_lock_with_unbolt")
assert state
assert state.state == LockState.UNLOCKED
set_node_attribute(matter_node, 1, 257, 0, 3)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("lock.mock_door_lock_with_unbolt")
assert state
assert state.state == LockState.OPEN
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_lock_operation_updates_changed_by(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test lock operation event updates changed_by with source."""
await trigger_subscription_callback(
hass,
matter_client,
EventType.NODE_EVENT,
MatterNodeEvent(
node_id=matter_node.node_id,
endpoint_id=1,
cluster_id=257,
event_id=2,
event_number=0,
priority=1,
timestamp=0,
timestamp_type=0,
data={"operationSource": 7, "lockOperationType": 1},
),
)
state = hass.states.get("lock.mock_door_lock")
assert state
assert state.attributes[ATTR_CHANGED_BY] == "Remote"
# --- Entity service tests ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_set_lock_user_service(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_user entity service creates user."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{"userStatus": None}, # GetUser(1): empty slot
None, # SetUser: success
]
)
await hass.services.async_call(
DOMAIN,
"set_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_NAME: "TestUser",
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 2
# Verify GetUser was called to find empty slot
assert matter_client.send_device_command.call_args_list[0] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetUser(userIndex=1),
)
# Verify SetUser was called with kAdd operation
set_user_cmd = matter_client.send_device_command.call_args_list[1]
assert set_user_cmd == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.SetUser(
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd,
userIndex=1,
userName="TestUser",
userUniqueID=None,
userStatus=clusters.DoorLock.Enums.UserStatusEnum.kOccupiedEnabled,
userType=clusters.DoorLock.Enums.UserTypeEnum.kUnrestrictedUser,
credentialRule=clusters.DoorLock.Enums.CredentialRuleEnum.kSingle,
),
timed_request_timeout_ms=10000,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_set_lock_user_update_existing(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_user service updates existing user."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{ # GetUser: existing user
"userStatus": 1,
"userName": "Old Name",
"userUniqueID": 123,
"userType": 0,
"credentialRule": 0,
"credentials": None,
},
None, # SetUser: modify
]
)
await hass.services.async_call(
DOMAIN,
"set_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_INDEX: 1,
ATTR_USER_NAME: "New Name",
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 2
# Verify GetUser was called to check existing user
assert matter_client.send_device_command.call_args_list[0] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetUser(userIndex=1),
)
# Verify SetUser was called with kModify, preserving existing values
assert matter_client.send_device_command.call_args_list[1] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.SetUser(
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kModify,
userIndex=1,
userName="New Name",
userUniqueID=123,
userStatus=1, # Preserved from existing user
userType=0, # Preserved from existing user
credentialRule=0, # Preserved from existing user
),
timed_request_timeout_ms=10000,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_set_lock_user_no_available_slots(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_user when no user slots are available."""
# All user slots are occupied
matter_client.send_device_command = AsyncMock(
return_value={"userStatus": 1} # All slots occupied
)
with pytest.raises(ServiceValidationError, match="No available user slots"):
await hass.services.async_call(
DOMAIN,
"set_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_NAME: "Test User",
},
blocking=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_set_lock_user_empty_slot_error(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_user errors when updating non-existent user."""
matter_client.send_device_command = AsyncMock(
return_value={"userStatus": None} # User doesn't exist
)
with pytest.raises(ServiceValidationError, match="is empty"):
await hass.services.async_call(
DOMAIN,
"set_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_INDEX: 5,
ATTR_USER_NAME: "Test User",
},
blocking=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_clear_lock_user_service(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test clear_lock_user entity service."""
matter_client.send_device_command = AsyncMock(return_value=None)
await hass.services.async_call(
DOMAIN,
"clear_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_INDEX: 1,
},
blocking=True,
)
# ClearUser handles credential cleanup per the Matter spec
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.DoorLock.Commands.ClearUser(userIndex=1),
timed_request_timeout_ms=10000,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_info_service(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_info entity service returns capabilities."""
result = await hass.services.async_call(
DOMAIN,
"get_lock_info",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"supports_user_management": True,
"supported_credential_types": ["pin"],
"max_users": 10,
"max_pin_users": 10,
"max_rfid_users": 10,
"max_credentials_per_user": 5,
"min_pin_length": 6,
"max_pin_length": 8,
"min_rfid_length": 10,
"max_rfid_length": 20,
}
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_users_service(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_users entity service returns users."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{
"userIndex": 1,
"userName": "Alice",
"userUniqueID": None,
"userStatus": 1,
"userType": 0,
"credentialRule": 0,
"credentials": None,
"nextUserIndex": None,
},
]
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_users",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=True,
)
# Verify GetUser command was sent
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.DoorLock.Commands.GetUser(userIndex=1),
)
assert result["lock.mock_door_lock"] == {
"max_users": 10,
"users": [
{
"user_index": 1,
"user_name": "Alice",
"user_unique_id": None,
"user_status": "occupied_enabled",
"user_type": "unrestricted_user",
"credential_rule": "single",
"credentials": [],
"creator_fabric_index": None,
"last_modified_fabric_index": None,
"next_user_index": None,
}
],
}
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_service_on_lock_without_user_management(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test entity services on lock without USR feature raise error."""
# Default door_lock fixture has featuremap=0, no USR support
with pytest.raises(ServiceValidationError, match="does not support"):
await hass.services.async_call(
DOMAIN,
"set_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_NAME: "Test",
},
blocking=True,
)
with pytest.raises(ServiceValidationError, match="does not support"):
await hass.services.async_call(
DOMAIN,
"clear_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_INDEX: 1,
},
blocking=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_on_matter_node_event_filters_non_matching_events(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test that node events for different endpoints/clusters are filtered."""
state = hass.states.get("lock.mock_door_lock")
assert state is not None
original_changed_by = state.attributes.get(ATTR_CHANGED_BY)
# Fire event for different endpoint - should be ignored
await trigger_subscription_callback(
hass,
matter_client,
EventType.NODE_EVENT,
MatterNodeEvent(
node_id=matter_node.node_id,
endpoint_id=99, # Different endpoint
cluster_id=257,
event_id=2,
event_number=0,
priority=1,
timestamp=0,
timestamp_type=0,
data={"operationSource": 7}, # Remote source
),
)
# changed_by should not have changed
state = hass.states.get("lock.mock_door_lock")
assert state.attributes.get(ATTR_CHANGED_BY) == original_changed_by
# Fire event for different cluster - should also be ignored
await trigger_subscription_callback(
hass,
matter_client,
EventType.NODE_EVENT,
MatterNodeEvent(
node_id=matter_node.node_id,
endpoint_id=1,
cluster_id=999, # Different cluster
event_id=2,
event_number=0,
priority=1,
timestamp=0,
timestamp_type=0,
data={"operationSource": 7},
),
)
state = hass.states.get("lock.mock_door_lock")
assert state.attributes.get(ATTR_CHANGED_BY) == original_changed_by
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_users_iterates_with_next_index(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_users uses nextUserIndex for efficient iteration."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{ # First user at index 1
"userIndex": 1,
"userStatus": 1,
"userName": "User 1",
"userUniqueID": None,
"userType": 0,
"credentialRule": 0,
"credentials": None,
"nextUserIndex": 5, # Next user at index 5
},
{ # Second user at index 5
"userIndex": 5,
"userStatus": 1,
"userName": "User 5",
"userUniqueID": None,
"userType": 0,
"credentialRule": 0,
"credentials": None,
"nextUserIndex": None, # No more users
},
]
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_users",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=True,
)
assert matter_client.send_device_command.call_count == 2
# Verify it jumped from index 1 to index 5 via nextUserIndex
assert matter_client.send_device_command.call_args_list[0] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetUser(userIndex=1),
)
assert matter_client.send_device_command.call_args_list[1] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetUser(userIndex=5),
)
entity_result = result["lock.mock_door_lock"]
assert entity_result == {
"max_users": 10,
"users": [
{
"user_index": 1,
"user_name": "User 1",
"user_unique_id": None,
"user_status": "occupied_enabled",
"user_type": "unrestricted_user",
"credential_rule": "single",
"credentials": [],
"creator_fabric_index": None,
"last_modified_fabric_index": None,
"next_user_index": 5,
},
{
"user_index": 5,
"user_name": "User 5",
"user_unique_id": None,
"user_status": "occupied_enabled",
"user_type": "unrestricted_user",
"credential_rule": "single",
"credentials": [],
"creator_fabric_index": None,
"last_modified_fabric_index": None,
"next_user_index": None,
},
],
}
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/51": True, # RequirePINforRemoteOperation (attribute 51)
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_code_format_property_with_pin_required(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test code_format property returns regex when PIN is required."""
state = hass.states.get("lock.mock_door_lock")
assert state is not None
# code_format should be set when RequirePINforRemoteOperation is True
# The format should be a regex like ^\d{4,8}$
code_format = state.attributes.get("code_format")
assert code_format is not None
assert "\\d" in code_format
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_users_next_user_index_loop_prevention(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_users handles nextUserIndex <= current to prevent loops."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{ # User at index 1
"userIndex": 1,
"userStatus": 1,
"userName": "User 1",
"userUniqueID": None,
"userType": 0,
"credentialRule": 0,
"credentials": None,
"nextUserIndex": 1, # Same as current - should break loop
},
]
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_users",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=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.DoorLock.Commands.GetUser(userIndex=1),
)
assert result is not None
# Result is keyed by entity_id
lock_users = result["lock.mock_door_lock"]
assert len(lock_users["users"]) == 1
# Should have stopped after first user due to nextUserIndex <= current
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_users_with_credentials(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_users returns credential info for users."""
pin_cred_type = clusters.DoorLock.Enums.CredentialTypeEnum.kPin
matter_client.send_device_command = AsyncMock(
side_effect=[
{ # User with credentials
"userIndex": 1,
"userStatus": 1,
"userName": "User With PIN",
"userUniqueID": 123,
"userType": 0,
"credentialRule": 0,
"credentials": [
{"credentialType": pin_cred_type, "credentialIndex": 1},
{"credentialType": pin_cred_type, "credentialIndex": 2},
],
"nextUserIndex": None,
},
]
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_users",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=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.DoorLock.Commands.GetUser(userIndex=1),
)
assert result["lock.mock_door_lock"] == {
"max_users": 10,
"users": [
{
"user_index": 1,
"user_name": "User With PIN",
"user_unique_id": 123,
"user_status": "occupied_enabled",
"user_type": "unrestricted_user",
"credential_rule": "single",
"credentials": [
{"type": "pin", "index": 1},
{"type": "pin", "index": 2},
],
"creator_fabric_index": None,
"last_modified_fabric_index": None,
"next_user_index": None,
}
],
}
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_users_with_nullvalue_credentials(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_users handles NullValue credentials from Matter SDK."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{
"userIndex": 1,
"userStatus": 1,
"userName": "User No Creds",
"userUniqueID": 100,
"userType": 0,
"credentialRule": 0,
"credentials": NullValue,
"nextUserIndex": None,
},
]
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_users",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=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.DoorLock.Commands.GetUser(userIndex=1),
)
lock_users = result["lock.mock_door_lock"]
assert len(lock_users["users"]) == 1
user = lock_users["users"][0]
assert user["user_index"] == 1
assert user["user_name"] == "User No Creds"
assert user["user_unique_id"] == 100
assert user["credentials"] == []
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_users_with_fabric_indices(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_users returns fabric indices and normalizes NullValue."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{
"userIndex": 1,
"userName": "HA User",
"userUniqueID": None,
"userStatus": 1,
"userType": 0,
"credentialRule": 0,
"credentials": None,
"creatorFabricIndex": 3,
"lastModifiedFabricIndex": NullValue,
"nextUserIndex": 2,
},
{
"userIndex": 2,
"userName": "External User",
"userUniqueID": None,
"userStatus": 1,
"userType": 0,
"credentialRule": 0,
"credentials": None,
"creatorFabricIndex": NullValue,
"lastModifiedFabricIndex": 5,
"nextUserIndex": None,
},
]
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_users",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=True,
)
users = result["lock.mock_door_lock"]["users"]
assert len(users) == 2
assert users[0]["creator_fabric_index"] == 3
assert users[0]["last_modified_fabric_index"] is None
assert users[1]["creator_fabric_index"] is None
assert users[1]["last_modified_fabric_index"] == 5
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
@pytest.mark.parametrize(
("service_name", "service_data", "return_response"),
[
("set_lock_user", {ATTR_USER_NAME: "Test"}, False),
("clear_lock_user", {ATTR_USER_INDEX: 1}, False),
("get_lock_users", {}, True),
(
"set_lock_credential",
{
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "123456",
ATTR_CREDENTIAL_INDEX: 1,
},
True,
),
(
"clear_lock_credential",
{ATTR_CREDENTIAL_TYPE: "pin", ATTR_CREDENTIAL_INDEX: 1},
False,
),
(
"get_lock_credential_status",
{ATTR_CREDENTIAL_TYPE: "pin", ATTR_CREDENTIAL_INDEX: 1},
True,
),
],
)
async def test_matter_error_converted_to_home_assistant_error(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
service_name: str,
service_data: dict[str, Any],
return_response: bool,
) -> None:
"""Test that MatterError from helpers is converted to HomeAssistantError."""
# Simulate a MatterError from the device command
matter_client.send_device_command = AsyncMock(
side_effect=MatterError("Device communication failed")
)
with pytest.raises(HomeAssistantError, match="Device communication failed"):
await hass.services.async_call(
DOMAIN,
service_name,
{ATTR_ENTITY_ID: "lock.mock_door_lock", **service_data},
blocking=True,
return_response=return_response,
)
# Verify a command was attempted before the error
assert matter_client.send_device_command.call_count >= 1
# --- Credential service tests ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_pin(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential with PIN type."""
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus: slot occupied -> kModify
{"credentialExists": True, "userIndex": 1, "nextCredentialIndex": 2},
# SetCredential response
{"status": 0, "userIndex": 1, "nextCredentialIndex": 2},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"credential_index": 1,
"user_index": 1,
"next_credential_index": 2,
}
assert matter_client.send_device_command.call_count == 2
# Verify GetCredentialStatus was called first
assert matter_client.send_device_command.call_args_list[0] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=clusters.DoorLock.Enums.CredentialTypeEnum.kPin,
credentialIndex=1,
),
),
)
# Verify SetCredential was called with kModify (occupied slot)
assert matter_client.send_device_command.call_args_list[1] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.SetCredential(
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kModify,
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=clusters.DoorLock.Enums.CredentialTypeEnum.kPin,
credentialIndex=1,
),
credentialData=b"1234",
userIndex=None,
userStatus=None,
userType=None,
),
timed_request_timeout_ms=10000,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/18": 3, # NumberOfPINUsersSupported
"1/257/28": 2, # NumberOfCredentialsSupportedPerUser (must NOT be used)
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_auto_find_slot(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential auto-finds first available PIN slot."""
# Place the empty slot at index 3 (the last position within
# NumberOfPINUsersSupported=3) so the test would fail if the code
# used NumberOfCredentialsSupportedPerUser=2 instead.
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus(1): occupied
{"credentialExists": True, "userIndex": 1, "nextCredentialIndex": 2},
# GetCredentialStatus(2): occupied
{"credentialExists": True, "userIndex": 2, "nextCredentialIndex": 3},
# GetCredentialStatus(3): empty — found at the bound limit
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": None,
},
# SetCredential response
{"status": 0, "userIndex": 1, "nextCredentialIndex": None},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "5678",
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"credential_index": 3,
"user_index": 1,
"next_credential_index": None,
}
# 3 GetCredentialStatus calls + 1 SetCredential = 4 total
assert matter_client.send_device_command.call_count == 4
# Verify SetCredential was called with kAdd for the empty slot at index 3
set_cred_cmd = matter_client.send_device_command.call_args_list[3]
assert (
set_cred_cmd.kwargs["command"].operationType
== clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd
)
assert set_cred_cmd.kwargs["command"].credential.credentialIndex == 3
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_with_user_index(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential passes user_index to command."""
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus: empty slot
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": 2,
},
# SetCredential response
{"status": 0, "userIndex": 3, "nextCredentialIndex": 2},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
ATTR_CREDENTIAL_INDEX: 1,
ATTR_USER_INDEX: 3,
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"credential_index": 1,
"user_index": 3,
"next_credential_index": 2,
}
# Verify user_index was passed in SetCredential command
set_cred_call = matter_client.send_device_command.call_args_list[1]
assert set_cred_call.kwargs["command"].userIndex == 3
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_invalid_pin_too_short(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential rejects PIN that is too short."""
with pytest.raises(ServiceValidationError, match="PIN length must be between"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "12", # Too short (min 4)
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_invalid_pin_non_digit(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential rejects non-digit PIN."""
with pytest.raises(ServiceValidationError, match="PIN must contain only digits"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "abcd",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR}])
async def test_set_lock_credential_unsupported_type(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential rejects unsupported credential type."""
# USR feature set but no PIN credential feature
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_status_failure(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential raises error on non-success status."""
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus: empty
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": 2,
},
# SetCredential response with duplicate status
{"status": 2, "userIndex": None, "nextCredentialIndex": None},
]
)
with pytest.raises(HomeAssistantError, match="duplicate"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/18": 3, # NumberOfPINUsersSupported
"1/257/28": 5, # NumberOfCredentialsSupportedPerUser (should NOT be used)
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_no_available_slot(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential raises error when all slots are full."""
# All GetCredentialStatus calls return occupied
matter_client.send_device_command = AsyncMock(
return_value={
"credentialExists": True,
"userIndex": 1,
"nextCredentialIndex": None,
}
)
with pytest.raises(ServiceValidationError, match="No available credential slots"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
},
blocking=True,
return_response=True,
)
# Verify it iterated over NumberOfPINUsersSupported (3), not
# NumberOfCredentialsSupportedPerUser (5)
assert matter_client.send_device_command.call_count == 3
pin_type = clusters.DoorLock.Enums.CredentialTypeEnum.kPin
for idx in range(3):
assert matter_client.send_device_command.call_args_list[idx] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=pin_type,
credentialIndex=idx + 1,
),
),
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/18": None, # NumberOfPINUsersSupported not available
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_auto_find_defaults_to_five(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential falls back to 5 slots when capacity is None."""
# All GetCredentialStatus calls return occupied
matter_client.send_device_command = AsyncMock(
return_value={
"credentialExists": True,
"userIndex": 1,
"nextCredentialIndex": None,
}
)
with pytest.raises(ServiceValidationError, match="No available credential slots"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
},
blocking=True,
return_response=True,
)
# With NumberOfPINUsersSupported=None, falls back to default of 5
assert matter_client.send_device_command.call_count == 5
pin_type = clusters.DoorLock.Enums.CredentialTypeEnum.kPin
for idx in range(5):
assert matter_client.send_device_command.call_args_list[idx] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=pin_type,
credentialIndex=idx + 1,
),
),
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_clear_lock_credential(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test clear_lock_credential sends ClearCredential command."""
matter_client.send_device_command = AsyncMock(return_value=None)
await hass.services.async_call(
DOMAIN,
"clear_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_INDEX: 1,
},
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.DoorLock.Commands.ClearCredential(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=clusters.DoorLock.Enums.CredentialTypeEnum.kPin,
credentialIndex=1,
),
),
timed_request_timeout_ms=10000,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_credential_status(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_credential_status returns credential info."""
matter_client.send_device_command = AsyncMock(
return_value={
"credentialExists": True,
"userIndex": 2,
"nextCredentialIndex": 3,
}
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_credential_status",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=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.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=clusters.DoorLock.Enums.CredentialTypeEnum.kPin,
credentialIndex=1,
),
),
)
assert result["lock.mock_door_lock"] == {
"credential_exists": True,
"user_index": 2,
"creator_fabric_index": None,
"last_modified_fabric_index": None,
"next_credential_index": 3,
}
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_credential_status_empty_slot(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_credential_status for empty slot."""
matter_client.send_device_command = AsyncMock(
return_value={
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": None,
}
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_credential_status",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_INDEX: 5,
},
blocking=True,
return_response=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.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=clusters.DoorLock.Enums.CredentialTypeEnum.kPin,
credentialIndex=5,
),
),
)
assert result["lock.mock_door_lock"] == {
"credential_exists": False,
"user_index": None,
"creator_fabric_index": None,
"last_modified_fabric_index": None,
"next_credential_index": None,
}
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
@pytest.mark.parametrize(
("creator", "last_modified", "expected_creator", "expected_last_modified"),
[
(3, NullValue, 3, None),
(NullValue, 2, None, 2),
],
)
async def test_get_lock_credential_status_with_fabric_indices(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
creator: int,
last_modified: int,
expected_creator: int | None,
expected_last_modified: int | None,
) -> None:
"""Test get_lock_credential_status returns fabric indices."""
matter_client.send_device_command = AsyncMock(
return_value={
"credentialExists": True,
"userIndex": 2,
"creatorFabricIndex": creator,
"lastModifiedFabricIndex": last_modified,
"nextCredentialIndex": 5,
}
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_credential_status",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"credential_exists": True,
"user_index": 2,
"creator_fabric_index": expected_creator,
"last_modified_fabric_index": expected_last_modified,
"next_credential_index": 5,
}
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_credential_services_without_usr_feature(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test credential services raise error without USR feature."""
# Default door_lock fixture has featuremap=0, no USR support
with pytest.raises(ServiceValidationError, match="does not support"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
with pytest.raises(ServiceValidationError, match="does not support"):
await hass.services.async_call(
DOMAIN,
"clear_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
)
with pytest.raises(ServiceValidationError, match="does not support"):
await hass.services.async_call(
DOMAIN,
"get_lock_credential_status",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
# --- RFID credential tests ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_RFID,
"1/257/26": 4, # MinRFIDCodeLength
"1/257/25": 20, # MaxRFIDCodeLength
}
],
)
async def test_set_lock_credential_rfid(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential with RFID type using hex data."""
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus: empty slot
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": 2,
},
# SetCredential response
{"status": 0, "userIndex": 1, "nextCredentialIndex": 2},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "rfid",
ATTR_CREDENTIAL_DATA: "AABBCCDD",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"credential_index": 1,
"user_index": 1,
"next_credential_index": 2,
}
assert matter_client.send_device_command.call_count == 2
# Verify SetCredential was called with RFID type and hex-decoded bytes
assert matter_client.send_device_command.call_args_list[1] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.SetCredential(
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd,
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=clusters.DoorLock.Enums.CredentialTypeEnum.kRfid,
credentialIndex=1,
),
credentialData=bytes.fromhex("AABBCCDD"),
userIndex=None,
userStatus=None,
userType=None,
),
timed_request_timeout_ms=10000,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_RFID,
"1/257/19": 3, # NumberOfRFIDUsersSupported
"1/257/28": 2, # NumberOfCredentialsSupportedPerUser (must NOT be used)
"1/257/26": 4, # MinRFIDCodeLength
"1/257/25": 20, # MaxRFIDCodeLength
}
],
)
async def test_set_lock_credential_rfid_auto_find_slot(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential auto-finds RFID slot."""
# Place the empty slot at index 3 (the last position within
# NumberOfRFIDUsersSupported=3) so the test would fail if the code
# used a smaller bound like NumberOfCredentialsSupportedPerUser=2
# or stopped iterating too early.
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus(1): occupied
{"credentialExists": True, "userIndex": 1, "nextCredentialIndex": 2},
# GetCredentialStatus(2): occupied
{"credentialExists": True, "userIndex": 2, "nextCredentialIndex": 3},
# GetCredentialStatus(3): empty — found at the bound limit
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": None,
},
# SetCredential response
{"status": 0, "userIndex": 1, "nextCredentialIndex": None},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "rfid",
ATTR_CREDENTIAL_DATA: "AABBCCDD",
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"credential_index": 3,
"user_index": 1,
"next_credential_index": None,
}
# 3 GetCredentialStatus calls + 1 SetCredential = 4 total
assert matter_client.send_device_command.call_count == 4
# Verify SetCredential was called with kAdd for the empty slot at index 3
set_cred_cmd = matter_client.send_device_command.call_args_list[3]
assert (
set_cred_cmd.kwargs["command"].operationType
== clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd
)
assert set_cred_cmd.kwargs["command"].credential.credentialIndex == 3
assert (
set_cred_cmd.kwargs["command"].credential.credentialType
== clusters.DoorLock.Enums.CredentialTypeEnum.kRfid
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_RFID,
"1/257/19": 3, # NumberOfRFIDUsersSupported
"1/257/28": 5, # NumberOfCredentialsSupportedPerUser (should NOT be used)
"1/257/26": 4, # MinRFIDCodeLength
"1/257/25": 20, # MaxRFIDCodeLength
}
],
)
async def test_set_lock_credential_rfid_no_available_slot(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential RFID raises error when all slots are full."""
matter_client.send_device_command = AsyncMock(
return_value={
"credentialExists": True,
"userIndex": 1,
"nextCredentialIndex": None,
}
)
with pytest.raises(ServiceValidationError, match="No available credential slots"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "rfid",
ATTR_CREDENTIAL_DATA: "AABBCCDD",
},
blocking=True,
return_response=True,
)
# Verify it iterated over NumberOfRFIDUsersSupported (3), not
# NumberOfCredentialsSupportedPerUser (5)
assert matter_client.send_device_command.call_count == 3
rfid_type = clusters.DoorLock.Enums.CredentialTypeEnum.kRfid
for idx in range(3):
assert matter_client.send_device_command.call_args_list[idx] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=rfid_type,
credentialIndex=idx + 1,
),
),
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_FINGER,
"1/257/17": 3, # NumberOfTotalUsersSupported (fallback for biometrics)
"1/257/18": 10, # NumberOfPINUsersSupported (should NOT be used)
"1/257/28": 2, # NumberOfCredentialsSupportedPerUser (should NOT be used)
}
],
)
async def test_set_lock_credential_fingerprint_auto_find_slot(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential auto-finds fingerprint slot."""
# Place the empty slot at index 3 (the last position within
# NumberOfTotalUsersSupported=3) so the test would fail if the code
# used NumberOfPINUsersSupported (10) or NumberOfCredentialsSupportedPerUser (2).
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus(1): occupied
{"credentialExists": True, "userIndex": 1, "nextCredentialIndex": 2},
# GetCredentialStatus(2): occupied
{"credentialExists": True, "userIndex": 2, "nextCredentialIndex": 3},
# GetCredentialStatus(3): empty — found at the bound limit
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": None,
},
# SetCredential response
{"status": 0, "userIndex": 1, "nextCredentialIndex": None},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "fingerprint",
ATTR_CREDENTIAL_DATA: "AABBCCDD",
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"credential_index": 3,
"user_index": 1,
"next_credential_index": None,
}
# 3 GetCredentialStatus calls + 1 SetCredential = 4 total
assert matter_client.send_device_command.call_count == 4
# Verify SetCredential was called with kAdd for the empty slot at index 3
set_cred_cmd = matter_client.send_device_command.call_args_list[3]
assert (
set_cred_cmd.kwargs["command"].operationType
== clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd
)
assert set_cred_cmd.kwargs["command"].credential.credentialIndex == 3
assert (
set_cred_cmd.kwargs["command"].credential.credentialType
== clusters.DoorLock.Enums.CredentialTypeEnum.kFingerprint
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_RFID,
"1/257/26": 4, # MinRFIDCodeLength
"1/257/25": 20, # MaxRFIDCodeLength
}
],
)
async def test_set_lock_credential_rfid_invalid_hex(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential rejects invalid hex RFID data."""
with pytest.raises(
ServiceValidationError, match="RFID data must be valid hexadecimal"
):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "rfid",
ATTR_CREDENTIAL_DATA: "ZZZZ",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_RFID,
"1/257/26": 4, # MinRFIDCodeLength (bytes)
"1/257/25": 20, # MaxRFIDCodeLength (bytes)
}
],
)
async def test_set_lock_credential_rfid_too_short(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential rejects RFID data below min byte length."""
# "AABB" = 2 bytes, min is 4
with pytest.raises(
ServiceValidationError, match="RFID data length must be between"
):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "rfid",
ATTR_CREDENTIAL_DATA: "AABB",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_RFID,
"1/257/26": 4, # MinRFIDCodeLength (bytes)
"1/257/25": 6, # MaxRFIDCodeLength (bytes)
}
],
)
async def test_set_lock_credential_rfid_too_long(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential rejects RFID data above max byte length."""
# "AABBCCDDEEFF0011" = 8 bytes, max is 6
with pytest.raises(
ServiceValidationError, match="RFID data length must be between"
):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "rfid",
ATTR_CREDENTIAL_DATA: "AABBCCDDEEFF0011",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_RFID}])
async def test_clear_lock_credential_rfid(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test clear_lock_credential with RFID type."""
matter_client.send_device_command = AsyncMock(return_value=None)
await hass.services.async_call(
DOMAIN,
"clear_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "rfid",
ATTR_CREDENTIAL_INDEX: 3,
},
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.DoorLock.Commands.ClearCredential(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=clusters.DoorLock.Enums.CredentialTypeEnum.kRfid,
credentialIndex=3,
),
),
timed_request_timeout_ms=10000,
)
# --- CLEAR_ALL_INDEX tests ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_clear_lock_user_clear_all(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test clear_lock_user with CLEAR_ALL_INDEX clears all users."""
matter_client.send_device_command = AsyncMock(return_value=None)
await hass.services.async_call(
DOMAIN,
"clear_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_INDEX: CLEAR_ALL_INDEX,
},
blocking=True,
)
# ClearUser handles credential cleanup per the Matter spec
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.DoorLock.Commands.ClearUser(userIndex=CLEAR_ALL_INDEX),
timed_request_timeout_ms=10000,
)
# --- SetCredential status code tests ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
@pytest.mark.parametrize(
("status_code", "expected_match"),
[
(1, "failure"), # kFailure
(3, "occupied"), # kOccupied
(99, "unknown\\(99\\)"), # Unknown status code
],
)
async def test_set_lock_credential_status_codes(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
status_code: int,
expected_match: str,
) -> None:
"""Test set_lock_credential raises error for non-success status codes."""
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus: empty
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": 2,
},
# SetCredential response with non-success status
{"status": status_code, "userIndex": None, "nextCredentialIndex": None},
]
)
with pytest.raises(HomeAssistantError, match=expected_match):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
# --- Node event edge case tests ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_lock_operation_event_missing_operation_source(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test LockOperation event with missing operationSource uses Unknown."""
await trigger_subscription_callback(
hass,
matter_client,
EventType.NODE_EVENT,
MatterNodeEvent(
node_id=matter_node.node_id,
endpoint_id=1,
cluster_id=257,
event_id=2, # LockOperation
event_number=0,
priority=1,
timestamp=0,
timestamp_type=0,
data={}, # No operationSource key
),
)
state = hass.states.get("lock.mock_door_lock")
assert state.attributes[ATTR_CHANGED_BY] == "Unknown"
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_lock_operation_event_null_data(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test LockOperation event with None data uses Unknown."""
await trigger_subscription_callback(
hass,
matter_client,
EventType.NODE_EVENT,
MatterNodeEvent(
node_id=matter_node.node_id,
endpoint_id=1,
cluster_id=257,
event_id=2, # LockOperation
event_number=0,
priority=1,
timestamp=0,
timestamp_type=0,
data=None,
),
)
state = hass.states.get("lock.mock_door_lock")
assert state.attributes[ATTR_CHANGED_BY] == "Unknown"
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_lock_operation_event_unknown_source(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test LockOperation event with unknown operationSource value."""
await trigger_subscription_callback(
hass,
matter_client,
EventType.NODE_EVENT,
MatterNodeEvent(
node_id=matter_node.node_id,
endpoint_id=1,
cluster_id=257,
event_id=2, # LockOperation
event_number=0,
priority=1,
timestamp=0,
timestamp_type=0,
data={"operationSource": 999}, # Unknown source
),
)
state = hass.states.get("lock.mock_door_lock")
assert state.attributes[ATTR_CHANGED_BY] == "Unknown"
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_non_lock_operation_event_ignored(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test non-LockOperation events on the DoorLock cluster are ignored."""
state = hass.states.get("lock.mock_door_lock")
original_changed_by = state.attributes.get(ATTR_CHANGED_BY)
await trigger_subscription_callback(
hass,
matter_client,
EventType.NODE_EVENT,
MatterNodeEvent(
node_id=matter_node.node_id,
endpoint_id=1,
cluster_id=257,
event_id=99, # Not LockOperation (event_id=2)
event_number=0,
priority=1,
timestamp=0,
timestamp_type=0,
data={"operationSource": 7},
),
)
state = hass.states.get("lock.mock_door_lock")
assert state.attributes.get(ATTR_CHANGED_BY) == original_changed_by
# --- get_lock_info edge case tests ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
async def test_get_lock_info_without_usr_feature(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_info on lock without USR returns None for capacity fields."""
# Default mock_door_lock has featuremap=0 (no USR)
result = await hass.services.async_call(
DOMAIN,
"get_lock_info",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"supports_user_management": False,
"supported_credential_types": [],
"max_users": None,
"max_pin_users": None,
"max_rfid_users": None,
"max_credentials_per_user": None,
"min_pin_length": None,
"max_pin_length": None,
"min_rfid_length": None,
"max_rfid_length": None,
}
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN_RFID}])
async def test_get_lock_info_with_multiple_credential_types(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_info reports multiple supported credential types."""
result = await hass.services.async_call(
DOMAIN,
"get_lock_info",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=True,
)
info = result["lock.mock_door_lock"]
assert info["supports_user_management"] is True
assert "pin" in info["supported_credential_types"]
assert "rfid" in info["supported_credential_types"]
# --- PIN boundary validation tests ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_pin_too_long(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential rejects PIN exceeding max length."""
with pytest.raises(ServiceValidationError, match="PIN length must be between"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "123456789", # 9 digits, max is 8
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_pin_exact_min_length(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential accepts PIN at exact minimum length."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{"credentialExists": False, "userIndex": None, "nextCredentialIndex": 2},
{"status": 0, "userIndex": 1, "nextCredentialIndex": 2},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234", # Exactly 4 digits (min)
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"]["credential_index"] == 1
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_pin_exact_max_length(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential accepts PIN at exact maximum length."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{"credentialExists": False, "userIndex": None, "nextCredentialIndex": 2},
{"status": 0, "userIndex": 1, "nextCredentialIndex": 2},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "12345678", # Exactly 8 digits (max)
ATTR_CREDENTIAL_INDEX: 1,
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"]["credential_index"] == 1
# --- set_lock_credential with user_status and user_type params ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_with_user_status_and_type(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential passes user_status and user_type to command."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{"credentialExists": False, "userIndex": None, "nextCredentialIndex": 2},
{"status": 0, "userIndex": 1, "nextCredentialIndex": 2},
]
)
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
ATTR_CREDENTIAL_INDEX: 1,
ATTR_USER_STATUS: "occupied_disabled",
ATTR_USER_TYPE: "non_access_user",
},
blocking=True,
return_response=True,
)
# Verify SetCredential was called with resolved user_status and user_type
set_cred_call = matter_client.send_device_command.call_args_list[1]
assert (
set_cred_call.kwargs["command"].userStatus
== clusters.DoorLock.Enums.UserStatusEnum.kOccupiedDisabled
)
assert (
set_cred_call.kwargs["command"].userType
== clusters.DoorLock.Enums.UserTypeEnum.kNonAccessUser
)
# --- set_lock_user with explicit params tests ---
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_set_lock_user_new_with_explicit_params(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_user creates new user with explicit type and credential rule."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{"userStatus": None}, # GetUser(1): empty slot
None, # SetUser: success
]
)
await hass.services.async_call(
DOMAIN,
"set_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_NAME: "Restricted",
ATTR_USER_TYPE: "week_day_schedule_user",
ATTR_CREDENTIAL_RULE: "dual",
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 2
set_user_cmd = matter_client.send_device_command.call_args_list[1]
assert set_user_cmd == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.SetUser(
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd,
userIndex=1,
userName="Restricted",
userUniqueID=None,
userStatus=clusters.DoorLock.Enums.UserStatusEnum.kOccupiedEnabled,
userType=clusters.DoorLock.Enums.UserTypeEnum.kWeekDayScheduleUser,
credentialRule=clusters.DoorLock.Enums.CredentialRuleEnum.kDual,
),
timed_request_timeout_ms=10000,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_set_lock_user_update_with_explicit_type_and_rule(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_user updates existing user with explicit type and rule."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{ # GetUser: existing user
"userStatus": 1,
"userName": "Old Name",
"userUniqueID": 42,
"userType": 0, # kUnrestrictedUser
"credentialRule": 0, # kSingle
"credentials": None,
},
None, # SetUser: modify
]
)
await hass.services.async_call(
DOMAIN,
"set_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_INDEX: 3,
ATTR_USER_TYPE: "programming_user",
ATTR_CREDENTIAL_RULE: "tri",
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 2
set_user_cmd = matter_client.send_device_command.call_args_list[1]
assert set_user_cmd == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.SetUser(
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kModify,
userIndex=3,
userName="Old Name", # Preserved
userUniqueID=42, # Preserved
userStatus=1, # Preserved
userType=clusters.DoorLock.Enums.UserTypeEnum.kProgrammingUser,
credentialRule=clusters.DoorLock.Enums.CredentialRuleEnum.kTri,
),
timed_request_timeout_ms=10000,
)