Add SmartStart/QR scan support for Z-Wave JS (#10726)

This commit is contained in:
Bram Kragten 2021-12-01 23:12:52 +01:00 committed by GitHub
parent 68373e6372
commit 4b49da58b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 602 additions and 110 deletions

View File

@ -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) { function copyMapPanel(staticDir) {
const staticPath = genStaticPath(staticDir); const staticPath = genStaticPath(staticDir);
copyFileDir( copyFileDir(
@ -125,6 +130,9 @@ gulp.task("copy-static-app", async () => {
// Panel assets // Panel assets
copyMapPanel(staticDir); copyMapPanel(staticDir);
// Qr Scanner assets
copyQrScannerWorker(staticDir);
}); });
gulp.task("copy-static-demo", async () => { gulp.task("copy-static-demo", async () => {

View File

@ -115,6 +115,7 @@
"node-vibrant": "3.2.1-alpha.1", "node-vibrant": "3.2.1-alpha.1",
"proxy-polyfill": "^0.3.2", "proxy-polyfill": "^0.3.2",
"punycode": "^2.1.1", "punycode": "^2.1.1",
"qr-scanner": "^1.3.0",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"regenerator-runtime": "^0.13.8", "regenerator-runtime": "^0.13.8",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",

View File

@ -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`<mwc-select
.label=${this.localize(
"ui.panel.config.zwave_js.add_node.select_camera"
)}
fixedMenuPosition
naturalMenuWidth
@closed=${stopPropagation}
@selected=${this._cameraChanged}
>
${this._cameras!.map(
(camera) => html`
<mwc-list-item .value=${camera.id}>${camera.label}</mwc-list-item>
`
)}
</mwc-select>`
: ""}
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
${navigator.mediaDevices
? html`<video></video>
<div id="canvas-container"></div>`
: html`<ha-alert alert-type="warning"
>${!window.isSecureContext
? "You can only use your camera to scan a QR core when using HTTPS."
: "Your browser doesn't support QR scanning."}</ha-alert
>`}`;
}
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<void> {
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<void> => {
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;
}
}

View File

@ -57,6 +57,45 @@ export enum SecurityClass {
S0_Legacy = 7, 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 { export interface ZWaveJSNodeIdentifiers {
home_id: string; home_id: string;
node_id: number; node_id: number;
@ -197,7 +236,7 @@ export const migrateZwave = (
dry_run, dry_run,
}); });
export const fetchNetworkStatus = ( export const fetchZwaveNetworkStatus = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string entry_id: string
): Promise<ZWaveJSNetwork> => ): Promise<ZWaveJSNetwork> =>
@ -206,7 +245,7 @@ export const fetchNetworkStatus = (
entry_id, entry_id,
}); });
export const fetchDataCollectionStatus = ( export const fetchZwaveDataCollectionStatus = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string entry_id: string
): Promise<ZWaveJSDataCollectionStatus> => ): Promise<ZWaveJSDataCollectionStatus> =>
@ -215,7 +254,7 @@ export const fetchDataCollectionStatus = (
entry_id, entry_id,
}); });
export const setDataCollectionPreference = ( export const setZwaveDataCollectionPreference = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
opted_in: boolean opted_in: boolean
@ -226,25 +265,31 @@ export const setDataCollectionPreference = (
opted_in, opted_in,
}); });
export const subscribeAddNode = ( export const subscribeAddZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
callbackFunction: (message: any) => void, 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<UnsubscribeFunc> => ): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage((message) => callbackFunction(message), { hass.connection.subscribeMessage((message) => callbackFunction(message), {
type: "zwave_js/add_node", type: "zwave_js/add_node",
entry_id: entry_id, entry_id: entry_id,
inclusion_strategy, 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({ hass.callWS({
type: "zwave_js/stop_inclusion", type: "zwave_js/stop_inclusion",
entry_id, entry_id,
}); });
export const grantSecurityClasses = ( export const zwaveGrantSecurityClasses = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
security_classes: SecurityClass[], security_classes: SecurityClass[],
@ -257,7 +302,7 @@ export const grantSecurityClasses = (
client_side_auth, client_side_auth,
}); });
export const validateDskAndEnterPin = ( export const zwaveValidateDskAndEnterPin = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
pin: string pin: string
@ -268,7 +313,44 @@ export const validateDskAndEnterPin = (
pin, 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<QRProvisioningInformation> =>
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<QRProvisioningInformation> =>
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, hass: HomeAssistant,
entry_id: string, entry_id: string,
node_id: number node_id: number
@ -279,7 +361,7 @@ export const fetchNodeStatus = (
node_id, node_id,
}); });
export const fetchNodeMetadata = ( export const fetchZwaveNodeMetadata = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
node_id: number node_id: number
@ -290,7 +372,7 @@ export const fetchNodeMetadata = (
node_id, node_id,
}); });
export const fetchNodeConfigParameters = ( export const fetchZwaveNodeConfigParameters = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
node_id: number node_id: number
@ -301,7 +383,7 @@ export const fetchNodeConfigParameters = (
node_id, node_id,
}); });
export const setNodeConfigParameter = ( export const setZwaveNodeConfigParameter = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
node_id: number, node_id: number,
@ -320,7 +402,7 @@ export const setNodeConfigParameter = (
return hass.callWS(data); return hass.callWS(data);
}; };
export const reinterviewNode = ( export const reinterviewZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
node_id: number, node_id: number,
@ -335,7 +417,7 @@ export const reinterviewNode = (
} }
); );
export const healNode = ( export const healZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
node_id: number node_id: number
@ -346,7 +428,7 @@ export const healNode = (
node_id, node_id,
}); });
export const removeFailedNode = ( export const removeFailedZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
node_id: number, node_id: number,
@ -361,7 +443,7 @@ export const removeFailedNode = (
} }
); );
export const healNetwork = ( export const healZwaveNetwork = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string entry_id: string
): Promise<UnsubscribeFunc> => ): Promise<UnsubscribeFunc> =>
@ -370,7 +452,7 @@ export const healNetwork = (
entry_id, entry_id,
}); });
export const stopHealNetwork = ( export const stopHealZwaveNetwork = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string entry_id: string
): Promise<UnsubscribeFunc> => ): Promise<UnsubscribeFunc> =>
@ -379,7 +461,7 @@ export const stopHealNetwork = (
entry_id, entry_id,
}); });
export const subscribeNodeReady = ( export const subscribeZwaveNodeReady = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
node_id: number, node_id: number,
@ -394,7 +476,7 @@ export const subscribeNodeReady = (
} }
); );
export const subscribeHealNetworkProgress = ( export const subscribeHealZwaveNetworkProgress = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
callbackFunction: (message: ZWaveJSHealNetworkStatusMessage) => void callbackFunction: (message: ZWaveJSHealNetworkStatusMessage) => void
@ -407,7 +489,7 @@ export const subscribeHealNetworkProgress = (
} }
); );
export const getIdentifiersFromDevice = ( export const getZwaveJsIdentifiersFromDevice = (
device: DeviceRegistryEntry device: DeviceRegistryEntry
): ZWaveJSNodeIdentifiers | undefined => { ): ZWaveJSNodeIdentifiers | undefined => {
if (!device) { if (!device) {

View File

@ -10,7 +10,7 @@ import {
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; import { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import { import {
getIdentifiersFromDevice, getZwaveJsIdentifiersFromDevice,
ZWaveJSNodeIdentifiers, ZWaveJSNodeIdentifiers,
} from "../../../../../../data/zwave_js"; } from "../../../../../../data/zwave_js";
import { haStyle } from "../../../../../../resources/styles"; import { haStyle } from "../../../../../../resources/styles";
@ -34,7 +34,7 @@ export class HaDeviceActionsZWaveJS extends LitElement {
this._entryId = this.device.config_entries[0]; this._entryId = this.device.config_entries[0];
const identifiers: ZWaveJSNodeIdentifiers | undefined = const identifiers: ZWaveJSNodeIdentifiers | undefined =
getIdentifiersFromDevice(this.device); getZwaveJsIdentifiersFromDevice(this.device);
if (!identifiers) { if (!identifiers) {
return; return;
} }

View File

@ -13,8 +13,8 @@ import {
getConfigEntries, getConfigEntries,
} from "../../../../../../data/config_entries"; } from "../../../../../../data/config_entries";
import { import {
fetchNodeStatus, fetchZwaveNodeStatus,
getIdentifiersFromDevice, getZwaveJsIdentifiersFromDevice,
nodeStatus, nodeStatus,
ZWaveJSNodeStatus, ZWaveJSNodeStatus,
ZWaveJSNodeIdentifiers, ZWaveJSNodeIdentifiers,
@ -42,7 +42,7 @@ export class HaDeviceInfoZWaveJS extends LitElement {
protected updated(changedProperties: PropertyValues) { protected updated(changedProperties: PropertyValues) {
if (changedProperties.has("device")) { if (changedProperties.has("device")) {
const identifiers: ZWaveJSNodeIdentifiers | undefined = const identifiers: ZWaveJSNodeIdentifiers | undefined =
getIdentifiersFromDevice(this.device); getZwaveJsIdentifiersFromDevice(this.device);
if (!identifiers) { if (!identifiers) {
return; return;
} }
@ -76,7 +76,11 @@ export class HaDeviceInfoZWaveJS extends LitElement {
zwaveJsConfEntries++; zwaveJsConfEntries++;
} }
this._node = await fetchNodeStatus(this.hass, this._entryId, this._nodeId); this._node = await fetchZwaveNodeStatus(
this.hass,
this._entryId,
this._nodeId
);
} }
protected render(): TemplateResult { protected render(): TemplateResult {

View File

@ -21,10 +21,10 @@ import {
import { import {
migrateZwave, migrateZwave,
ZWaveJsMigrationData, ZWaveJsMigrationData,
fetchNetworkStatus as fetchZwaveJsNetworkStatus, fetchZwaveNetworkStatus as fetchZwaveJsNetworkStatus,
fetchNodeStatus, fetchZwaveNodeStatus,
getIdentifiersFromDevice, getZwaveJsIdentifiersFromDevice,
subscribeNodeReady, subscribeZwaveNodeReady,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
import { import {
fetchMigrationConfig, fetchMigrationConfig,
@ -425,7 +425,7 @@ export class ZwaveMigration extends LitElement {
this._zwaveJsEntryId! this._zwaveJsEntryId!
); );
const nodeStatePromisses = networkStatus.controller.nodes.map((nodeId) => 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( const nodesNotReady = (await Promise.all(nodeStatePromisses)).filter(
(node) => !node.ready (node) => !node.ready
@ -436,13 +436,18 @@ export class ZwaveMigration extends LitElement {
return; return;
} }
this._nodeReadySubscriptions = nodesNotReady.map((node) => this._nodeReadySubscriptions = nodesNotReady.map((node) =>
subscribeNodeReady(this.hass, this._zwaveJsEntryId!, node.node_id, () => { subscribeZwaveNodeReady(
this._getZwaveJSNodesStatus(); this.hass,
}) this._zwaveJsEntryId!,
node.node_id,
() => {
this._getZwaveJSNodesStatus();
}
)
); );
const deviceReg = await fetchDeviceRegistry(this.hass); const deviceReg = await fetchDeviceRegistry(this.hass);
this._waitingOnDevices = deviceReg this._waitingOnDevices = deviceReg
.map((device) => getIdentifiersFromDevice(device)) .map((device) => getZwaveJsIdentifiersFromDevice(device))
.filter(Boolean); .filter(Boolean);
} }

View File

@ -1,30 +1,40 @@
import "@material/mwc-button/mwc-button"; 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 "@polymer/paper-input/paper-input";
import type { PaperInputElement } from "@polymer/paper-input/paper-input"; import type { PaperInputElement } from "@polymer/paper-input/paper-input";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert";
import { HaCheckbox } from "../../../../../components/ha-checkbox";
import "../../../../../components/ha-circular-progress"; import "../../../../../components/ha-circular-progress";
import { createCloseHeading } from "../../../../../components/ha-dialog"; import { createCloseHeading } from "../../../../../components/ha-dialog";
import "../../../../../components/ha-formfield"; import "../../../../../components/ha-formfield";
import "../../../../../components/ha-radio";
import "../../../../../components/ha-switch"; import "../../../../../components/ha-switch";
import { import {
grantSecurityClasses, zwaveGrantSecurityClasses,
InclusionStrategy, InclusionStrategy,
MINIMUM_QR_STRING_LENGTH,
zwaveParseQrCode,
provisionZwaveSmartStartNode,
QRProvisioningInformation,
RequestedGrant, RequestedGrant,
SecurityClass, SecurityClass,
stopInclusion, stopZwaveInclusion,
subscribeAddNode, subscribeAddZwaveNode,
validateDskAndEnterPin, zwaveSupportsFeature,
zwaveValidateDskAndEnterPin,
ZWaveFeature,
PlannedProvisioningEntry,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
import { haStyle, haStyleDialog } from "../../../../../resources/styles"; import { haStyle, haStyleDialog } from "../../../../../resources/styles";
import { HomeAssistant } from "../../../../../types"; import { HomeAssistant } from "../../../../../types";
import { ZWaveJSAddNodeDialogParams } from "./show-dialog-zwave_js-add-node"; import { ZWaveJSAddNodeDialogParams } from "./show-dialog-zwave_js-add-node";
import "../../../../../components/ha-radio"; import "../../../../../components/ha-qr-scanner";
import { HaCheckbox } from "../../../../../components/ha-checkbox";
import "../../../../../components/ha-alert";
export interface ZWaveJSAddNodeDevice { export interface ZWaveJSAddNodeDevice {
id: string; id: string;
@ -40,11 +50,14 @@ class DialogZWaveJSAddNode extends LitElement {
@state() private _status?: @state() private _status?:
| "loading" | "loading"
| "started" | "started"
| "started_specific"
| "choose_strategy" | "choose_strategy"
| "qr_scan"
| "interviewing" | "interviewing"
| "failed" | "failed"
| "timed_out" | "timed_out"
| "finished" | "finished"
| "provisioned"
| "validate_dsk_enter_pin" | "validate_dsk_enter_pin"
| "grant_security_classes"; | "grant_security_classes";
@ -64,10 +77,14 @@ class DialogZWaveJSAddNode extends LitElement {
@state() private _lowSecurity = false; @state() private _lowSecurity = false;
@state() private _supportsSmartStart?: boolean;
private _addNodeTimeoutHandle?: number; private _addNodeTimeoutHandle?: number;
private _subscribed?: Promise<UnsubscribeFunc>; private _subscribed?: Promise<UnsubscribeFunc>;
private _qrProcessing = false;
public disconnectedCallback(): void { public disconnectedCallback(): void {
super.disconnectedCallback(); super.disconnectedCallback();
this._unsubscribe(); this._unsubscribe();
@ -76,6 +93,7 @@ class DialogZWaveJSAddNode extends LitElement {
public async showDialog(params: ZWaveJSAddNodeDialogParams): Promise<void> { public async showDialog(params: ZWaveJSAddNodeDialogParams): Promise<void> {
this._entryId = params.entry_id; this._entryId = params.entry_id;
this._status = "loading"; this._status = "loading";
this._checkSmartStartSupport();
this._startInclusion(); this._startInclusion();
} }
@ -157,6 +175,22 @@ class DialogZWaveJSAddNode extends LitElement {
> >
Search device Search device
</mwc-button>` </mwc-button>`
: this._status === "qr_scan"
? html`<ha-qr-scanner
.localize=${this.hass.localize}
@qr-code-scanned=${this._qrCodeScanned}
></ha-qr-scanner>
<p>
If scanning doesn't work, you can enter the QR code value
manually:
</p>
<mwc-textfield
.label=${this.hass.localize(
"ui.panel.config.zwave_js.add_node.enter_qr_code"
)}
.disabled=${this._qrProcessing}
@keydown=${this._qrKeyDown}
></mwc-textfield>`
: this._status === "validate_dsk_enter_pin" : this._status === "validate_dsk_enter_pin"
? html` ? html`
<p> <p>
@ -241,18 +275,28 @@ class DialogZWaveJSAddNode extends LitElement {
Retry Retry
</mwc-button> </mwc-button>
` `
: this._status === "started_specific"
? html`<h3>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.searching_device"
)}
</h3>
<ha-circular-progress active></ha-circular-progress>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.follow_device_instructions"
)}
</p>`
: this._status === "started" : this._status === "started"
? html` ? html`
<div class="flex-container"> <div class="select-inclusion">
<ha-circular-progress active></ha-circular-progress> <div class="outline">
<div class="status"> <h2>
<p> ${this.hass.localize(
<b "ui.panel.config.zwave_js.add_node.searching_device"
>${this.hass.localize( )}
"ui.panel.config.zwave_js.add_node.controller_in_inclusion_mode" </h2>
)}</b <ha-circular-progress active></ha-circular-progress>
>
</p>
<p> <p>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.zwave_js.add_node.follow_device_instructions" "ui.panel.config.zwave_js.add_node.follow_device_instructions"
@ -263,15 +307,37 @@ class DialogZWaveJSAddNode extends LitElement {
class="link" class="link"
@click=${this._chooseInclusionStrategy} @click=${this._chooseInclusionStrategy}
> >
Advanced inclusion ${this.hass.localize(
"ui.panel.config.zwave_js.add_node.choose_inclusion_strategy"
)}
</button> </button>
</p> </p>
</div> </div>
${this._supportsSmartStart
? html` <div class="outline">
<h2>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.qr_code"
)}
</h2>
<ha-svg-icon .path=${mdiQrcodeScan}></ha-svg-icon>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.qr_code_paragraph"
)}
</p>
<p>
<mwc-button @click=${this._scanQRCode}>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.scan_qr_code"
)}
</mwc-button>
</p>
</div>`
: ""}
</div> </div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}> <mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize( ${this.hass.localize("ui.common.cancel")}
"ui.panel.config.zwave_js.add_node.cancel_inclusion"
)}
</mwc-button> </mwc-button>
` `
: this._status === "interviewing" : this._status === "interviewing"
@ -310,16 +376,18 @@ class DialogZWaveJSAddNode extends LitElement {
: this._status === "failed" : this._status === "failed"
? html` ? html`
<div class="flex-container"> <div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
<div class="status"> <div class="status">
<p> <ha-alert
${this.hass.localize( alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.zwave_js.add_node.inclusion_failed" "ui.panel.config.zwave_js.add_node.inclusion_failed"
)} )}
</p> >
${this._error ||
this.hass.localize(
"ui.panel.config.zwave_js.add_node.check_logs"
)}
</ha-alert>
${this._stages ${this._stages
? html` <div class="stages"> ? html` <div class="stages">
${this._stages.map( ${this._stages.map(
@ -391,6 +459,23 @@ class DialogZWaveJSAddNode extends LitElement {
${this.hass.localize("ui.panel.config.zwave_js.common.close")} ${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button> </mwc-button>
` `
: this._status === "provisioned"
? html` <div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.provisioning_finished"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>`
: ""} : ""}
</ha-dialog> </ha-dialog>
`; `;
@ -417,6 +502,83 @@ class DialogZWaveJSAddNode extends LitElement {
} }
} }
private async _scanQRCode(): Promise<void> {
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<void> {
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) { private _handlePinKeyUp(ev: KeyboardEvent) {
if (ev.key === "Enter") { if (ev.key === "Enter") {
this._validateDskAndEnterPin(); this._validateDskAndEnterPin();
@ -427,7 +589,7 @@ class DialogZWaveJSAddNode extends LitElement {
this._status = "loading"; this._status = "loading";
this._error = undefined; this._error = undefined;
try { try {
await validateDskAndEnterPin( await zwaveValidateDskAndEnterPin(
this.hass, this.hass,
this._entryId!, this._entryId!,
this._pinInput!.value as string this._pinInput!.value as string
@ -442,7 +604,7 @@ class DialogZWaveJSAddNode extends LitElement {
this._status = "loading"; this._status = "loading";
this._error = undefined; this._error = undefined;
try { try {
await grantSecurityClasses( await zwaveGrantSecurityClasses(
this.hass, this.hass,
this._entryId!, this._entryId!,
this._securityClasses this._securityClasses
@ -460,17 +622,33 @@ class DialogZWaveJSAddNode extends LitElement {
this._startInclusion(); 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) { if (!this.hass) {
return; return;
} }
this._lowSecurity = false; this._lowSecurity = false;
this._subscribed = subscribeAddNode( const specificDevice =
qrProvisioningInformation || qrCodeString || plannedProvisioningEntry;
this._subscribed = subscribeAddZwaveNode(
this.hass, this.hass,
this._entryId!, this._entryId!,
(message) => { (message) => {
if (message.event === "inclusion started") { if (message.event === "inclusion started") {
this._status = "started"; this._status = specificDevice ? "started_specific" : "started";
} }
if (message.event === "inclusion failed") { if (message.event === "inclusion failed") {
this._unsubscribe(); this._unsubscribe();
@ -491,7 +669,7 @@ class DialogZWaveJSAddNode extends LitElement {
if (message.event === "grant security classes") { if (message.event === "grant security classes") {
if (this._inclusionStrategy === undefined) { if (this._inclusionStrategy === undefined) {
grantSecurityClasses( zwaveGrantSecurityClasses(
this.hass, this.hass,
this._entryId!, this._entryId!,
message.requested_grant.securityClasses, message.requested_grant.securityClasses,
@ -525,7 +703,10 @@ class DialogZWaveJSAddNode extends LitElement {
} }
} }
}, },
this._inclusionStrategy this._inclusionStrategy,
qrProvisioningInformation,
qrCodeString,
plannedProvisioningEntry
); );
this._addNodeTimeoutHandle = window.setTimeout(() => { this._addNodeTimeoutHandle = window.setTimeout(() => {
this._unsubscribe(); this._unsubscribe();
@ -539,7 +720,7 @@ class DialogZWaveJSAddNode extends LitElement {
this._subscribed = undefined; this._subscribed = undefined;
} }
if (this._entryId) { if (this._entryId) {
stopInclusion(this.hass, this._entryId); stopZwaveInclusion(this.hass, this._entryId);
} }
this._requestedGrant = undefined; this._requestedGrant = undefined;
this._dsk = undefined; this._dsk = undefined;
@ -558,6 +739,7 @@ class DialogZWaveJSAddNode extends LitElement {
this._status = undefined; this._status = undefined;
this._device = undefined; this._device = undefined;
this._stages = undefined; this._stages = undefined;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@ -578,10 +760,6 @@ class DialogZWaveJSAddNode extends LitElement {
color: var(--warning-color); color: var(--warning-color);
} }
.failed {
color: var(--error-color);
}
.stages { .stages {
margin-top: 16px; margin-top: 16px;
display: grid; display: grid;
@ -610,6 +788,39 @@ class DialogZWaveJSAddNode extends LitElement {
padding: 8px 0; 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 { ha-svg-icon {
width: 68px; width: 68px;
height: 48px; height: 48px;

View File

@ -7,10 +7,10 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
import { createCloseHeading } from "../../../../../components/ha-dialog"; import { createCloseHeading } from "../../../../../components/ha-dialog";
import { import {
fetchNetworkStatus, fetchZwaveNetworkStatus,
healNetwork, healZwaveNetwork,
stopHealNetwork, stopHealZwaveNetwork,
subscribeHealNetworkProgress, subscribeHealZwaveNetworkProgress,
ZWaveJSHealNetworkStatusMessage, ZWaveJSHealNetworkStatusMessage,
ZWaveJSNetwork, ZWaveJSNetwork,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
@ -202,13 +202,13 @@ class DialogZWaveJSHealNetwork extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
const network: ZWaveJSNetwork = await fetchNetworkStatus( const network: ZWaveJSNetwork = await fetchZwaveNetworkStatus(
this.hass!, this.hass!,
this.entry_id! this.entry_id!
); );
if (network.controller.is_heal_network_active) { if (network.controller.is_heal_network_active) {
this._status = "started"; this._status = "started";
this._subscribed = subscribeHealNetworkProgress( this._subscribed = subscribeHealZwaveNetworkProgress(
this.hass, this.hass,
this.entry_id!, this.entry_id!,
this._handleMessage.bind(this) this._handleMessage.bind(this)
@ -220,9 +220,9 @@ class DialogZWaveJSHealNetwork extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
healNetwork(this.hass, this.entry_id!); healZwaveNetwork(this.hass, this.entry_id!);
this._status = "started"; this._status = "started";
this._subscribed = subscribeHealNetworkProgress( this._subscribed = subscribeHealZwaveNetworkProgress(
this.hass, this.hass,
this.entry_id!, this.entry_id!,
this._handleMessage.bind(this) this._handleMessage.bind(this)
@ -233,7 +233,7 @@ class DialogZWaveJSHealNetwork extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
stopHealNetwork(this.hass, this.entry_id!); stopHealZwaveNetwork(this.hass, this.entry_id!);
this._unsubscribe(); this._unsubscribe();
this._status = "cancelled"; this._status = "cancelled";
} }

View File

@ -10,8 +10,8 @@ import {
computeDeviceName, computeDeviceName,
} from "../../../../../data/device_registry"; } from "../../../../../data/device_registry";
import { import {
fetchNetworkStatus, fetchZwaveNetworkStatus,
healNode, healZwaveNode,
ZWaveJSNetwork, ZWaveJSNetwork,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles"; import { haStyleDialog } from "../../../../../resources/styles";
@ -206,7 +206,7 @@ class DialogZWaveJSHealNode extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
const network: ZWaveJSNetwork = await fetchNetworkStatus( const network: ZWaveJSNetwork = await fetchZwaveNetworkStatus(
this.hass!, this.hass!,
this.entry_id! this.entry_id!
); );
@ -221,7 +221,11 @@ class DialogZWaveJSHealNode extends LitElement {
} }
this._status = "started"; this._status = "started";
try { 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" ? "finished"
: "failed"; : "failed";
} catch (err: any) { } catch (err: any) {

View File

@ -6,7 +6,7 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-circular-progress"; import "../../../../../components/ha-circular-progress";
import { createCloseHeading } from "../../../../../components/ha-dialog"; import { createCloseHeading } from "../../../../../components/ha-dialog";
import { reinterviewNode } from "../../../../../data/zwave_js"; import { reinterviewZwaveNode } from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles"; import { haStyleDialog } from "../../../../../resources/styles";
import { HomeAssistant } from "../../../../../types"; import { HomeAssistant } from "../../../../../types";
import { ZWaveJSReinterviewNodeDialogParams } from "./show-dialog-zwave_js-reinterview-node"; import { ZWaveJSReinterviewNodeDialogParams } from "./show-dialog-zwave_js-reinterview-node";
@ -157,7 +157,7 @@ class DialogZWaveJSReinterviewNode extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
this._subscribed = reinterviewNode( this._subscribed = reinterviewZwaveNode(
this.hass, this.hass,
this.entry_id!, this.entry_id!,
this.node_id!, this.node_id!,

View File

@ -7,7 +7,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-circular-progress"; import "../../../../../components/ha-circular-progress";
import { createCloseHeading } from "../../../../../components/ha-dialog"; import { createCloseHeading } from "../../../../../components/ha-dialog";
import { import {
removeFailedNode, removeFailedZwaveNode,
ZWaveJSRemovedNode, ZWaveJSRemovedNode,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles"; import { haStyleDialog } from "../../../../../resources/styles";
@ -164,7 +164,7 @@ class DialogZWaveJSRemoveFailedNode extends LitElement {
return; return;
} }
this._status = "started"; this._status = "started";
this._subscribed = removeFailedNode( this._subscribed = removeFailedZwaveNode(
this.hass, this.hass,
this.entry_id!, this.entry_id!,
this.node_id!, this.node_id!,

View File

@ -9,11 +9,11 @@ import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-svg-icon"; import "../../../../../components/ha-svg-icon";
import { getSignedPath } from "../../../../../data/auth"; import { getSignedPath } from "../../../../../data/auth";
import { import {
fetchDataCollectionStatus, fetchZwaveDataCollectionStatus,
fetchNetworkStatus, fetchZwaveNetworkStatus,
fetchNodeStatus, fetchZwaveNodeStatus,
NodeStatus, NodeStatus,
setDataCollectionPreference, setZwaveDataCollectionPreference,
ZWaveJSNetwork, ZWaveJSNetwork,
ZWaveJSNodeStatus, ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
@ -317,8 +317,8 @@ class ZWaveJSConfigDashboard extends LitElement {
} }
const [network, dataCollectionStatus] = await Promise.all([ const [network, dataCollectionStatus] = await Promise.all([
fetchNetworkStatus(this.hass!, this.configEntryId), fetchZwaveNetworkStatus(this.hass!, this.configEntryId),
fetchDataCollectionStatus(this.hass!, this.configEntryId), fetchZwaveDataCollectionStatus(this.hass!, this.configEntryId),
]); ]);
this._network = network; this._network = network;
@ -340,7 +340,7 @@ class ZWaveJSConfigDashboard extends LitElement {
return; return;
} }
const nodeStatePromisses = this._network.controller.nodes.map((nodeId) => 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); this._nodes = await Promise.all(nodeStatePromisses);
} }
@ -364,7 +364,7 @@ class ZWaveJSConfigDashboard extends LitElement {
} }
private _dataCollectionToggled(ev) { private _dataCollectionToggled(ev) {
setDataCollectionPreference( setZwaveDataCollectionPreference(
this.hass!, this.hass!,
this.configEntryId!, this.configEntryId!,
ev.target.checked ev.target.checked

View File

@ -32,9 +32,9 @@ import {
subscribeDeviceRegistry, subscribeDeviceRegistry,
} from "../../../../../data/device_registry"; } from "../../../../../data/device_registry";
import { import {
fetchNodeConfigParameters, fetchZwaveNodeConfigParameters,
fetchNodeMetadata, fetchZwaveNodeMetadata,
setNodeConfigParameter, setZwaveNodeConfigParameter,
ZWaveJSNodeConfigParams, ZWaveJSNodeConfigParams,
ZwaveJSNodeMetadata, ZwaveJSNodeMetadata,
ZWaveJSSetConfigParamResult, ZWaveJSSetConfigParamResult,
@ -377,7 +377,7 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
private async _updateConfigParameter(target, value) { private async _updateConfigParameter(target, value) {
const nodeId = getNodeId(this._device!); const nodeId = getNodeId(this._device!);
try { try {
const result = await setNodeConfigParameter( const result = await setZwaveNodeConfigParameter(
this.hass, this.hass,
this.configEntryId!, this.configEntryId!,
nodeId!, nodeId!,
@ -429,8 +429,8 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
} }
[this._nodeMetadata, this._config] = await Promise.all([ [this._nodeMetadata, this._config] = await Promise.all([
fetchNodeMetadata(this.hass, this.configEntryId, nodeId!), fetchZwaveNodeMetadata(this.hass, this.configEntryId, nodeId!),
fetchNodeConfigParameters(this.hass, this.configEntryId, nodeId!), fetchZwaveNodeConfigParameters(this.hass, this.configEntryId, nodeId!),
]); ]);
} }

View File

@ -2855,11 +2855,18 @@
}, },
"add_node": { "add_node": {
"title": "Add a Z-Wave Device", "title": "Add a Z-Wave Device",
"cancel_inclusion": "Cancel Inclusion", "searching_device": "Searching for device",
"controller_in_inclusion_mode": "Your Z-Wave controller is now in inclusion mode.",
"follow_device_instructions": "Follow the directions that came with your device to trigger pairing on the 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.", "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", "view_device": "View Device",
"interview_started": "The device is being interviewed. This may take some time.", "interview_started": "The device is being interviewed. This may take some time.",
"interview_failed": "The device interview failed. Additional information may be available in the logs." "interview_failed": "The device interview failed. Additional information may be available in the logs."

View File

@ -9139,6 +9139,7 @@ fsevents@^1.2.7:
prettier: ^2.4.1 prettier: ^2.4.1
proxy-polyfill: ^0.3.2 proxy-polyfill: ^0.3.2
punycode: ^2.1.1 punycode: ^2.1.1
qr-scanner: ^1.3.0
qrcode: ^1.4.4 qrcode: ^1.4.4
regenerator-runtime: ^0.13.8 regenerator-runtime: ^0.13.8
require-dir: ^1.2.0 require-dir: ^1.2.0
@ -13007,6 +13008,13 @@ fsevents@^1.2.7:
languageName: node languageName: node
linkType: hard 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": "qrcode@npm:^1.4.4":
version: 1.4.4 version: 1.4.4
resolution: "qrcode@npm:1.4.4" resolution: "qrcode@npm:1.4.4"