From 4b49da58b19bd20ac75a2ab21d2f110ad33f5246 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Dec 2021 23:12:52 +0100 Subject: [PATCH] Add SmartStart/QR scan support for Z-Wave JS (#10726) --- build-scripts/gulp/gather-static.js | 8 + package.json | 1 + src/components/ha-qr-scanner.ts | 162 ++++++++++ src/data/zwave_js.ts | 122 ++++++-- .../zwave_js/ha-device-actions-zwave_js.ts | 4 +- .../zwave_js/ha-device-info-zwave_js.ts | 12 +- .../zwave/zwave-migration.ts | 23 +- .../zwave_js/dialog-zwave_js-add-node.ts | 293 +++++++++++++++--- .../zwave_js/dialog-zwave_js-heal-network.ts | 18 +- .../zwave_js/dialog-zwave_js-heal-node.ts | 12 +- .../dialog-zwave_js-reinterview-node.ts | 4 +- .../dialog-zwave_js-remove-failed-node.ts | 4 +- .../zwave_js/zwave_js-config-dashboard.ts | 16 +- .../zwave_js/zwave_js-node-config.ts | 12 +- src/translations/en.json | 13 +- yarn.lock | 8 + 16 files changed, 602 insertions(+), 110 deletions(-) create mode 100644 src/components/ha-qr-scanner.ts diff --git a/build-scripts/gulp/gather-static.js b/build-scripts/gulp/gather-static.js index c0f36d02af..306ae3158a 100644 --- a/build-scripts/gulp/gather-static.js +++ b/build-scripts/gulp/gather-static.js @@ -79,6 +79,11 @@ function copyFonts(staticDir) { ); } +function copyQrScannerWorker(staticDir) { + const staticPath = genStaticPath(staticDir); + copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js")); +} + function copyMapPanel(staticDir) { const staticPath = genStaticPath(staticDir); copyFileDir( @@ -125,6 +130,9 @@ gulp.task("copy-static-app", async () => { // Panel assets copyMapPanel(staticDir); + + // Qr Scanner assets + copyQrScannerWorker(staticDir); }); gulp.task("copy-static-demo", async () => { diff --git a/package.json b/package.json index 0d9726b250..df1ce7d236 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "node-vibrant": "3.2.1-alpha.1", "proxy-polyfill": "^0.3.2", "punycode": "^2.1.1", + "qr-scanner": "^1.3.0", "qrcode": "^1.4.4", "regenerator-runtime": "^0.13.8", "resize-observer-polyfill": "^1.5.1", diff --git a/src/components/ha-qr-scanner.ts b/src/components/ha-qr-scanner.ts new file mode 100644 index 0000000000..0872c2556d --- /dev/null +++ b/src/components/ha-qr-scanner.ts @@ -0,0 +1,162 @@ +import "@material/mwc-list/mwc-list-item"; +import "@material/mwc-select/mwc-select"; +import type { Select } from "@material/mwc-select/mwc-select"; +import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import type QrScanner from "qr-scanner"; +import { fireEvent } from "../common/dom/fire_event"; +import { stopPropagation } from "../common/dom/stop_propagation"; +import { LocalizeFunc } from "../common/translations/localize"; +import "./ha-alert"; + +@customElement("ha-qr-scanner") +class HaQrScanner extends LitElement { + @property() localize!: LocalizeFunc; + + @state() private _cameras?: QrScanner.Camera[]; + + @state() private _error?: string; + + private _qrScanner?: QrScanner; + + private _qrNotFoundCount = 0; + + @query("video", true) private _video!: HTMLVideoElement; + + @query("#canvas-container", true) private _canvasContainer!: HTMLDivElement; + + public disconnectedCallback(): void { + super.disconnectedCallback(); + this._qrNotFoundCount = 0; + if (this._qrScanner) { + this._qrScanner.stop(); + this._qrScanner.destroy(); + this._qrScanner = undefined; + } + while (this._canvasContainer.lastChild) { + this._canvasContainer.removeChild(this._canvasContainer.lastChild); + } + } + + public connectedCallback(): void { + super.connectedCallback(); + if (this.hasUpdated && navigator.mediaDevices) { + this._loadQrScanner(); + } + } + + protected firstUpdated() { + if (navigator.mediaDevices) { + this._loadQrScanner(); + } + } + + protected updated(changedProps: PropertyValues) { + if (changedProps.has("_error") && this._error) { + fireEvent(this, "qr-code-error", { message: this._error }); + } + } + + protected render(): TemplateResult { + return html`${this._cameras && this._cameras.length > 1 + ? html` + ${this._cameras!.map( + (camera) => html` + ${camera.label} + ` + )} + ` + : ""} + ${this._error + ? html`${this._error}` + : ""} + ${navigator.mediaDevices + ? html` +
` + : html`${!window.isSecureContext + ? "You can only use your camera to scan a QR core when using HTTPS." + : "Your browser doesn't support QR scanning."}`}`; + } + + private async _loadQrScanner() { + const QrScanner = (await import("qr-scanner")).default; + if (!(await QrScanner.hasCamera())) { + this._error = "No camera found"; + return; + } + QrScanner.WORKER_PATH = "/static/js/qr-scanner-worker.min.js"; + this._listCameras(QrScanner); + this._qrScanner = new QrScanner( + this._video, + this._qrCodeScanned, + this._qrCodeError + ); + // @ts-ignore + const canvas = this._qrScanner.$canvas; + this._canvasContainer.appendChild(canvas); + canvas.style.display = "block"; + try { + await this._qrScanner.start(); + } catch (err: any) { + this._error = err; + } + } + + private async _listCameras(qrScanner: typeof QrScanner): Promise { + this._cameras = await qrScanner.listCameras(true); + } + + private _qrCodeError = (err: any) => { + if (err === "No QR code found") { + this._qrNotFoundCount++; + if (this._qrNotFoundCount === 250) { + this._error = err; + } + return; + } + this._error = err.message || err; + // eslint-disable-next-line no-console + console.log(err); + }; + + private _qrCodeScanned = async (qrCodeString: string): Promise => { + this._qrNotFoundCount = 0; + fireEvent(this, "qr-code-scanned", { value: qrCodeString }); + }; + + private _cameraChanged(ev: CustomEvent): void { + this._qrScanner?.setCamera((ev.target as Select).value); + } + + static styles = css` + canvas { + width: 100%; + } + mwc-select { + width: 100%; + margin-bottom: 16px; + } + `; +} + +declare global { + // for fire event + interface HASSDomEvents { + "qr-code-scanned": { value: string }; + "qr-code-error": { message: string }; + } + + interface HTMLElementTagNameMap { + "ha-qr-scanner": HaQrScanner; + } +} diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index fbd9113d5c..cf278eaf2b 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -57,6 +57,45 @@ export enum SecurityClass { S0_Legacy = 7, } +/** A named list of Z-Wave features */ +export enum ZWaveFeature { + // Available starting with Z-Wave SDK 6.81 + SmartStart, +} + +enum QRCodeVersion { + S2 = 0, + SmartStart = 1, +} + +enum Protocols { + ZWave = 0, + ZWaveLongRange = 1, +} +export interface QRProvisioningInformation { + version: QRCodeVersion; + securityClasses: SecurityClass[]; + dsk: string; + genericDeviceClass: number; + specificDeviceClass: number; + installerIconType: number; + manufacturerId: number; + productType: number; + productId: number; + applicationVersion: string; + maxInclusionRequestInterval?: number | undefined; + uuid?: string | undefined; + supportedProtocols?: Protocols[] | undefined; +} + +export interface PlannedProvisioningEntry { + /** The device specific key (DSK) in the form aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-11111-22222 */ + dsk: string; + security_classes: SecurityClass[]; +} + +export const MINIMUM_QR_STRING_LENGTH = 52; + export interface ZWaveJSNodeIdentifiers { home_id: string; node_id: number; @@ -197,7 +236,7 @@ export const migrateZwave = ( dry_run, }); -export const fetchNetworkStatus = ( +export const fetchZwaveNetworkStatus = ( hass: HomeAssistant, entry_id: string ): Promise => @@ -206,7 +245,7 @@ export const fetchNetworkStatus = ( entry_id, }); -export const fetchDataCollectionStatus = ( +export const fetchZwaveDataCollectionStatus = ( hass: HomeAssistant, entry_id: string ): Promise => @@ -215,7 +254,7 @@ export const fetchDataCollectionStatus = ( entry_id, }); -export const setDataCollectionPreference = ( +export const setZwaveDataCollectionPreference = ( hass: HomeAssistant, entry_id: string, opted_in: boolean @@ -226,25 +265,31 @@ export const setDataCollectionPreference = ( opted_in, }); -export const subscribeAddNode = ( +export const subscribeAddZwaveNode = ( hass: HomeAssistant, entry_id: string, callbackFunction: (message: any) => void, - inclusion_strategy: InclusionStrategy = InclusionStrategy.Default + inclusion_strategy: InclusionStrategy = InclusionStrategy.Default, + qr_provisioning_information?: QRProvisioningInformation, + qr_code_string?: string, + planned_provisioning_entry?: PlannedProvisioningEntry ): Promise => hass.connection.subscribeMessage((message) => callbackFunction(message), { type: "zwave_js/add_node", entry_id: entry_id, inclusion_strategy, + qr_code_string, + qr_provisioning_information, + planned_provisioning_entry, }); -export const stopInclusion = (hass: HomeAssistant, entry_id: string) => +export const stopZwaveInclusion = (hass: HomeAssistant, entry_id: string) => hass.callWS({ type: "zwave_js/stop_inclusion", entry_id, }); -export const grantSecurityClasses = ( +export const zwaveGrantSecurityClasses = ( hass: HomeAssistant, entry_id: string, security_classes: SecurityClass[], @@ -257,7 +302,7 @@ export const grantSecurityClasses = ( client_side_auth, }); -export const validateDskAndEnterPin = ( +export const zwaveValidateDskAndEnterPin = ( hass: HomeAssistant, entry_id: string, pin: string @@ -268,7 +313,44 @@ export const validateDskAndEnterPin = ( pin, }); -export const fetchNodeStatus = ( +export const zwaveSupportsFeature = ( + hass: HomeAssistant, + entry_id: string, + feature: ZWaveFeature +): Promise<{ supported: boolean }> => + hass.callWS({ + type: "zwave_js/supports_feature", + entry_id, + feature, + }); + +export const zwaveParseQrCode = ( + hass: HomeAssistant, + entry_id: string, + qr_code_string: string +): Promise => + hass.callWS({ + type: "zwave_js/parse_qr_code_string", + entry_id, + qr_code_string, + }); + +export const provisionZwaveSmartStartNode = ( + hass: HomeAssistant, + entry_id: string, + qr_provisioning_information?: QRProvisioningInformation, + qr_code_string?: string, + planned_provisioning_entry?: PlannedProvisioningEntry +): Promise => + hass.callWS({ + type: "zwave_js/provision_smart_start_node", + entry_id, + qr_code_string, + qr_provisioning_information, + planned_provisioning_entry, + }); + +export const fetchZwaveNodeStatus = ( hass: HomeAssistant, entry_id: string, node_id: number @@ -279,7 +361,7 @@ export const fetchNodeStatus = ( node_id, }); -export const fetchNodeMetadata = ( +export const fetchZwaveNodeMetadata = ( hass: HomeAssistant, entry_id: string, node_id: number @@ -290,7 +372,7 @@ export const fetchNodeMetadata = ( node_id, }); -export const fetchNodeConfigParameters = ( +export const fetchZwaveNodeConfigParameters = ( hass: HomeAssistant, entry_id: string, node_id: number @@ -301,7 +383,7 @@ export const fetchNodeConfigParameters = ( node_id, }); -export const setNodeConfigParameter = ( +export const setZwaveNodeConfigParameter = ( hass: HomeAssistant, entry_id: string, node_id: number, @@ -320,7 +402,7 @@ export const setNodeConfigParameter = ( return hass.callWS(data); }; -export const reinterviewNode = ( +export const reinterviewZwaveNode = ( hass: HomeAssistant, entry_id: string, node_id: number, @@ -335,7 +417,7 @@ export const reinterviewNode = ( } ); -export const healNode = ( +export const healZwaveNode = ( hass: HomeAssistant, entry_id: string, node_id: number @@ -346,7 +428,7 @@ export const healNode = ( node_id, }); -export const removeFailedNode = ( +export const removeFailedZwaveNode = ( hass: HomeAssistant, entry_id: string, node_id: number, @@ -361,7 +443,7 @@ export const removeFailedNode = ( } ); -export const healNetwork = ( +export const healZwaveNetwork = ( hass: HomeAssistant, entry_id: string ): Promise => @@ -370,7 +452,7 @@ export const healNetwork = ( entry_id, }); -export const stopHealNetwork = ( +export const stopHealZwaveNetwork = ( hass: HomeAssistant, entry_id: string ): Promise => @@ -379,7 +461,7 @@ export const stopHealNetwork = ( entry_id, }); -export const subscribeNodeReady = ( +export const subscribeZwaveNodeReady = ( hass: HomeAssistant, entry_id: string, node_id: number, @@ -394,7 +476,7 @@ export const subscribeNodeReady = ( } ); -export const subscribeHealNetworkProgress = ( +export const subscribeHealZwaveNetworkProgress = ( hass: HomeAssistant, entry_id: string, callbackFunction: (message: ZWaveJSHealNetworkStatusMessage) => void @@ -407,7 +489,7 @@ export const subscribeHealNetworkProgress = ( } ); -export const getIdentifiersFromDevice = ( +export const getZwaveJsIdentifiersFromDevice = ( device: DeviceRegistryEntry ): ZWaveJSNodeIdentifiers | undefined => { if (!device) { diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts index cd6b6e5f7d..2cd06cbea6 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts @@ -10,7 +10,7 @@ import { import { customElement, property, state } from "lit/decorators"; import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; import { - getIdentifiersFromDevice, + getZwaveJsIdentifiersFromDevice, ZWaveJSNodeIdentifiers, } from "../../../../../../data/zwave_js"; import { haStyle } from "../../../../../../resources/styles"; @@ -34,7 +34,7 @@ export class HaDeviceActionsZWaveJS extends LitElement { this._entryId = this.device.config_entries[0]; const identifiers: ZWaveJSNodeIdentifiers | undefined = - getIdentifiersFromDevice(this.device); + getZwaveJsIdentifiersFromDevice(this.device); if (!identifiers) { return; } diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts index b69484180e..dc24356fff 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts @@ -13,8 +13,8 @@ import { getConfigEntries, } from "../../../../../../data/config_entries"; import { - fetchNodeStatus, - getIdentifiersFromDevice, + fetchZwaveNodeStatus, + getZwaveJsIdentifiersFromDevice, nodeStatus, ZWaveJSNodeStatus, ZWaveJSNodeIdentifiers, @@ -42,7 +42,7 @@ export class HaDeviceInfoZWaveJS extends LitElement { protected updated(changedProperties: PropertyValues) { if (changedProperties.has("device")) { const identifiers: ZWaveJSNodeIdentifiers | undefined = - getIdentifiersFromDevice(this.device); + getZwaveJsIdentifiersFromDevice(this.device); if (!identifiers) { return; } @@ -76,7 +76,11 @@ export class HaDeviceInfoZWaveJS extends LitElement { zwaveJsConfEntries++; } - this._node = await fetchNodeStatus(this.hass, this._entryId, this._nodeId); + this._node = await fetchZwaveNodeStatus( + this.hass, + this._entryId, + this._nodeId + ); } protected render(): TemplateResult { diff --git a/src/panels/config/integrations/integration-panels/zwave/zwave-migration.ts b/src/panels/config/integrations/integration-panels/zwave/zwave-migration.ts index 99cf52fb36..ee19b1aae6 100644 --- a/src/panels/config/integrations/integration-panels/zwave/zwave-migration.ts +++ b/src/panels/config/integrations/integration-panels/zwave/zwave-migration.ts @@ -21,10 +21,10 @@ import { import { migrateZwave, ZWaveJsMigrationData, - fetchNetworkStatus as fetchZwaveJsNetworkStatus, - fetchNodeStatus, - getIdentifiersFromDevice, - subscribeNodeReady, + fetchZwaveNetworkStatus as fetchZwaveJsNetworkStatus, + fetchZwaveNodeStatus, + getZwaveJsIdentifiersFromDevice, + subscribeZwaveNodeReady, } from "../../../../../data/zwave_js"; import { fetchMigrationConfig, @@ -425,7 +425,7 @@ export class ZwaveMigration extends LitElement { this._zwaveJsEntryId! ); const nodeStatePromisses = networkStatus.controller.nodes.map((nodeId) => - fetchNodeStatus(this.hass, this._zwaveJsEntryId!, nodeId) + fetchZwaveNodeStatus(this.hass, this._zwaveJsEntryId!, nodeId) ); const nodesNotReady = (await Promise.all(nodeStatePromisses)).filter( (node) => !node.ready @@ -436,13 +436,18 @@ export class ZwaveMigration extends LitElement { return; } this._nodeReadySubscriptions = nodesNotReady.map((node) => - subscribeNodeReady(this.hass, this._zwaveJsEntryId!, node.node_id, () => { - this._getZwaveJSNodesStatus(); - }) + subscribeZwaveNodeReady( + this.hass, + this._zwaveJsEntryId!, + node.node_id, + () => { + this._getZwaveJSNodesStatus(); + } + ) ); const deviceReg = await fetchDeviceRegistry(this.hass); this._waitingOnDevices = deviceReg - .map((device) => getIdentifiersFromDevice(device)) + .map((device) => getZwaveJsIdentifiersFromDevice(device)) .filter(Boolean); } 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 index b33c340cc5..b9f846fa09 100644 --- 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 @@ -1,30 +1,40 @@ import "@material/mwc-button/mwc-button"; -import { mdiAlertCircle, mdiCheckCircle, mdiCloseCircle } from "@mdi/js"; +import type { TextField } from "@material/mwc-textfield/mwc-textfield"; +import "@material/mwc-textfield/mwc-textfield"; +import { mdiAlertCircle, mdiCheckCircle, mdiQrcodeScan } from "@mdi/js"; import "@polymer/paper-input/paper-input"; import type { PaperInputElement } from "@polymer/paper-input/paper-input"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { fireEvent } from "../../../../../common/dom/fire_event"; +import "../../../../../components/ha-alert"; +import { HaCheckbox } from "../../../../../components/ha-checkbox"; import "../../../../../components/ha-circular-progress"; import { createCloseHeading } from "../../../../../components/ha-dialog"; import "../../../../../components/ha-formfield"; +import "../../../../../components/ha-radio"; import "../../../../../components/ha-switch"; import { - grantSecurityClasses, + zwaveGrantSecurityClasses, InclusionStrategy, + MINIMUM_QR_STRING_LENGTH, + zwaveParseQrCode, + provisionZwaveSmartStartNode, + QRProvisioningInformation, RequestedGrant, SecurityClass, - stopInclusion, - subscribeAddNode, - validateDskAndEnterPin, + stopZwaveInclusion, + subscribeAddZwaveNode, + zwaveSupportsFeature, + zwaveValidateDskAndEnterPin, + ZWaveFeature, + PlannedProvisioningEntry, } from "../../../../../data/zwave_js"; import { haStyle, haStyleDialog } from "../../../../../resources/styles"; import { HomeAssistant } from "../../../../../types"; import { ZWaveJSAddNodeDialogParams } from "./show-dialog-zwave_js-add-node"; -import "../../../../../components/ha-radio"; -import { HaCheckbox } from "../../../../../components/ha-checkbox"; -import "../../../../../components/ha-alert"; +import "../../../../../components/ha-qr-scanner"; export interface ZWaveJSAddNodeDevice { id: string; @@ -40,11 +50,14 @@ class DialogZWaveJSAddNode extends LitElement { @state() private _status?: | "loading" | "started" + | "started_specific" | "choose_strategy" + | "qr_scan" | "interviewing" | "failed" | "timed_out" | "finished" + | "provisioned" | "validate_dsk_enter_pin" | "grant_security_classes"; @@ -64,10 +77,14 @@ class DialogZWaveJSAddNode extends LitElement { @state() private _lowSecurity = false; + @state() private _supportsSmartStart?: boolean; + private _addNodeTimeoutHandle?: number; private _subscribed?: Promise; + private _qrProcessing = false; + public disconnectedCallback(): void { super.disconnectedCallback(); this._unsubscribe(); @@ -76,6 +93,7 @@ class DialogZWaveJSAddNode extends LitElement { public async showDialog(params: ZWaveJSAddNodeDialogParams): Promise { this._entryId = params.entry_id; this._status = "loading"; + this._checkSmartStartSupport(); this._startInclusion(); } @@ -157,6 +175,22 @@ class DialogZWaveJSAddNode extends LitElement { > Search device ` + : this._status === "qr_scan" + ? html` +

+ If scanning doesn't work, you can enter the QR code value + manually: +

+ ` : this._status === "validate_dsk_enter_pin" ? html`

@@ -241,18 +275,28 @@ class DialogZWaveJSAddNode extends LitElement { 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.controller_in_inclusion_mode" - )} -

+
+
+

+ ${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" @@ -263,15 +307,37 @@ class DialogZWaveJSAddNode extends LitElement { class="link" @click=${this._chooseInclusionStrategy} > - Advanced inclusion + ${this.hass.localize( + "ui.panel.config.zwave_js.add_node.choose_inclusion_strategy" + )}

+ ${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.panel.config.zwave_js.add_node.cancel_inclusion" - )} + ${this.hass.localize("ui.common.cancel")} ` : this._status === "interviewing" @@ -310,16 +376,18 @@ class DialogZWaveJSAddNode extends LitElement { : this._status === "failed" ? html`
-
-

- ${this.hass.localize( + + > + ${this._error || + this.hass.localize( + "ui.panel.config.zwave_js.add_node.check_logs" + )} + ${this._stages ? html`

${this._stages.map( @@ -391,6 +459,23 @@ class DialogZWaveJSAddNode extends LitElement { ${this.hass.localize("ui.panel.config.zwave_js.common.close")} ` + : this._status === "provisioned" + ? html`
+ +
+

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

+
+
+ + ${this.hass.localize("ui.panel.config.zwave_js.common.close")} + ` : ""} `; @@ -417,6 +502,83 @@ class DialogZWaveJSAddNode extends LitElement { } } + private async _scanQRCode(): Promise { + this._unsubscribe(); + this._status = "qr_scan"; + } + + private _qrKeyDown(ev: KeyboardEvent) { + if (this._qrProcessing) { + return; + } + if (ev.key === "Enter") { + this._handleQrCodeScanned((ev.target as TextField).value); + } + } + + private _qrCodeScanned(ev: CustomEvent): void { + if (this._qrProcessing) { + return; + } + this._handleQrCodeScanned(ev.detail.value); + } + + private async _handleQrCodeScanned(qrCodeString: string): Promise { + this._error = undefined; + if (this._status !== "qr_scan" || this._qrProcessing) { + return; + } + this._qrProcessing = true; + 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); + this._startInclusion(undefined, undefined, { + dsk: "34673-15546-46480-39591-32400-22155-07715-45994", + security_classes: [0, 1, 7], + }); + } else { + this._error = "This QR code is not supported"; + this._status = "failed"; + } + } + private _handlePinKeyUp(ev: KeyboardEvent) { if (ev.key === "Enter") { this._validateDskAndEnterPin(); @@ -427,7 +589,7 @@ class DialogZWaveJSAddNode extends LitElement { this._status = "loading"; this._error = undefined; try { - await validateDskAndEnterPin( + await zwaveValidateDskAndEnterPin( this.hass, this._entryId!, this._pinInput!.value as string @@ -442,7 +604,7 @@ class DialogZWaveJSAddNode extends LitElement { this._status = "loading"; this._error = undefined; try { - await grantSecurityClasses( + await zwaveGrantSecurityClasses( this.hass, this._entryId!, this._securityClasses @@ -460,17 +622,33 @@ class DialogZWaveJSAddNode extends LitElement { this._startInclusion(); } - private _startInclusion(): void { + private async _checkSmartStartSupport() { + this._supportsSmartStart = ( + await zwaveSupportsFeature( + this.hass, + this._entryId!, + ZWaveFeature.SmartStart + ) + ).supported; + } + + private _startInclusion( + qrProvisioningInformation?: QRProvisioningInformation, + qrCodeString?: string, + plannedProvisioningEntry?: PlannedProvisioningEntry + ): void { if (!this.hass) { return; } this._lowSecurity = false; - this._subscribed = subscribeAddNode( + const specificDevice = + qrProvisioningInformation || qrCodeString || plannedProvisioningEntry; + this._subscribed = subscribeAddZwaveNode( this.hass, this._entryId!, (message) => { if (message.event === "inclusion started") { - this._status = "started"; + this._status = specificDevice ? "started_specific" : "started"; } if (message.event === "inclusion failed") { this._unsubscribe(); @@ -491,7 +669,7 @@ class DialogZWaveJSAddNode extends LitElement { if (message.event === "grant security classes") { if (this._inclusionStrategy === undefined) { - grantSecurityClasses( + zwaveGrantSecurityClasses( this.hass, this._entryId!, message.requested_grant.securityClasses, @@ -525,7 +703,10 @@ class DialogZWaveJSAddNode extends LitElement { } } }, - this._inclusionStrategy + this._inclusionStrategy, + qrProvisioningInformation, + qrCodeString, + plannedProvisioningEntry ); this._addNodeTimeoutHandle = window.setTimeout(() => { this._unsubscribe(); @@ -539,7 +720,7 @@ class DialogZWaveJSAddNode extends LitElement { this._subscribed = undefined; } if (this._entryId) { - stopInclusion(this.hass, this._entryId); + stopZwaveInclusion(this.hass, this._entryId); } this._requestedGrant = undefined; this._dsk = undefined; @@ -558,6 +739,7 @@ class DialogZWaveJSAddNode extends LitElement { this._status = undefined; this._device = undefined; this._stages = undefined; + this._error = undefined; fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -578,10 +760,6 @@ class DialogZWaveJSAddNode extends LitElement { color: var(--warning-color); } - .failed { - color: var(--error-color); - } - .stages { margin-top: 16px; display: grid; @@ -610,6 +788,39 @@ class DialogZWaveJSAddNode extends LitElement { padding: 8px 0; } + .select-inclusion { + display: flex; + align-items: center; + } + + .select-inclusion .outline:nth-child(2) { + margin-left: 16px; + } + + .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-top: 16px; + } + } + + mwc-textfield { + width: 100%; + } + ha-svg-icon { width: 68px; height: 48px; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-network.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-network.ts index 33b60fe685..f99b073869 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-network.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-network.ts @@ -7,10 +7,10 @@ import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../../../../common/dom/fire_event"; import { createCloseHeading } from "../../../../../components/ha-dialog"; import { - fetchNetworkStatus, - healNetwork, - stopHealNetwork, - subscribeHealNetworkProgress, + fetchZwaveNetworkStatus, + healZwaveNetwork, + stopHealZwaveNetwork, + subscribeHealZwaveNetworkProgress, ZWaveJSHealNetworkStatusMessage, ZWaveJSNetwork, } from "../../../../../data/zwave_js"; @@ -202,13 +202,13 @@ class DialogZWaveJSHealNetwork extends LitElement { if (!this.hass) { return; } - const network: ZWaveJSNetwork = await fetchNetworkStatus( + const network: ZWaveJSNetwork = await fetchZwaveNetworkStatus( this.hass!, this.entry_id! ); if (network.controller.is_heal_network_active) { this._status = "started"; - this._subscribed = subscribeHealNetworkProgress( + this._subscribed = subscribeHealZwaveNetworkProgress( this.hass, this.entry_id!, this._handleMessage.bind(this) @@ -220,9 +220,9 @@ class DialogZWaveJSHealNetwork extends LitElement { if (!this.hass) { return; } - healNetwork(this.hass, this.entry_id!); + healZwaveNetwork(this.hass, this.entry_id!); this._status = "started"; - this._subscribed = subscribeHealNetworkProgress( + this._subscribed = subscribeHealZwaveNetworkProgress( this.hass, this.entry_id!, this._handleMessage.bind(this) @@ -233,7 +233,7 @@ class DialogZWaveJSHealNetwork extends LitElement { if (!this.hass) { return; } - stopHealNetwork(this.hass, this.entry_id!); + stopHealZwaveNetwork(this.hass, this.entry_id!); this._unsubscribe(); this._status = "cancelled"; } diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-node.ts index 632c19286b..6db1483fb7 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-node.ts @@ -10,8 +10,8 @@ import { computeDeviceName, } from "../../../../../data/device_registry"; import { - fetchNetworkStatus, - healNode, + fetchZwaveNetworkStatus, + healZwaveNode, ZWaveJSNetwork, } from "../../../../../data/zwave_js"; import { haStyleDialog } from "../../../../../resources/styles"; @@ -206,7 +206,7 @@ class DialogZWaveJSHealNode extends LitElement { if (!this.hass) { return; } - const network: ZWaveJSNetwork = await fetchNetworkStatus( + const network: ZWaveJSNetwork = await fetchZwaveNetworkStatus( this.hass!, this.entry_id! ); @@ -221,7 +221,11 @@ class DialogZWaveJSHealNode extends LitElement { } this._status = "started"; try { - this._status = (await healNode(this.hass, this.entry_id!, this.node_id!)) + this._status = (await healZwaveNode( + this.hass, + this.entry_id!, + this.node_id! + )) ? "finished" : "failed"; } catch (err: any) { diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts index a56f2ed050..fb4e12785c 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts @@ -6,7 +6,7 @@ import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../../../../common/dom/fire_event"; import "../../../../../components/ha-circular-progress"; import { createCloseHeading } from "../../../../../components/ha-dialog"; -import { reinterviewNode } from "../../../../../data/zwave_js"; +import { reinterviewZwaveNode } from "../../../../../data/zwave_js"; import { haStyleDialog } from "../../../../../resources/styles"; import { HomeAssistant } from "../../../../../types"; import { ZWaveJSReinterviewNodeDialogParams } from "./show-dialog-zwave_js-reinterview-node"; @@ -157,7 +157,7 @@ class DialogZWaveJSReinterviewNode extends LitElement { if (!this.hass) { return; } - this._subscribed = reinterviewNode( + this._subscribed = reinterviewZwaveNode( this.hass, this.entry_id!, this.node_id!, diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts index aa8a264649..32faabced2 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts @@ -7,7 +7,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event"; import "../../../../../components/ha-circular-progress"; import { createCloseHeading } from "../../../../../components/ha-dialog"; import { - removeFailedNode, + removeFailedZwaveNode, ZWaveJSRemovedNode, } from "../../../../../data/zwave_js"; import { haStyleDialog } from "../../../../../resources/styles"; @@ -164,7 +164,7 @@ class DialogZWaveJSRemoveFailedNode extends LitElement { return; } this._status = "started"; - this._subscribed = removeFailedNode( + this._subscribed = removeFailedZwaveNode( this.hass, this.entry_id!, this.node_id!, 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 dbd163508f..e7e97addf4 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 @@ -9,11 +9,11 @@ import "../../../../../components/ha-icon-next"; import "../../../../../components/ha-svg-icon"; import { getSignedPath } from "../../../../../data/auth"; import { - fetchDataCollectionStatus, - fetchNetworkStatus, - fetchNodeStatus, + fetchZwaveDataCollectionStatus, + fetchZwaveNetworkStatus, + fetchZwaveNodeStatus, NodeStatus, - setDataCollectionPreference, + setZwaveDataCollectionPreference, ZWaveJSNetwork, ZWaveJSNodeStatus, } from "../../../../../data/zwave_js"; @@ -317,8 +317,8 @@ class ZWaveJSConfigDashboard extends LitElement { } const [network, dataCollectionStatus] = await Promise.all([ - fetchNetworkStatus(this.hass!, this.configEntryId), - fetchDataCollectionStatus(this.hass!, this.configEntryId), + fetchZwaveNetworkStatus(this.hass!, this.configEntryId), + fetchZwaveDataCollectionStatus(this.hass!, this.configEntryId), ]); this._network = network; @@ -340,7 +340,7 @@ class ZWaveJSConfigDashboard extends LitElement { return; } const nodeStatePromisses = this._network.controller.nodes.map((nodeId) => - fetchNodeStatus(this.hass, this.configEntryId!, nodeId) + fetchZwaveNodeStatus(this.hass, this.configEntryId!, nodeId) ); this._nodes = await Promise.all(nodeStatePromisses); } @@ -364,7 +364,7 @@ class ZWaveJSConfigDashboard extends LitElement { } private _dataCollectionToggled(ev) { - setDataCollectionPreference( + setZwaveDataCollectionPreference( this.hass!, this.configEntryId!, ev.target.checked diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts index 9b2e0a4bdf..19b7d24dd8 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts @@ -32,9 +32,9 @@ import { subscribeDeviceRegistry, } from "../../../../../data/device_registry"; import { - fetchNodeConfigParameters, - fetchNodeMetadata, - setNodeConfigParameter, + fetchZwaveNodeConfigParameters, + fetchZwaveNodeMetadata, + setZwaveNodeConfigParameter, ZWaveJSNodeConfigParams, ZwaveJSNodeMetadata, ZWaveJSSetConfigParamResult, @@ -377,7 +377,7 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) { private async _updateConfigParameter(target, value) { const nodeId = getNodeId(this._device!); try { - const result = await setNodeConfigParameter( + const result = await setZwaveNodeConfigParameter( this.hass, this.configEntryId!, nodeId!, @@ -429,8 +429,8 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) { } [this._nodeMetadata, this._config] = await Promise.all([ - fetchNodeMetadata(this.hass, this.configEntryId, nodeId!), - fetchNodeConfigParameters(this.hass, this.configEntryId, nodeId!), + fetchZwaveNodeMetadata(this.hass, this.configEntryId, nodeId!), + fetchZwaveNodeConfigParameters(this.hass, this.configEntryId, nodeId!), ]); } diff --git a/src/translations/en.json b/src/translations/en.json index 1846d12698..a5cb447e03 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2855,11 +2855,18 @@ }, "add_node": { "title": "Add a Z-Wave Device", - "cancel_inclusion": "Cancel Inclusion", - "controller_in_inclusion_mode": "Your Z-Wave controller is now in inclusion mode.", + "searching_device": "Searching for device", "follow_device_instructions": "Follow the directions that came with your device to trigger pairing on the device.", - "inclusion_failed": "The device could not be added. Please check the logs for more information.", + "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", + "enter_qr_code": "Enter QR code value", + "select_camera": "Select camera", + "inclusion_failed": "The device could not be added.", + "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." diff --git a/yarn.lock b/yarn.lock index 0bfb5c39ae..1322aea8a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9139,6 +9139,7 @@ fsevents@^1.2.7: prettier: ^2.4.1 proxy-polyfill: ^0.3.2 punycode: ^2.1.1 + qr-scanner: ^1.3.0 qrcode: ^1.4.4 regenerator-runtime: ^0.13.8 require-dir: ^1.2.0 @@ -13007,6 +13008,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"qr-scanner@npm:^1.3.0": + version: 1.3.0 + resolution: "qr-scanner@npm:1.3.0" + checksum: 421ff00626252d0f9e50550fb550a463166e4d0438baffb469c9450079f1f802f6df22784509bb571ef50ece81aecaadc00f91d442959f37655ad29710c81c8b + languageName: node + linkType: hard + "qrcode@npm:^1.4.4": version: 1.4.4 resolution: "qrcode@npm:1.4.4"