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:
132
src/data/matter-lock.ts
Normal file
132
src/data/matter-lock.ts
Normal 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];
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
mdiAccessPoint,
|
mdiAccessPoint,
|
||||||
|
mdiAccountLock,
|
||||||
mdiChatProcessing,
|
mdiChatProcessing,
|
||||||
mdiChatQuestion,
|
mdiChatQuestion,
|
||||||
mdiExportVariant,
|
mdiExportVariant,
|
||||||
@@ -10,11 +11,13 @@ import {
|
|||||||
NetworkType,
|
NetworkType,
|
||||||
getMatterNodeDiagnostics,
|
getMatterNodeDiagnostics,
|
||||||
} from "../../../../../../data/matter";
|
} from "../../../../../../data/matter";
|
||||||
|
import { getMatterLockInfo } from "../../../../../../data/matter-lock";
|
||||||
import type { HomeAssistant } from "../../../../../../types";
|
import type { HomeAssistant } from "../../../../../../types";
|
||||||
import { showMatterManageFabricsDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-manage-fabrics";
|
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 { 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 { showMatterPingNodeDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-ping-node";
|
||||||
import { showMatterReinterviewNodeDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-reinterview-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";
|
import type { DeviceAction } from "../../../ha-config-device-page";
|
||||||
|
|
||||||
export const getMatterDeviceDefaultActions = (
|
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;
|
return actions;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { getMatterNodeDiagnostics } from "../../../../../../data/matter";
|
|||||||
import { SubscribeMixin } from "../../../../../../mixins/subscribe-mixin";
|
import { SubscribeMixin } from "../../../../../../mixins/subscribe-mixin";
|
||||||
import { haStyle } from "../../../../../../resources/styles";
|
import { haStyle } from "../../../../../../resources/styles";
|
||||||
import type { HomeAssistant } from "../../../../../../types";
|
import type { HomeAssistant } from "../../../../../../types";
|
||||||
|
import "./ha-device-info-matter-lock";
|
||||||
|
|
||||||
@customElement("ha-device-info-matter")
|
@customElement("ha-device-info-matter")
|
||||||
export class HaDeviceInfoMatter extends SubscribeMixin(LitElement) {
|
export class HaDeviceInfoMatter extends SubscribeMixin(LitElement) {
|
||||||
@@ -124,6 +125,10 @@ export class HaDeviceInfoMatter extends SubscribeMixin(LitElement) {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</ha-expansion-panel>
|
</ha-expansion-panel>
|
||||||
|
<ha-device-info-matter-lock
|
||||||
|
.hass=${this.hass}
|
||||||
|
.device=${this.device}
|
||||||
|
></ha-device-info-matter-lock>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -7656,6 +7656,7 @@
|
|||||||
"ping_device": "Ping device",
|
"ping_device": "Ping device",
|
||||||
"open_commissioning_window": "Share device",
|
"open_commissioning_window": "Share device",
|
||||||
"manage_fabrics": "Manage fabrics",
|
"manage_fabrics": "Manage fabrics",
|
||||||
|
"manage_lock": "Manage lock",
|
||||||
"view_thread_network": "View Thread network"
|
"view_thread_network": "View Thread network"
|
||||||
},
|
},
|
||||||
"manage_fabrics": {
|
"manage_fabrics": {
|
||||||
@@ -7696,6 +7697,102 @@
|
|||||||
"success": "Your device is ready to be added to another Matter platform.",
|
"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.",
|
"scan_code": "With their app, scan the QR code or enter the sharing code below to finish setup.",
|
||||||
"copy_code": "Copy code"
|
"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": {
|
"tips": {
|
||||||
|
|||||||
209
test/data/matter-lock.test.ts
Normal file
209
test/data/matter-lock.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user