diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index 57d7568e77..265fb8bcb8 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -424,7 +424,7 @@ export interface RequestedGrant { clientSideAuth: boolean; } -export const invokeZWaveCCApi = ( +export const invokeZWaveCCApi = ( hass: HomeAssistant, device_id: string, command_class: number, @@ -432,7 +432,7 @@ export const invokeZWaveCCApi = ( method_name: string, parameters: any[], wait_for_result?: boolean -): Promise => +): Promise => hass.callWS({ type: "zwave_js/invoke_cc_api", device_id, diff --git a/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-door-lock.ts b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-door-lock.ts new file mode 100644 index 0000000000..2c5b5082a0 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-door-lock.ts @@ -0,0 +1,459 @@ +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import type { DeviceRegistryEntry } from "../../../../../../data/device_registry"; +import type { HomeAssistant } from "../../../../../../types"; +import { invokeZWaveCCApi } from "../../../../../../data/zwave_js"; +import "../../../../../../components/ha-button"; +import "../../../../../../components/buttons/ha-progress-button"; +import "../../../../../../components/ha-textfield"; +import "../../../../../../components/ha-select"; +import "../../../../../../components/ha-list-item"; +import "../../../../../../components/ha-alert"; +import "../../../../../../components/ha-switch"; +import "../../../../../../components/ha-formfield"; +import "../../../../../../components/ha-circular-progress"; +import type { HaSwitch } from "../../../../../../components/ha-switch"; +import type { HaProgressButton } from "../../../../../../components/buttons/ha-progress-button"; +import { extractApiErrorMessage } from "../../../../../../data/hassio/common"; + +type DoorHandleStatus = [boolean, boolean, boolean, boolean]; + +type DoorLockConfiguration = { + operationType: number; + outsideHandlesCanOpenDoorConfiguration: DoorHandleStatus; + insideHandlesCanOpenDoorConfiguration: DoorHandleStatus; + lockTimeoutConfiguration?: number; + autoRelockTime?: number; + holdAndReleaseTime?: number; + twistAssist?: boolean; + blockToBlock?: boolean; +}; + +enum DoorLockMode { + Unsecured = 0x00, + UnsecuredWithTimeout = 0x01, + InsideUnsecured = 0x10, + InsideUnsecuredWithTimeout = 0x11, + OutsideUnsecured = 0x20, + OutsideUnsecuredWithTimeout = 0x21, + Unknown = 0xfe, + Secured = 0xff, +} + +type DoorLockCapabilities = { + supportedOperationTypes: number[]; + supportedDoorLockModes: DoorLockMode[]; + blockToBlockSupported?: boolean; + twistAssistSupported?: boolean; + holdAndReleaseSupported?: boolean; + autoRelockSupported?: boolean; +}; + +const TIMED_MODES = [ + DoorLockMode.UnsecuredWithTimeout, + DoorLockMode.InsideUnsecuredWithTimeout, + DoorLockMode.OutsideUnsecuredWithTimeout, +]; + +const DEFAULT_CAPABILITIES: DoorLockCapabilities = { + supportedOperationTypes: [1, 2], + supportedDoorLockModes: [ + DoorLockMode.Unsecured, + DoorLockMode.UnsecuredWithTimeout, + DoorLockMode.InsideUnsecured, + DoorLockMode.InsideUnsecuredWithTimeout, + DoorLockMode.OutsideUnsecured, + DoorLockMode.OutsideUnsecuredWithTimeout, + DoorLockMode.Secured, + ], +}; + +const DEFAULT_MODE = DoorLockMode.Unsecured; + +@customElement("zwave_js-capability-control-door_lock") +class ZWaveJSCapabilityDoorLock extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public device!: DeviceRegistryEntry; + + @property({ type: Number }) public endpoint!: number; + + @property({ type: Number }) public command_class!: number; + + @property({ type: Number }) public version!: number; + + @state() private _configuration?: DoorLockConfiguration; + + @state() private _capabilities?: DoorLockCapabilities; + + @state() private _currentDoorLockMode?: DoorLockMode; + + @state() private _error?: string; + + protected render() { + if (this._error) { + return html`${this._error}`; + } + + if ( + !this._configuration || + !this._capabilities || + this._currentDoorLockMode === undefined + ) { + return html``; + } + + const isValid = this._isValid(); + + const supportedDoorLockModes = + this._configuration.operationType === 2 + ? this._capabilities.supportedDoorLockModes + : this._capabilities.supportedDoorLockModes.filter( + (mode) => !TIMED_MODES.includes(mode) + ); + + return html` +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.capability_controls.door_lock.title" + )} +

+ +
+ + ${supportedDoorLockModes.map( + (mode) => html` + + ${this.hass.localize( + `ui.panel.config.zwave_js.node_installer.capability_controls.door_lock.modes.${mode}` + )} + + ` + )} + +
+
+ + ${this._capabilities.supportedOperationTypes.map( + (type) => html` + + ${this.hass.localize( + `ui.panel.config.zwave_js.node_installer.capability_controls.door_lock.operation_types.${type}` + )} + + ` + )} + +
+ + ${this._configuration.operationType === 2 + ? html` +
+ + +
+ ` + : nothing} + ${this._capabilities?.twistAssistSupported + ? html` +
+ + + + +
+ ` + : nothing} + ${this._capabilities?.blockToBlockSupported + ? html` +
+ + + + +
+ ` + : nothing} + ${this._capabilities?.autoRelockSupported + ? html` +
+ + +
+ ` + : nothing} + ${this._capabilities?.holdAndReleaseSupported + ? html` +
+ + +
+ ` + : nothing} + +
+ + ${this.hass.localize("ui.common.save")} + +
+ `; + } + + protected firstUpdated() { + this._loadConfiguration(); + this._loadCapabilities(); + this._loadCurrentDoorLockMode(); + } + + private async _loadConfiguration() { + try { + const config = await invokeZWaveCCApi( + this.hass, + this.device.id, + this.command_class, + this.endpoint, + "getConfiguration", + [], + true + ); + this._configuration = config ?? { + // The server can return null but I think a real device will always have a configuration + operationType: 1, + outsideHandlesCanOpenDoorConfiguration: [false, false, false, false], + insideHandlesCanOpenDoorConfiguration: [false, false, false, false], + }; + } catch (err) { + this._error = extractApiErrorMessage(err); + } + } + + private async _loadCapabilities() { + try { + const capabilities = await invokeZWaveCCApi( + this.hass, + this.device.id, + this.command_class, + this.endpoint, + "getCapabilities", + [], + true + ); + this._capabilities = capabilities ?? DEFAULT_CAPABILITIES; + } catch (err: any) { + if ( + err?.code === "FailedZWaveCommand" && + err?.message.includes("ZW0302") + ) { + // getCapabilities is not supported by some devices + this._capabilities = DEFAULT_CAPABILITIES; + } else { + this._error = extractApiErrorMessage(err); + } + } + } + + private async _loadCurrentDoorLockMode() { + try { + const data = await invokeZWaveCCApi<{ + currentMode: DoorLockMode; + } | null>( + this.hass, + this.device.id, + this.command_class, + this.endpoint, + "get", + [], + true + ); + this._currentDoorLockMode = data?.currentMode ?? DEFAULT_MODE; + } catch (err) { + this._error = extractApiErrorMessage(err); + } + } + + private _isValid() { + return ( + this._configuration && + this._currentDoorLockMode && + (this._configuration.operationType !== 2 || + this._configuration.lockTimeoutConfiguration) && + !( + this._configuration.operationType !== 2 && + TIMED_MODES.includes(this._currentDoorLockMode) + ) + ); + } + + private _operationTypeChanged(ev: CustomEvent) { + const target = ev.target as HTMLSelectElement; + const newType = parseInt(target.value); + if (this._configuration) { + this._configuration = { + ...this._configuration, + operationType: newType, + // Clear the timeout configuration if switching away from timed operation + lockTimeoutConfiguration: + newType === 2 + ? this._configuration.lockTimeoutConfiguration + : undefined, + }; + } + if ( + newType !== 2 && + this._currentDoorLockMode && + TIMED_MODES.includes(this._currentDoorLockMode) + ) { + // timed modes are not allowed for non-timed operation + this._currentDoorLockMode = DEFAULT_MODE; + } + } + + private _booleanChanged(ev: CustomEvent) { + const target = ev.target as HaSwitch; + const key = target.getAttribute("key")!; + if (this._configuration) { + this._configuration = { + ...this._configuration, + [key]: target.checked, + }; + } + } + + private _numberChanged(ev: CustomEvent) { + const target = ev.target as HTMLInputElement; + const key = target.getAttribute("key")!; + const value = parseInt(target.value); + if (this._configuration) { + this._configuration = { + ...this._configuration, + [key]: Number.isNaN(value) ? undefined : value, + }; + } + } + + private _doorLockModeChanged(ev: CustomEvent) { + const target = ev.target as HTMLSelectElement; + this._currentDoorLockMode = parseInt(target.value) as DoorLockMode; + } + + private async _saveConfig(ev: CustomEvent) { + const button = ev.target as HaProgressButton; + if (!this._configuration) return; + + button.progress = true; + this._error = undefined; + + try { + await invokeZWaveCCApi( + this.hass, + this.device.id, + this.command_class, + this.endpoint, + "setConfiguration", + [this._configuration], + true + ); + await invokeZWaveCCApi( + this.hass, + this.device.id, + this.command_class, + this.endpoint, + "set", + [this._currentDoorLockMode], + true + ); + button.actionSuccess(); + } catch (err) { + this._error = extractApiErrorMessage(err); + button.actionError(); + } + + button.progress = false; + } + + static styles = css` + .row { + margin-top: 8px; + margin-bottom: 8px; + } + .actions { + text-align: right; + margin-top: 16px; + } + ha-textfield { + display: block; + width: 100%; + } + .loading { + padding: 16px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave_js-capability-control-door_lock": ZWaveJSCapabilityDoorLock; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts index 03596963cb..3b4297f626 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts @@ -22,11 +22,13 @@ import type { HomeAssistant, Route } from "../../../../../types"; import "../../../ha-config-section"; import "./capability-controls/zwave_js-capability-control-multilevel-switch"; import "./capability-controls/zwave_js-capability-control-thermostat-setback"; +import "./capability-controls/zwave_js-capability-control-door-lock"; import "./capability-controls/zwave_js-capability-control-color-switch"; const CAPABILITY_CONTROLS = { 38: "multilevel_switch", 71: "thermostat_setback", + 98: "door_lock", 51: "color_switch", }; diff --git a/src/translations/en.json b/src/translations/en.json index 0cefe7cc83..f35577bf9e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5274,6 +5274,31 @@ "stop_transition": "Stop transition", "control_failed": "Failed to control transition. {error}" }, + "door_lock": { + "title": "Door Lock", + "twist_assist": "Twist assist", + "block_to_block": "Block to block", + "auto_relock_time": "Auto relock time", + "hold_release_time": "Hold and release time", + "operation_type": "Operation type", + "operation_types": { + "1": "Constant", + "2": "Timed" + }, + "mode": "Mode", + "modes": { + "0": "Unsecured", + "1": "Unsecured with timeout", + "16": "Inside unsecured", + "17": "Inside unsecured with timeout", + "32": "Outside unsecured", + "33": "Outside unsecured with timeout", + "254": "Unknown", + "255": "Secured" + }, + "lock_timeout": "Lock timeout", + "lock_timeout_helper": "Number of seconds before the lock automatically locks after being unlocked" + }, "color_switch": { "color_component": "Color component", "colors": {