Add support for native QR code scanner (#21187)

This commit is contained in:
Bram Kragten 2024-06-27 17:15:33 +02:00 committed by GitHub
parent 7603fa3aa8
commit 49c42fc757
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 138 additions and 44 deletions

View File

@ -1,72 +1,92 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import "@material/mwc-list/mwc-list-item";
import { mdiCamera } from "@mdi/js"; import { mdiCamera } from "@mdi/js";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import type QrScanner from "qr-scanner"; import type QrScanner from "qr-scanner";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation"; import { stopPropagation } from "../common/dom/stop_propagation";
import { LocalizeFunc } from "../common/translations/localize"; import { LocalizeFunc } from "../common/translations/localize";
import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint";
import { HomeAssistant } from "../types";
import "./ha-alert"; import "./ha-alert";
import "./ha-button-menu"; import "./ha-button-menu";
import "./ha-list-item";
import "./ha-textfield"; import "./ha-textfield";
import type { HaTextField } from "./ha-textfield"; import type { HaTextField } from "./ha-textfield";
@customElement("ha-qr-scanner") @customElement("ha-qr-scanner")
class HaQrScanner extends LitElement { class HaQrScanner extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc; @property({ attribute: false }) public localize!: LocalizeFunc;
@property() public description?: string;
@property({ attribute: "alternative_option_label" })
public alternativeOptionLabel?: string;
@property() public error?: string;
@state() private _cameras?: QrScanner.Camera[]; @state() private _cameras?: QrScanner.Camera[];
@state() private _error?: string; @state() private _manual = false;
private _qrScanner?: QrScanner; private _qrScanner?: QrScanner;
private _qrNotFoundCount = 0; private _qrNotFoundCount = 0;
@query("video", true) private _video!: HTMLVideoElement; private _removeListener?: UnsubscribeFunc;
@query("#canvas-container", true) private _canvasContainer!: HTMLDivElement; @query("video", true) private _video?: HTMLVideoElement;
@query("#canvas-container", true) private _canvasContainer?: HTMLDivElement;
@query("ha-textfield") private _manualInput?: HaTextField; @query("ha-textfield") private _manualInput?: HaTextField;
public disconnectedCallback(): void { public disconnectedCallback(): void {
super.disconnectedCallback(); super.disconnectedCallback();
this._qrNotFoundCount = 0; this._qrNotFoundCount = 0;
if (this._nativeBarcodeScanner) {
this._closeExternalScanner();
}
if (this._qrScanner) { if (this._qrScanner) {
this._qrScanner.stop(); this._qrScanner.stop();
this._qrScanner.destroy(); this._qrScanner.destroy();
this._qrScanner = undefined; this._qrScanner = undefined;
} }
while (this._canvasContainer.lastChild) { while (this._canvasContainer?.lastChild) {
this._canvasContainer.removeChild(this._canvasContainer.lastChild); this._canvasContainer.removeChild(this._canvasContainer.lastChild);
} }
} }
public connectedCallback(): void { public connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
if (this.hasUpdated && navigator.mediaDevices) { if (this.hasUpdated) {
this._loadQrScanner(); this._loadQrScanner();
} }
} }
protected firstUpdated() { protected firstUpdated() {
if (navigator.mediaDevices) { this._loadQrScanner();
this._loadQrScanner();
}
} }
protected updated(changedProps: PropertyValues) { protected updated(changedProps: PropertyValues) {
if (changedProps.has("_error") && this._error) { if (changedProps.has("error") && this.error) {
fireEvent(this, "qr-code-error", { message: this._error }); alert(`error: ${this.error}`);
this._notifyExternalScanner(this.error);
} }
} }
protected render(): TemplateResult { protected render() {
return html`${this._error if (this._nativeBarcodeScanner && !this._manual) {
? html`<ha-alert alert-type="error">${this._error}</ha-alert>` return nothing;
}
return html`${this.error
? html`<ha-alert alert-type="error">${this.error}</ha-alert>`
: ""} : ""}
${navigator.mediaDevices ${navigator.mediaDevices && !this._manual
? html`<video></video> ? html`<video></video>
<div id="canvas-container"> <div id="canvas-container">
${this._cameras && this._cameras.length > 1 ${this._cameras && this._cameras.length > 1
@ -80,21 +100,26 @@ class HaQrScanner extends LitElement {
></ha-icon-button> ></ha-icon-button>
${this._cameras!.map( ${this._cameras!.map(
(camera) => html` (camera) => html`
<mwc-list-item <ha-list-item
.value=${camera.id} .value=${camera.id}
@click=${this._cameraChanged} @click=${this._cameraChanged}
>${camera.label}</mwc-list-item
> >
${camera.label}
</ha-list-item>
` `
)} )}
</ha-button-menu>` </ha-button-menu>`
: ""} : nothing}
</div>` </div>`
: html`<ha-alert alert-type="warning"> : html`${this._manual
${!window.isSecureContext ? nothing
? this.localize("ui.components.qr-scanner.only_https_supported") : html`<ha-alert alert-type="warning">
: this.localize("ui.components.qr-scanner.not_supported")} ${!window.isSecureContext
</ha-alert> ? this.localize(
"ui.components.qr-scanner.only_https_supported"
)
: this.localize("ui.components.qr-scanner.not_supported")}
</ha-alert>`}
<p>${this.localize("ui.components.qr-scanner.manual_input")}</p> <p>${this.localize("ui.components.qr-scanner.manual_input")}</p>
<div class="row"> <div class="row">
<ha-textfield <ha-textfield
@ -102,33 +127,44 @@ class HaQrScanner extends LitElement {
@keyup=${this._manualKeyup} @keyup=${this._manualKeyup}
@paste=${this._manualPaste} @paste=${this._manualPaste}
></ha-textfield> ></ha-textfield>
<mwc-button @click=${this._manualSubmit} <mwc-button @click=${this._manualSubmit}>
>${this.localize("ui.common.submit")}</mwc-button ${this.localize("ui.common.submit")}
> </mwc-button>
</div>`}`; </div>`}`;
} }
private get _nativeBarcodeScanner(): boolean {
return Boolean(this.hass.auth.external?.config.hasBarCodeScanner);
}
private async _loadQrScanner() { private async _loadQrScanner() {
if (this._nativeBarcodeScanner) {
this._openExternalScanner();
return;
}
if (!navigator.mediaDevices) {
return;
}
const QrScanner = (await import("qr-scanner")).default; const QrScanner = (await import("qr-scanner")).default;
if (!(await QrScanner.hasCamera())) { if (!(await QrScanner.hasCamera())) {
this._error = "No camera found"; this._reportError("No camera found");
return; return;
} }
QrScanner.WORKER_PATH = "/static/js/qr-scanner-worker.min.js"; QrScanner.WORKER_PATH = "/static/js/qr-scanner-worker.min.js";
this._listCameras(QrScanner); this._listCameras(QrScanner);
this._qrScanner = new QrScanner( this._qrScanner = new QrScanner(
this._video, this._video!,
this._qrCodeScanned, this._qrCodeScanned,
this._qrCodeError this._qrCodeError
); );
// @ts-ignore // @ts-ignore
const canvas = this._qrScanner.$canvas; const canvas = this._qrScanner.$canvas;
this._canvasContainer.appendChild(canvas); this._canvasContainer!.appendChild(canvas);
canvas.style.display = "block"; canvas.style.display = "block";
try { try {
await this._qrScanner.start(); await this._qrScanner.start();
} catch (err: any) { } catch (err: any) {
this._error = err; this._reportError(err);
} }
} }
@ -140,16 +176,16 @@ class HaQrScanner extends LitElement {
if (err === "No QR code found") { if (err === "No QR code found") {
this._qrNotFoundCount++; this._qrNotFoundCount++;
if (this._qrNotFoundCount === 250) { if (this._qrNotFoundCount === 250) {
this._error = err; this._reportError(err);
} }
return; return;
} }
this._error = err.message || err; this._reportError(err.message || err);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(err); console.log(err);
}; };
private _qrCodeScanned = async (qrCodeString: string): Promise<void> => { private _qrCodeScanned = (qrCodeString: string): void => {
this._qrNotFoundCount = 0; this._qrNotFoundCount = 0;
fireEvent(this, "qr-code-scanned", { value: qrCodeString }); fireEvent(this, "qr-code-scanned", { value: qrCodeString });
}; };
@ -175,6 +211,62 @@ class HaQrScanner extends LitElement {
this._qrScanner?.setCamera((ev.target as any).value); this._qrScanner?.setCamera((ev.target as any).value);
} }
private _openExternalScanner() {
this._removeListener = addExternalBarCodeListener((msg) => {
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.`
);
} else {
this._qrCodeScanned(msg.payload.rawValue);
}
} else if (msg.command === "bar_code/aborted") {
this._closeExternalScanner();
if (msg.payload.reason === "canceled") {
fireEvent(this, "qr-code-closed");
} else {
this._manual = true;
}
}
return true;
});
this.hass.auth.external!.fireMessage({
type: "bar_code/scan",
payload: {
title: this.title || "Scan QR code",
description: this.description || "Scan a barcode.",
alternative_option_label:
this.alternativeOptionLabel || "Click to manually enter the barcode",
},
});
}
private _closeExternalScanner() {
this._removeListener?.();
this._removeListener = undefined;
this.hass.auth.external!.fireMessage({
type: "bar_code/close",
});
}
private _notifyExternalScanner(message: string) {
if (!this.hass.auth.external) {
return;
}
this.hass.auth.external.fireMessage({
type: "bar_code/notify",
payload: {
message,
},
});
this.error = undefined;
}
private _reportError(message: string) {
fireEvent(this, "qr-code-error", { message });
}
static styles = css` static styles = css`
canvas { canvas {
width: 100%; width: 100%;
@ -210,6 +302,7 @@ declare global {
interface HASSDomEvents { interface HASSDomEvents {
"qr-code-scanned": { value: string }; "qr-code-scanned": { value: string };
"qr-code-error": { message: string }; "qr-code-error": { message: string };
"qr-code-closed": undefined;
} }
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {

View File

@ -205,14 +205,13 @@ class DialogZWaveJSAddNode extends LitElement {
Search device Search device
</mwc-button>` </mwc-button>`
: this._status === "qr_scan" : this._status === "qr_scan"
? html`${this._error ? html` <ha-qr-scanner
? html`<ha-alert alert-type="error" .hass=${this.hass}
>${this._error}</ha-alert
>`
: ""}
<ha-qr-scanner
.localize=${this.hass.localize} .localize=${this.hass.localize}
.error=${this._error}
@qr-code-scanned=${this._qrCodeScanned} @qr-code-scanned=${this._qrCodeScanned}
@qr-code-error=${this._qrCodeError}
@qr-code-closed=${this._startOver}
></ha-qr-scanner> ></ha-qr-scanner>
<mwc-button <mwc-button
slot="secondaryAction" slot="secondaryAction"
@ -361,7 +360,7 @@ class DialogZWaveJSAddNode extends LitElement {
</p> </p>
</div> </div>
${this._supportsSmartStart ${this._supportsSmartStart
? html` <div class="outline"> ? html`<div class="outline">
<h2> <h2>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.zwave_js.add_node.qr_code" "ui.panel.config.zwave_js.add_node.qr_code"
@ -498,9 +497,7 @@ class DialogZWaveJSAddNode extends LitElement {
</ha-alert>` </ha-alert>`
: ""} : ""}
<a <a
href=${`/config/devices/device/${ href=${`/config/devices/device/${this._device?.id}`}
this._device?.id
}`}
> >
<mwc-button> <mwc-button>
${this.hass.localize( ${this.hass.localize(
@ -599,6 +596,10 @@ class DialogZWaveJSAddNode extends LitElement {
this._handleQrCodeScanned(ev.detail.value); this._handleQrCodeScanned(ev.detail.value);
} }
private _qrCodeError(ev: CustomEvent): void {
this._error = ev.detail.message;
}
private async _handleQrCodeScanned(qrCodeString: string): Promise<void> { private async _handleQrCodeScanned(qrCodeString: string): Promise<void> {
this._error = undefined; this._error = undefined;
if (this._status !== "qr_scan" || this._qrProcessing) { if (this._status !== "qr_scan" || this._qrProcessing) {