mirror of
https://github.com/home-assistant/core.git
synced 2026-09-08 06:31:35 +01:00
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
660 lines
22 KiB
Python
660 lines
22 KiB
Python
"""Lock helpers for Z-Wave JS credential management.
|
|
|
|
Provides business logic for user/credential CRUD, capability queries,
|
|
auto-find logic, and validation.
|
|
"""
|
|
|
|
import asyncio
|
|
from collections import defaultdict
|
|
import logging
|
|
from typing import TypedDict
|
|
|
|
from zwave_js_server.const.command_class.access_control import (
|
|
SetCredentialResult,
|
|
SetUserResult,
|
|
UserCredentialRule,
|
|
UserCredentialType,
|
|
UserCredentialUserType,
|
|
)
|
|
from zwave_js_server.exceptions import FailedZWaveCommand
|
|
from zwave_js_server.model.access_control import (
|
|
AddUserCredential,
|
|
SetUserOptions,
|
|
UserCredentialCapability,
|
|
)
|
|
from zwave_js_server.model.node import Node
|
|
|
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
|
|
|
from .const import (
|
|
CREDENTIAL_RULE_DUAL,
|
|
CREDENTIAL_RULE_SINGLE,
|
|
CREDENTIAL_RULE_TRIPLE,
|
|
CREDENTIAL_TYPE_BLE,
|
|
CREDENTIAL_TYPE_DESFIRE,
|
|
CREDENTIAL_TYPE_EYE_BIOMETRIC,
|
|
CREDENTIAL_TYPE_FACE_BIOMETRIC,
|
|
CREDENTIAL_TYPE_FINGER_BIOMETRIC,
|
|
CREDENTIAL_TYPE_HAND_BIOMETRIC,
|
|
CREDENTIAL_TYPE_NFC,
|
|
CREDENTIAL_TYPE_PASSWORD,
|
|
CREDENTIAL_TYPE_PIN_CODE,
|
|
CREDENTIAL_TYPE_RFID_CODE,
|
|
CREDENTIAL_TYPE_UNSPECIFIED_BIOMETRIC,
|
|
CREDENTIAL_TYPE_UWB,
|
|
DOMAIN,
|
|
USER_TYPE_DISPOSABLE,
|
|
USER_TYPE_DURESS,
|
|
USER_TYPE_EXPIRING,
|
|
USER_TYPE_GENERAL,
|
|
USER_TYPE_NON_ACCESS,
|
|
USER_TYPE_PROGRAMMING,
|
|
USER_TYPE_REMOTE_ONLY,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# --- Enum <-> string mappings ---
|
|
|
|
CREDENTIAL_TYPE_MAP: dict[UserCredentialType, str] = {
|
|
UserCredentialType.PIN_CODE: CREDENTIAL_TYPE_PIN_CODE,
|
|
UserCredentialType.PASSWORD: CREDENTIAL_TYPE_PASSWORD,
|
|
UserCredentialType.RFID_CODE: CREDENTIAL_TYPE_RFID_CODE,
|
|
UserCredentialType.BLE: CREDENTIAL_TYPE_BLE,
|
|
UserCredentialType.NFC: CREDENTIAL_TYPE_NFC,
|
|
UserCredentialType.UWB: CREDENTIAL_TYPE_UWB,
|
|
UserCredentialType.EYE_BIOMETRIC: CREDENTIAL_TYPE_EYE_BIOMETRIC,
|
|
UserCredentialType.FACE_BIOMETRIC: CREDENTIAL_TYPE_FACE_BIOMETRIC,
|
|
UserCredentialType.FINGER_BIOMETRIC: CREDENTIAL_TYPE_FINGER_BIOMETRIC,
|
|
UserCredentialType.HAND_BIOMETRIC: CREDENTIAL_TYPE_HAND_BIOMETRIC,
|
|
UserCredentialType.UNSPECIFIED_BIOMETRIC: CREDENTIAL_TYPE_UNSPECIFIED_BIOMETRIC,
|
|
UserCredentialType.DESFIRE: CREDENTIAL_TYPE_DESFIRE,
|
|
}
|
|
CREDENTIAL_TYPE_REVERSE_MAP: dict[str, UserCredentialType] = {
|
|
v: k for k, v in CREDENTIAL_TYPE_MAP.items()
|
|
}
|
|
|
|
USER_TYPE_MAP: dict[UserCredentialUserType, str] = {
|
|
UserCredentialUserType.GENERAL: USER_TYPE_GENERAL,
|
|
UserCredentialUserType.PROGRAMMING: USER_TYPE_PROGRAMMING,
|
|
UserCredentialUserType.NON_ACCESS: USER_TYPE_NON_ACCESS,
|
|
UserCredentialUserType.DURESS: USER_TYPE_DURESS,
|
|
UserCredentialUserType.DISPOSABLE: USER_TYPE_DISPOSABLE,
|
|
UserCredentialUserType.EXPIRING: USER_TYPE_EXPIRING,
|
|
UserCredentialUserType.REMOTE_ONLY: USER_TYPE_REMOTE_ONLY,
|
|
}
|
|
USER_TYPE_REVERSE_MAP: dict[str, UserCredentialUserType] = {
|
|
v: k for k, v in USER_TYPE_MAP.items()
|
|
}
|
|
|
|
CREDENTIAL_RULE_MAP: dict[UserCredentialRule, str] = {
|
|
UserCredentialRule.SINGLE: CREDENTIAL_RULE_SINGLE,
|
|
UserCredentialRule.DUAL: CREDENTIAL_RULE_DUAL,
|
|
UserCredentialRule.TRIPLE: CREDENTIAL_RULE_TRIPLE,
|
|
}
|
|
CREDENTIAL_RULE_REVERSE_MAP: dict[str, UserCredentialRule] = {
|
|
v: k for k, v in CREDENTIAL_RULE_MAP.items()
|
|
}
|
|
|
|
|
|
_SET_USER_RESULT_KEYS: dict[SetUserResult, str] = {
|
|
SetUserResult.ERROR_ADD_REJECTED_LOCATION_OCCUPIED: "user_rejected_add_occupied",
|
|
SetUserResult.ERROR_MODIFY_REJECTED_LOCATION_EMPTY: "user_rejected_modify_empty",
|
|
SetUserResult.ERROR_UNKNOWN: "user_rejected_unknown",
|
|
}
|
|
|
|
_SET_CREDENTIAL_RESULT_KEYS: dict[SetCredentialResult, str] = {
|
|
SetCredentialResult.ERROR_ADD_REJECTED_LOCATION_OCCUPIED: (
|
|
"credential_rejected_add_occupied"
|
|
),
|
|
SetCredentialResult.ERROR_MODIFY_REJECTED_LOCATION_EMPTY: (
|
|
"credential_rejected_modify_empty"
|
|
),
|
|
SetCredentialResult.ERROR_DUPLICATE_CREDENTIAL: "credential_rejected_duplicate",
|
|
SetCredentialResult.ERROR_MANUFACTURER_SECURITY_RULES: (
|
|
"credential_rejected_manufacturer_rules"
|
|
),
|
|
SetCredentialResult.ERROR_DUPLICATE_ADMIN_PIN_CODE: "credential_rejected_duplicate",
|
|
SetCredentialResult.ERROR_WRONG_USER_UNIQUE_IDENTIFIER: (
|
|
"credential_rejected_wrong_uuid"
|
|
),
|
|
SetCredentialResult.ERROR_UNKNOWN: "credential_rejected_unknown",
|
|
}
|
|
|
|
|
|
def _raise_on_set_user_error(status: SetUserResult) -> None:
|
|
"""Raise HomeAssistantError when a user-mutation command is rejected."""
|
|
if status is SetUserResult.OK:
|
|
return
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key=_SET_USER_RESULT_KEYS.get(status, "user_rejected_unknown"),
|
|
)
|
|
|
|
|
|
def _raise_on_set_credential_error(status: SetCredentialResult) -> None:
|
|
"""Raise HomeAssistantError when a credential-mutation command is rejected."""
|
|
if status is SetCredentialResult.OK:
|
|
return
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key=_SET_CREDENTIAL_RESULT_KEYS.get(
|
|
status, "credential_rejected_unknown"
|
|
),
|
|
)
|
|
|
|
|
|
# --- TypedDicts for structured return values ---
|
|
|
|
|
|
class CredentialTypeCapability(TypedDict):
|
|
"""Capability info for a single credential type."""
|
|
|
|
num_slots: int
|
|
min_length: int
|
|
max_length: int
|
|
supports_learn: bool
|
|
|
|
|
|
class CredentialCapabilitiesResult(TypedDict):
|
|
"""Return type for get_credential_capabilities."""
|
|
|
|
supports_user_management: bool
|
|
max_users: int
|
|
supported_user_types: list[str]
|
|
max_user_name_length: int
|
|
supported_credential_rules: list[str]
|
|
supported_credential_types: dict[str, CredentialTypeCapability]
|
|
|
|
|
|
class Credential(TypedDict):
|
|
"""A credential reference within a user entry."""
|
|
|
|
type: str
|
|
slot: int
|
|
|
|
|
|
class UserEntry(TypedDict):
|
|
"""A single user entry in the users list."""
|
|
|
|
user_id: int
|
|
user_name: str | None
|
|
active: bool
|
|
user_type: str
|
|
credential_rule: str | None
|
|
credentials: list[Credential]
|
|
|
|
|
|
class UsersResult(TypedDict):
|
|
"""Return type for get_users."""
|
|
|
|
max_users: int
|
|
users: list[UserEntry]
|
|
|
|
|
|
class SetUserReturn(TypedDict):
|
|
"""Return type for set_user."""
|
|
|
|
user_id: int
|
|
# None unless a credential was written in the same call.
|
|
credential_slot: int | None
|
|
|
|
|
|
class SetCredentialReturn(TypedDict):
|
|
"""Return type for set_credential."""
|
|
|
|
credential_slot: int
|
|
user_id: int
|
|
|
|
|
|
async def _validate_credential_data(
|
|
node: Node,
|
|
credential_type: UserCredentialType,
|
|
credential_data: str,
|
|
) -> UserCredentialCapability:
|
|
"""Validate a credential payload against the device capabilities.
|
|
|
|
Returns the capability for the credential type so callers can reuse its
|
|
slot information.
|
|
"""
|
|
cred_type_str = CREDENTIAL_TYPE_MAP.get(credential_type, str(credential_type))
|
|
cred_caps = await node.access_control.get_credential_capabilities_cached()
|
|
type_cap = cred_caps.supported_credential_types.get(credential_type)
|
|
if type_cap is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="credential_type_not_supported",
|
|
translation_placeholders={"credential_type": cred_type_str},
|
|
)
|
|
|
|
if not (
|
|
type_cap.min_credential_length
|
|
<= len(credential_data)
|
|
<= type_cap.max_credential_length
|
|
):
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="credential_data_invalid_length",
|
|
translation_placeholders={
|
|
"credential_type": cred_type_str,
|
|
"min_length": str(type_cap.min_credential_length),
|
|
"max_length": str(type_cap.max_credential_length),
|
|
},
|
|
)
|
|
if credential_type is UserCredentialType.PIN_CODE and not (
|
|
credential_data.isascii() and credential_data.isdigit()
|
|
):
|
|
# str.isdigit() accepts non-ASCII digit code points (e.g. Arabic-Indic),
|
|
# which the lock firmware cannot store. Restrict to ASCII 0-9.
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="credential_data_pin_not_digits",
|
|
)
|
|
|
|
return type_cap
|
|
|
|
|
|
async def _async_find_available_user_slot(node: Node) -> int:
|
|
"""Return the first unused user slot, raising if the lock is full."""
|
|
user_caps = await node.access_control.get_user_capabilities_cached()
|
|
users = await node.access_control.get_users_cached()
|
|
used_ids = {u.user_id for u in users}
|
|
user_id = next(
|
|
(i for i in range(1, user_caps.max_users + 1) if i not in used_ids),
|
|
None,
|
|
)
|
|
if user_id is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_available_user_slots",
|
|
)
|
|
return user_id
|
|
|
|
|
|
async def _async_find_available_credential_slot(
|
|
node: Node,
|
|
credential_type: UserCredentialType,
|
|
type_cap: UserCredentialCapability,
|
|
) -> int:
|
|
"""Return the first unused slot for a credential type, raising if full."""
|
|
existing = await node.access_control.get_credentials_by_type_cached(credential_type)
|
|
used_slots = {c.slot for c in existing}
|
|
slot = next(
|
|
(
|
|
s
|
|
for s in range(1, type_cap.number_of_credential_slots + 1)
|
|
if s not in used_slots
|
|
),
|
|
None,
|
|
)
|
|
if slot is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_available_credential_slots",
|
|
translation_placeholders={
|
|
"credential_type": CREDENTIAL_TYPE_MAP.get(
|
|
credential_type, str(credential_type)
|
|
)
|
|
},
|
|
)
|
|
return slot
|
|
|
|
|
|
async def _async_resolve_credential_slot(
|
|
node: Node,
|
|
user_id: int,
|
|
credential_type: UserCredentialType,
|
|
type_cap: UserCredentialCapability,
|
|
credential_slot: int | None,
|
|
) -> int:
|
|
"""Resolve the slot a credential should be written to for a user."""
|
|
# An explicit slot is honored as-is.
|
|
if credential_slot is not None:
|
|
return credential_slot
|
|
# When the lock supports independent user and credentials, find the first
|
|
# available slot for the credential.
|
|
user_caps = await node.access_control.get_user_capabilities_cached()
|
|
if user_caps.supports_users_without_credentials:
|
|
return await _async_find_available_credential_slot(
|
|
node, credential_type, type_cap
|
|
)
|
|
# Otherwise the credential must live in the user's own slot.
|
|
return user_id
|
|
|
|
|
|
# --- Business logic functions ---
|
|
|
|
|
|
async def async_get_credential_capabilities(
|
|
node: Node,
|
|
) -> CredentialCapabilitiesResult:
|
|
"""Query access-control capabilities for the node."""
|
|
supported = await node.access_control.is_supported()
|
|
if not supported:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="access_control_not_supported",
|
|
)
|
|
|
|
user_caps = await node.access_control.get_user_capabilities_cached()
|
|
cred_caps = await node.access_control.get_credential_capabilities_cached()
|
|
|
|
supported_credential_types: dict[str, CredentialTypeCapability] = {}
|
|
for cred_type, capability in cred_caps.supported_credential_types.items():
|
|
type_str = CREDENTIAL_TYPE_MAP.get(cred_type)
|
|
if type_str is None:
|
|
continue
|
|
supported_credential_types[type_str] = CredentialTypeCapability(
|
|
num_slots=capability.number_of_credential_slots,
|
|
min_length=capability.min_credential_length,
|
|
max_length=capability.max_credential_length,
|
|
supports_learn=capability.supports_credential_learn,
|
|
)
|
|
|
|
return CredentialCapabilitiesResult(
|
|
supports_user_management=True,
|
|
max_users=user_caps.max_users,
|
|
supported_user_types=[
|
|
USER_TYPE_MAP[ut]
|
|
for ut in user_caps.supported_user_types
|
|
if ut in USER_TYPE_MAP
|
|
],
|
|
max_user_name_length=user_caps.max_user_name_length or 0,
|
|
supported_credential_rules=[
|
|
CREDENTIAL_RULE_MAP[cr]
|
|
for cr in user_caps.supported_credential_rules
|
|
if cr in CREDENTIAL_RULE_MAP
|
|
],
|
|
supported_credential_types=supported_credential_types,
|
|
)
|
|
|
|
|
|
async def async_get_users(node: Node) -> UsersResult:
|
|
"""List all users with their credential references."""
|
|
supported = await node.access_control.is_supported()
|
|
if not supported:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="access_control_not_supported",
|
|
)
|
|
|
|
user_caps = await node.access_control.get_user_capabilities_cached()
|
|
users = await node.access_control.get_users_cached()
|
|
all_credentials = await node.access_control.get_all_credentials_cached()
|
|
|
|
credentials_by_user: defaultdict[int, list[Credential]] = defaultdict(list)
|
|
for cred in all_credentials:
|
|
credentials_by_user[cred.user_id].append(
|
|
Credential(
|
|
type=CREDENTIAL_TYPE_MAP.get(cred.type, str(cred.type)),
|
|
slot=cred.slot,
|
|
)
|
|
)
|
|
|
|
user_list: list[UserEntry] = [
|
|
UserEntry(
|
|
user_id=user.user_id,
|
|
user_name=user.user_name,
|
|
active=user.active,
|
|
user_type=USER_TYPE_MAP.get(user.user_type, str(user.user_type)),
|
|
credential_rule=(
|
|
CREDENTIAL_RULE_MAP.get(user.credential_rule)
|
|
if user.credential_rule is not None
|
|
else None
|
|
),
|
|
credentials=credentials_by_user.get(user.user_id, []),
|
|
)
|
|
for user in users
|
|
]
|
|
|
|
return UsersResult(
|
|
max_users=user_caps.max_users,
|
|
users=user_list,
|
|
)
|
|
|
|
|
|
async def async_set_user(
|
|
node: Node,
|
|
user_id: int | None = None,
|
|
user_name: str | None = None,
|
|
user_type: UserCredentialUserType | None = None,
|
|
credential_rule: UserCredentialRule | None = None,
|
|
active: bool | None = None,
|
|
credential_slot: int | None = None,
|
|
credential_type: UserCredentialType | None = None,
|
|
credential_data: str | None = None,
|
|
) -> SetUserReturn:
|
|
"""Create or update an access-control user, optionally with a credential."""
|
|
if not await node.access_control.is_supported():
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="access_control_not_supported",
|
|
)
|
|
|
|
options = SetUserOptions(
|
|
active=active,
|
|
user_type=user_type,
|
|
user_name=user_name,
|
|
credential_rule=credential_rule,
|
|
)
|
|
|
|
# No user_id => create a new user via addUser, which handles locks that require
|
|
# credentials to be created alongside their user.
|
|
if user_id is None:
|
|
return await _async_create_user(
|
|
node, options, credential_slot, credential_type, credential_data
|
|
)
|
|
|
|
# A user_id => update the existing user via setUser, then write any
|
|
# credential separately (addUser only creates new users).
|
|
status = await node.access_control.set_user(user_id, options)
|
|
_raise_on_set_user_error(status)
|
|
|
|
resolved_slot: int | None = None
|
|
if credential_type is not None and credential_data is not None:
|
|
type_cap = await _validate_credential_data(
|
|
node, credential_type, credential_data
|
|
)
|
|
resolved_slot = await _async_resolve_credential_slot(
|
|
node, user_id, credential_type, type_cap, credential_slot
|
|
)
|
|
cred_status = await node.access_control.set_credential(
|
|
user_id, credential_type, resolved_slot, credential_data
|
|
)
|
|
_raise_on_set_credential_error(cred_status)
|
|
|
|
return SetUserReturn(user_id=user_id, credential_slot=resolved_slot)
|
|
|
|
|
|
async def _async_create_user(
|
|
node: Node,
|
|
options: SetUserOptions,
|
|
credential_slot: int | None,
|
|
credential_type: UserCredentialType | None,
|
|
credential_data: str | None,
|
|
) -> SetUserReturn:
|
|
"""Create a new user via addUser, optionally bundling a credential."""
|
|
user_id = await _async_find_available_user_slot(node)
|
|
|
|
credential: AddUserCredential | None = None
|
|
resolved_slot: int | None = None
|
|
if credential_type is not None and credential_data is not None:
|
|
type_cap = await _validate_credential_data(
|
|
node, credential_type, credential_data
|
|
)
|
|
resolved_slot = await _async_resolve_credential_slot(
|
|
node, user_id, credential_type, type_cap, credential_slot
|
|
)
|
|
credential = AddUserCredential(
|
|
credential_type=credential_type,
|
|
credential_slot=resolved_slot,
|
|
data=credential_data,
|
|
)
|
|
else:
|
|
user_caps = await node.access_control.get_user_capabilities_cached()
|
|
if not user_caps.supports_users_without_credentials:
|
|
# On User Code CC a user cannot exist without its code, so zwave-js
|
|
# rejects addUser without a credential. Fail early with a clear
|
|
# error instead of letting the command fail downstream.
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="credential_required",
|
|
)
|
|
|
|
result = await node.access_control.add_user(user_id, options, credential)
|
|
_raise_on_set_user_error(result.user)
|
|
if (
|
|
result.credential is not None
|
|
and result.credential is not SetCredentialResult.OK
|
|
):
|
|
# addUser creates the user before writing the credential, so on User
|
|
# Credential CC a failed credential write leaves a credential-less user
|
|
# behind. Roll it back before surfacing the error. (On User Code CC the
|
|
# user and credential share a slot, so this cannot occur.)
|
|
try:
|
|
await node.access_control.delete_user(user_id)
|
|
except FailedZWaveCommand:
|
|
_LOGGER.warning(
|
|
"Could not roll back user %s after its credential failed to add",
|
|
user_id,
|
|
)
|
|
_raise_on_set_credential_error(result.credential)
|
|
|
|
return SetUserReturn(user_id=user_id, credential_slot=resolved_slot)
|
|
|
|
|
|
async def async_delete_user(node: Node, user_id: int) -> None:
|
|
"""Delete a single access-control user."""
|
|
if not await node.access_control.is_supported():
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="access_control_not_supported",
|
|
)
|
|
|
|
status = await node.access_control.delete_user(user_id)
|
|
_raise_on_set_user_error(status)
|
|
|
|
|
|
async def async_delete_all_users(node: Node) -> None:
|
|
"""Delete all access-control users."""
|
|
if not await node.access_control.is_supported():
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="access_control_not_supported",
|
|
)
|
|
|
|
status = await node.access_control.delete_all_users()
|
|
_raise_on_set_user_error(status)
|
|
|
|
|
|
async def async_set_credential(
|
|
node: Node,
|
|
user_id: int,
|
|
credential_type: UserCredentialType,
|
|
credential_data: str,
|
|
credential_slot: int | None = None,
|
|
) -> SetCredentialReturn:
|
|
"""Add or update a credential (PIN/password only).
|
|
|
|
user_id must refer to an existing user. To create a new user, call
|
|
async_set_user first, then pass the returned user_id here. This service
|
|
does not create or modify users.
|
|
"""
|
|
supported = await node.access_control.is_supported()
|
|
if not supported:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="access_control_not_supported",
|
|
)
|
|
|
|
type_cap = await _validate_credential_data(node, credential_type, credential_data)
|
|
|
|
if credential_slot is None:
|
|
credential_slot = await _async_find_available_credential_slot(
|
|
node, credential_type, type_cap
|
|
)
|
|
|
|
status = await node.access_control.set_credential(
|
|
user_id, credential_type, credential_slot, credential_data
|
|
)
|
|
_raise_on_set_credential_error(status)
|
|
|
|
return SetCredentialReturn(
|
|
credential_slot=credential_slot,
|
|
user_id=user_id,
|
|
)
|
|
|
|
|
|
async def async_delete_credential(
|
|
node: Node,
|
|
user_id: int,
|
|
credential_type: UserCredentialType,
|
|
credential_slot: int,
|
|
) -> None:
|
|
"""Delete a single credential.
|
|
|
|
On User Code CC the credential shares its user's slot, so zwave-js clears
|
|
the whole slot and deletes the user along with the credential; no special
|
|
handling is needed here.
|
|
"""
|
|
if not await node.access_control.is_supported():
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="access_control_not_supported",
|
|
)
|
|
|
|
status = await node.access_control.delete_credential(
|
|
user_id, credential_type, credential_slot
|
|
)
|
|
_raise_on_set_credential_error(status)
|
|
|
|
|
|
async def async_delete_all_credentials(node: Node, user_id: int) -> None:
|
|
"""Delete all credentials for a user."""
|
|
if not await node.access_control.is_supported():
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="access_control_not_supported",
|
|
)
|
|
|
|
credentials = await node.access_control.get_credentials_cached(user_id)
|
|
# Until Z-Wave JS exposes a bulk-delete API, we have to delete credentials one at a time.
|
|
# Use return_exceptions=True so a single failure does not cancel the remaining deletions
|
|
# and leave the user with a partially-deleted credential set.
|
|
results = await asyncio.gather(
|
|
*(
|
|
node.access_control.delete_credential(user_id, cred.type, cred.slot)
|
|
for cred in credentials
|
|
),
|
|
return_exceptions=True,
|
|
)
|
|
failures: list[tuple[int, BaseException]] = []
|
|
for cred, result in zip(credentials, results, strict=True):
|
|
if isinstance(result, BaseException):
|
|
failures.append((cred.slot, result))
|
|
continue
|
|
try:
|
|
_raise_on_set_credential_error(result)
|
|
except HomeAssistantError as err:
|
|
failures.append((cred.slot, err))
|
|
|
|
if not failures:
|
|
return
|
|
for slot, failure in failures:
|
|
_LOGGER.warning(
|
|
"Failed to delete credential at slot %s for user %s: %s",
|
|
slot,
|
|
user_id,
|
|
failure,
|
|
)
|
|
if len(failures) == 1:
|
|
raise failures[0][1]
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="delete_all_credentials_partial_failure",
|
|
translation_placeholders={
|
|
"user_id": str(user_id),
|
|
"failed_count": str(len(failures)),
|
|
},
|
|
)
|