From 933fb1327a3c75998c4362656915dc42c0eee59c Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 17 Apr 2025 13:12:04 +0200 Subject: [PATCH] Implement new Z-Wave add device flow (#24667) Co-authored-by: Petar Petrov --- .../images/z-wave-add-node/long-range.svg | 15 + .../z-wave-add-node/long-range_dark.svg | 15 + public/static/images/z-wave-add-node/mesh.svg | 19 + .../images/z-wave-add-node/mesh_dark.svg | 19 + .../integrations/protocolIntegrationPicked.ts | 2 +- src/components/ha-qr-scanner.ts | 181 ++- src/data/zwave_js.ts | 67 +- .../zwave_js/add-node/data.ts | 54 + .../add-node/dialog-zwave_js-add-node.ts | 1136 +++++++++++++++++ .../show-dialog-zwave_js-add-node.ts | 4 +- .../zwave-js-add-node-added-insecure.ts | 65 + .../add-node/zwave-js-add-node-code-input.ts | 99 ++ .../zwave-js-add-node-configure-device.ts | 152 +++ .../add-node/zwave-js-add-node-failed.ts | 68 + ...wave-js-add-node-grant-security-classes.ts | 108 ++ .../add-node/zwave-js-add-node-loading.ts | 46 + .../zwave-js-add-node-searching-devices.ts | 164 +++ .../zwave-js-add-node-select-method.ts | 112 ++ ...ve-js-add-node-select-security-strategy.ts | 107 ++ .../{ => add-node}/zwave_js-add-node.ts | 5 +- .../zwave_js/dialog-zwave_js-add-node.ts | 1017 --------------- .../zwave_js/zwave_js-config-dashboard.ts | 8 +- .../zwave_js/zwave_js-config-router.ts | 2 +- src/translations/en.json | 116 +- 24 files changed, 2476 insertions(+), 1105 deletions(-) create mode 100644 public/static/images/z-wave-add-node/long-range.svg create mode 100644 public/static/images/z-wave-add-node/long-range_dark.svg create mode 100644 public/static/images/z-wave-add-node/mesh.svg create mode 100644 public/static/images/z-wave-add-node/mesh_dark.svg create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/data.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/dialog-zwave_js-add-node.ts rename src/panels/config/integrations/integration-panels/zwave_js/{ => add-node}/show-dialog-zwave_js-add-node.ts (78%) create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-added-insecure.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-code-input.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-configure-device.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-failed.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-grant-security-classes.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-loading.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-searching-devices.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-select-method.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-select-security-strategy.ts rename src/panels/config/integrations/integration-panels/zwave_js/{ => add-node}/zwave_js-add-node.ts (85%) delete mode 100644 src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts diff --git a/public/static/images/z-wave-add-node/long-range.svg b/public/static/images/z-wave-add-node/long-range.svg new file mode 100644 index 0000000000..48deddc513 --- /dev/null +++ b/public/static/images/z-wave-add-node/long-range.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/static/images/z-wave-add-node/long-range_dark.svg b/public/static/images/z-wave-add-node/long-range_dark.svg new file mode 100644 index 0000000000..ccc1ee8152 --- /dev/null +++ b/public/static/images/z-wave-add-node/long-range_dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/static/images/z-wave-add-node/mesh.svg b/public/static/images/z-wave-add-node/mesh.svg new file mode 100644 index 0000000000..92a03c444a --- /dev/null +++ b/public/static/images/z-wave-add-node/mesh.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/static/images/z-wave-add-node/mesh_dark.svg b/public/static/images/z-wave-add-node/mesh_dark.svg new file mode 100644 index 0000000000..1824489e47 --- /dev/null +++ b/public/static/images/z-wave-add-node/mesh_dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/common/integrations/protocolIntegrationPicked.ts b/src/common/integrations/protocolIntegrationPicked.ts index 1f7ea0b3b8..6b4604ac8f 100644 --- a/src/common/integrations/protocolIntegrationPicked.ts +++ b/src/common/integrations/protocolIntegrationPicked.ts @@ -5,7 +5,7 @@ import { getIntegrationDescriptions } from "../../data/integrations"; import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow"; import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; import { showMatterAddDeviceDialog } from "../../panels/config/integrations/integration-panels/matter/show-dialog-add-matter-device"; -import { showZWaveJSAddNodeDialog } from "../../panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-add-node"; +import { showZWaveJSAddNodeDialog } from "../../panels/config/integrations/integration-panels/zwave_js/add-node/show-dialog-zwave_js-add-node"; import type { HomeAssistant } from "../../types"; import { documentationUrl } from "../../util/documentation-url"; import { isComponentLoaded } from "../config/is_component_loaded"; diff --git a/src/components/ha-qr-scanner.ts b/src/components/ha-qr-scanner.ts index 1aca4f3982..94a905596e 100644 --- a/src/components/ha-qr-scanner.ts +++ b/src/components/ha-qr-scanner.ts @@ -1,7 +1,5 @@ -import "@material/mwc-button/mwc-button"; import { mdiCamera } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; -import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; // The BarcodeDetector Web API is not yet supported in all browsers, @@ -12,12 +10,13 @@ import { prepareZXingModule } from "barcode-detector"; import type QrScanner from "qr-scanner"; import { fireEvent } from "../common/dom/fire_event"; import { stopPropagation } from "../common/dom/stop_propagation"; -import type { LocalizeFunc } from "../common/translations/localize"; import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint"; import type { HomeAssistant } from "../types"; import "./ha-alert"; +import "./ha-button"; import "./ha-button-menu"; import "./ha-list-item"; +import "./ha-spinner"; import "./ha-textfield"; import type { HaTextField } from "./ha-textfield"; @@ -36,18 +35,22 @@ prepareZXingModule({ class HaQrScanner extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public localize!: LocalizeFunc; - @property() public description?: string; @property({ attribute: "alternative_option_label" }) public alternativeOptionLabel?: string; - @property() public error?: string; + @property({ attribute: false }) public validate?: ( + value: string + ) => string | undefined; @state() private _cameras?: QrScanner.Camera[]; - @state() private _manual = false; + @state() private _loading = true; + + @state() private _error?: string; + + @state() private _warning?: string; private _qrScanner?: QrScanner; @@ -88,29 +91,40 @@ class HaQrScanner extends LitElement { this._loadQrScanner(); } - protected updated(changedProps: PropertyValues) { - if (changedProps.has("error") && this.error) { - alert(`error: ${this.error}`); - this._notifyExternalScanner(this.error); - } - } - protected render() { - if (this._nativeBarcodeScanner && !this._manual) { + if (this._nativeBarcodeScanner) { return nothing; } - return html`${this.error - ? html`${this.error}` - : ""} - ${navigator.mediaDevices && !this._manual + return html`${this._error || this._warning + ? html` + ${this._error || this._warning} + ${this._error + ? html` + ${this.hass.localize("ui.components.qr-scanner.retry")} + ` + : nothing} + ` + : nothing} + ${navigator.mediaDevices ? html`
- ${this._cameras && this._cameras.length > 1 + ${this._loading + ? html`
+ +
` + : nothing} + ${!this._loading && + !this._error && + this._cameras && + this._cameras.length > 1 ? html` ` : nothing}
` - : html`${this._manual - ? nothing - : html` - ${!window.isSecureContext - ? this.localize( - "ui.components.qr-scanner.only_https_supported" - ) - : this.localize("ui.components.qr-scanner.not_supported")} - `} -

${this.localize("ui.components.qr-scanner.manual_input")}

+ : html` + ${!window.isSecureContext + ? this.hass.localize( + "ui.components.qr-scanner.only_https_supported" + ) + : this.hass.localize("ui.components.qr-scanner.not_supported")} + +

${this.hass.localize("ui.components.qr-scanner.manual_input")}

- - ${this.localize("ui.common.submit")} - + + ${this.hass.localize("ui.common.submit")} +
`}`; } @@ -165,7 +179,9 @@ class HaQrScanner extends LitElement { // eslint-disable-next-line @typescript-eslint/naming-convention const QrScanner = (await import("qr-scanner")).default; if (!(await QrScanner.hasCamera())) { - this._reportError("No camera found"); + this._reportError( + this.hass.localize("ui.components.qr-scanner.no_camera_found") + ); return; } QrScanner.WORKER_PATH = "/static/js/qr-scanner-worker.min.js"; @@ -181,6 +197,7 @@ class HaQrScanner extends LitElement { canvas.style.display = "block"; try { await this._qrScanner.start(); + this._loading = false; } catch (err: any) { this._reportError(err); } @@ -193,8 +210,8 @@ class HaQrScanner extends LitElement { private _qrCodeError = (err: any) => { if (err.endsWith("No QR code found")) { this._qrNotFoundCount++; - if (this._qrNotFoundCount === 250) { - this._reportError(err); + if (this._qrNotFoundCount >= 250) { + this._reportWarning(err); } return; } @@ -204,7 +221,17 @@ class HaQrScanner extends LitElement { }; private _qrCodeScanned = (qrCodeString: string): void => { + this._warning = undefined; this._qrNotFoundCount = 0; + if (this.validate) { + const validationMessage = this.validate(qrCodeString); + + if (validationMessage) { + this._reportWarning(validationMessage); + return; + } + } + fireEvent(this, "qr-code-scanned", { value: qrCodeString }); }; @@ -234,7 +261,10 @@ class HaQrScanner extends LitElement { if (msg.command === "bar_code/scan_result") { if (msg.payload.format !== "qr_code") { this._notifyExternalScanner( - `Wrong barcode scanned! ${msg.payload.format}: ${msg.payload.rawValue}, we need a QR code.` + this.hass.localize("ui.components.qr-scanner.wrong_code", { + format: msg.payload.format, + rawValue: msg.payload.rawValue, + }) ); } else { this._qrCodeScanned(msg.payload.rawValue); @@ -244,7 +274,7 @@ class HaQrScanner extends LitElement { if (msg.payload.reason === "canceled") { fireEvent(this, "qr-code-closed"); } else { - this._manual = true; + fireEvent(this, "qr-code-more-options"); } } return true; @@ -252,10 +282,17 @@ class HaQrScanner extends LitElement { this.hass.auth.external!.fireMessage({ type: "bar_code/scan", payload: { - title: this.title || "Scan QR code", - description: this.description || "Scan a barcode.", + title: + this.title || + this.hass.localize("ui.components.qr-scanner.app.title"), + description: + this.description || + this.hass.localize("ui.components.qr-scanner.app.description"), alternative_option_label: - this.alternativeOptionLabel || "Click to manually enter the barcode", + this.alternativeOptionLabel || + this.hass.localize( + "ui.components.qr-scanner.app.alternativeOptionLabel" + ), }, }); } @@ -269,25 +306,55 @@ class HaQrScanner extends LitElement { } private _notifyExternalScanner(message: string) { - if (!this.hass.auth.external) { + if (!this._nativeBarcodeScanner) { return; } - this.hass.auth.external.fireMessage({ + this.hass.auth.external!.fireMessage({ type: "bar_code/notify", payload: { message, }, }); - this.error = undefined; + this._warning = undefined; + this._error = undefined; } private _reportError(message: string) { - fireEvent(this, "qr-code-error", { message }); + const canvas = this._qrScanner?.$canvas; + if (canvas) { + canvas.style.display = "none"; + } + this._error = message; + } + + private _reportWarning(message: string) { + if (this._nativeBarcodeScanner) { + this._notifyExternalScanner(message); + } else { + this._warning = message; + } + } + + private async _retry() { + if (this._qrScanner) { + this._loading = true; + this._error = undefined; + this._warning = undefined; + const canvas = this._qrScanner.$canvas; + canvas.style.display = "block"; + this._qrNotFoundCount = 0; + await this._qrScanner.start(); + this._loading = false; + } } static styles = css` + :root { + position: relative; + } canvas { width: 100%; + border-radius: 16px; } #canvas-container { position: relative; @@ -312,6 +379,24 @@ class HaQrScanner extends LitElement { margin-inline-end: 8px; margin-inline-start: initial; } + .loading { + display: flex; + position: absolute; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + } + ha-alert { + display: block; + } + ha-alert.warning { + position: absolute; + z-index: 1; + background-color: var(--primary-background-color); + top: 0; + width: calc(100% - 48px); + } `; } @@ -319,8 +404,8 @@ declare global { // for fire event interface HASSDomEvents { "qr-code-scanned": { value: string }; - "qr-code-error": { message: string }; "qr-code-closed": undefined; + "qr-code-more-options": undefined; } interface HTMLElementTagNameMap { diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index 99f0866bb9..b0e5ae8cbe 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -80,7 +80,7 @@ enum QRCodeVersion { SmartStart = 1, } -enum Protocols { +export enum Protocols { ZWave = 0, ZWaveLongRange = 1, } @@ -151,12 +151,35 @@ export interface QRProvisioningInformation { maxInclusionRequestInterval?: number | undefined; uuid?: string | undefined; supportedProtocols?: Protocols[] | undefined; + status?: ProvisioningEntryStatus; } export interface PlannedProvisioningEntry { /** The device specific key (DSK) in the form aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-11111-22222 */ dsk: string; securityClasses: SecurityClass[]; + status?: ProvisioningEntryStatus; +} + +export enum ProvisioningEntryStatus { + Active = 0, + Inactive = 1, +} + +export interface DeviceConfig { + filename: string; + manufacturer: string; + manufacturerId: number; + label: string; + description: string; + devices: { + productType: number; + productId: number; + }[]; + firmwareVersion: { + min: string; + max: string; + }; } export const MINIMUM_QR_STRING_LENGTH = 52; @@ -195,6 +218,7 @@ export interface ZWaveJSController { is_rebuilding_routes: boolean; inclusion_state: InclusionState; nodes: ZWaveJSNodeStatus[]; + supports_long_range: boolean; } export interface ZWaveJSNodeStatus { @@ -555,7 +579,7 @@ export const zwaveTryParseDskFromQrCode = ( export const zwaveValidateDskAndEnterPin = ( hass: HomeAssistant, entry_id: string, - pin: string + pin: string | false ) => hass.callWS({ type: "zwave_js/validate_dsk_and_enter_pin", @@ -585,19 +609,38 @@ export const zwaveParseQrCode = ( qr_code_string, }); +export const lookupZwaveDevice = ( + hass: HomeAssistant, + entry_id: string, + manufacturerId: number, + productType: number, + productId: number, + applicationVersion?: string +): Promise => + hass.callWS({ + type: "zwave_js/lookup_device", + entry_id, + manufacturerId, + productType, + productId, + applicationVersion, + }); + export const provisionZwaveSmartStartNode = ( hass: HomeAssistant, entry_id: string, qr_provisioning_information?: QRProvisioningInformation, - qr_code_string?: string, - planned_provisioning_entry?: PlannedProvisioningEntry -): Promise => + protocol?: Protocols, + device_name?: string, + area_id?: string +): Promise => hass.callWS({ type: "zwave_js/provision_smart_start_node", entry_id, - qr_code_string, qr_provisioning_information, - planned_provisioning_entry, + protocol, + device_name, + area_id, }); export const unprovisionZwaveSmartStartNode = ( @@ -613,6 +656,16 @@ export const unprovisionZwaveSmartStartNode = ( node_id, }); +export const subscribeNewDevices = ( + hass: HomeAssistant, + entry_id: string, + callbackFunction: (message: any) => void +): Promise => + hass.connection.subscribeMessage((message) => callbackFunction(message), { + type: "zwave_js/subscribe_new_devices", + entry_id: entry_id, + }); + export const fetchZwaveNodeStatus = ( hass: HomeAssistant, device_id: string diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/data.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/data.ts new file mode 100644 index 0000000000..7a2d6a145c --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/data.ts @@ -0,0 +1,54 @@ +import type { QRProvisioningInformation } from "../../../../../../data/zwave_js"; + +export const backButtonStages: Partial[] = [ + "qr_scan", + "select_other_method", + "qr_code_input", + "choose_security_strategy", + "configure_device", +]; + +export const closeButtonStages: Partial[] = [ + "select_method", + "search_devices", + "search_smart_start_device", + "search_s2_device", + "failed", + "interviewing", + "validate_dsk_enter_pin", + "added_insecure", + "grant_security_classes", + "rename_device", +]; + +export type ZWaveJSAddNodeStage = + | "loading" + | "qr_scan" + | "select_method" + | "select_other_method" + | "qr_code_input" + | "search_devices" + | "search_smart_start_device" + | "search_s2_device" + | "choose_security_strategy" + | "configure_device" + | "interviewing" + | "failed" + | "timed_out" + | "added_insecure" + | "validate_dsk_enter_pin" + | "grant_security_classes" + | "waiting_for_device" + | "rename_device"; + +export interface ZWaveJSAddNodeSmartStartOptions { + name: string; + area?: string; + network_type?: string; +} + +export interface ZWaveJSAddNodeDevice { + id?: string; + name: string; + provisioningInfo?: QRProvisioningInformation; +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/dialog-zwave_js-add-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/dialog-zwave_js-add-node.ts new file mode 100644 index 0000000000..203f5b9cc6 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/dialog-zwave_js-add-node.ts @@ -0,0 +1,1136 @@ +import { mdiChevronLeft, mdiClose } from "@mdi/js"; +import "@shoelace-style/shoelace/dist/components/animation/animation"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { CSSResultGroup, TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../../../common/dom/fire_event"; +import type { HaDialog } from "../../../../../../components/ha-dialog"; +import { updateDeviceRegistryEntry } from "../../../../../../data/device_registry"; +import type { + QRProvisioningInformation, + RequestedGrant, + SecurityClass, +} from "../../../../../../data/zwave_js"; +import { + cancelSecureBootstrapS2, + fetchZwaveNetworkStatus, + InclusionStrategy, + lookupZwaveDevice, + MINIMUM_QR_STRING_LENGTH, + Protocols, + ProvisioningEntryStatus, + provisionZwaveSmartStartNode, + stopZwaveInclusion, + subscribeAddZwaveNode, + subscribeNewDevices, + ZWaveFeature, + zwaveGrantSecurityClasses, + zwaveParseQrCode, + zwaveSupportsFeature, + zwaveTryParseDskFromQrCode, + zwaveValidateDskAndEnterPin, +} from "../../../../../../data/zwave_js"; +import type { HomeAssistant } from "../../../../../../types"; +import { + backButtonStages, + closeButtonStages, + type ZWaveJSAddNodeDevice, + type ZWaveJSAddNodeSmartStartOptions, + type ZWaveJSAddNodeStage, +} from "./data"; +import type { ZWaveJSAddNodeDialogParams } from "./show-dialog-zwave_js-add-node"; + +import "../../../../../../components/ha-button"; +import "../../../../../../components/ha-dialog"; +import "../../../../../../components/ha-dialog-header"; +import "../../../../../../components/ha-fade-in"; +import "../../../../../../components/ha-icon-button"; +import "../../../../../../components/ha-qr-scanner"; +import "../../../../../../components/ha-spinner"; + +import { computeStateName } from "../../../../../../common/entity/compute_state_name"; +import { navigate } from "../../../../../../common/navigate"; +import { slugify } from "../../../../../../common/string/slugify"; +import type { EntityRegistryEntry } from "../../../../../../data/entity_registry"; +import { + subscribeEntityRegistry, + updateEntityRegistryEntry, +} from "../../../../../../data/entity_registry"; +import { SubscribeMixin } from "../../../../../../mixins/subscribe-mixin"; +import "./zwave-js-add-node-added-insecure"; +import "./zwave-js-add-node-code-input"; +import "./zwave-js-add-node-configure-device"; +import "./zwave-js-add-node-failed"; +import "./zwave-js-add-node-grant-security-classes"; +import "./zwave-js-add-node-loading"; +import "./zwave-js-add-node-searching-devices"; +import "./zwave-js-add-node-select-method"; +import "./zwave-js-add-node-select-security-strategy"; + +const INCLUSION_TIMEOUT_MINUTES = 5; + +@customElement("dialog-zwave_js-add-node") +class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) { + // #region variables + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _open = false; + + @state() private _step?: ZWaveJSAddNodeStage; + + @state() private _entryId?: string; + + @state() private _controllerSupportsLongRange? = false; + + @state() private _supportsSmartStart?: boolean; + + @state() private _dsk?: string; + + @state() private _dskPin = ""; + + @state() private _error?: string; + + @state() private _inclusionStrategy?: InclusionStrategy; + + @state() private _lowSecurity = false; + + @state() private _lowSecurityReason?: number; + + @state() private _device?: ZWaveJSAddNodeDevice; + + @state() private _deviceOptions?: ZWaveJSAddNodeSmartStartOptions; + + @state() private _requestedGrant?: RequestedGrant; + + @state() private _securityClasses: SecurityClass[] = []; + + @state() private _codeInput = ""; + + @query("ha-dialog") private _dialog?: HaDialog; + + private _qrProcessing = false; + + private _addNodeTimeoutHandle?: number; + + private _onStop?: () => void; + + private _subscribed?: Promise; + + private _newDeviceSubscription?: Promise; + + @state() private _entities: EntityRegistryEntry[] = []; + + // #endregion + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeEntityRegistry(this.hass.connection, (entities) => { + this._entities = entities; + }), + ]; + } + + protected render() { + if (!this._entryId) { + return nothing; + } + + // Prevent accidentally closing the dialog in certain stages + const preventClose = !!this._step && this._shouldPreventClose(this._step); + + const { headerText, headerHtml } = this._renderHeader(); + + return html` + + ${headerHtml} + ${this._renderStep()} + + `; + } + + private _renderHeader(): { headerText: string; headerHtml: TemplateResult } { + let headerText = this.hass.localize( + `ui.panel.config.zwave_js.add_node.title` + ); + + if (this._step === "loading") { + return { + headerText, + headerHtml: html` + + ${headerText} + + `, + }; + } + + let icon: string | undefined; + if ( + (this._step && closeButtonStages.includes(this._step)) || + (this._step === "search_devices" && !this._supportsSmartStart) || + (this._step === "configure_device" && this._device?.id) + ) { + icon = mdiClose; + } else if ( + (this._step && backButtonStages.includes(this._step)) || + (this._step === "search_devices" && this._supportsSmartStart) + ) { + icon = mdiChevronLeft; + } + + let titleTranslationKey = "title"; + + switch (this._step) { + case "qr_scan": + titleTranslationKey = "qr.scan_code"; + break; + case "qr_code_input": + titleTranslationKey = "qr.manual.title"; + break; + case "select_other_method": + titleTranslationKey = "qr.other_add_options"; + break; + case "search_devices": + titleTranslationKey = "searching_devices"; + break; + case "search_smart_start_device": + titleTranslationKey = "specific_device.title"; + break; + case "choose_security_strategy": + titleTranslationKey = "security_options"; + break; + case "validate_dsk_enter_pin": + titleTranslationKey = "validate_dsk_pin.title"; + break; + case "configure_device": + case "rename_device": + titleTranslationKey = "configure_device.title"; + break; + case "added_insecure": + titleTranslationKey = "added_insecure.title"; + break; + case "grant_security_classes": + titleTranslationKey = "grant_security_classes.title"; + break; + case "failed": + titleTranslationKey = "add_device_failed"; + break; + } + + headerText = this.hass.localize( + `ui.panel.config.zwave_js.add_node.${titleTranslationKey}` + ); + + return { + headerText, + headerHtml: html` + ${icon + ? html`` + : nothing} + ${headerText} + `, + }; + } + + private _renderStep() { + if (["select_method", "select_other_method"].includes(this._step!)) { + return html``; + } + + if (this._step === "qr_scan") { + return html` +
+ +
+ `; + } + + if (this._step === "qr_code_input") { + return html` + + + ${this.hass.localize("ui.common.next")} + + `; + } + + if ( + this._step === "search_devices" || + this._step === "search_smart_start_device" || + this._step === "search_s2_device" + ) { + return html` + + ${this._step === "search_smart_start_device" + ? html` + + ${this.hass.localize("ui.common.close")} + + ` + : nothing} + `; + } + + if (this._step === "choose_security_strategy") { + return html` + + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.select_method.search_device" + )} + `; + } + + if (this._step === "configure_device") { + return html` + + ${this.hass.localize( + this._device?.id + ? "ui.common.save" + : "ui.panel.config.zwave_js.add_node.configure_device.add_device" + )} + `; + } + + if (this._step === "validate_dsk_enter_pin") { + return html` + + + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.configure_device.add_device" + )} + + `; + } + + if ( + ["interviewing", "waiting_for_device", "rename_device"].includes( + this._step ?? "" + ) + ) { + return html` + + `; + } + + if (this._step === "added_insecure") { + return html` + + + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.added_insecure.view_device" + )} + + `; + } + + if (this._step === "grant_security_classes") { + return html` + + + ${this.hass.localize("ui.common.submit")} + + `; + } + + if (this._step === "failed") { + return html` + + `; + } + + return html``; + } + + public connectedCallback(): void { + super.connectedCallback(); + window.addEventListener("beforeunload", this._onBeforeUnload); + } + + private _onBeforeUnload = (event: BeforeUnloadEvent) => { + if (this._step && this._shouldPreventClose(this._step)) { + event.preventDefault(); + // support for legacy browsers + event.returnValue = true; + } + }; + + private _showFirstStep() { + if (this._supportsSmartStart) { + if (this.hass.auth.external?.config.hasBarCodeScanner) { + this._step = "qr_scan"; + } else { + this._step = "select_method"; + this._open = true; + } + } else { + this._open = true; + this._step = "search_devices"; + this._startInclusion(); + } + } + + public async showDialog(params: ZWaveJSAddNodeDialogParams): Promise { + if (this._step) { + // already started + return; + } + this._onStop = params?.onStop; + this._entryId = params.entry_id; + this._controllerSupportsLongRange = params.longRangeSupported; + + this._step = "loading"; + + if (this._controllerSupportsLongRange === undefined) { + try { + const zwaveNetwork = await fetchZwaveNetworkStatus(this.hass, { + entry_id: this._entryId, + }); + this._controllerSupportsLongRange = + zwaveNetwork?.controller?.supports_long_range; + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } + } + + if (params.dsk) { + this._open = true; + this._dskPin = ""; + this._step = "validate_dsk_enter_pin"; + this._dsk = params.dsk; + + this._startInclusion(); + return; + } + + this._supportsSmartStart = ( + await zwaveSupportsFeature( + this.hass, + this._entryId!, + ZWaveFeature.SmartStart + ) + ).supported; + + if (params?.inclusionOngoing) { + this._open = true; + this._step = "search_devices"; + this._startInclusion(); + return; + } + + this._showFirstStep(); + } + + /** + * prevent esc key, click out of dialog and close tab/browser + */ + private _shouldPreventClose = memoizeOne((step: ZWaveJSAddNodeStage) => + [ + "loading", + "qr_scan", + "qr_code_input", + "search_smart_start_device", + "search_s2_device", + "choose_security_strategy", + "configure_device", + "interviewing", + "validate_dsk_enter_pin", + "grant_security_classes", + "waiting_for_device", + ].includes(step) + ); + + private _handleCloseOrBack() { + if ( + (this._step && closeButtonStages.includes(this._step!)) || + (this._step === "search_devices" && !this._supportsSmartStart) + ) { + this.closeDialog(); + return; + } + + switch (this._step) { + case "select_other_method": + this._step = "qr_scan"; + break; + case "qr_scan": + this._step = "select_method"; + break; + case "qr_code_input": + if (this.hass.auth.external?.config.hasBarCodeScanner) { + this._step = "select_other_method"; + break; + } + this._step = "select_method"; + break; + case "search_devices": + this._unsubscribe(); + if ( + this._supportsSmartStart && + this.hass.auth.external?.config.hasBarCodeScanner + ) { + this._step = "select_other_method"; + break; + } else if (this._supportsSmartStart) { + this._step = "select_method"; + break; + } + break; + case "choose_security_strategy": + this._inclusionStrategy = undefined; + this._step = "loading"; + this._startInclusion(); + break; + case "configure_device": + this._showFirstStep(); + break; + } + } + + private _methodSelected(ev: CustomEvent): void { + const method = ev.detail.method; + if (method === "qr_code_webcam") { + this._step = "qr_scan"; + } else if (method === "qr_code_manual") { + this._codeInput = ""; + this._step = "qr_code_input"; + } else if (method === "search_device") { + this._step = "loading"; + this._startInclusion(); + } + } + + private _qrScanShowMoreOptions() { + this._open = true; + this._step = "select_other_method"; + } + + private _searchDevicesShowSecurityOptions() { + this._unsubscribe(); + this._step = "choose_security_strategy"; + } + + private _searchDevicesWithStrategy() { + if (this._inclusionStrategy !== undefined) { + this._step = "loading"; + this._startInclusion(); + } + } + + private _setSecurityStrategy(ev: CustomEvent): void { + this._inclusionStrategy = ev.detail.strategy; + } + + private _startInclusion( + qrProvisioningInformation?: QRProvisioningInformation, + dsk?: string + ): void { + this._lowSecurity = false; + + const s2Device = qrProvisioningInformation || dsk; + this._subscribed = subscribeAddZwaveNode( + this.hass, + this._entryId!, + (message) => { + switch (message.event) { + case "inclusion started": + this._step = s2Device ? "search_s2_device" : "search_devices"; + break; + case "inclusion failed": + this._unsubscribe(); + this._step = "failed"; + break; + case "inclusion stopped": + // We either found a device, or it failed, either way, cancel the timeout as we are no longer searching + if (this._addNodeTimeoutHandle) { + clearTimeout(this._addNodeTimeoutHandle); + } + this._addNodeTimeoutHandle = undefined; + break; + case "node found": + // The user may have to enter a PIN. Until then prevent accidentally + // closing the dialog + this._step = "waiting_for_device"; + break; + case "validate dsk and enter pin": + this._step = "validate_dsk_enter_pin"; + this._dsk = message.dsk; + break; + case "grant security classes": + if (this._inclusionStrategy === undefined) { + zwaveGrantSecurityClasses( + this.hass, + this._entryId!, + message.requested_grant.securityClasses, + message.requested_grant.clientSideAuth + ); + break; + } + this._requestedGrant = message.requested_grant; + this._securityClasses = message.requested_grant.securityClasses; + this._step = "grant_security_classes"; + break; + case "device registered": + this._device = message.device; + break; + case "node added": + this._step = "interviewing"; + this._lowSecurity = message.node.low_security; + this._lowSecurityReason = message.node.low_security_reason; + break; + case "interview completed": + this._unsubscribe(); + this._step = "configure_device"; + break; + } + }, + qrProvisioningInformation, + undefined, + undefined, + dsk, + this._inclusionStrategy + ).catch((err) => { + this._error = err.message; + this._step = "failed"; + return undefined; + }); + this._addNodeTimeoutHandle = window.setTimeout( + () => { + this._unsubscribe(); + this._error = this.hass.localize( + "ui.panel.config.zwave_js.add_node.timeout_error", + { minutes: INCLUSION_TIMEOUT_MINUTES } + ); + this._step = "failed"; + }, + INCLUSION_TIMEOUT_MINUTES * 1000 * 60 + ); + } + + private _validateQrCode = (qrCode: string): boolean => + qrCode.length >= MINIMUM_QR_STRING_LENGTH && qrCode.startsWith("90"); + + private _getQrCodeValidationError = (qrCode: string): string | undefined => + this._validateQrCode(qrCode) + ? undefined + : this.hass.localize( + "ui.panel.config.zwave_js.add_node.qr.invalid_code", + { code: qrCode } + ); + + private async _qrCodeScanned(ev?: CustomEvent): Promise { + let qrCodeString: string; + this._error = undefined; + this._open = true; + + if ( + (this._step !== "qr_scan" && this._step !== "qr_code_input") || + this._qrProcessing || + (this._step === "qr_scan" && !ev?.detail.value) + ) { + return; + } + + if (this._step === "qr_code_input") { + if (!this._codeInput) { + return; + } + + if (!this._validateQrCode(this._codeInput)) { + this._step = "failed"; + this._error = this.hass.localize( + "ui.panel.config.zwave_js.add_node.qr.invalid_code", + { code: this._codeInput } + ); + return; + } + + qrCodeString = this._codeInput; + } else { + qrCodeString = ev!.detail.value; + } + + this._qrProcessing = true; + const dsk = await zwaveTryParseDskFromQrCode( + this.hass, + this._entryId!, + qrCodeString + ); + + if (dsk) { + // own screen add device inclusion + this._step = "loading"; + // wait for QR scanner to be removed before resetting qr processing + this.updateComplete.then(() => { + this._qrProcessing = false; + }); + this._inclusionStrategy = InclusionStrategy.Security_S2; + this._startInclusion(undefined, dsk); + return; + } + + let provisioningInfo: QRProvisioningInformation; + + try { + provisioningInfo = await zwaveParseQrCode( + this.hass, + this._entryId!, + qrCodeString + ); + } catch (err: any) { + this._qrProcessing = false; + this._error = err.message; + this._step = "failed"; + return; + } + + let deviceName = ""; + try { + const device = await lookupZwaveDevice( + this.hass, + this._entryId!, + provisioningInfo.manufacturerId, + provisioningInfo.productType, + provisioningInfo.productId, + provisioningInfo.applicationVersion + ); + + deviceName = device?.description ?? ""; + } catch (_err: any) { + // ignore + // if device is not found in z-wave db set empty as default name + } + + this._device = { + name: deviceName, + provisioningInfo, + }; + + // wait for QR scanner to be removed before resetting qr processing + this.updateComplete.then(() => { + this._qrProcessing = false; + }); + + if (provisioningInfo.version === 1) { + this._step = "configure_device"; + } else if (provisioningInfo.version === 0) { + this._step = "loading"; + this._inclusionStrategy = InclusionStrategy.Security_S2; + this._startInclusion(provisioningInfo); + } else { + this._error = this.hass.localize( + "ui.panel.config.zwave_js.add_node.qr.unsupported_code", + { code: qrCodeString } + ); + this._step = "failed"; + } + } + + private _setDeviceOptions(ev: CustomEvent) { + this._deviceOptions = ev.detail.value; + } + + private async _saveDevice() { + // smart start device + if (!this._device?.id) { + if (!this._deviceOptions?.name || !this._device?.provisioningInfo) { + return; + } + + this._step = "loading"; + + try { + const id = await provisionZwaveSmartStartNode( + this.hass, + this._entryId!, + { + ...this._device.provisioningInfo, + status: ProvisioningEntryStatus.Active, + }, + this._deviceOptions.network_type + ? Number(this._deviceOptions.network_type) + : undefined, + this._deviceOptions.name, + this._deviceOptions.area + ); + this._device.id = id; + this._subscribeNewDeviceSearch(); + this._step = "search_smart_start_device"; + } catch (err: any) { + this._error = err.message; + this._step = "failed"; + } + } else { + this._step = "rename_device"; + const nameChanged = this._device.name !== this._deviceOptions?.name; + if (nameChanged || this._deviceOptions?.area) { + const oldDeviceName = this._device.name; + const newDeviceName = this._deviceOptions!.name; + try { + await updateDeviceRegistryEntry(this.hass, this._device.id, { + name_by_user: this._deviceOptions!.name, + area_id: this._deviceOptions!.area, + }); + + if (nameChanged) { + // rename entities + const oldDeviceEntityId = slugify(oldDeviceName); + const newDeviceEntityId = slugify(newDeviceName); + + await Promise.all( + this._entities + .filter((entity) => entity.device_id === this._device!.id) + .map((entity) => { + const entityState = this.hass.states[entity.entity_id]; + const name = + entity.name || + (entityState && computeStateName(entityState)); + let newEntityId: string | null = null; + let newName: string | null = null; + + if (name && name.includes(oldDeviceName)) { + newName = name.replace(oldDeviceName, newDeviceName); + } + + newEntityId = entity.entity_id.replace( + oldDeviceEntityId, + newDeviceEntityId + ); + + if (!newName && newEntityId === entity.entity_id) { + return undefined; + } + + return updateEntityRegistryEntry( + this.hass!, + entity.entity_id, + { + name: newName || name, + new_entity_id: newEntityId || entity.entity_id, + } + ); + }) + ); + } + } catch (_err: any) { + this._error = this.hass.localize( + "ui.panel.config.zwave_js.add_node.configure_device.save_device_failed" + ); + this._step = "failed"; + return; + } + } + + // if device wasn't added securely show added added-insecure screen + if (this._lowSecurity) { + this._step = "added_insecure"; + return; + } + + this._navigateToDevice(); + } + } + + private _navigateToDevice() { + const deviceId = this._device?.id; + + if (deviceId) { + setTimeout(() => { + // delay to ensure the node is added after smart start + // in this case we don't have a subscription to the node's "node added" event + // and it is near simultaneous with "device registered" event + navigate(`/config/devices/device/${deviceId}`); + }, 1000); + } else { + this.closeDialog(); + } + } + + private _subscribeNewDeviceSearch() { + if (!this._device?.id) { + return; + } + this._newDeviceSubscription = subscribeNewDevices( + this.hass, + this._entryId!, + ({ event, device }) => { + if ( + event === "device registered" && + this._device?.id && + device.id === this._device.id + ) { + this._unsubscribeNewDeviceSearch(); + this._navigateToDevice(); + } + } + ); + } + + private _addAnotherDevice() { + this._unsubscribeNewDeviceSearch(); + this._showFirstStep(); + } + + private _manualQrCodeInputChange(ev: CustomEvent): void { + this._codeInput = ev.detail.value; + } + + private _dskPinChanged(ev: CustomEvent): void { + this._dskPin = ev.detail.value; + } + + private async _validateDskAndEnterPin(): Promise { + this._step = "waiting_for_device"; + this._error = undefined; + try { + await zwaveValidateDskAndEnterPin( + this.hass, + this._entryId!, + this._dskPin + ); + } catch (err: any) { + this._error = err.message; + this._step = "validate_dsk_enter_pin"; + } + } + + private async _grantSecurityClasses() { + this._step = "waiting_for_device"; + this._error = undefined; + try { + await zwaveGrantSecurityClasses( + this.hass, + this._entryId!, + this._securityClasses + ); + } catch (err: any) { + this._error = err.message; + this._step = "grant_security_classes"; + } + } + + private _securityClassChange(ev: CustomEvent) { + this._securityClasses = ev.detail.value; + } + + private _unsubscribeNewDeviceSearch() { + if (this._newDeviceSubscription) { + this._newDeviceSubscription.then((unsub) => unsub && unsub()); + this._newDeviceSubscription = undefined; + } + } + + private _unsubscribe(): void { + if (this._subscribed) { + this._subscribed.then((unsub) => unsub && unsub()); + this._subscribed = undefined; + + if (this._entryId) { + stopZwaveInclusion(this.hass, this._entryId); + if ( + this._step && + [ + "waiting_for_device", + "validate_dsk_enter_pin", + "grant_security_classes", + ].includes(this._step) + ) { + cancelSecureBootstrapS2(this.hass, this._entryId); + } + } + } + + this._unsubscribeNewDeviceSearch(); + + this._requestedGrant = undefined; + this._securityClasses = []; + this._dsk = undefined; + this._dskPin = ""; + this._lowSecurity = false; + this._lowSecurityReason = undefined; + this._inclusionStrategy = undefined; + + if (this._addNodeTimeoutHandle) { + clearTimeout(this._addNodeTimeoutHandle); + } + this._addNodeTimeoutHandle = undefined; + window.removeEventListener("beforeunload", this._onBeforeUnload); + } + + private _dialogClosed() { + this._unsubscribe(); + this._open = false; + this._entryId = undefined; + this._step = undefined; + this._device = undefined; + this._error = undefined; + this._codeInput = ""; + this._deviceOptions = undefined; + + this._onStop?.(); + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + public async closeDialog() { + // special case for validate dsk and enter pin to stop 4 minute waiting process + if (this._step === "validate_dsk_enter_pin") { + this._step = "loading"; + try { + await zwaveValidateDskAndEnterPin(this.hass, this._entryId!, false); + } catch (err: any) { + // ignore + // eslint-disable-next-line no-console + console.error("Failed to cancel DSK validation"); + // eslint-disable-next-line no-console + console.error(err); + } + } + + if (this._open) { + this._dialog?.close(); + } else { + this._dialogClosed(); + } + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener("beforeunload", this._onBeforeUnload); + + this._unsubscribe(); + } + + static get styles(): CSSResultGroup { + return [ + css` + ha-dialog { + --mdc-dialog-min-width: 512px; + } + @media all and (max-width: 500px), all and (max-height: 500px) { + ha-dialog { + --mdc-dialog-min-width: calc( + 100vw - env(safe-area-inset-right) - env(safe-area-inset-left) + ); + --mdc-dialog-max-width: calc( + 100vw - env(safe-area-inset-right) - env(safe-area-inset-left) + ); + --mdc-dialog-min-height: 100%; + --mdc-dialog-max-height: 100%; + --vertical-align-dialog: flex-end; + --ha-dialog-border-radius: 0; + } + } + ha-fade-in { + display: block; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-zwave_js-add-node": DialogZWaveJSAddNode; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-add-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/show-dialog-zwave_js-add-node.ts similarity index 78% rename from src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-add-node.ts rename to src/panels/config/integrations/integration-panels/zwave_js/add-node/show-dialog-zwave_js-add-node.ts index 27ef0ccd87..39e12e7bab 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-add-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/show-dialog-zwave_js-add-node.ts @@ -1,7 +1,9 @@ -import { fireEvent } from "../../../../../common/dom/fire_event"; +import { fireEvent } from "../../../../../../common/dom/fire_event"; export interface ZWaveJSAddNodeDialogParams { entry_id: string; + longRangeSupported?: boolean; + inclusionOngoing?: boolean; dsk?: string; onStop?: () => void; } diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-added-insecure.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-added-insecure.ts new file mode 100644 index 0000000000..b19a8262ab --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-added-insecure.ts @@ -0,0 +1,65 @@ +import { mdiCheckCircleOutline } from "@mdi/js"; +import { customElement, property } from "lit/decorators"; +import "@shoelace-style/shoelace/dist/components/animation/animation"; +import { css, html, LitElement } from "lit"; +import type { HomeAssistant } from "../../../../../../types"; + +import "../../../../../../components/ha-svg-icon"; +import "../../../../../../components/ha-alert"; + +@customElement("zwave-js-add-node-added-insecure") +export class ZWaveJsAddNodeFinished extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: "device-name" }) public deviceName?: string; + + @property() public reason?; + + render() { + return html` + + + + + ${this.reason + ? this.hass.localize( + `ui.panel.config.zwave_js.add_node.added_insecure.low_security_reason.${this.reason}` + ) + : ""} + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.added_insecure.added_insecurely_text", + { + deviceName: html`${this.deviceName}`, + } + )} +

+ ${this.hass.localize( + `ui.panel.config.zwave_js.add_node.added_insecure.try_again_text` + )} +

+
+ `; + } + + static styles = css` + :host { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + ha-svg-icon { + --mdc-icon-size: 96px; + color: var(--warning-color); + } + ha-alert { + margin-top: 16px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave-js-add-node-added-insecure": ZWaveJsAddNodeFinished; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-code-input.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-code-input.ts new file mode 100644 index 0000000000..ed91ba56bf --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-code-input.ts @@ -0,0 +1,99 @@ +import { customElement, property } from "lit/decorators"; +import { css, html, LitElement, nothing } from "lit"; + +import { fireEvent } from "../../../../../../common/dom/fire_event"; +import type { HaTextField } from "../../../../../../components/ha-textfield"; + +import "../../../../../../components/ha-textfield"; +import "../../../../../../components/ha-alert"; + +@customElement("zwave-js-add-node-code-input") +export class ZWaveJsAddNodeCodeInput extends LitElement { + @property() public value = ""; + + @property() public description = ""; + + @property() public placeholder = ""; + + @property({ attribute: "reference-key" }) public referenceKey = ""; + + @property() public error?: string; + + @property({ type: Boolean }) public numeric = false; + + render() { + return html` +

${this.description}

+ ${this.error + ? html`${this.error}` + : nothing} + + ${this.referenceKey + ? html`
+ ${this.value.padEnd(5, "·")}${this.referenceKey} +
` + : nothing} + `; + } + + private _handleKeyup(ev: KeyboardEvent): void { + if (ev.key === "Enter" && this.value) { + fireEvent(this, "z-wave-submit"); + } + } + + private _handleChange(ev: InputEvent): void { + const inputElement = ev.target as HaTextField; + if ( + this.numeric && + (isNaN(Number(inputElement.value)) || inputElement.value.length > 5) + ) { + inputElement.value = this.value; + return; + } + + this.value = (ev.target as HaTextField).value; + + fireEvent(this, "value-changed", { + value: (ev.target as HaTextField).value, + }); + } + + static styles = css` + ha-textfield { + width: 100%; + } + ha-alert { + display: block; + margin-bottom: 16px; + } + p { + color: var(--secondary-text-color); + margin-top: 0; + margin-bottom: 16px; + } + div { + font-family: "Roboto Mono", "Consolas", "Menlo", monospace; + margin-top: 16px; + } + div span { + color: var(--primary-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave-js-add-node-code-input": ZWaveJsAddNodeCodeInput; + } + interface HASSDomEvents { + "z-wave-submit"; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-configure-device.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-configure-device.ts new file mode 100644 index 0000000000..721c1a9417 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-configure-device.ts @@ -0,0 +1,152 @@ +import { customElement, property, state } from "lit/decorators"; +import { html, LitElement, type PropertyValues } from "lit"; +import memoizeOne from "memoize-one"; + +import type { HomeAssistant } from "../../../../../../types"; +import type { LocalizeFunc } from "../../../../../../common/translations/localize"; +import type { HaFormSchema } from "../../../../../../components/ha-form/types"; +import { fireEvent } from "../../../../../../common/dom/fire_event"; +import { Protocols } from "../../../../../../data/zwave_js"; +import type { ZWaveJSAddNodeSmartStartOptions } from "./data"; + +import "../../../../../../components/ha-form/ha-form"; + +@customElement("zwave-js-add-node-configure-device") +export class ZWaveJsAddNodeConfigureDevice extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: "device-name" }) public deviceName = ""; + + @property({ type: Boolean, attribute: "lr-supported" }) + public longRangeSupported = false; + + @state() private _options?: ZWaveJSAddNodeSmartStartOptions; + + protected willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + + if (!this.hasUpdated) { + this._options = { + name: this.deviceName, + }; + + if (this.longRangeSupported) { + this._options.network_type = Protocols.ZWaveLongRange.toString(); + } + + fireEvent(this, "value-changed", { value: this._options }); + } + } + + render() { + return html` + + + `; + } + + private _getSchema = memoizeOne( + (localize: LocalizeFunc, longRangeSupported: boolean): HaFormSchema[] => { + const schema: HaFormSchema[] = [ + { + name: "name", + required: true, + default: this.deviceName, + type: "string", + autofocus: true, + }, + { + name: "area", + selector: { + area: {}, + }, + }, + ]; + + if (longRangeSupported) { + schema.push({ + name: "network_type", + required: true, + selector: { + select: { + box_max_columns: 1, + mode: "box", + options: [ + { + value: Protocols.ZWaveLongRange.toString(), + label: localize( + "ui.panel.config.zwave_js.add_node.configure_device.long_range_label" + ), + description: localize( + "ui.panel.config.zwave_js.add_node.configure_device.long_range_description" + ), + image: { + src: "/static/images/z-wave-add-node/long-range.svg", + src_dark: + "/static/images/z-wave-add-node/long-range_dark.svg", + flip_rtl: true, + }, + }, + { + value: Protocols.ZWave.toString(), + label: localize( + "ui.panel.config.zwave_js.add_node.configure_device.mesh_label" + ), + description: localize( + "ui.panel.config.zwave_js.add_node.configure_device.mesh_description" + ), + image: { + src: "/static/images/z-wave-add-node/mesh.svg", + src_dark: "/static/images/z-wave-add-node/mesh_dark.svg", + flip_rtl: true, + }, + }, + ], + }, + }, + }); + } + return schema; + } + ); + + private _computeLabel = (schema: HaFormSchema): string | undefined => { + if (schema.name === "network_type") { + return this.hass.localize( + "ui.panel.config.zwave_js.add_node.configure_device.choose_network_type" + ); + } + if (schema.name === "name") { + return this.hass.localize( + "ui.panel.config.zwave_js.add_node.configure_device.device_name" + ); + } + if (schema.name === "area") { + return this.hass.localize( + "ui.panel.config.zwave_js.add_node.configure_device.device_area" + ); + } + return undefined; + }; + + private _setOptions(event: any) { + this._options = { + ...this._options!, + ...event.detail.value, + }; + + fireEvent(this, "value-changed", { value: this._options }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "zwave-js-add-node-configure-device": ZWaveJsAddNodeConfigureDevice; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-failed.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-failed.ts new file mode 100644 index 0000000000..7f9ef1412d --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-failed.ts @@ -0,0 +1,68 @@ +import { customElement, property } from "lit/decorators"; +import { css, html, LitElement, nothing } from "lit"; +import type { HomeAssistant } from "../../../../../../types"; +import type { ZWaveJSAddNodeDevice } from "./data"; + +import "../../../../../../components/ha-alert"; +import "../../../../../../components/ha-button"; + +@customElement("zwave-js-add-node-failed") +export class ZWaveJsAddNodeFailed extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public error?: string; + + @property({ attribute: false }) public device?: ZWaveJSAddNodeDevice; + + render() { + return html` + + ${this.error || + this.hass.localize("ui.panel.config.zwave_js.add_node.check_logs")} + + ${this.error + ? html`
+ ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.check_logs" + )} +
` + : nothing} + ${this.device?.id + ? html` + + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.view_device" + )} + + ` + : nothing} + `; + } + + static styles = css` + :host { + display: block; + padding: 16px; + } + div.note { + text-align: center; + margin-top: 16px; + font-size: 12px; + color: var(--secondary-text-color); + } + ha-button { + margin-top: 32px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave-js-add-node-failed": ZWaveJsAddNodeFailed; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-grant-security-classes.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-grant-security-classes.ts new file mode 100644 index 0000000000..3155d74354 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-grant-security-classes.ts @@ -0,0 +1,108 @@ +import { customElement, property } from "lit/decorators"; +import "@shoelace-style/shoelace/dist/components/animation/animation"; +import { css, html, LitElement, nothing } from "lit"; +import type { HomeAssistant } from "../../../../../../types"; +import { SecurityClass } from "../../../../../../data/zwave_js"; +import type { HaCheckbox } from "../../../../../../components/ha-checkbox"; +import { fireEvent } from "../../../../../../common/dom/fire_event"; + +import "../../../../../../components/ha-alert"; +import "../../../../../../components/ha-formfield"; +import "../../../../../../components/ha-checkbox"; + +@customElement("zwave-js-add-node-grant-security-classes") +export class ZWaveJsAddNodeGrantSecurityClasses extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public error?: string; + + @property({ attribute: false }) public securityClassOptions!: SecurityClass[]; + + @property({ attribute: false }) + public selectedSecurityClasses: SecurityClass[] = []; + + render() { + return html` + ${this.error + ? html` ${this.error} ` + : nothing} +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.grant_security_classes.description" + )} +

+
+ ${this.securityClassOptions + .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`${this.hass.localize( + `ui.panel.config.zwave_js.security_classes.${SecurityClass[securityClass]}.title` + )} +
+ ${this.hass.localize( + `ui.panel.config.zwave_js.security_classes.${SecurityClass[securityClass]}.description` + )} +
`} + > + + +
` + )} +
+ `; + } + + private _handleSecurityClassChange(ev: CustomEvent) { + const checkbox = ev.currentTarget as HaCheckbox; + const securityClass = Number(checkbox.value); + if ( + checkbox.checked && + !this.selectedSecurityClasses.includes(securityClass) + ) { + fireEvent(this, "value-changed", { + value: [...this.selectedSecurityClasses, securityClass], + }); + } else if (!checkbox.checked) { + fireEvent(this, "value-changed", { + value: this.selectedSecurityClasses.filter( + (val) => val !== securityClass + ), + }); + } + } + + static styles = css` + ha-alert { + display: block; + margin-bottom: 16px; + } + .flex-column { + display: flex; + flex-direction: column; + } + .secondary { + color: var(--secondary-text-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave-js-add-node-grant-security-classes": ZWaveJsAddNodeGrantSecurityClasses; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-loading.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-loading.ts new file mode 100644 index 0000000000..bae3acb94b --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-loading.ts @@ -0,0 +1,46 @@ +import { customElement, property } from "lit/decorators"; +import { css, html, LitElement, nothing } from "lit"; + +import "../../../../../../components/ha-fade-in"; +import "../../../../../../components/ha-spinner"; + +@customElement("zwave-js-add-node-loading") +export class ZWaveJsAddNodeLoading extends LitElement { + @property() public description?: string; + + @property({ type: Number }) public delay = 0; + + render() { + return html` + +
+ +
+ ${this.description ? html`

${this.description}

` : nothing} +
+ `; + } + + static styles = css` + ha-fade-in { + display: block; + } + .loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + } + p { + margin-top: 16px; + color: var(--secondary-text-color); + text-align: center; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave-js-add-node-loading": ZWaveJsAddNodeLoading; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-searching-devices.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-searching-devices.ts new file mode 100644 index 0000000000..c92c856e9b --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-searching-devices.ts @@ -0,0 +1,164 @@ +import "@shoelace-style/shoelace/dist/components/animation/animation"; +import { mdiRestart } from "@mdi/js"; + +import { customElement, property } from "lit/decorators"; +import { css, html, LitElement, nothing } from "lit"; +import type { HomeAssistant } from "../../../../../../types"; +import { fireEvent } from "../../../../../../common/dom/fire_event"; +import { InclusionStrategy } from "../../../../../../data/zwave_js"; + +import "../../../../../../components/ha-spinner"; +import "../../../../../../components/ha-button"; +import "../../../../../../components/ha-alert"; + +@customElement("zwave-js-add-node-searching-devices") +export class ZWaveJsAddNodeSearchingDevices extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, attribute: "smart-start" }) + public smartStart = false; + + @property({ type: Boolean, attribute: "show-security-options" }) + public showSecurityOptions = false; + + @property({ type: Boolean, attribute: "show-add-another-device" }) + public showAddAnotherDevice = false; + + @property({ attribute: false }) public inclusionStrategy?: InclusionStrategy; + + render() { + let inclusionStrategyTranslationKey = ""; + if (this.inclusionStrategy !== undefined) { + switch (this.inclusionStrategy) { + case InclusionStrategy.Security_S0: + inclusionStrategyTranslationKey = "s0"; + break; + case InclusionStrategy.Insecure: + inclusionStrategyTranslationKey = "insecure"; + break; + default: + inclusionStrategyTranslationKey = "default"; + } + } + + return html` +
+
+
+ +
+ +
+
+
+ ${this.smartStart + ? html` + + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.specific_device.turn_on_device_description" + )} + +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.specific_device.close_description" + )} +

` + : html` +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.follow_device_instructions" + )} +

+ `} + ${this.showSecurityOptions && !inclusionStrategyTranslationKey + ? html` + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.security_options" + )} + ` + : inclusionStrategyTranslationKey + ? html` + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.select_strategy.inclusion_strategy", + { + strategy: this.hass.localize( + `ui.panel.config.zwave_js.add_node.select_strategy.${inclusionStrategyTranslationKey}_label` + ), + } + )} + ` + : nothing} + ${this.showAddAnotherDevice + ? html` + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.specific_device.add_another_z_wave_device" + )} + ` + : nothing} +
+ `; + } + + private _handleSecurityOptions() { + fireEvent(this, "show-z-wave-security-options"); + } + + private _handleAddAnotherDevice() { + fireEvent(this, "add-another-z-wave-device"); + } + + static styles = css` + :host { + text-align: center; + display: block; + } + ha-alert { + margin-top: 32px; + display: block; + } + .note { + font-size: 12px; + color: var(--secondary-text-color); + } + .searching-spinner { + margin-left: auto; + margin-right: auto; + position: relative; + width: 128px; + height: 128px; + } + .searching-spinner .circle { + border-radius: 50%; + background-color: var(--light-primary-color); + position: absolute; + width: calc(100% - 32px); + height: calc(100% - 32px); + margin: 16px; + } + .searching-spinner .spinner { + z-index: 1; + position: absolute; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + --ha-spinner-divider-color: var(--light-primary-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave-js-add-node-searching-devices": ZWaveJsAddNodeSearchingDevices; + } + + interface HASSDomEvents { + "show-z-wave-security-options": undefined; + "add-another-z-wave-device": undefined; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-select-method.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-select-method.ts new file mode 100644 index 0000000000..e60595dbff --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-select-method.ts @@ -0,0 +1,112 @@ +import { customElement, property } from "lit/decorators"; +import { css, html, LitElement, nothing } from "lit"; +import { fireEvent } from "../../../../../../common/dom/fire_event"; +import type { HomeAssistant } from "../../../../../../types"; + +import "../../../../../../components/ha-md-list"; +import "../../../../../../components/ha-md-list-item"; +import "../../../../../../components/ha-alert"; +import "../../../../../../components/ha-icon-next"; + +@customElement("zwave-js-add-node-select-method") +export class ZWaveJsAddNodeSelectMethod extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, attribute: "hide-qr-webcam" }) + public hideQrWebcam = false; + + render() { + return html` + ${!this.hideQrWebcam && !window.isSecureContext + ? html` + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.select_method.webcam_unsupported" + )}` + : nothing} + + ${!this.hideQrWebcam + ? html` +
+ ${this.hass.localize( + `ui.panel.config.zwave_js.add_node.select_method.qr_code_webcam` + )} +
+
+ ${this.hass.localize( + `ui.panel.config.zwave_js.add_node.select_method.qr_code_webcam_description` + )} +
+ +
` + : nothing} + +
+ ${this.hass.localize( + `ui.panel.config.zwave_js.add_node.select_method.qr_code_manual` + )} +
+
+ ${this.hass.localize( + `ui.panel.config.zwave_js.add_node.select_method.qr_code_manual_description` + )} +
+ +
+ +
+ ${this.hass.localize( + `ui.panel.config.zwave_js.add_node.select_method.search_device` + )} +
+
+ ${this.hass.localize( + `ui.panel.config.zwave_js.add_node.select_method.search_device_description` + )} +
+ +
+
+ `; + } + + private _selectMethod(event: any) { + const method = event.currentTarget.value; + if (method !== "qr_code_webcam" || window.isSecureContext) { + fireEvent(this, "z-wave-method-selected", { method }); + } + } + + static styles = css` + ha-md-list { + padding: 0; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave-js-add-node-select-method": ZWaveJsAddNodeSelectMethod; + } + interface HASSDomEvents { + "z-wave-method-selected": { + method: "qr_code_webcam" | "qr_code_manual" | "search_device"; + }; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-select-security-strategy.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-select-security-strategy.ts new file mode 100644 index 0000000000..05adc59102 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-select-security-strategy.ts @@ -0,0 +1,107 @@ +import { customElement, property, state } from "lit/decorators"; +import { css, html, LitElement } from "lit"; +import memoizeOne from "memoize-one"; + +import { fireEvent } from "../../../../../../common/dom/fire_event"; +import type { HomeAssistant } from "../../../../../../types"; +import { InclusionStrategy } from "../../../../../../data/zwave_js"; +import type { LocalizeFunc } from "../../../../../../common/translations/localize"; +import type { HaFormSchema } from "../../../../../../components/ha-form/types"; + +import "../../../../../../components/ha-form/ha-form"; + +@customElement("zwave-js-add-node-select-security-strategy") +export class ZWaveJsAddNodeSelectMethod extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() public _inclusionStrategy?: InclusionStrategy; + + private _getSchema = memoizeOne((localize: LocalizeFunc): HaFormSchema[] => [ + { + name: "strategy", + required: true, + selector: { + select: { + box_max_columns: 1, + mode: "box", + options: [ + { + value: InclusionStrategy.Default.toString(), + label: localize( + "ui.panel.config.zwave_js.add_node.select_strategy.default_label" + ), + description: localize( + "ui.panel.config.zwave_js.add_node.select_strategy.default_description" + ), + }, + { + value: InclusionStrategy.Security_S0.toString(), + label: localize( + "ui.panel.config.zwave_js.add_node.select_strategy.s0_label" + ), + description: localize( + "ui.panel.config.zwave_js.add_node.select_strategy.s0_description" + ), + }, + { + value: InclusionStrategy.Insecure.toString(), + label: localize( + "ui.panel.config.zwave_js.add_node.select_strategy.insecure_label" + ), + description: localize( + "ui.panel.config.zwave_js.add_node.select_strategy.insecure_description" + ), + }, + ], + }, + }, + }, + ]); + + render() { + return html` + + + `; + } + + private _computeLabel = () => + this.hass.localize( + "ui.panel.config.zwave_js.add_node.select_strategy.title" + ); + + private _selectStrategy(event: any) { + const selectedStrategy = Number( + event.detail.value.strategy + ) as InclusionStrategy; + fireEvent(this, "z-wave-strategy-selected", { + strategy: selectedStrategy, + }); + } + + static styles = css` + :host { + display: block; + padding: 16px; + } + ha-md-list { + padding: 0; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave-js-add-node-select-security-strategy": ZWaveJsAddNodeSelectMethod; + } + interface HASSDomEvents { + "z-wave-strategy-selected": { + strategy: InclusionStrategy; + }; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-add-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave_js-add-node.ts similarity index 85% rename from src/panels/config/integrations/integration-panels/zwave_js/zwave_js-add-node.ts rename to src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave_js-add-node.ts index 6c7a36486b..83f919c544 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-add-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave_js-add-node.ts @@ -1,7 +1,7 @@ /* eslint-disable lit/lifecycle-super */ import { customElement } from "lit/decorators"; -import { navigate } from "../../../../../common/navigate"; -import type { HomeAssistant } from "../../../../../types"; +import { navigate } from "../../../../../../common/navigate"; +import type { HomeAssistant } from "../../../../../../types"; import { showZWaveJSAddNodeDialog } from "./show-dialog-zwave_js-add-node"; @customElement("zwave_js-add-node") @@ -21,6 +21,7 @@ export class DialogZWaveJSAddNode extends HTMLElement { replace: true, } ); + showZWaveJSAddNodeDialog(this, { entry_id: this.configEntryId, }); diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts deleted file mode 100644 index 2151578fce..0000000000 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts +++ /dev/null @@ -1,1017 +0,0 @@ -import "@material/mwc-button/mwc-button"; -import { mdiAlertCircle, mdiCheckCircle, mdiQrcodeScan } from "@mdi/js"; -import type { UnsubscribeFunc } from "home-assistant-js-websocket"; -import type { CSSResultGroup } from "lit"; -import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; -import { ifDefined } from "lit/directives/if-defined"; -import { fireEvent } from "../../../../../common/dom/fire_event"; -import "../../../../../components/ha-alert"; -import type { HaCheckbox } from "../../../../../components/ha-checkbox"; -import "../../../../../components/ha-spinner"; -import { createCloseHeading } from "../../../../../components/ha-dialog"; -import "../../../../../components/ha-checkbox"; -import "../../../../../components/ha-formfield"; -import "../../../../../components/ha-qr-scanner"; -import "../../../../../components/ha-radio"; -import "../../../../../components/ha-switch"; -import "../../../../../components/ha-textfield"; -import type { HaTextField } from "../../../../../components/ha-textfield"; -import type { - QRProvisioningInformation, - RequestedGrant, -} from "../../../../../data/zwave_js"; -import { - cancelSecureBootstrapS2, - InclusionStrategy, - MINIMUM_QR_STRING_LENGTH, - provisionZwaveSmartStartNode, - SecurityClass, - stopZwaveInclusion, - subscribeAddZwaveNode, - ZWaveFeature, - zwaveGrantSecurityClasses, - zwaveParseQrCode, - zwaveSupportsFeature, - zwaveTryParseDskFromQrCode, - zwaveValidateDskAndEnterPin, -} from "../../../../../data/zwave_js"; -import { haStyle, haStyleDialog } from "../../../../../resources/styles"; -import type { HomeAssistant } from "../../../../../types"; -import type { ZWaveJSAddNodeDialogParams } from "./show-dialog-zwave_js-add-node"; - -export interface ZWaveJSAddNodeDevice { - id: string; - name: string; -} - -@customElement("dialog-zwave_js-add-node") -class DialogZWaveJSAddNode extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @state() private _params?: ZWaveJSAddNodeDialogParams; - - @state() private _entryId?: string; - - @state() private _status?: - | "loading" - | "started" - | "started_specific" - | "choose_strategy" - | "qr_scan" - | "interviewing" - | "failed" - | "timed_out" - | "finished" - | "provisioned" - | "validate_dsk_enter_pin" - | "grant_security_classes" - | "waiting_for_device"; - - @state() private _device?: ZWaveJSAddNodeDevice; - - @state() private _stages?: string[]; - - @state() private _inclusionStrategy?: InclusionStrategy; - - @state() private _dsk?: string; - - @state() private _error?: string; - - @state() private _requestedGrant?: RequestedGrant; - - @state() private _securityClasses: SecurityClass[] = []; - - @state() private _lowSecurity = false; - - @state() private _lowSecurityReason?: number; - - @state() private _supportsSmartStart?: boolean; - - private _addNodeTimeoutHandle?: number; - - private _subscribed?: Promise; - - private _qrProcessing = false; - - public connectedCallback(): void { - super.connectedCallback(); - window.addEventListener("beforeunload", this._onBeforeUnload); - } - - public disconnectedCallback(): void { - super.disconnectedCallback(); - this._unsubscribe(); - } - - public async showDialog(params: ZWaveJSAddNodeDialogParams): Promise { - if (this._status) { - // already started - return; - } - this._params = params; - this._entryId = params.entry_id; - this._status = "loading"; - this._checkSmartStartSupport(); - if (params.dsk) { - this._status = "validate_dsk_enter_pin"; - this._dsk = params.dsk; - } - this._startInclusion(); - } - - @query("#pin-input") private _pinInput?: HaTextField; - - protected render() { - if (!this._entryId) { - 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` - - ${this._status === "loading" - ? html`
- -
` - : this._status === "waiting_for_device" - ? html`
- -

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.waiting_for_device" - )} -

-
` - : this._status === "choose_strategy" - ? html`

Choose strategy

-
- Secure if possible -
- Requires user interaction during inclusion. Fast and - secure with S2 when supported. Allows manually - selecting which security keys to grant. Fallback to - legacy S0 or no encryption when necessary. -
`} - > - - -
- Legacy Secure -
- Uses the older S0 security that is secure, but slow - due to a lot of overhead. Allows securely including S2 - capable devices which fail to be included with S2. -
`} - > - - -
- Insecure -
Do not use encryption.
`} - > - - -
-
- - Search device - ` - : this._status === "qr_scan" - ? html` - - ${this.hass.localize( - "ui.panel.config.zwave_js.common.back" - )} - ` - : this._status === "validate_dsk_enter_pin" - ? html` -

- Please enter the 5-digit PIN for your device and verify that - the rest of the device-specific key matches the one that can - be found on your device or the manual. -

- ${ - this._error - ? html` - ${this._error} - ` - : "" - } -
- - ${this._dsk} -
- - Submit - - - ` - : this._status === "grant_security_classes" - ? html` -

- The device has requested the following security - classes: -

- ${this._error - ? html`${this._error}` - : ""} -
- ${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`${this.hass.localize( - `ui.panel.config.zwave_js.security_classes.${SecurityClass[securityClass]}.title` - )} -
- ${this.hass.localize( - `ui.panel.config.zwave_js.security_classes.${SecurityClass[securityClass]}.description` - )} -
`} - > - - -
` - )} -
- - Submit - - ` - : this._status === "timed_out" - ? html` -

Timed out!

-

- We have not found any device in inclusion mode. Make - sure the device is active and in inclusion mode. -

- - Retry - - ` - : this._status === "started_specific" - ? html`

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.searching_device" - )} -

- -

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.follow_device_instructions" - )} -

` - : this._status === "started" - ? html` -
-
-

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.searching_device" - )} -

- -

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.follow_device_instructions" - )} -

-

- -

-
- ${this._supportsSmartStart - ? html`
-

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.qr_code" - )} -

- -

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.qr_code_paragraph" - )} -

-

- - ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.scan_qr_code" - )} - -

-
` - : ""} -
- - ${this.hass.localize("ui.common.cancel")} - - ` - : this._status === "interviewing" - ? html` -
- -
-

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.interview_started" - )} -

- ${this._lowSecurity - ? html` - ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.added_insecurely_text" - )} - ${typeof this._lowSecurityReason !== - "undefined" - ? html`

- ${this.hass.localize( - `ui.panel.config.zwave_js.add_node.low_security_reason.${this._lowSecurityReason}` - )} -

` - : ""} -
` - : ""} - ${this._stages - ? html`
- ${this._stages.map( - (stage) => html` - - - ${stage} - - ` - )} -
` - : ""} -
-
- - ${this.hass.localize("ui.common.close")} - - ` - : this._status === "failed" - ? html` -
-
- - ${this._error || - this.hass.localize( - "ui.panel.config.zwave_js.add_node.check_logs" - )} - - ${this._stages - ? html`
- ${this._stages.map( - (stage) => html` - - - ${stage} - - ` - )} -
` - : ""} -
-
- - ${this.hass.localize("ui.common.close")} - - ` - : this._status === "finished" - ? html` -
- -
-

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.inclusion_finished" - )} -

- ${this._lowSecurity - ? html` - ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.added_insecurely_text" - )} - ${typeof this - ._lowSecurityReason !== - "undefined" - ? html`

- ${this.hass.localize( - `ui.panel.config.zwave_js.add_node.low_security_reason.${this._lowSecurityReason}` - )} -

` - : nothing} -
` - : ""} - - - ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.view_device" - )} - - - ${this._stages - ? html`
- ${this._stages.map( - (stage) => html` - - - ${stage} - - ` - )} -
` - : ""} -
-
- - ${this.hass.localize("ui.common.close")} - - ` - : this._status === "provisioned" - ? html`
- -
-

- ${this.hass.localize( - "ui.panel.config.zwave_js.add_node.provisioning_finished" - )} -

-
-
- - ${this.hass.localize("ui.common.close")} - ` - : ""} -
- `; - } - - 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 { - this._unsubscribe(); - this._status = "choose_strategy"; - } - - private _handleStrategyChange(ev: CustomEvent): void { - this._inclusionStrategy = (ev.target as any).value; - } - - private _handleSecurityClassChange(ev: CustomEvent): void { - const checkbox = ev.currentTarget as HaCheckbox; - const securityClass = Number(checkbox.value); - if (checkbox.checked && !this._securityClasses.includes(securityClass)) { - this._securityClasses = [...this._securityClasses, securityClass]; - } else if (!checkbox.checked) { - this._securityClasses = this._securityClasses.filter( - (val) => val !== securityClass - ); - } - } - - private async _scanQRCode(): Promise { - this._unsubscribe(); - this._status = "qr_scan"; - } - - private _qrCodeScanned(ev: CustomEvent): void { - if (this._qrProcessing) { - return; - } - this._handleQrCodeScanned(ev.detail.value); - } - - private _qrCodeError(ev: CustomEvent): void { - this._error = ev.detail.message; - } - - private async _handleQrCodeScanned(qrCodeString: string): Promise { - this._error = undefined; - if (this._status !== "qr_scan" || this._qrProcessing) { - return; - } - this._qrProcessing = true; - const dsk = await zwaveTryParseDskFromQrCode( - this.hass, - this._entryId!, - qrCodeString - ); - if (dsk) { - this._status = "loading"; - // wait for QR scanner to be removed before resetting qr processing - this.updateComplete.then(() => { - this._qrProcessing = false; - }); - this._inclusionStrategy = InclusionStrategy.Security_S2; - this._startInclusion(undefined, dsk); - return; - } - if ( - qrCodeString.length < MINIMUM_QR_STRING_LENGTH || - !qrCodeString.startsWith("90") - ) { - this._qrProcessing = false; - this._error = `Invalid QR code (${qrCodeString})`; - return; - } - let provisioningInfo: QRProvisioningInformation; - try { - provisioningInfo = await zwaveParseQrCode( - this.hass, - this._entryId!, - qrCodeString - ); - } catch (err: any) { - this._qrProcessing = false; - this._error = err.message; - return; - } - this._status = "loading"; - // wait for QR scanner to be removed before resetting qr processing - this.updateComplete.then(() => { - this._qrProcessing = false; - }); - if (provisioningInfo.version === 1) { - try { - await provisionZwaveSmartStartNode( - this.hass, - this._entryId!, - provisioningInfo - ); - this._status = "provisioned"; - } catch (err: any) { - this._error = err.message; - this._status = "failed"; - } - } else if (provisioningInfo.version === 0) { - this._inclusionStrategy = InclusionStrategy.Security_S2; - this._startInclusion(provisioningInfo); - } else { - this._error = "This QR code is not supported"; - this._status = "failed"; - } - } - - private _handlePinKeyUp(ev: KeyboardEvent) { - if (ev.key === "Enter") { - this._validateDskAndEnterPin(); - } - } - - private async _validateDskAndEnterPin(): Promise { - this._status = "waiting_for_device"; - this._error = undefined; - try { - await zwaveValidateDskAndEnterPin( - this.hass, - this._entryId!, - this._pinInput!.value as string - ); - } catch (err: any) { - this._error = err.message; - this._status = "validate_dsk_enter_pin"; - await this.updateComplete; - this._pinInput?.focus(); - } - } - - private async _grantSecurityClasses(): Promise { - this._status = "waiting_for_device"; - this._error = undefined; - try { - await zwaveGrantSecurityClasses( - this.hass, - this._entryId!, - this._securityClasses - ); - } catch (err: any) { - this._error = err.message; - this._status = "grant_security_classes"; - } - } - - private _startManualInclusion() { - if (!this._inclusionStrategy) { - this._inclusionStrategy = InclusionStrategy.Default; - } - this._startInclusion(); - } - - private async _checkSmartStartSupport() { - this._supportsSmartStart = ( - await zwaveSupportsFeature( - this.hass, - this._entryId!, - ZWaveFeature.SmartStart - ) - ).supported; - } - - private _startOver(_ev: Event) { - this._startInclusion(); - } - - private _startInclusion( - qrProvisioningInformation?: QRProvisioningInformation, - dsk?: string - ): void { - if (!this.hass) { - return; - } - this._lowSecurity = false; - const specificDevice = qrProvisioningInformation || dsk; - this._subscribed = subscribeAddZwaveNode( - this.hass, - this._entryId!, - (message) => { - if (message.event === "inclusion started") { - this._status = specificDevice ? "started_specific" : "started"; - } - if (message.event === "inclusion failed") { - this._unsubscribe(); - this._status = "failed"; - } - if (message.event === "inclusion stopped") { - // We either found a device, or it failed, either way, cancel the timeout as we are no longer searching - if (this._addNodeTimeoutHandle) { - clearTimeout(this._addNodeTimeoutHandle); - } - 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") { - this._status = "validate_dsk_enter_pin"; - this._dsk = message.dsk; - } - - if (message.event === "grant security classes") { - if (this._inclusionStrategy === undefined) { - zwaveGrantSecurityClasses( - this.hass, - this._entryId!, - message.requested_grant.securityClasses, - message.requested_grant.clientSideAuth - ); - return; - } - this._requestedGrant = message.requested_grant; - this._securityClasses = message.requested_grant.securityClasses; - this._status = "grant_security_classes"; - } - - if (message.event === "device registered") { - this._device = message.device; - } - if (message.event === "node added") { - this._status = "interviewing"; - this._lowSecurity = message.node.low_security; - this._lowSecurityReason = message.node.low_security_reason; - } - - if (message.event === "interview completed") { - this._unsubscribe(); - this._status = "finished"; - } - - if (message.event === "interview stage completed") { - if (this._stages === undefined) { - this._stages = [message.stage]; - } else { - this._stages = [...this._stages, message.stage]; - } - } - }, - qrProvisioningInformation, - undefined, - undefined, - dsk, - this._inclusionStrategy - ).catch((err) => { - this._error = err.message; - this._status = "failed"; - return undefined; - }); - this._addNodeTimeoutHandle = window.setTimeout(() => { - this._unsubscribe(); - this._status = "timed_out"; - }, 300000); - } - - private _onBeforeUnload = (event: BeforeUnloadEvent) => { - if (this._shouldPreventClose()) { - event.preventDefault(); - } - event.returnValue = true; - }; - - private _unsubscribe(): void { - if (this._subscribed) { - this._subscribed.then((unsub) => unsub && unsub()); - this._subscribed = undefined; - } - if (this._entryId) { - stopZwaveInclusion(this.hass, this._entryId); - if ( - this._status && - [ - "waiting_for_device", - "validate_dsk_enter_pin", - "grant_security_classes", - ].includes(this._status) - ) { - cancelSecureBootstrapS2(this.hass, this._entryId); - } - if (this._params?.onStop) { - this._params.onStop(); - } - } - this._requestedGrant = undefined; - this._dsk = undefined; - this._securityClasses = []; - this._status = undefined; - if (this._addNodeTimeoutHandle) { - clearTimeout(this._addNodeTimeoutHandle); - } - this._addNodeTimeoutHandle = undefined; - window.removeEventListener("beforeunload", this._onBeforeUnload); - } - - public closeDialog(): void { - this._unsubscribe(); - this._inclusionStrategy = undefined; - this._entryId = undefined; - this._status = undefined; - this._device = undefined; - this._stages = undefined; - this._error = undefined; - fireEvent(this, "dialog-closed", { dialog: this.localName }); - } - - static get styles(): CSSResultGroup { - return [ - haStyleDialog, - haStyle, - css` - h3 { - margin-top: 0; - } - - .success { - color: var(--success-color); - } - - .warning { - color: var(--warning-color); - } - - .stages { - margin-top: 16px; - display: grid; - } - - .flex-container .stage ha-svg-icon { - width: 16px; - height: 16px; - margin-right: 0px; - margin-inline-end: 0px; - margin-inline-start: initial; - } - .stage { - padding: 8px; - } - - .flex-container { - display: flex; - align-items: center; - } - - .flex-column { - display: flex; - flex-direction: column; - } - - .flex-column ha-formfield { - padding: 8px 0; - } - - .select-inclusion { - display: flex; - align-items: center; - } - - .select-inclusion .outline:nth-child(2) { - margin-left: 16px; - margin-inline-start: 16px; - margin-inline-end: initial; - } - - .select-inclusion .outline { - border: 1px solid var(--divider-color); - border-radius: 4px; - padding: 16px; - min-height: 250px; - text-align: center; - flex: 1; - } - - @media all and (max-width: 500px) { - .select-inclusion { - flex-direction: column; - } - - .select-inclusion .outline:nth-child(2) { - margin-left: 0; - margin-inline-start: 0; - margin-inline-end: initial; - margin-top: 16px; - } - } - - ha-svg-icon { - width: 68px; - height: 48px; - } - ha-textfield { - display: block; - } - .secondary { - color: var(--secondary-text-color); - } - - .flex-container ha-spinner, - .flex-container ha-svg-icon { - margin-right: 20px; - margin-inline-end: 20px; - margin-inline-start: initial; - } - - .status { - flex: 1; - } - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "dialog-zwave_js-add-node": DialogZWaveJSAddNode; - } -} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts index 27190a1622..d6edc2aea5 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts @@ -49,7 +49,7 @@ import "../../../../../layouts/hass-tabs-subpage"; import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; import { haStyle } from "../../../../../resources/styles"; import type { HomeAssistant, Route } from "../../../../../types"; -import { showZWaveJSAddNodeDialog } from "./show-dialog-zwave_js-add-node"; +import { showZWaveJSAddNodeDialog } from "./add-node/show-dialog-zwave_js-add-node"; import { showZWaveJSRebuildNetworkRoutesDialog } from "./show-dialog-zwave_js-rebuild-network-routes"; import { showZWaveJSRemoveNodeDialog } from "./show-dialog-zwave_js-remove-node"; import { configTabs } from "./zwave_js-config-router"; @@ -101,7 +101,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { const inclusion_state = this._network?.controller.inclusion_state; // show dialog if inclusion/exclusion is already in progress if (inclusion_state === InclusionState.Including) { - this._addNodeClicked(); + this._openInclusionDialog(undefined, true); } else if (inclusion_state === InclusionState.Excluding) { this._removeNodeClicked(); } @@ -743,7 +743,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { input.value = ""; } - private _openInclusionDialog(dsk?: string) { + private _openInclusionDialog(dsk?: string, inclusionOngoing = false) { if (!this._dialogOpen) { // Unsubscribe from S2 inclusion before opening dialog if (this._s2InclusionUnsubscribe) { @@ -755,6 +755,8 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { entry_id: this.configEntryId!, dsk, onStop: this._handleInclusionDialogClosed, + longRangeSupported: !!this._network?.controller?.supports_long_range, + inclusionOngoing, }); this._dialogOpen = true; } diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts index 28e1110e46..83cac24c77 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts @@ -42,7 +42,7 @@ class ZWaveJSConfigRouter extends HassRouterPage { }, add: { tag: "zwave_js-add-node", - load: () => import("./zwave_js-add-node"), + load: () => import("./add-node/zwave_js-add-node"), }, node_config: { tag: "zwave_js-node-config", diff --git a/src/translations/en.json b/src/translations/en.json index 6e9e10bff6..b99f043ce4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1106,7 +1106,15 @@ "only_https_supported": "You can only use your camera to scan a QR code when using HTTPS.", "not_supported": "Your browser doesn't support QR scanning.", "manual_input": "You can scan the QR code with another QR scanner and paste the code in the input below", - "enter_qr_code": "Enter QR code value" + "enter_qr_code": "Enter QR code value", + "retry": "Retry", + "wrong_code": "Wrong barcode scanned! {format}: {rawValue}, we need a QR code.", + "no_camera_found": "No camera found", + "app": { + "title": "Scan QR code", + "description": "Find the code on your device", + "alternativeOptionLabel": "More options" + } }, "climate-control": { "temperature_up": "Increase temperature", @@ -5876,33 +5884,91 @@ }, "add_node": { "title": "Add a Z-Wave device", - "searching_device": "Searching for devices…", - "follow_device_instructions": "Follow the directions that came with your device to trigger pairing on the device.", + "searching_devices": "Searching for devices", + "follow_device_instructions": "Follow the directions provided with your device to put it into pairing mode (sometimes called \"inclusion mode\")", + "security_options": "Advanced security options", "choose_inclusion_strategy": "How do you want to add your device", - "qr_code": "QR Code", - "qr_code_paragraph": "If your device supports SmartStart you can scan the QR code for easy pairing.", - "scan_qr_code": "Scan QR code", + "add_device_failed": "Add device failed", "inclusion_failed": "The device could not be added.", + "getting_device_information": "Getting device information", + "saving_device": "Saving device", "check_logs": "Please check the logs for more information.", - "inclusion_finished": "The device has been added.", - "provisioning_finished": "The device has been added. Once you power it on, it will become available.", - "view_device": "View Device", - "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.", - "waiting_for_device": "Communicating with the device. Please wait.", - "adding_insecurely": "The device is being added insecurely", - "added_insecurely": "The device was added insecurely", - "added_insecurely_text": "There was an error during secure inclusion. You can try again by excluding the device and adding it again.", - "low_security_reason": { - "0": "Security bootstrapping was canceled by the user.", - "1": "The required security keys were not configured in the driver.", - "2": "No Security S2 user callbacks (or provisioning info) were provided to grant security classes and/or validate the DSK.", - "3": "An expected message was not received within the corresponding timeout.", - "4": "There was no possible match in encryption parameters between the controller and the node.", - "5": "Security bootstrapping was canceled by the included node.", - "6": "The PIN was incorrect, so the included node could not decode the key exchange commands.", - "7": "There was a mismatch in security keys between the controller and the node.", - "8": "Unknown error occurred." + "timeout_error": "No device found after {minutes} minutes. Please check the device and try again.", + "select_method": { + "webcam_unsupported": "You can only use your camera to scan a QR code when using a secure connection with the app or over HTTPS.", + "qr_code_webcam": "Scan QR code using webcam", + "qr_code_webcam_description": "Find and scan the code on your device.", + "qr_code_manual": "Enter QR code manually", + "qr_code_manual_description": "You can scan the QR code with another QR scanner and paste the code.", + "search_device": "Search for device", + "search_device_description": "Use this option to manually include a device that does not have a SmartStart QR code." + }, + "qr": { + "manual": { + "title": "Enter QR code manually", + "text": "You can scan the QR code with another QR scanner and paste the code here.", + "placeholder": "Code" + }, + "scan_code": "Scan QR code", + "other_add_options": "Other add options", + "invalid_code": "Invalid QR code ({code})", + "unsupported_code": "This QR code is not supported \"{code}\"" + }, + "specific_device": { + "title": "Searching for device", + "turn_on_device": "Turn on the device", + "turn_on_device_description": "If your device is already turned on, you might need to turn it off and on again. Consult your device manual if you are unsure.", + "add_another_z_wave_device": "Add another Z-Wave device", + "close_description": "Feel free to close this screen, as this process will continue in the background." + }, + "select_strategy": { + "title": "Choose security strategy", + "default_label": "Secure if possible", + "default_description": "Requires user interaction during inclusion. Fast and secure with S2 when supported. Allows manually selecting which security keys to grant. Fallback to legacy S0 or no encryption when necessary.", + "s0_label": "Legacy Secure", + "s0_description": "Uses the older S0 security that is secure, but slow due to a lot of overhead. Allows securely including S2 capable devices which fail to be included with S2.", + "insecure_label": "Insecure", + "insecure_description": "Do not use encryption.", + "inclusion_strategy": "Inclusion strategy: {strategy}" + }, + "configure_device": { + "title": "Add device", + "device_name": "Name", + "device_area": "Area", + "choose_network_type": "Choose network type", + "long_range_label": "Direct long range", + "long_range_description": "Direct connection to Home Assistant for extended coverage, without a mesh network.", + "mesh_label": "Mesh network", + "mesh_description": "Devices relay signals to each other, enhancing coverage and reliability.", + "add_device": "Add device", + "save_device_failed": "Saving new device info failed" + }, + "validate_dsk_pin": { + "title": "Verify key", + "text": "Find the key on your device or manual and enter the 5-digit PIN.", + "placeholder": "PIN" + }, + "added_insecure": { + "title": "Device added insecurely", + "text": "The device {name} has been added to your Z-Wave network.", + "added_insecurely_text": "{deviceName} has been added without secure inclusion. You can still use your device.", + "try_again_text": "You can try again by removing the device and adding it again.", + "view_device": "View device", + "low_security_reason": { + "0": "Security bootstrapping was canceled by the user.", + "1": "The required security keys were not configured in the driver.", + "2": "No Security S2 user callbacks (or provisioning info) were provided to grant security classes and/or validate the DSK.", + "3": "An expected message was not received within the corresponding timeout.", + "4": "There was no possible match in encryption parameters between the controller and the node.", + "5": "Security bootstrapping was canceled by the included node.", + "6": "The PIN was incorrect, so the included node could not decode the key exchange commands.", + "7": "There was a mismatch in security keys between the controller and the node.", + "8": "Unknown error occurred." + } + }, + "grant_security_classes": { + "title": "Security classes", + "description": "The device has requested the following security classes" } }, "provisioned": {