diff --git a/src/data/matter-lock.ts b/src/data/matter-lock.ts new file mode 100644 index 0000000000..d5a45f06f4 --- /dev/null +++ b/src/data/matter-lock.ts @@ -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 => { + const result = await hass.callService>( + "matter", + "get_lock_info", + {}, + { entity_id }, + true, + true + ); + return result.response![entity_id]; +}; + +export const getMatterLockUsers = async ( + hass: HomeAssistant, + entity_id: string +): Promise => { + const result = await hass.callService< + Record + >("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 => { + const result = await hass.callService< + Record + >("matter", "set_lock_credential", params, { entity_id }, true, true); + return result.response![entity_id]; +}; diff --git a/src/panels/config/devices/device-detail/integration-elements/matter/device-actions.ts b/src/panels/config/devices/device-detail/integration-elements/matter/device-actions.ts index 73efbfa4a0..db8d292efc 100644 --- a/src/panels/config/devices/device-detail/integration-elements/matter/device-actions.ts +++ b/src/panels/config/devices/device-detail/integration-elements/matter/device-actions.ts @@ -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; }; diff --git a/src/panels/config/devices/device-detail/integration-elements/matter/ha-device-info-matter-lock.ts b/src/panels/config/devices/device-detail/integration-elements/matter/ha-device-info-matter-lock.ts new file mode 100644 index 0000000000..ff2bdbffa6 --- /dev/null +++ b/src/panels/config/devices/device-detail/integration-elements/matter/ha-device-info-matter-lock.ts @@ -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 = { + 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` + + ${this._events.length === 0 + ? html`

+ ${this.hass.localize( + "ui.panel.config.matter.lock.events.no_events" + )} +

` + : html` +
+ ${this._events.map( + (event) => html` +
+ +
+ ${event.description} + + ${this._formatTime(event.timestamp)} + +
+
+ ` + )} +
+ `} +
+ `; + } + + 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; + } +} diff --git a/src/panels/config/devices/device-detail/integration-elements/matter/ha-device-info-matter.ts b/src/panels/config/devices/device-detail/integration-elements/matter/ha-device-info-matter.ts index 16009a2a9c..d52764db2c 100644 --- a/src/panels/config/devices/device-detail/integration-elements/matter/ha-device-info-matter.ts +++ b/src/panels/config/devices/device-detail/integration-elements/matter/ha-device-info-matter.ts @@ -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) { > + `; } diff --git a/src/panels/config/integrations/integration-panels/matter/dialog-matter-lock-manage.ts b/src/panels/config/integrations/integration-panels/matter/dialog-matter-lock-manage.ts new file mode 100644 index 0000000000..fedb043979 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/matter/dialog-matter-lock-manage.ts @@ -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 { + this._entityId = params.entity_id; + this._loading = true; + this._open = true; + await this._fetchData(); + } + + private async _fetchData(): Promise { + 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` + + ${this._loading + ? html`
+ +
` + : html`
${this._renderUsers()}
`} +
+ `; + } + + private _renderUsers() { + const occupiedUsers = this._users.filter( + (u) => u.user_status !== "available" + ); + + return html` +
+ ${occupiedUsers.length === 0 + ? html`

+ ${this.hass.localize( + "ui.panel.config.matter.lock.users.no_users" + )} +

` + : html` + + ${occupiedUsers.map( + (user) => html` + +
+ +
+
+ ${user.user_name || `User ${user.user_index}`} +
+
+ ${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()}` + : ""} +
+ +
+ ` + )} +
+ `} +
+ + + ${this.hass.localize("ui.panel.config.matter.lock.users.add")} + +
+
+ `; + } + + 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 { + 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; + } +} diff --git a/src/panels/config/integrations/integration-panels/matter/dialog-matter-lock-user-edit.ts b/src/panels/config/integrations/integration-panels/matter/dialog-matter-lock-user-edit.ts new file mode 100644 index 0000000000..cac295a75c --- /dev/null +++ b/src/panels/config/integrations/integration-panels/matter/dialog-matter-lock-user-edit.ts @@ -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 { + 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` + +
+ ${this._error + ? html`${this._error}` + : nothing} + + + + ${isNew + ? html` + + ` + : nothing} + +
+ + +
+
+ + + + ${this.hass.localize("ui.common.cancel")} + + + ${this._saving + ? html`` + : isNew + ? this.hass.localize("ui.panel.config.matter.lock.users.add") + : this.hass.localize("ui.common.save")} + + +
+ `; + } + + 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 { + 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; + } +} diff --git a/src/panels/config/integrations/integration-panels/matter/show-dialog-matter-lock-manage.ts b/src/panels/config/integrations/integration-panels/matter/show-dialog-matter-lock-manage.ts new file mode 100644 index 0000000000..1d51c65378 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/matter/show-dialog-matter-lock-manage.ts @@ -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, + }); +}; diff --git a/src/panels/config/integrations/integration-panels/matter/show-dialog-matter-lock-user-edit.ts b/src/panels/config/integrations/integration-panels/matter/show-dialog-matter-lock-user-edit.ts new file mode 100644 index 0000000000..ddfe224c91 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/matter/show-dialog-matter-lock-user-edit.ts @@ -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, + }); +}; diff --git a/src/translations/en.json b/src/translations/en.json index 858bba2c5d..5eb57b9eb2 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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": { diff --git a/test/data/matter-lock.test.ts b/test/data/matter-lock.test.ts new file mode 100644 index 0000000000..cfa3d538ea --- /dev/null +++ b/test/data/matter-lock.test.ts @@ -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"); + }); + }); +});