From 6ccede7f30731a25e4b4bbb4e8783e55e85e7726 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 10 Apr 2026 11:18:10 +0200 Subject: [PATCH] Add fabric index fields to Matter lock user and credential responses (#167875) --- .../components/matter/lock_helpers.py | 11 +- tests/components/matter/test_lock.py | 115 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/lock_helpers.py b/homeassistant/components/matter/lock_helpers.py index 1f95aba1987..4cfef792a7d 100644 --- a/homeassistant/components/matter/lock_helpers.py +++ b/homeassistant/components/matter/lock_helpers.py @@ -71,6 +71,8 @@ class LockUserData(TypedDict): user_type: str credential_rule: str credentials: list[LockUserCredentialData] + creator_fabric_index: int | None + last_modified_fabric_index: int | None next_user_index: int | None @@ -115,6 +117,8 @@ class GetLockCredentialStatusResult(TypedDict): credential_exists: bool user_index: int | None + creator_fabric_index: int | None + last_modified_fabric_index: int | None next_credential_index: int | None @@ -214,6 +218,8 @@ def _format_user_response(user_data: Any) -> LockUserData | None: _get_attr(user_data, "credentialRule"), "unknown" ), credentials=credentials, + creator_fabric_index=_get_attr(user_data, "creatorFabricIndex"), + last_modified_fabric_index=_get_attr(user_data, "lastModifiedFabricIndex"), next_user_index=_get_attr(user_data, "nextUserIndex"), ) @@ -817,7 +823,8 @@ async def get_lock_credential_status( ) -> GetLockCredentialStatusResult: """Get the status of a credential slot on the lock. - Returns typed dict with credential_exists, user_index, next_credential_index. + Returns typed dict with credential_exists, user_index, creator_fabric_index, + last_modified_fabric_index, and next_credential_index. Raises HomeAssistantError on failure. """ lock_endpoint = _get_lock_endpoint_or_raise(node) @@ -839,5 +846,7 @@ async def get_lock_credential_status( return GetLockCredentialStatusResult( credential_exists=bool(_get_attr(response, "credentialExists")), user_index=_get_attr(response, "userIndex"), + creator_fabric_index=_get_attr(response, "creatorFabricIndex"), + last_modified_fabric_index=_get_attr(response, "lastModifiedFabricIndex"), next_credential_index=_get_attr(response, "nextCredentialIndex"), ) diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index 39d5ccd69f8..5f74c633a34 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -587,6 +587,8 @@ async def test_get_lock_users_service( "user_type": "unrestricted_user", "credential_rule": "single", "credentials": [], + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_user_index": None, } ], @@ -745,6 +747,8 @@ async def test_get_lock_users_iterates_with_next_index( "user_type": "unrestricted_user", "credential_rule": "single", "credentials": [], + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_user_index": 5, }, { @@ -755,6 +759,8 @@ async def test_get_lock_users_iterates_with_next_index( "user_type": "unrestricted_user", "credential_rule": "single", "credentials": [], + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_user_index": None, }, ], @@ -889,6 +895,8 @@ async def test_get_lock_users_with_credentials( {"type": "pin", "index": 1}, {"type": "pin", "index": 2}, ], + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_user_index": None, } ], @@ -942,6 +950,59 @@ async def test_get_lock_users_with_nullvalue_credentials( 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( @@ -1524,6 +1585,8 @@ async def test_get_lock_credential_status( 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, } @@ -1571,10 +1634,62 @@ async def test_get_lock_credential_status_empty_slot( 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 and normalizes NullValue.""" + 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,