Z-Wave: Prevent closing the Add Device dialog when user input is required (#20999)

* prevent closing the Z-Wave Add node dialog when user input is required

* ask user for confirmation before leaving page during bootstrapping

* fix: no non-null assertion
This commit is contained in:
AlCalzone 2024-06-12 13:48:40 +02:00 committed by GitHub
parent c7b4e8f37c
commit 9a3f7df25e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 348 additions and 296 deletions

View File

@ -3,6 +3,7 @@ import { mdiAlertCircle, mdiCheckCircle, mdiQrcodeScan } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert"; import "../../../../../components/ha-alert";
import type { HaCheckbox } from "../../../../../components/ha-checkbox"; import type { HaCheckbox } from "../../../../../components/ha-checkbox";
@ -60,7 +61,8 @@ class DialogZWaveJSAddNode extends LitElement {
| "finished" | "finished"
| "provisioned" | "provisioned"
| "validate_dsk_enter_pin" | "validate_dsk_enter_pin"
| "grant_security_classes"; | "grant_security_classes"
| "waiting_for_device";
@state() private _device?: ZWaveJSAddNodeDevice; @state() private _device?: ZWaveJSAddNodeDevice;
@ -86,6 +88,11 @@ class DialogZWaveJSAddNode extends LitElement {
private _qrProcessing = false; private _qrProcessing = false;
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener("beforeunload", this._onBeforeUnload);
}
public disconnectedCallback(): void { public disconnectedCallback(): void {
super.disconnectedCallback(); super.disconnectedCallback();
this._unsubscribe(); this._unsubscribe();
@ -106,14 +113,22 @@ class DialogZWaveJSAddNode extends LitElement {
return nothing; return nothing;
} }
// Prevent accidentally closing the dialog in certain stages
const preventClose = this._shouldPreventClose();
const heading = this.hass.localize(
"ui.panel.config.zwave_js.add_node.title"
);
return html` return html`
<ha-dialog <ha-dialog
open open
@closed=${this.closeDialog} @closed=${this.closeDialog}
.heading=${createCloseHeading( .heading=${preventClose
this.hass, ? heading
this.hass.localize("ui.panel.config.zwave_js.add_node.title") : createCloseHeading(this.hass, heading)}
)} scrimClickAction=${ifDefined(preventClose ? "" : undefined)}
escapeKeyAction=${ifDefined(preventClose ? "" : undefined)}
> >
${this._status === "loading" ${this._status === "loading"
? html`<div style="display: flex; justify-content: center;"> ? html`<div style="display: flex; justify-content: center;">
@ -122,81 +137,93 @@ class DialogZWaveJSAddNode extends LitElement {
indeterminate indeterminate
></ha-circular-progress> ></ha-circular-progress>
</div>` </div>`
: this._status === "choose_strategy" : this._status === "waiting_for_device"
? html`<h3>Choose strategy</h3> ? html`<div class="flex-container">
<div class="flex-column"> <ha-circular-progress indeterminate></ha-circular-progress>
<ha-formfield <p>
.label=${html`<b>Secure if possible</b> ${this.hass.localize(
<div class="secondary"> "ui.panel.config.zwave_js.add_node.waiting_for_device"
Requires user interaction during inclusion. Fast and )}
secure with S2 when supported. Fallback to legacy S0 or </p>
no encryption when necessary. </div>`
</div>`} : this._status === "choose_strategy"
> ? html`<h3>Choose strategy</h3>
<ha-radio <div class="flex-column">
name="strategy" <ha-formfield
@change=${this._handleStrategyChange} .label=${html`<b>Secure if possible</b>
.value=${InclusionStrategy.Default} <div class="secondary">
.checked=${this._inclusionStrategy === Requires user interaction during inclusion. Fast and
InclusionStrategy.Default || secure with S2 when supported. Fallback to legacy S0
this._inclusionStrategy === undefined} or no encryption when necessary.
</div>`}
> >
</ha-radio> <ha-radio
</ha-formfield> name="strategy"
<ha-formfield @change=${this._handleStrategyChange}
.label=${html`<b>Legacy Secure</b> .value=${InclusionStrategy.Default}
<div class="secondary"> .checked=${this._inclusionStrategy ===
Uses the older S0 security that is secure, but slow due InclusionStrategy.Default ||
to a lot of overhead. Allows securely including S2 this._inclusionStrategy === undefined}
capable devices which fail to be included with S2. >
</div>`} </ha-radio>
> </ha-formfield>
<ha-radio <ha-formfield
name="strategy" .label=${html`<b>Legacy Secure</b>
@change=${this._handleStrategyChange} <div class="secondary">
.value=${InclusionStrategy.Security_S0} Uses the older S0 security that is secure, but slow
.checked=${this._inclusionStrategy === due to a lot of overhead. Allows securely including S2
InclusionStrategy.Security_S0} capable devices which fail to be included with S2.
</div>`}
> >
</ha-radio> <ha-radio
</ha-formfield> name="strategy"
<ha-formfield @change=${this._handleStrategyChange}
.label=${html`<b>Insecure</b> .value=${InclusionStrategy.Security_S0}
<div class="secondary">Do not use encryption.</div>`} .checked=${this._inclusionStrategy ===
> InclusionStrategy.Security_S0}
<ha-radio >
name="strategy" </ha-radio>
@change=${this._handleStrategyChange} </ha-formfield>
.value=${InclusionStrategy.Insecure} <ha-formfield
.checked=${this._inclusionStrategy === .label=${html`<b>Insecure</b>
InclusionStrategy.Insecure} <div class="secondary">Do not use encryption.</div>`}
> >
</ha-radio> <ha-radio
</ha-formfield> name="strategy"
</div> @change=${this._handleStrategyChange}
<mwc-button .value=${InclusionStrategy.Insecure}
slot="primaryAction" .checked=${this._inclusionStrategy ===
@click=${this._startManualInclusion} InclusionStrategy.Insecure}
> >
Search device </ha-radio>
</mwc-button>` </ha-formfield>
: this._status === "qr_scan" </div>
? html`${this._error <mwc-button
? html`<ha-alert alert-type="error" slot="primaryAction"
>${this._error}</ha-alert @click=${this._startManualInclusion}
>` >
: ""} Search device
<ha-qr-scanner
.localize=${this.hass.localize}
@qr-code-scanned=${this._qrCodeScanned}
></ha-qr-scanner>
<mwc-button slot="secondaryAction" @click=${this._startOver}>
${this.hass.localize(
"ui.panel.config.zwave_js.common.back"
)}
</mwc-button>` </mwc-button>`
: this._status === "validate_dsk_enter_pin" : this._status === "qr_scan"
? html` ? html`${this._error
? html`<ha-alert alert-type="error"
>${this._error}</ha-alert
>`
: ""}
<ha-qr-scanner
.localize=${this.hass.localize}
@qr-code-scanned=${this._qrCodeScanned}
></ha-qr-scanner>
<mwc-button
slot="secondaryAction"
@click=${this._startOver}
>
${this.hass.localize(
"ui.panel.config.zwave_js.common.back"
)}
</mwc-button>`
: this._status === "validate_dsk_enter_pin"
? html`
<p> <p>
Please enter the 5-digit PIN for your device and verify that Please enter the 5-digit PIN for your device and verify that
the rest of the device-specific key matches the one that can the rest of the device-specific key matches the one that can
@ -225,198 +252,160 @@ class DialogZWaveJSAddNode extends LitElement {
</mwc-button> </mwc-button>
</div> </div>
` `
: this._status === "grant_security_classes" : this._status === "grant_security_classes"
? html`
<h3>
The device has requested the following security classes:
</h3>
${this._error
? html`<ha-alert alert-type="error"
>${this._error}</ha-alert
>`
: ""}
<div class="flex-column">
${this._requestedGrant?.securityClasses
.sort((a, b) => {
// Put highest security classes at the top, S0 at the bottom
if (a === SecurityClass.S0_Legacy) return 1;
if (b === SecurityClass.S0_Legacy) return -1;
return b - a;
})
.map(
(securityClass) =>
html`<ha-formfield
.label=${html`<b
>${this.hass.localize(
`ui.panel.config.zwave_js.security_classes.${SecurityClass[securityClass]}.title`
)}</b
>
<div class="secondary">
${this.hass.localize(
`ui.panel.config.zwave_js.security_classes.${SecurityClass[securityClass]}.description`
)}
</div>`}
>
<ha-checkbox
@change=${this._handleSecurityClassChange}
.value=${securityClass}
.checked=${this._securityClasses.includes(
securityClass
)}
>
</ha-checkbox>
</ha-formfield>`
)}
</div>
<mwc-button
slot="primaryAction"
.disabled=${!this._securityClasses.length}
@click=${this._grantSecurityClasses}
>
Submit
</mwc-button>
`
: this._status === "timed_out"
? html` ? html`
<h3>Timed out!</h3> <h3>
<p> The device has requested the following security
We have not found any device in inclusion mode. Make classes:
sure the device is active and in inclusion mode. </h3>
</p> ${this._error
<mwc-button ? html`<ha-alert alert-type="error"
slot="primaryAction" >${this._error}</ha-alert
@click=${this._startOver} >`
> : ""}
Retry <div class="flex-column">
</mwc-button> ${this._requestedGrant?.securityClasses
` .sort((a, b) => {
: this._status === "started_specific" // Put highest security classes at the top, S0 at the bottom
? html`<h3> if (a === SecurityClass.S0_Legacy) return 1;
${this.hass.localize( if (b === SecurityClass.S0_Legacy) return -1;
"ui.panel.config.zwave_js.add_node.searching_device" return b - a;
)} })
</h3> .map(
<ha-circular-progress (securityClass) =>
indeterminate html`<ha-formfield
></ha-circular-progress> .label=${html`<b
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.follow_device_instructions"
)}
</p>`
: this._status === "started"
? html`
<div class="select-inclusion">
<div class="outline">
<h2>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.searching_device"
)}
</h2>
<ha-circular-progress
indeterminate
></ha-circular-progress>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.follow_device_instructions"
)}
</p>
<p>
<button
class="link"
@click=${this._chooseInclusionStrategy}
>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.choose_inclusion_strategy"
)}
</button>
</p>
</div>
${this._supportsSmartStart
? html` <div class="outline">
<h2>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.qr_code"
)}
</h2>
<ha-svg-icon
.path=${mdiQrcodeScan}
></ha-svg-icon>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.qr_code_paragraph"
)}
</p>
<p>
<mwc-button @click=${this._scanQRCode}>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.scan_qr_code"
)}
</mwc-button>
</p>
</div>`
: ""}
</div>
<mwc-button
slot="primaryAction"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</mwc-button>
`
: this._status === "interviewing"
? html`
<div class="flex-container">
<ha-circular-progress
indeterminate
></ha-circular-progress>
<div class="status">
<p>
<b
>${this.hass.localize( >${this.hass.localize(
"ui.panel.config.zwave_js.add_node.interview_started" `ui.panel.config.zwave_js.security_classes.${SecurityClass[securityClass]}.title`
)}</b )}</b
> >
<div class="secondary">
${this.hass.localize(
`ui.panel.config.zwave_js.security_classes.${SecurityClass[securityClass]}.description`
)}
</div>`}
>
<ha-checkbox
@change=${this._handleSecurityClassChange}
.value=${securityClass}
.checked=${this._securityClasses.includes(
securityClass
)}
>
</ha-checkbox>
</ha-formfield>`
)}
</div>
<mwc-button
slot="primaryAction"
.disabled=${!this._securityClasses.length}
@click=${this._grantSecurityClasses}
>
Submit
</mwc-button>
`
: this._status === "timed_out"
? html`
<h3>Timed out!</h3>
<p>
We have not found any device in inclusion mode. Make
sure the device is active and in inclusion mode.
</p>
<mwc-button
slot="primaryAction"
@click=${this._startOver}
>
Retry
</mwc-button>
`
: this._status === "started_specific"
? html`<h3>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.searching_device"
)}
</h3>
<ha-circular-progress
indeterminate
></ha-circular-progress>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.follow_device_instructions"
)}
</p>`
: this._status === "started"
? html`
<div class="select-inclusion">
<div class="outline">
<h2>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.searching_device"
)}
</h2>
<ha-circular-progress
indeterminate
></ha-circular-progress>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.follow_device_instructions"
)}
</p>
<p>
<button
class="link"
@click=${this._chooseInclusionStrategy}
>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.choose_inclusion_strategy"
)}
</button>
</p> </p>
${this._stages
? html` <div class="stages">
${this._stages.map(
(stage) => html`
<span class="stage">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
${stage}
</span>
`
)}
</div>`
: ""}
</div> </div>
${this._supportsSmartStart
? html` <div class="outline">
<h2>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.qr_code"
)}
</h2>
<ha-svg-icon
.path=${mdiQrcodeScan}
></ha-svg-icon>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.qr_code_paragraph"
)}
</p>
<p>
<mwc-button @click=${this._scanQRCode}>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.scan_qr_code"
)}
</mwc-button>
</p>
</div>`
: ""}
</div> </div>
<mwc-button <mwc-button
slot="primaryAction" slot="primaryAction"
@click=${this.closeDialog} @click=${this.closeDialog}
> >
${this.hass.localize("ui.common.close")} ${this.hass.localize("ui.common.cancel")}
</mwc-button> </mwc-button>
` `
: this._status === "failed" : this._status === "interviewing"
? html` ? html`
<div class="flex-container"> <div class="flex-container">
<ha-circular-progress
indeterminate
></ha-circular-progress>
<div class="status"> <div class="status">
<ha-alert <p>
alert-type="error" <b
.title=${this.hass.localize( >${this.hass.localize(
"ui.panel.config.zwave_js.add_node.inclusion_failed" "ui.panel.config.zwave_js.add_node.interview_started"
)} )}</b
> >
${this._error || </p>
this.hass.localize(
"ui.panel.config.zwave_js.add_node.check_logs"
)}
</ha-alert>
${this._stages ${this._stages
? html` <div class="stages"> ? html` <div class="stages">
${this._stages.map( ${this._stages.map(
@ -441,45 +430,21 @@ class DialogZWaveJSAddNode extends LitElement {
${this.hass.localize("ui.common.close")} ${this.hass.localize("ui.common.close")}
</mwc-button> </mwc-button>
` `
: this._status === "finished" : this._status === "failed"
? html` ? html`
<div class="flex-container"> <div class="flex-container">
<ha-svg-icon
.path=${this._lowSecurity
? mdiAlertCircle
: mdiCheckCircle}
class=${this._lowSecurity
? "warning"
: "success"}
></ha-svg-icon>
<div class="status"> <div class="status">
<p> <ha-alert
${this.hass.localize( alert-type="error"
"ui.panel.config.zwave_js.add_node.inclusion_finished" .title=${this.hass.localize(
"ui.panel.config.zwave_js.add_node.inclusion_failed"
)} )}
</p>
${this._lowSecurity
? html`<ha-alert
alert-type="warning"
title="The device was added insecurely"
>
There was an error during secure
inclusion. You can try again by
excluding the device and adding it
again.
</ha-alert>`
: ""}
<a
href=${`/config/devices/device/${
this._device!.id
}`}
> >
<mwc-button> ${this._error ||
${this.hass.localize( this.hass.localize(
"ui.panel.config.zwave_js.add_node.view_device" "ui.panel.config.zwave_js.add_node.check_logs"
)} )}
</mwc-button> </ha-alert>
</a>
${this._stages ${this._stages
? html` <div class="stages"> ? html` <div class="stages">
${this._stages.map( ${this._stages.map(
@ -504,18 +469,60 @@ class DialogZWaveJSAddNode extends LitElement {
${this.hass.localize("ui.common.close")} ${this.hass.localize("ui.common.close")}
</mwc-button> </mwc-button>
` `
: this._status === "provisioned" : this._status === "finished"
? html` <div class="flex-container"> ? html`
<div class="flex-container">
<ha-svg-icon <ha-svg-icon
.path=${mdiCheckCircle} .path=${this._lowSecurity
class="success" ? mdiAlertCircle
: mdiCheckCircle}
class=${this._lowSecurity
? "warning"
: "success"}
></ha-svg-icon> ></ha-svg-icon>
<div class="status"> <div class="status">
<p> <p>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.zwave_js.add_node.provisioning_finished" "ui.panel.config.zwave_js.add_node.inclusion_finished"
)} )}
</p> </p>
${this._lowSecurity
? html`<ha-alert
alert-type="warning"
title="The device was added insecurely"
>
There was an error during secure
inclusion. You can try again by
excluding the device and adding it
again.
</ha-alert>`
: ""}
<a
href=${`/config/devices/device/${
this._device?.id
}`}
>
<mwc-button>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.view_device"
)}
</mwc-button>
</a>
${this._stages
? html` <div class="stages">
${this._stages.map(
(stage) => html`
<span class="stage">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
${stage}
</span>
`
)}
</div>`
: ""}
</div> </div>
</div> </div>
<mwc-button <mwc-button
@ -523,12 +530,42 @@ class DialogZWaveJSAddNode extends LitElement {
@click=${this.closeDialog} @click=${this.closeDialog}
> >
${this.hass.localize("ui.common.close")} ${this.hass.localize("ui.common.close")}
</mwc-button>` </mwc-button>
: ""} `
: this._status === "provisioned"
? html` <div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.provisioning_finished"
)}
</p>
</div>
</div>
<mwc-button
slot="primaryAction"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.close")}
</mwc-button>`
: ""}
</ha-dialog> </ha-dialog>
`; `;
} }
private _shouldPreventClose(): boolean {
return (
this._status === "started_specific" ||
this._status === "validate_dsk_enter_pin" ||
this._status === "grant_security_classes" ||
this._status === "waiting_for_device"
);
}
private _chooseInclusionStrategy(): void { private _chooseInclusionStrategy(): void {
this._unsubscribe(); this._unsubscribe();
this._status = "choose_strategy"; this._status = "choose_strategy";
@ -639,7 +676,7 @@ class DialogZWaveJSAddNode extends LitElement {
} }
private async _validateDskAndEnterPin(): Promise<void> { private async _validateDskAndEnterPin(): Promise<void> {
this._status = "loading"; this._status = "waiting_for_device";
this._error = undefined; this._error = undefined;
try { try {
await zwaveValidateDskAndEnterPin( await zwaveValidateDskAndEnterPin(
@ -656,7 +693,7 @@ class DialogZWaveJSAddNode extends LitElement {
} }
private async _grantSecurityClasses(): Promise<void> { private async _grantSecurityClasses(): Promise<void> {
this._status = "loading"; this._status = "waiting_for_device";
this._error = undefined; this._error = undefined;
try { try {
await zwaveGrantSecurityClasses( await zwaveGrantSecurityClasses(
@ -719,6 +756,12 @@ class DialogZWaveJSAddNode extends LitElement {
this._addNodeTimeoutHandle = undefined; this._addNodeTimeoutHandle = undefined;
} }
if (message.event === "node found") {
// The user may have to enter a PIN. Until then prevent accidentally
// closing the dialog
this._status = "waiting_for_device";
}
if (message.event === "validate dsk and enter pin") { if (message.event === "validate dsk and enter pin") {
this._status = "validate_dsk_enter_pin"; this._status = "validate_dsk_enter_pin";
this._dsk = message.dsk; this._dsk = message.dsk;
@ -775,6 +818,13 @@ class DialogZWaveJSAddNode extends LitElement {
}, 90000); }, 90000);
} }
private _onBeforeUnload = (event: BeforeUnloadEvent) => {
if (this._shouldPreventClose()) {
event.preventDefault();
}
event.returnValue = true;
};
private _unsubscribe(): void { private _unsubscribe(): void {
if (this._subscribed) { if (this._subscribed) {
this._subscribed.then((unsub) => unsub()); this._subscribed.then((unsub) => unsub());
@ -791,6 +841,7 @@ class DialogZWaveJSAddNode extends LitElement {
clearTimeout(this._addNodeTimeoutHandle); clearTimeout(this._addNodeTimeoutHandle);
} }
this._addNodeTimeoutHandle = undefined; this._addNodeTimeoutHandle = undefined;
window.removeEventListener("beforeunload", this._onBeforeUnload);
} }
public closeDialog(): void { public closeDialog(): void {

View File

@ -4863,7 +4863,8 @@
"provisioning_finished": "The device has been added. Once you power it on, it will become available.", "provisioning_finished": "The device has been added. Once you power it on, it will become available.",
"view_device": "View Device", "view_device": "View Device",
"interview_started": "The device is being interviewed. This may take some time.", "interview_started": "The device is being interviewed. This may take some time.",
"interview_failed": "The device interview failed. Additional information may be available in the logs." "interview_failed": "The device interview failed. Additional information may be available in the logs.",
"waiting_for_device": "Communicating with the device. Please wait."
}, },
"provisioned": { "provisioned": {
"dsk": "DSK", "dsk": "DSK",