mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-25 18:26:35 +00:00
Add support for native QR code scanner (#21187)
This commit is contained in:
parent
7603fa3aa8
commit
49c42fc757
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user