1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

Matter lock manager (#28672)

* Initial implementation of Matter lock pin management and events.

- Implement lock codes
- Implement lock events
- Implement lock schedules and guest codes

* Initial implementation of Matter lock pin management and events.

- Implement lock codes
- Implement lock events
- Implement lock schedules and guest codes

* Initial implementation of Matter lock pin management and events.

- Implement lock codes
- Implement lock events
- Implement lock schedules and guest codes

* - Copilot fixes

* - Requested improvements on how the UI screens render including:
   - Cancel button location
   - Alignment of delete icons and buttons

* Updates to support new PR for backend

* Update as per PR comments

* Fixes to align to new backend design.

* Fixes for user deletion

* Fixes for PR comments

* Delete test/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json

* Remove unused code

* Updates with review feedback

* PR Comments

* Fixed linting error

* Fixes for new dialog changes

* Added debugging for errors, aligning to other areas where this is used.

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
This commit is contained in:
Alex Brown
2026-03-13 12:20:21 -04:00
committed by GitHub
parent e21f7baa93
commit d10376e9d8
10 changed files with 1357 additions and 0 deletions

132
src/data/matter-lock.ts Normal file
View File

@@ -0,0 +1,132 @@
import type { HomeAssistant } from "../types";
export interface MatterLockInfo {
supports_user_management: boolean;
supported_credential_types: string[];
max_users: number | null;
max_pin_users: number | null;
max_rfid_users: number | null;
max_credentials_per_user: number | null;
min_pin_length: number | null;
max_pin_length: number | null;
min_rfid_length: number | null;
max_rfid_length: number | null;
}
export type MatterLockCredentialType =
| "pin"
| "rfid"
| "fingerprint"
| "finger_vein"
| "face";
export type MatterLockUserType =
| "unrestricted_user"
| "year_day_schedule_user"
| "week_day_schedule_user"
| "programming_user"
| "non_access_user"
| "forced_user"
| "disposable_user"
| "expiring_user"
| "schedule_restricted_user"
| "remote_only_user";
export type MatterLockUserStatus =
| "available"
| "occupied_enabled"
| "occupied_disabled";
export type MatterLockCredentialRule = "single" | "dual" | "tri";
export interface MatterLockCredentialRef {
type: string;
index: number | null;
}
export interface MatterLockUser {
user_index: number | null;
user_name: string | null;
user_unique_id: number | null;
user_status: MatterLockUserStatus;
user_type: MatterLockUserType;
credential_rule: MatterLockCredentialRule;
credentials: MatterLockCredentialRef[];
next_user_index: number | null;
}
export interface MatterLockUsersResponse {
max_users: number;
users: MatterLockUser[];
}
export interface SetMatterLockUserParams {
user_index?: number;
user_name?: string | null;
user_type?: MatterLockUserType;
credential_rule?: MatterLockCredentialRule;
}
export interface SetMatterLockCredentialParams {
credential_type: MatterLockCredentialType;
credential_data: string;
credential_index?: number | null;
user_index?: number | null;
user_status?: MatterLockUserStatus;
user_type?: MatterLockUserType;
}
export interface SetMatterLockCredentialResult {
credential_index: number;
user_index: number | null;
next_credential_index: number | null;
}
export const getMatterLockInfo = async (
hass: HomeAssistant,
entity_id: string
): Promise<MatterLockInfo> => {
const result = await hass.callService<Record<string, MatterLockInfo>>(
"matter",
"get_lock_info",
{},
{ entity_id },
true,
true
);
return result.response![entity_id];
};
export const getMatterLockUsers = async (
hass: HomeAssistant,
entity_id: string
): Promise<MatterLockUsersResponse> => {
const result = await hass.callService<
Record<string, MatterLockUsersResponse>
>("matter", "get_lock_users", {}, { entity_id }, true, true);
return result.response![entity_id];
};
export const setMatterLockUser = (
hass: HomeAssistant,
entity_id: string,
params: SetMatterLockUserParams
) => hass.callService("matter", "set_lock_user", params, { entity_id });
export const clearMatterLockUser = (
hass: HomeAssistant,
entity_id: string,
user_index: number
) =>
hass.callService("matter", "clear_lock_user", { user_index }, { entity_id });
export const setMatterLockCredential = async (
hass: HomeAssistant,
entity_id: string,
params: SetMatterLockCredentialParams
): Promise<SetMatterLockCredentialResult> => {
const result = await hass.callService<
Record<string, SetMatterLockCredentialResult>
>("matter", "set_lock_credential", params, { entity_id }, true, true);
return result.response![entity_id];
};

View File

@@ -1,5 +1,6 @@
import {
mdiAccessPoint,
mdiAccountLock,
mdiChatProcessing,
mdiChatQuestion,
mdiExportVariant,
@@ -10,11 +11,13 @@ import {
NetworkType,
getMatterNodeDiagnostics,
} from "../../../../../../data/matter";
import { getMatterLockInfo } from "../../../../../../data/matter-lock";
import type { HomeAssistant } from "../../../../../../types";
import { showMatterManageFabricsDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-manage-fabrics";
import { showMatterOpenCommissioningWindowDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-open-commissioning-window";
import { showMatterPingNodeDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-ping-node";
import { showMatterReinterviewNodeDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-reinterview-node";
import { showMatterLockManageDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-lock-manage";
import type { DeviceAction } from "../../../ha-config-device-page";
export const getMatterDeviceDefaultActions = (
@@ -99,5 +102,31 @@ export const getMatterDeviceActions = async (
});
}
// Check if this device has a lock entity and supports user management
const lockEntity = Object.values(hass.entities).find(
(entity) =>
entity.device_id === device.id && entity.entity_id.startsWith("lock.")
);
if (lockEntity) {
try {
const lockInfo = await getMatterLockInfo(hass, lockEntity.entity_id);
if (lockInfo.supports_user_management) {
actions.push({
label: hass.localize(
"ui.panel.config.matter.device_actions.manage_lock"
),
icon: mdiAccountLock,
action: () =>
showMatterLockManageDialog(el, {
entity_id: lockEntity.entity_id,
}),
});
}
} catch {
// Lock info not available, skip lock management action
}
}
return actions;
};

View File

@@ -0,0 +1,246 @@
import {
mdiLock,
mdiLockOpen,
mdiLockAlert,
mdiAlertCircle,
mdiKeyAlert,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../../components/ha-expansion-panel";
import "../../../../../../components/ha-svg-icon";
import type { DeviceRegistryEntry } from "../../../../../../data/device/device_registry";
import { haStyle } from "../../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../../types";
interface LockEvent {
event_type: string;
timestamp: Date;
description: string;
}
const EVENT_ICONS: Record<string, string> = {
lock: mdiLock,
unlock: mdiLockOpen,
lock_jammed: mdiLockAlert,
lock_failure: mdiAlertCircle,
invalid_pin: mdiKeyAlert,
};
@customElement("ha-device-info-matter-lock")
export class HaDeviceInfoMatterLock extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@state() private _events: LockEvent[] = [];
@state() private _lockEntityId?: string;
@state() private _isLock = false;
private _prevLockState?: HassEntity;
private _eventEntityIds?: string[];
public willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("device")) {
this._findLockEntity();
}
if (changedProperties.has("hass") && this._lockEntityId) {
const currentState = this.hass.states[this._lockEntityId];
if (currentState !== this._prevLockState) {
this._prevLockState = currentState;
this._updateEvents();
}
}
}
private _findLockEntity(): void {
if (!this.hass || !this.device) {
return;
}
const entities = Object.values(this.hass.entities || {});
const lockEntity = entities.find(
(entity) =>
entity.device_id === this.device.id &&
entity.entity_id.startsWith("lock.")
);
this._lockEntityId = lockEntity?.entity_id;
this._isLock = !!this._lockEntityId;
// Cache event entity IDs for this device so we don't search on every update
this._eventEntityIds = entities
.filter(
(entity) =>
entity.device_id === this.device.id &&
entity.entity_id.startsWith("event.")
)
.map((entity) => entity.entity_id);
}
private _updateEvents(): void {
if (!this._lockEntityId || !this.hass.states[this._lockEntityId]) {
return;
}
const lockState: HassEntity = this.hass.states[this._lockEntityId];
const events: LockEvent[] = [];
// Add current state as most recent event
if (lockState.state === "locked") {
events.push({
event_type: "lock",
timestamp: new Date(lockState.last_changed),
description: this.hass.localize(
"ui.panel.config.matter.lock.events.types.lock"
),
});
} else if (lockState.state === "unlocked") {
events.push({
event_type: "unlock",
timestamp: new Date(lockState.last_changed),
description: this.hass.localize(
"ui.panel.config.matter.lock.events.types.unlock"
),
});
} else if (lockState.state === "jammed") {
events.push({
event_type: "lock_jammed",
timestamp: new Date(lockState.last_changed),
description: this.hass.localize(
"ui.panel.config.matter.lock.events.types.lock_jammed"
),
});
}
// Use cached event entity IDs instead of searching all entities
for (const entityId of this._eventEntityIds || []) {
const eventState = this.hass.states[entityId];
if (eventState && eventState.attributes.event_type) {
const eventType = eventState.attributes.event_type as string;
if (EVENT_ICONS[eventType]) {
events.push({
event_type: eventType,
timestamp: new Date(eventState.last_changed),
description:
this.hass.localize(
`ui.panel.config.matter.lock.events.types.${eventType}` as any
) || eventType,
});
}
}
}
// Sort by timestamp descending and take the first 10
this._events = events
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 10);
}
protected render() {
if (!this._isLock) {
return nothing;
}
return html`
<ha-expansion-panel
.header=${this.hass.localize(
"ui.panel.config.matter.lock.events.title"
)}
>
${this._events.length === 0
? html`<p class="empty">
${this.hass.localize(
"ui.panel.config.matter.lock.events.no_events"
)}
</p>`
: html`
<div class="events-list">
${this._events.map(
(event) => html`
<div class="event-row">
<ha-svg-icon
.path=${EVENT_ICONS[event.event_type] || mdiLock}
></ha-svg-icon>
<div class="event-details">
<span class="event-description"
>${event.description}</span
>
<span class="event-time">
${this._formatTime(event.timestamp)}
</span>
</div>
</div>
`
)}
</div>
`}
</ha-expansion-panel>
`;
}
private _formatTime(date: Date): string {
return date.toLocaleString(this.hass.locale.language, {
dateStyle: "short",
timeStyle: "short",
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-expansion-panel {
margin: 8px -16px 0;
--expansion-panel-summary-padding: 0 16px;
--expansion-panel-content-padding: 0 16px;
--ha-card-border-radius: var(--ha-border-radius-square);
}
.empty {
text-align: center;
color: var(--secondary-text-color);
padding: 16px;
}
.events-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.event-row {
display: flex;
align-items: center;
gap: 12px;
}
.event-row ha-svg-icon {
color: var(--secondary-text-color);
--mdc-icon-size: 20px;
}
.event-details {
display: flex;
flex-direction: column;
flex: 1;
}
.event-description {
font-weight: 500;
}
.event-time {
font-size: 0.875em;
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-info-matter-lock": HaDeviceInfoMatterLock;
}
}

View File

@@ -8,6 +8,7 @@ import { getMatterNodeDiagnostics } from "../../../../../../data/matter";
import { SubscribeMixin } from "../../../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../../types";
import "./ha-device-info-matter-lock";
@customElement("ha-device-info-matter")
export class HaDeviceInfoMatter extends SubscribeMixin(LitElement) {
@@ -124,6 +125,10 @@ export class HaDeviceInfoMatter extends SubscribeMixin(LitElement) {
>
</div>
</ha-expansion-panel>
<ha-device-info-matter-lock
.hass=${this.hass}
.device=${this.device}
></ha-device-info-matter-lock>
`;
}

View File

@@ -0,0 +1,285 @@
import { mdiDelete, mdiLock, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-dialog";
import type {
MatterLockInfo,
MatterLockUser,
} from "../../../../../data/matter-lock";
import {
getMatterLockInfo,
getMatterLockUsers,
clearMatterLockUser,
} from "../../../../../data/matter-lock";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../../dialogs/generic/show-dialog-box";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { MatterLockManageDialogParams } from "./show-dialog-matter-lock-manage";
import { showMatterLockUserEditDialog } from "./show-dialog-matter-lock-user-edit";
@customElement("dialog-matter-lock-manage")
class DialogMatterLockManage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _entityId?: string;
@state() private _lockInfo?: MatterLockInfo;
@state() private _users: MatterLockUser[] = [];
@state() private _loading = true;
@state() private _open = false;
public async showDialog(params: MatterLockManageDialogParams): Promise<void> {
this._entityId = params.entity_id;
this._loading = true;
this._open = true;
await this._fetchData();
}
private async _fetchData(): Promise<void> {
if (!this._entityId) {
return;
}
try {
this._lockInfo = await getMatterLockInfo(this.hass, this._entityId);
if (this._lockInfo.supports_user_management) {
const usersResponse = await getMatterLockUsers(
this.hass,
this._entityId
);
this._users = usersResponse.users;
}
} catch (err: unknown) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.lock.errors.load_failed"
),
text: (err as Error).message,
});
} finally {
this._loading = false;
}
}
protected render() {
if (!this._entityId) {
return nothing;
}
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.matter.lock.dialog_title"
)}
@closed=${this._dialogClosed}
>
${this._loading
? html`<div class="center">
<ha-spinner></ha-spinner>
</div>`
: html`<div class="content">${this._renderUsers()}</div>`}
</ha-dialog>
`;
}
private _renderUsers() {
const occupiedUsers = this._users.filter(
(u) => u.user_status !== "available"
);
return html`
<div class="users-content">
${occupiedUsers.length === 0
? html`<p class="empty">
${this.hass.localize(
"ui.panel.config.matter.lock.users.no_users"
)}
</p>`
: html`
<ha-md-list>
${occupiedUsers.map(
(user) => html`
<ha-md-list-item
type="button"
.user=${user}
@click=${this._handleUserClick}
>
<div slot="start" class="icon-background">
<ha-svg-icon .path=${mdiLock}></ha-svg-icon>
</div>
<div slot="headline">
${user.user_name || `User ${user.user_index}`}
</div>
<div slot="supporting-text">
${this.hass.localize(
`ui.panel.config.matter.lock.users.user_type.${user.user_type}`
)}
${user.credentials.length > 0
? ` - ${user.credentials.length} ${this.hass.localize("ui.panel.config.matter.lock.users.credentials").toLowerCase()}`
: ""}
</div>
<ha-icon-button
slot="end"
.path=${mdiDelete}
.user=${user}
@click=${this._handleDeleteUserClick}
></ha-icon-button>
</ha-md-list-item>
`
)}
</ha-md-list>
`}
<div class="actions">
<ha-button @click=${this._addUser}>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.matter.lock.users.add")}
</ha-button>
</div>
</div>
`;
}
private _handleUserClick(ev: Event): void {
// Ignore clicks that originated from the delete button
const path = ev.composedPath();
if (path.some((el) => (el as HTMLElement).tagName === "HA-ICON-BUTTON")) {
return;
}
const user = (ev.currentTarget as any).user as MatterLockUser;
this._editUser(user);
}
private _handleDeleteUserClick(ev: Event): void {
ev.preventDefault();
ev.stopPropagation();
const user = (ev.currentTarget as any).user as MatterLockUser;
this._deleteUser(user);
}
private _addUser(): void {
showMatterLockUserEditDialog(this, {
entity_id: this._entityId!,
lockInfo: this._lockInfo!,
onSaved: () => this._fetchData(),
});
}
private _editUser(user: MatterLockUser): void {
showMatterLockUserEditDialog(this, {
entity_id: this._entityId!,
lockInfo: this._lockInfo!,
user,
onSaved: () => this._fetchData(),
});
}
private async _deleteUser(user: MatterLockUser): Promise<void> {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.matter.lock.users.delete"),
text: this.hass.localize(
"ui.panel.config.matter.lock.confirm_delete_user",
{
name: user.user_name || `User ${user.user_index}`,
}
),
destructive: true,
});
if (!confirmed) {
return;
}
try {
await clearMatterLockUser(
this.hass,
this._entityId!,
user.user_index as number
);
} catch (err: unknown) {
// Some locks auto-remove the user when the last credential is cleared,
// which may cause the subsequent clear_user call to fail.
// eslint-disable-next-line no-console
console.debug("Failed to clear lock user:", err);
}
await this._fetchData();
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._entityId = undefined;
this._lockInfo = undefined;
this._users = [];
this._loading = true;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.center {
display: flex;
align-items: center;
justify-content: center;
padding: var(--ha-space-6);
}
.content {
min-height: 300px;
}
.users-content {
padding: var(--ha-space-4) 0;
}
.empty {
text-align: center;
color: var(--secondary-text-color);
padding: var(--ha-space-6);
}
.actions {
padding: var(--ha-space-2) var(--ha-space-6);
display: flex;
justify-content: flex-end;
}
.icon-background {
border-radius: var(--ha-border-radius-circle);
background-color: var(--primary-color);
color: #fff;
display: flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-matter-lock-manage": DialogMatterLockManage;
}
}

View File

@@ -0,0 +1,309 @@
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-select-box";
import type { SelectBoxOption } from "../../../../../components/ha-select-box";
import "../../../../../components/ha-textfield";
import "../../../../../components/ha-dialog";
import type { MatterLockUserType } from "../../../../../data/matter-lock";
import {
setMatterLockCredential,
setMatterLockUser,
} from "../../../../../data/matter-lock";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { MatterLockUserEditDialogParams } from "./show-dialog-matter-lock-user-edit";
const SIMPLE_USER_TYPES: MatterLockUserType[] = [
"unrestricted_user",
"disposable_user",
];
@customElement("dialog-matter-lock-user-edit")
class DialogMatterLockUserEdit extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: MatterLockUserEditDialogParams;
@state() private _userName = "";
@state() private _userType: MatterLockUserType = "unrestricted_user";
@state() private _pinCode = "";
@state() private _saving = false;
@state() private _error = "";
@state() private _open = false;
public async showDialog(
params: MatterLockUserEditDialogParams
): Promise<void> {
this._params = params;
this._error = "";
this._pinCode = "";
this._open = true;
if (params.user) {
this._userName = params.user.user_name || "";
this._userType = params.user.user_type;
} else {
this._userName = "";
this._userType = "unrestricted_user";
}
}
protected render() {
if (!this._params) {
return nothing;
}
const isNew = !this._params.user;
const title = isNew
? this.hass.localize("ui.panel.config.matter.lock.users.add")
: this.hass.localize("ui.panel.config.matter.lock.users.edit");
const minPin = this._params.lockInfo?.min_pin_length || 4;
const maxPin = this._params.lockInfo?.max_pin_length || 8;
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${title}
@closed=${this._dialogClosed}
>
<div class="form">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.matter.lock.users.name"
)}
.value=${this._userName}
@input=${this._handleNameChange}
maxlength="10"
></ha-textfield>
${isNew
? html`
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.matter.lock.credentials.data"
)}
.value=${this._pinCode}
@input=${this._handlePinChange}
type="password"
inputmode="numeric"
pattern="[0-9]*"
placeholder=${this.hass.localize(
"ui.panel.config.matter.lock.errors.pin_placeholder",
{ min: minPin, max: maxPin }
)}
minlength=${minPin}
maxlength=${maxPin}
required
></ha-textfield>
`
: nothing}
<div class="user-type-section">
<label
>${this.hass.localize(
"ui.panel.config.matter.lock.users.type"
)}</label
>
<ha-select-box
.options=${this._userTypeOptions}
.value=${this._userType}
.maxColumns=${1}
@value-changed=${this._handleUserTypeChanged}
></ha-select-box>
</div>
</div>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._saving}
>
${this._saving
? html`<ha-spinner size="small"></ha-spinner>`
: isNew
? this.hass.localize("ui.panel.config.matter.lock.users.add")
: this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _handleNameChange(ev: InputEvent): void {
this._userName = (ev.target as HTMLInputElement).value;
}
private _handlePinChange(ev: InputEvent): void {
const value = (ev.target as HTMLInputElement).value.replace(/\D/g, "");
this._pinCode = value;
(ev.target as HTMLInputElement).value = value;
}
private get _userTypeOptions(): SelectBoxOption[] {
return SIMPLE_USER_TYPES.map((type) => ({
value: type,
label: this.hass.localize(
`ui.panel.config.matter.lock.users.user_types.${type}.label` as any
),
description: this.hass.localize(
`ui.panel.config.matter.lock.users.user_types.${type}.description` as any
),
}));
}
private _handleUserTypeChanged(ev: CustomEvent): void {
this._userType = ev.detail.value as MatterLockUserType;
}
private async _save(): Promise<void> {
if (!this._params) {
return;
}
this._error = "";
const isNew = !this._params.user;
const minPin = this._params.lockInfo?.min_pin_length || 4;
const maxPin = this._params.lockInfo?.max_pin_length || 8;
if (!this._userName.trim()) {
this._error = this.hass.localize(
"ui.panel.config.matter.lock.errors.name_required"
);
return;
}
if (isNew) {
if (!this._pinCode) {
this._error = this.hass.localize(
"ui.panel.config.matter.lock.errors.pin_required"
);
return;
}
if (this._pinCode.length < minPin || this._pinCode.length > maxPin) {
this._error = this.hass.localize(
"ui.panel.config.matter.lock.errors.pin_length",
{ min: minPin, max: maxPin }
);
return;
}
if (!/^\d+$/.test(this._pinCode)) {
this._error = this.hass.localize(
"ui.panel.config.matter.lock.errors.pin_digits_only"
);
return;
}
}
this._saving = true;
try {
if (isNew) {
// Create credential (auto-creates a user), then set the user name
const result = await setMatterLockCredential(
this.hass,
this._params.entity_id,
{
credential_type: "pin",
credential_data: this._pinCode,
user_type: this._userType,
}
);
if (result.user_index !== null && this._userName.trim()) {
await setMatterLockUser(this.hass, this._params.entity_id, {
user_index: result.user_index,
user_name: this._userName.trim(),
});
}
} else {
await setMatterLockUser(this.hass, this._params.entity_id, {
user_index: this._params.user!.user_index as number,
user_name: this._userName.trim(),
user_type: this._userType,
});
}
this._params.onSaved();
this.closeDialog();
} catch (err: unknown) {
this._error =
(err as Error).message ||
this.hass.localize("ui.panel.config.matter.lock.errors.save_failed");
} finally {
this._saving = false;
}
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
this._userName = "";
this._userType = "unrestricted_user";
this._pinCode = "";
this._saving = false;
this._error = "";
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.form {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
.user-type-section {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.user-type-section > label {
font-weight: 500;
color: var(--primary-text-color);
}
ha-alert {
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-matter-lock-user-edit": DialogMatterLockUserEdit;
}
}

View File

@@ -0,0 +1,19 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
export interface MatterLockManageDialogParams {
entity_id: string;
}
export const loadMatterLockManageDialog = () =>
import("./dialog-matter-lock-manage");
export const showMatterLockManageDialog = (
element: HTMLElement,
dialogParams: MatterLockManageDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-matter-lock-manage",
dialogImport: loadMatterLockManageDialog,
dialogParams,
});
};

View File

@@ -0,0 +1,26 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import type {
MatterLockInfo,
MatterLockUser,
} from "../../../../../data/matter-lock";
export interface MatterLockUserEditDialogParams {
entity_id: string;
lockInfo: MatterLockInfo;
user?: MatterLockUser;
onSaved: () => void;
}
export const loadMatterLockUserEditDialog = () =>
import("./dialog-matter-lock-user-edit");
export const showMatterLockUserEditDialog = (
element: HTMLElement,
dialogParams: MatterLockUserEditDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-matter-lock-user-edit",
dialogImport: loadMatterLockUserEditDialog,
dialogParams,
});
};

View File

@@ -7656,6 +7656,7 @@
"ping_device": "Ping device",
"open_commissioning_window": "Share device",
"manage_fabrics": "Manage fabrics",
"manage_lock": "Manage lock",
"view_thread_network": "View Thread network"
},
"manage_fabrics": {
@@ -7696,6 +7697,102 @@
"success": "Your device is ready to be added to another Matter platform.",
"scan_code": "With their app, scan the QR code or enter the sharing code below to finish setup.",
"copy_code": "Copy code"
},
"lock": {
"manage": "Manage lock",
"dialog_title": "Manage lock",
"users": {
"title": "Users",
"add": "Add user",
"edit": "Edit user",
"delete": "Delete user",
"name": "Name",
"status": "Status",
"type": "Type",
"credential_rule": "Credential rule",
"credentials": "Credentials",
"no_users": "No users configured",
"user_status": {
"available": "Available",
"occupied_enabled": "Enabled",
"occupied_disabled": "Disabled"
},
"user_type": {
"unrestricted_user": "Unrestricted",
"year_day_schedule_user": "Day-of-the-year schedule",
"week_day_schedule_user": "Day-of-the-week schedule",
"programming_user": "Programming",
"non_access_user": "Non-access",
"forced_user": "Forced",
"disposable_user": "Disposable",
"expiring_user": "Expiring",
"schedule_restricted_user": "Schedule-restricted",
"remote_only_user": "Remote-only"
},
"credential_rules": {
"single": "Single credential",
"dual": "Dual credentials",
"tri": "Triple credentials"
},
"user_types": {
"unrestricted_user": {
"label": "Full access",
"description": "Can lock/unlock anytime, 24/7"
},
"disposable_user": {
"label": "One-time access",
"description": "Code works once, then is deleted"
}
}
},
"credentials": {
"add": "Add credential",
"edit": "Edit credential",
"delete": "Delete credential",
"type": "Type",
"data": "PIN code",
"pin": "PIN",
"types": {
"pin": "PIN code",
"rfid": "RFID",
"fingerprint": "Fingerprint",
"finger_vein": "Finger vein",
"face": "Face",
"aliro_credential": "Aliro credential",
"aliro_evictable": "Aliro evictable",
"aliro_non_evictable": "Aliro non-evictable"
}
},
"events": {
"title": "Recent lock events",
"types": {
"lock": "Locked",
"unlock": "Unlocked",
"lock_jammed": "Lock jammed",
"lock_failure": "Lock failure",
"invalid_pin": "Invalid PIN"
},
"sources": {
"manual": "Manual",
"pin": "PIN",
"remote": "Remote",
"auto": "Auto"
},
"no_events": "No recent events"
},
"errors": {
"load_failed": "Failed to load lock information",
"save_failed": "Failed to save changes",
"not_supported": "This feature is not supported by your lock",
"user_not_found": "User not found",
"name_required": "Please enter a name for this user.",
"pin_required": "Please enter a PIN code.",
"pin_length": "PIN code must be {min}-{max} digits.",
"pin_digits_only": "PIN code must contain only numbers.",
"pin_placeholder": "{min}-{max} digits"
},
"confirm_delete_user": "Are you sure you want to delete user {name}? All credentials for this user will also be deleted.",
"confirm_delete_credential": "Are you sure you want to delete this credential?"
}
},
"tips": {

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
getMatterLockInfo,
getMatterLockUsers,
setMatterLockUser,
clearMatterLockUser,
setMatterLockCredential,
} from "../../src/data/matter-lock";
import type { HomeAssistant } from "../../src/types";
const ENTITY_ID = "lock.front_door";
// Entity services wrap responses in a dict keyed by entity_id
const mockHass = (response?: unknown) =>
({
callService: vi
.fn()
.mockResolvedValue(
response !== undefined
? { response: { [ENTITY_ID]: response } }
: { response: undefined }
),
}) as unknown as HomeAssistant;
describe("matter-lock", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("getMatterLockInfo", () => {
it("calls the correct service with entity_id target and returnResponse", async () => {
const lockInfo = {
supports_user_management: true,
supported_credential_types: ["pin"],
max_users: 10,
max_pin_users: 10,
max_rfid_users: null,
max_credentials_per_user: 5,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: null,
max_rfid_length: null,
};
const hass = mockHass(lockInfo);
const result = await getMatterLockInfo(hass, ENTITY_ID);
expect(hass.callService).toHaveBeenCalledWith(
"matter",
"get_lock_info",
{},
{ entity_id: ENTITY_ID },
true,
true
);
expect(result).toEqual(lockInfo);
});
it("propagates errors from callService", async () => {
const hass = {
callService: vi
.fn()
.mockRejectedValue(new Error("Service unavailable")),
} as unknown as HomeAssistant;
await expect(getMatterLockInfo(hass, ENTITY_ID)).rejects.toThrow(
"Service unavailable"
);
});
});
describe("getMatterLockUsers", () => {
it("calls the correct service with entity_id target and returnResponse", async () => {
const usersResponse = {
max_users: 10,
users: [
{
user_index: 1,
user_name: "Alice",
user_unique_id: 42,
user_status: "occupied_enabled",
user_type: "unrestricted_user",
credential_rule: "single",
credentials: [{ type: "pin", index: 1 }],
next_user_index: 2,
},
],
};
const hass = mockHass(usersResponse);
const result = await getMatterLockUsers(hass, ENTITY_ID);
expect(hass.callService).toHaveBeenCalledWith(
"matter",
"get_lock_users",
{},
{ entity_id: ENTITY_ID },
true,
true
);
expect(result).toEqual(usersResponse);
expect(result.users).toHaveLength(1);
expect(result.users[0].user_name).toBe("Alice");
});
it("propagates errors from callService", async () => {
const hass = {
callService: vi.fn().mockRejectedValue(new Error("Not a lock")),
} as unknown as HomeAssistant;
await expect(getMatterLockUsers(hass, ENTITY_ID)).rejects.toThrow(
"Not a lock"
);
});
});
describe("setMatterLockUser", () => {
it("calls the correct service with params and entity_id target", async () => {
const hass = mockHass();
await setMatterLockUser(hass, ENTITY_ID, {
user_index: 1,
user_name: "Bob",
user_type: "unrestricted_user",
});
expect(hass.callService).toHaveBeenCalledWith(
"matter",
"set_lock_user",
{ user_index: 1, user_name: "Bob", user_type: "unrestricted_user" },
{ entity_id: ENTITY_ID }
);
});
it("can be called with partial params", async () => {
const hass = mockHass();
await setMatterLockUser(hass, ENTITY_ID, { user_name: "Carol" });
expect(hass.callService).toHaveBeenCalledWith(
"matter",
"set_lock_user",
{ user_name: "Carol" },
{ entity_id: ENTITY_ID }
);
});
});
describe("clearMatterLockUser", () => {
it("calls the correct service with user_index and entity_id target", async () => {
const hass = mockHass();
await clearMatterLockUser(hass, ENTITY_ID, 2);
expect(hass.callService).toHaveBeenCalledWith(
"matter",
"clear_lock_user",
{ user_index: 2 },
{ entity_id: ENTITY_ID }
);
});
});
describe("setMatterLockCredential", () => {
it("calls the correct service and returns the response", async () => {
const credentialResult = {
credential_index: 1,
user_index: 3,
next_credential_index: 2,
};
const hass = mockHass(credentialResult);
const result = await setMatterLockCredential(hass, ENTITY_ID, {
credential_type: "pin",
credential_data: "1234",
user_type: "unrestricted_user",
});
expect(hass.callService).toHaveBeenCalledWith(
"matter",
"set_lock_credential",
{
credential_type: "pin",
credential_data: "1234",
user_type: "unrestricted_user",
},
{ entity_id: ENTITY_ID },
true,
true
);
expect(result).toEqual(credentialResult);
expect(result.credential_index).toBe(1);
expect(result.user_index).toBe(3);
});
it("propagates errors from callService", async () => {
const hass = {
callService: vi.fn().mockRejectedValue(new Error("Invalid PIN")),
} as unknown as HomeAssistant;
await expect(
setMatterLockCredential(hass, ENTITY_ID, {
credential_type: "pin",
credential_data: "abc",
})
).rejects.toThrow("Invalid PIN");
});
});
});